| author | |
| committer | |
| log | e8dfc42f444600237beb2fa7772c93b618307e32 |
| tree | 4e9d13f3a9e1e3bd82c18e9774f27ab063c05981 |
| parent | 37e17e9207125a357f8121799a6f5ff44d90eff5 |
| parent | 21c823ecdc63ebfae0226f174f639191a971cd85 |
Co-authored-by: codex <codex@users.noreply.github.com>293 files changed, 51753 insertions(+), 16653 deletions(-)
.github/workflows/ci.yml+7-2| ... | ... | @@ -9,7 +9,7 @@ on: |
| 9 | 9 | jobs: |
| 10 | 10 | quality: |
| 11 | 11 | name: Lint, Typecheck, Test, Build |
| 12 | runs-on: ubuntu-latest | |
| 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 13 | 13 | steps: |
| 14 | 14 | - name: Checkout |
| 15 | 15 | uses: actions/checkout@v4 |
| ... | ... | @@ -17,7 +17,12 @@ jobs: |
| 17 | 17 | - name: Setup Bun |
| 18 | 18 | uses: oven-sh/setup-bun@v2 |
| 19 | 19 | with: |
| 20 | bun-version: 1.3.9 | |
| 20 | bun-version-file: package.json | |
| 21 | ||
| 22 | - name: Setup Node | |
| 23 | uses: actions/setup-node@v4 | |
| 24 | with: | |
| 25 | node-version-file: package.json | |
| 21 | 26 | |
| 22 | 27 | - name: Cache Bun and Turbo |
| 23 | 28 | uses: actions/cache@v4 |
.gitignore+1| ... | ... | @@ -11,3 +11,4 @@ packages/*/dist |
| 11 | 11 | build/ |
| 12 | 12 | .logs/ |
| 13 | 13 | release/ |
| 14 | .t3 | |
| \ No newline at end of file |
.oxfmtrc.json+2-1| ... | ... | @@ -6,7 +6,8 @@ |
| 6 | 6 | "dist-electron", |
| 7 | 7 | "node_modules", |
| 8 | 8 | "bun.lock", |
| 9 | "*.tsbuildinfo" | |
| 9 | "*.tsbuildinfo", | |
| 10 | "**/routeTree.gen.ts" | |
| 10 | 11 | ], |
| 11 | 12 | "experimentalSortPackageJson": {} |
| 12 | 13 | } |
.oxlintrc.json+3-1| ... | ... | @@ -15,6 +15,8 @@ |
| 15 | 15 | "perf": "warn" |
| 16 | 16 | }, |
| 17 | 17 | "rules": { |
| 18 | "react-in-jsx-scope": "off" | |
| 18 | "react-in-jsx-scope": "off", | |
| 19 | "eslint/no-shadow": "off", | |
| 20 | "eslint/no-await-in-loop": "off" | |
| 19 | 21 | } |
| 20 | 22 | } |
.plans/11-effect.md created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | ||
| 2 | ||
| 3 | PR 1: Service contracts + error taxonomy | |
| 4 | Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. | |
| 5 | Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). | |
| 6 | No behavior change yet, just interfaces and compile-time wiring points. | |
| 7 | PR 2: CheckpointStore Effect adapter | |
| 8 | Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). | |
| 9 | Map all thrown/Promise errors to tagged errors. | |
| 10 | Add service tests proving parity for isGitRepository, capture, restore, diff, prune. | |
| 11 | PR 3: CodexService Effect adapter | |
| 12 | Wrap current CodexAppServerManager behind CodexServiceLive (adapter). | |
| 13 | Convert public API to Effect return types with typed errors. | |
| 14 | Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. | |
| 15 | PR 4: ProviderService Effect adapter | |
| 16 | Wrap current ProviderManager behind ProviderServiceLive (adapter). | |
| 17 | Provider methods become Effect methods with typed errors. | |
| 18 | Route emitted provider events through an Effect PubSub surface. | |
| 19 | PR 5: wsServer migration to Effect services | |
| 20 | Stop instantiating provider/codex classes directly in wsServer. | |
| 21 | Resolve ProviderService (and related services) from one runtime/layer graph. | |
| 22 | Keep WS contract behavior identical. | |
| 23 | PR 6: Native CheckpointStore implementation | |
| 24 | Refactor checkpoint internals from Promise/throws to native Effect. | |
| 25 | Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). | |
| 26 | Keep adapter tests plus new failure-path tests. | |
| 27 | PR 7: Codex transport/RPC core as native Effect | |
| 28 | Split codex into scoped process layer + RPC request/response layer + session registry. | |
| 29 | Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. | |
| 30 | Keep protocol behavior and ordering guarantees. | |
| 31 | PR 8: Codex protocol decoding hardening | |
| 32 | Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. | |
| 33 | Map decode failures to typed tagged errors (with root cause). | |
| 34 | Add regression tests for malformed/partial protocol messages. | |
| 35 | PR 9: Native ProviderService orchestration | |
| 36 | Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. | |
| 37 | Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. | |
| 38 | Remove throw-based flow entirely from provider path. | |
| 39 | PR 10: Cleanup + deprecation removal | |
| 40 | Remove legacy class implementations/adapters once parity is proven. | |
| 41 | Finalize layer composition and startup graph docs. | |
| 42 | Add architecture notes for service boundaries and error model. | |
| \ No newline at end of file |
.plans/12-effect-new.md created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | # Effect Migration Plan (From Current State) | |
| 2 | ||
| 3 | Current status summary: | |
| 4 | - Service contracts, typed errors, and most checkpoint/persistence services exist. | |
| 5 | - `ProviderServiceLive` is already native orchestration (not a thin adapter). | |
| 6 | - Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. | |
| 7 | - Checkpoint flow now avoids snapshot re-sync and is write-time driven. | |
| 8 | ||
| 9 | ## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` | |
| 10 | - Build one runtime layer graph for provider + checkpoint + persistence + orchestration. | |
| 11 | - Resolve `ProviderService` from runtime in `wsServer`. | |
| 12 | - Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. | |
| 13 | - Forward provider events by subscribing to `ProviderService.subscribeToEvents`. | |
| 14 | - Keep WS method/push payloads identical. | |
| 15 | ||
| 16 | ## PR 2: Runtime Composition + Startup Ownership | |
| 17 | - Create/centralize `AppLive` composition for server startup. | |
| 18 | - Ensure outer runtime provides Node/platform services once. | |
| 19 | - Ensure migrations run at startup via scoped/layer startup path. | |
| 20 | - Remove ad-hoc service initialization in request-time paths. | |
| 21 | ||
| 22 | ## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants | |
| 23 | - Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. | |
| 24 | - Remove per-session lock/cwd map leaks. | |
| 25 | - Keep strict invariant model: | |
| 26 | - root checkpoint created at session initialization before agent modifications | |
| 27 | - each completed turn captures filesystem checkpoint and persists metadata | |
| 28 | - no after-the-fact metadata rebuild/sync | |
| 29 | - Add tests for lifecycle cleanup and invariant-failure surfaces. | |
| 30 | ||
| 31 | ## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) | |
| 32 | - Keep `ProviderService` as the public event surface. | |
| 33 | - Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. | |
| 34 | - Keep API as `subscribeToEvents` unless we explicitly choose stream API later. | |
| 35 | - Add tests for ordering and subscriber isolation under load. | |
| 36 | ||
| 37 | ## PR 5: Codex Runtime Split (Scoped Effect Core) | |
| 38 | - Extract `CodexAppServerManager` responsibilities into Effect-native layers: | |
| 39 | - scoped process lifecycle | |
| 40 | - RPC request/response + pending map via `Deferred` | |
| 41 | - session registry/state | |
| 42 | - Keep `CodexAdapter` contract stable while swapping internals. | |
| 43 | - Preserve protocol behavior and timeout semantics. | |
| 44 | ||
| 45 | ## PR 6: Codex Protocol Decode Hardening | |
| 46 | - Replace ad-hoc unknown parsing with runtime schema decode. | |
| 47 | - Map decode failures to typed tagged errors with `cause` retained. | |
| 48 | - Add regression tests for malformed/partial protocol frames. | |
| 49 | ||
| 50 | ## PR 7: Remove Legacy Provider Stack | |
| 51 | - Remove `ProviderManager` + legacy checkpoint integration from runtime path. | |
| 52 | - Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). | |
| 53 | - Update tests to assert only Effect service path is used. | |
| 54 | ||
| 55 | ## PR 8: Final Cleanup + Docs | |
| 56 | - Update architecture docs with final layer graph and service boundaries. | |
| 57 | - Document error model and recovery semantics. | |
| 58 | - Trim dead compatibility code and stale plan references. |
.plans/13-provider-service-integration-tests.md created+110| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | # ProviderService Integration Test Plan | |
| 2 | ||
| 3 | Goal: | |
| 4 | - Validate end-to-end `ProviderService` behavior with real layers: | |
| 5 | - `ProviderServiceLive` | |
| 6 | - `CheckpointServiceLive` | |
| 7 | - `CheckpointStoreLive` | |
| 8 | - `CheckpointRepositoryLive` (sqlite in-memory) | |
| 9 | - `ProviderSessionDirectoryLive` | |
| 10 | - Only fake the adapter event source (deterministic Codex-like stream). | |
| 11 | - Avoid mocking checkpointing/persistence orchestration logic. | |
| 12 | ||
| 13 | ## Test Harness | |
| 14 | ||
| 15 | Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: | |
| 16 | - Service contract: `ProviderAdapterShape<ProviderAdapterError>`. | |
| 17 | - Internal state: | |
| 18 | - session registry (session + cwd + threadId) | |
| 19 | - thread snapshot store (`threadId`, `turns`) | |
| 20 | - event subscribers | |
| 21 | - Behavior: | |
| 22 | - `startSession`: creates session with threadId. | |
| 23 | - `sendTurn`: appends a deterministic turn snapshot and emits ordered events: | |
| 24 | - `turn/started` | |
| 25 | - `item/started` / `item/completed` (tool + approval variants depending on scenario) | |
| 26 | - `item/agentMessage/delta` chunks | |
| 27 | - `turn/completed` | |
| 28 | - optional "mutator" callback per turn to change workspace files before completion. | |
| 29 | - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. | |
| 30 | ||
| 31 | Use real git-backed temporary workspaces in integration tests: | |
| 32 | - initialize repo with baseline commit | |
| 33 | - run provider turn in workspace | |
| 34 | - assert checkpoint diffs against real git refs | |
| 35 | ||
| 36 | ## Core Integration Specs | |
| 37 | ||
| 38 | 1) `startSession` initializes checkpoint root exactly once | |
| 39 | - Arrange: | |
| 40 | - start provider session in git repo. | |
| 41 | - Assert: | |
| 42 | - `provider_checkpoints` contains root row (turn 0). | |
| 43 | - checkpoint ref exists in git. | |
| 44 | - second `startSession` for new session creates a new independent root. | |
| 45 | ||
| 46 | 2) Turn without filesystem change | |
| 47 | - Arrange: | |
| 48 | - emit normal turn events, no file mutation. | |
| 49 | - Assert: | |
| 50 | - provider subscribers receive: | |
| 51 | - `turn/started` | |
| 52 | - `turn/completed` | |
| 53 | - synthetic `checkpoint/captured` | |
| 54 | - `listCheckpoints` returns root + turn 1. | |
| 55 | - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. | |
| 56 | ||
| 57 | 3) Turn with filesystem change | |
| 58 | - Arrange: | |
| 59 | - mutate `README.md` during turn. | |
| 60 | - Assert: | |
| 61 | - `listCheckpoints` returns root + turn 1. | |
| 62 | - `getCheckpointDiff(0 -> 1)` contains file path and hunk. | |
| 63 | - persisted checkpoint metadata includes non-empty `checkpointRef`. | |
| 64 | ||
| 65 | 4) Multi-turn sequencing and checkpoint monotonicity | |
| 66 | - Arrange: | |
| 67 | - turn 1: no file change | |
| 68 | - turn 2: file change | |
| 69 | - turn 3: file change | |
| 70 | - Assert: | |
| 71 | - turn counts are monotonic and contiguous in DB (0,1,2,3). | |
| 72 | - latest checkpoint is marked current. | |
| 73 | - diffs for adjacent turns map to expected filesystem deltas. | |
| 74 | ||
| 75 | 5) Revert to checkpoint | |
| 76 | - Arrange: | |
| 77 | - execute 3 turns with at least one file-changing turn. | |
| 78 | - call `revertToCheckpoint(turnCount=1)`. | |
| 79 | - Assert: | |
| 80 | - workspace content matches turn 1 state. | |
| 81 | - adapter `rollbackThread` called with `numTurns=2`. | |
| 82 | - DB rows for turns >1 are removed. | |
| 83 | - later refs are deleted from git. | |
| 84 | ||
| 85 | 6) Capture failure surface | |
| 86 | - Arrange: | |
| 87 | - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. | |
| 88 | - Assert: | |
| 89 | - `ProviderService` emits `checkpoint/captureError`. | |
| 90 | - no partial metadata/ref divergence is left behind. | |
| 91 | ||
| 92 | ## WebSocket Coverage (Thin Integration) | |
| 93 | ||
| 94 | Add one ws server integration spec: | |
| 95 | - Subscribe to `providers.event`. | |
| 96 | - Run a deterministic provider turn through ws methods. | |
| 97 | - Assert push stream includes: | |
| 98 | - `turn/started`, tool events, `turn/completed`, `checkpoint/captured`. | |
| 99 | - Assert orchestration projection still updates assistant message and turn diff summary. | |
| 100 | ||
| 101 | ## Proposed PR Split | |
| 102 | ||
| 103 | PR A: | |
| 104 | - Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). | |
| 105 | ||
| 106 | PR B: | |
| 107 | - Core ProviderService integration specs (cases 1-4). | |
| 108 | ||
| 109 | PR C: | |
| 110 | - Revert + failure-path specs (cases 5-6) + ws thin integration spec. |
.plans/14-server-authoritative-event-sourcing-cleanup.md created+215| ... | ... | @@ -0,0 +1,215 @@ |
| 1 | # Server-Authoritative Event-Sourcing Cleanup Plan | |
| 2 | ||
| 3 | Goal: | |
| 4 | - Move to a cleaner service architecture with: | |
| 5 | - durable, server-authoritative event sourcing | |
| 6 | - strict command routing/validation | |
| 7 | - pluggable provider adapters | |
| 8 | - explicit separation between transport, domain orchestration, provider runtime, and persistence | |
| 9 | ||
| 10 | ## Target Service Graph (ASCII) | |
| 11 | ||
| 12 | ```text | |
| 13 | +---------------------------+ | |
| 14 | | wsServer | | |
| 15 | | transport | | |
| 16 | +---------------------------+ | |
| 17 | | orchestration.dispatchCommand | |
| 18 | v | |
| 19 | +-------------------------------------------+ | |
| 20 | | OrchestrationCommandRouter | | |
| 21 | +-------------------------------------------+ | |
| 22 | | | |
| 23 | v | |
| 24 | +-------------------------------------------+ | |
| 25 | | OrchestrationCommandHandlers | | |
| 26 | +-------------------------------------------+ | |
| 27 | | | |
| 28 | v | |
| 29 | +-------------------------------------------+ | |
| 30 | | OrchestrationEventStore | | |
| 31 | +-------------------------------------------+ | |
| 32 | | | |
| 33 | v | |
| 34 | +-------------------------------------------+ | |
| 35 | | OrchestrationProjectionService | | |
| 36 | +-------------------------------------------+ | |
| 37 | | snapshot/replay | |
| 38 | +---------------------------> wsServer | |
| 39 | ||
| 40 | ||
| 41 | wsServer -- providers.* RPC --> +---------------------------+ | |
| 42 | | ProviderService | | |
| 43 | +---------------------------+ | |
| 44 | | | | |
| 45 | v v | |
| 46 | +-------------------+ +-------------------------+ | |
| 47 | | ProviderSession | | ProviderAdapterRegistry | | |
| 48 | | Registry (durable)| +-------------------------+ | |
| 49 | +-------------------+ | | |
| 50 | ^ v | |
| 51 | | +-------------------------+ | |
| 52 | | | ProviderAdapter(s) | | |
| 53 | | +-------------------------+ | |
| 54 | | | | |
| 55 | | runtime events v | |
| 56 | | +---------------------------+ | |
| 57 | +----------| ProviderRuntimeIngestion | | |
| 58 | +---------------------------+ | |
| 59 | | | | | |
| 60 | v v v | |
| 61 | Router Session Checkpoint | |
| 62 | Registry Service | |
| 63 | ||
| 64 | +-------------------------------------------+ | |
| 65 | | CheckpointService | | |
| 66 | +-------------------------------------------+ | |
| 67 | | | | | |
| 68 | v v v | |
| 69 | +--------------------+ +-------------+ +-------------------+ | |
| 70 | | CheckpointCatalog | | Checkpoint | | ProviderAdapter(s)| | |
| 71 | | (durable) | | Store (git) | | (read/rollback) | | |
| 72 | +--------------------+ +-------------+ +-------------------+ | |
| 73 | | | |
| 74 | v | |
| 75 | +------+ | |
| 76 | |SQLite| | |
| 77 | +------+ | |
| 78 | ||
| 79 | OrchestrationEventStore ------> SQLite | |
| 80 | OrchestrationProjectionService -> SQLite | |
| 81 | ProviderSessionRegistry ------> SQLite | |
| 82 | CheckpointCatalog ------> SQLite | |
| 83 | ``` | |
| 84 | ||
| 85 | ## Commit Series | |
| 86 | ||
| 87 | ### Commit 1: Split public vs system orchestration command contracts | |
| 88 | - Create separate schemas/types: | |
| 89 | - `ClientOrchestrationCommandSchema` | |
| 90 | - `SystemOrchestrationCommandSchema` | |
| 91 | - `OrchestrationCommandSchema = union(client, system)` | |
| 92 | - Ensure client transport can only submit client commands. | |
| 93 | - Keep system commands for server-internal workflows only. | |
| 94 | - Expected files: | |
| 95 | - `packages/contracts/src/orchestration.ts` | |
| 96 | - `apps/server/src/wsServer.ts` | |
| 97 | - orchestration/service tests | |
| 98 | - Tests: | |
| 99 | - reject system-only command via WS dispatch path | |
| 100 | - preserve internal dispatch functionality for system commands | |
| 101 | ||
| 102 | ### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary | |
| 103 | - Add dedicated router service to validate, authorize, and route commands. | |
| 104 | - Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. | |
| 105 | - Add aggregate-level invariant checks before append (thread exists, project exists, etc.). | |
| 106 | - Expected files: | |
| 107 | - `apps/server/src/orchestration/Services/CommandRouter.ts` (new) | |
| 108 | - `apps/server/src/orchestration/Layers/CommandRouter.ts` (new) | |
| 109 | - `apps/server/src/orchestration/Layer.ts` | |
| 110 | - `apps/server/src/orchestration/reducer.ts` (only if needed for event payload changes) | |
| 111 | - Tests: | |
| 112 | - router validation and invariant failures | |
| 113 | - handler happy-path tests per command type | |
| 114 | ||
| 115 | ### Commit 3: Harden event store for idempotency + optimistic append metadata | |
| 116 | - Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). | |
| 117 | - Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. | |
| 118 | - Add optional aggregate version metadata for future optimistic concurrency. | |
| 119 | - Expected files: | |
| 120 | - `apps/server/src/persistence/Migrations/00x_*.ts` (new migration) | |
| 121 | - `apps/server/src/persistence/Services/OrchestrationEvents.ts` | |
| 122 | - `apps/server/src/persistence/Layers/OrchestrationEvents.ts` | |
| 123 | - Tests: | |
| 124 | - duplicate command ID append returns same event/sequence (or explicit idempotent behavior) | |
| 125 | - concurrent append behavior stays ordered and deterministic | |
| 126 | ||
| 127 | ### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` | |
| 128 | - Create `ProviderRuntimeIngestionService` that: | |
| 129 | - subscribes to `ProviderService.streamEvents` | |
| 130 | - translates runtime events into orchestration commands | |
| 131 | - dispatches through router/engine | |
| 132 | - Remove provider-to-orchestration state mutation logic from `wsServer`. | |
| 133 | - Expected files: | |
| 134 | - `apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts` (new) | |
| 135 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (new) | |
| 136 | - `apps/server/src/wsServer.ts` | |
| 137 | - Tests: | |
| 138 | - ingestion service mapping tests (turn started/completed, message delta/completed, runtime error) | |
| 139 | - ws integration confirms same external push behavior | |
| 140 | ||
| 141 | ### Commit 5: Make session directory durable (`ProviderSessionRegistry`) | |
| 142 | - Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. | |
| 143 | - Keep in-memory cache optional, but source of truth must be persistent. | |
| 144 | - Add startup reconciliation to prune dead sessions / keep known thread mapping. | |
| 145 | - Expected files: | |
| 146 | - `apps/server/src/provider/Services/ProviderSessionDirectory.ts` (or new SessionRegistry service) | |
| 147 | - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` | |
| 148 | - `apps/server/src/persistence/Migrations/00x_*.ts` (new table/indexes) | |
| 149 | - provider persistence tests | |
| 150 | - Tests: | |
| 151 | - survives server restart with correct mapping | |
| 152 | - stale session cleanup semantics | |
| 153 | ||
| 154 | ### Commit 6: Re-key checkpoint metadata from session to thread identity | |
| 155 | - Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. | |
| 156 | - Keep `session_id` as nullable metadata only. | |
| 157 | - Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. | |
| 158 | - Expected files: | |
| 159 | - `apps/server/src/persistence/Migrations/00x_*.ts` (checkpoint schema migration) | |
| 160 | - `apps/server/src/persistence/Services/Checkpoints.ts` | |
| 161 | - `apps/server/src/persistence/Layers/Checkpoints.ts` | |
| 162 | - `apps/server/src/checkpointing/Layers/CheckpointService.ts` | |
| 163 | - Tests: | |
| 164 | - resume/new session over same thread sees same checkpoint history | |
| 165 | - revert/diff still work after session churn | |
| 166 | ||
| 167 | ### Commit 7: Add durable projection persistence for orchestration read models | |
| 168 | - Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. | |
| 169 | - Keep event stream as source of truth; projection rebuild stays deterministic. | |
| 170 | - `getSnapshot` reads from projection store (memory cache optional). | |
| 171 | - Expected files: | |
| 172 | - `apps/server/src/persistence/Migrations/00x_*.ts` (projection tables) | |
| 173 | - `apps/server/src/orchestration/*` projection service/layer | |
| 174 | - `apps/server/src/wsServer.ts` (snapshot/replay path wiring) | |
| 175 | - Tests: | |
| 176 | - cold boot snapshot load without replaying full history in process | |
| 177 | - projection rebuild from events yields same result as previous reducer semantics | |
| 178 | ||
| 179 | ### Commit 8: Narrow `ProviderService` responsibilities | |
| 180 | - Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. | |
| 181 | - Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. | |
| 182 | - Preserve adapter pluggability and provider-neutral contracts. | |
| 183 | - Expected files: | |
| 184 | - `apps/server/src/provider/Layers/ProviderService.ts` | |
| 185 | - new orchestration/checkpoint runtime coordinator service(s) | |
| 186 | - Tests: | |
| 187 | - provider service routing stays intact | |
| 188 | - checkpoint capture still triggered by turn completion through new coordinator | |
| 189 | ||
| 190 | ### Commit 9: Look over schemas (contracts and events) | |
| 191 | - Scan for unused schemas. | |
| 192 | - Use effect/Schema everywhere | |
| 193 | - Analyze which we need | |
| 194 | - RPC Input/Output (both for routeRequest and command handler) | |
| 195 | - Event payloads | |
| 196 | - Persistence entities | |
| 197 | ||
| 198 | ### Commit 10: Remove dead legacy path and finalize docs | |
| 199 | - Remove unused legacy manager/store path from active architecture: | |
| 200 | - `providerManager.ts` | |
| 201 | - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) | |
| 202 | - Look over effect services for unused methods, errors, etc | |
| 203 | - Update architecture docs with final service boundaries and boot/runtime graph. | |
| 204 | - Expected files: | |
| 205 | - legacy files + references | |
| 206 | - `AGENTS.md`/docs as needed | |
| 207 | - `.plans` docs linkage | |
| 208 | - Tests: | |
| 209 | - full server integration suite passes on Effect-only path | |
| 210 | - no regressions in WS protocol behavior | |
| 211 | ||
| 212 | ## Risk Controls | |
| 213 | - Keep WS method names and payload contracts stable throughout. | |
| 214 | - Gate each commit with targeted integration tests before moving forward. | |
| 215 | - Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows. |
.plans/15-effect-server.md created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | Rewrite `createServer` and `index.ts` to be Effect native. | |
| 2 | ||
| 3 | Maybe use `effect/unstable/Socket` for the web socket server | |
| 4 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts | |
| 5 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts | |
| 6 | ||
| 7 | - Migrate remaining runtime code to Effect | |
| 8 | - `gitManager` -> `src/git` | |
| 9 | - `terminalManager` -> `src/terminal` (Manager + PTY) | |
| 10 | - ... | |
| \ No newline at end of file |
.plans/16-pr89-review-remediation-phases.md created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | # PR #89 Review Remediation Plan (Phased) | |
| 2 | ||
| 3 | ## How To Use These Files | |
| 4 | ||
| 5 | - Working checklist with updateable status per item (single source of truth): `.plans/16c-pr89-remediation-checklist.md` | |
| 6 | - This file (`16-pr89-review-remediation-phases.md`): phase strategy and grouping. | |
| 7 | ||
| 8 | ## Scope | |
| 9 | ||
| 10 | - Source: GitHub review comments on PR #89 (`Add server-side orchestration engine with event sourcing`). | |
| 11 | - Triage baseline used here: | |
| 12 | - Total threads: 185 | |
| 13 | - Outdated: 94 (excluded) | |
| 14 | - Active unresolved: 85 | |
| 15 | - Invalid/false-positive: 3 (excluded) | |
| 16 | - Duplicate reposts: collapsed | |
| 17 | - Unique actionable findings after filtering: 58 | |
| 18 | - Post-rewrite validity audit: 5 additional stale items marked invalid, leaving 53 actionable (`34 valid` + `19 partially-valid`) | |
| 19 | ||
| 20 | ## Phase 0: Canonical Triage Lock | |
| 21 | ||
| 22 | - Create a single tracking checklist for the 53 currently actionable findings. | |
| 23 | - Map every duplicate thread to its canonical item. | |
| 24 | - Mark invalid/false-positive items with explicit rationale. | |
| 25 | ||
| 26 | Exit criteria: | |
| 27 | - Every open thread is mapped to one canonical fix item or marked invalid. | |
| 28 | ||
| 29 | ## Phase 1: Runtime Survival and Critical Event Wiring | |
| 30 | ||
| 31 | Related bug groups solved together: | |
| 32 | - Worker loop/fiber fatal error handling in orchestration reactors. | |
| 33 | - WebSocket message error boundaries and unhandled rejection guards. | |
| 34 | - Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected). | |
| 35 | ||
| 36 | Primary files: | |
| 37 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | |
| 38 | - `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | |
| 39 | - `apps/server/src/wsServer.ts` | |
| 40 | ||
| 41 | Exit criteria: | |
| 42 | - A single event-processing failure cannot permanently stop ingestion/reactor loops. | |
| 43 | - WS message handling cannot produce unhandled promise rejections. | |
| 44 | - Invalid provider-event-channel review findings are closed with architecture rationale. | |
| 45 | ||
| 46 | ## Phase 2: State Consistency and Ordering | |
| 47 | ||
| 48 | Related bug groups solved together: | |
| 49 | - Fire-and-forget revert completion causing consistency windows. | |
| 50 | - Non-atomic append/projection paths and retry behavior. | |
| 51 | - Race-sensitive thread/event association issues. | |
| 52 | ||
| 53 | Primary files: | |
| 54 | - `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | |
| 55 | - `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` | |
| 56 | - `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` | |
| 57 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | |
| 58 | ||
| 59 | Exit criteria: | |
| 60 | - Revert flow is deterministically reflected in read model updates. | |
| 61 | - Append/project failure mode is explicit and safe under retry. | |
| 62 | - No cross-thread misassociation under concurrent runtime events. | |
| 63 | ||
| 64 | ## Phase 3: Checkpointing Correctness Bundle | |
| 65 | ||
| 66 | Related bug groups solved together: | |
| 67 | - Checkpoint input normalization consistency. | |
| 68 | - Snapshot/projector coverage mismatches. | |
| 69 | - Checkpoint ref/workspace CWD utility duplication. | |
| 70 | - Checkpoint diff/error handling behavior gaps. | |
| 71 | ||
| 72 | Primary files: | |
| 73 | - `apps/server/src/checkpointing/Layers/CheckpointStore.ts` | |
| 74 | - `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts` | |
| 75 | - `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` | |
| 76 | - `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | |
| 77 | - `apps/server/src/wsServer.ts` | |
| 78 | ||
| 79 | Exit criteria: | |
| 80 | - Checkpoint capture/restore/revert paths use one normalization policy. | |
| 81 | - Required projectors are actually represented in snapshot reads. | |
| 82 | - Shared checkpoint/ref/CWD helpers are centralized. | |
| 83 | ||
| 84 | ## Phase 4: Memory and Lifecycle Hygiene | |
| 85 | ||
| 86 | Related bug groups solved together: | |
| 87 | - Unbounded in-memory dedup sets/maps. | |
| 88 | - Missing cleanup/lifecycle protections in long-lived effects/resources. | |
| 89 | ||
| 90 | Primary files: | |
| 91 | - `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | |
| 92 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | |
| 93 | - `apps/server/src/config.ts` | |
| 94 | ||
| 95 | Exit criteria: | |
| 96 | - Long-running server memory does not grow unbounded from dedup bookkeeping. | |
| 97 | - Resource cleanup paths are registered for interruption/shutdown. | |
| 98 | ||
| 99 | ## Phase 5: Transport, Parsing, and Platform Edge Cases | |
| 100 | ||
| 101 | Related bug groups solved together: | |
| 102 | - UTF-8 chunk boundary decode correctness. | |
| 103 | - Markdown/file-link parsing edge cases. | |
| 104 | - Shell/OS-specific PATH parsing behavior. | |
| 105 | - Git rename parsing and small keybinding edge cases. | |
| 106 | ||
| 107 | Primary files: | |
| 108 | - `apps/server/src/wsServer.ts` | |
| 109 | - `apps/server/src/git/Layers/CodexTextGeneration.ts` | |
| 110 | - `apps/web/src/markdown-links.ts` | |
| 111 | - `apps/server/src/os-jank.ts` | |
| 112 | - `apps/server/src/git/Layers/GitCore.ts` | |
| 113 | - `apps/server/src/keybindings.ts` | |
| 114 | ||
| 115 | Exit criteria: | |
| 116 | - Edge-case parsers are robust across valid but non-trivial inputs. | |
| 117 | - Platform-dependent command behavior has safe fallbacks. | |
| 118 | ||
| 119 | ## Phase 6: Build and Maintainability Cleanup | |
| 120 | ||
| 121 | Related bug groups solved together: | |
| 122 | - Build script/runtime assumption cleanup. | |
| 123 | - Redundant error-union declarations and utility/type duplication. | |
| 124 | - Non-functional cleanup comments/docs markers. | |
| 125 | ||
| 126 | Primary files: | |
| 127 | - `apps/server/package.json` | |
| 128 | - `apps/server/src/checkpointing/Errors.ts` | |
| 129 | - Shared utility locations introduced during earlier phases | |
| 130 | - `AGENTS.md` (if cleanup is still pending) | |
| 131 | ||
| 132 | Exit criteria: | |
| 133 | - Build path is explicit and environment-safe. | |
| 134 | - Redundant types/utilities are removed in favor of single sources of truth. | |
| 135 | ||
| 136 | ## Phase 7: Verification and Closeout | |
| 137 | ||
| 138 | - Add backend tests for all behavioral fixes (integration-focused; external services may be layered/mocked, core business logic not mocked out). | |
| 139 | - Run lint and backend tests for all touched packages. | |
| 140 | - Resolve threads with fix references per canonical checklist item. | |
| 141 | ||
| 142 | Exit criteria: | |
| 143 | - Lint passes. | |
| 144 | - Backend tests pass. | |
| 145 | - All actionable review threads are resolved or explicitly justified. |
.plans/16c-pr89-remediation-checklist.md created+477| ... | ... | @@ -0,0 +1,477 @@ |
| 1 | # PR #89 Remediation Checklist (Consolidated) | |
| 2 | ||
| 3 | _Last updated: 2026-02-26_ | |
| 4 | ||
| 5 | This is the working checklist for remediation execution. | |
| 6 | ||
| 7 | Status values: | |
| 8 | - `TODO`: Not started | |
| 9 | - `IN_PROGRESS`: Currently being worked | |
| 10 | - `BLOCKED`: Waiting on decision/dependency | |
| 11 | - `DONE`: Implemented and verified | |
| 12 | - `CLOSED_INVALID`: Stale/invalid review finding | |
| 13 | ||
| 14 | Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` | |
| 15 | ||
| 16 | ## Active Checklist | |
| 17 | ||
| 18 | ### Phase 1 | |
| 19 | ||
| 20 | - [x] `C002` A dispatch error in `processEvent` will terminate the `Effect.forever` loop, permanently halting event ingestion. Consider adding error recovery (e.g., `Effect.catchAll` with logging) around `processEvent` so failures don't kill the fiber. | |
| 21 | - Status: `DONE` | |
| 22 | - Verdict: `valid` | |
| 23 | - Severity: `High` | |
| 24 | - Area: `Runtime resilience and failure handling` | |
| 25 | - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:333` | |
| 26 | - Threads: PRRT_kwDORLtfbc5wj4cH, PRRT_kwDORLtfbc5wnWwF, PRRT_kwDORLtfbc5wyTaP, PRRT_kwDORLtfbc5wzliw, PRRT_kwDORLtfbc5w0_g3, PRRT_kwDORLtfbc5w1HGT (+5 duplicate thread(s)) | |
| 27 | - Audit note: Ingestion worker loop can terminate on unhandled processEvent failure. | |
| 28 | ||
| 29 | - [x] `C003` Consider attaching a no-op error listener before `socket.write` (e.g., `socket.on('error', () => {})`) to prevent an unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake. | |
| 30 | - Status: `DONE` | |
| 31 | - Verdict: `valid` | |
| 32 | - Severity: `High` | |
| 33 | - Area: `WebSocket robustness` | |
| 34 | - File: `apps/server/src/wsServer.ts:75` | |
| 35 | - Threads: PRRT_kwDORLtfbc5v-cf4 | |
| 36 | - Audit note: Upgrade reject writes then destroys socket without defensive error listener. | |
| 37 | ||
| 38 | - [x] `C012` Forked revert dispatch risks read model inconsistency | |
| 39 | - Status: `DONE` | |
| 40 | - Verdict: `valid` | |
| 41 | - Severity: `Medium` | |
| 42 | - Area: `Runtime resilience and failure handling` | |
| 43 | - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:542` | |
| 44 | - Threads: PRRT_kwDORLtfbc5whszW, PRRT_kwDORLtfbc5wyTaS, PRRT_kwDORLtfbc5wzli0, PRRT_kwDORLtfbc5w0_g4, PRRT_kwDORLtfbc5w1HGX (+4 duplicate thread(s)) | |
| 45 | - Audit note: Revert completion dispatch remains forked; state consistency window remains. | |
| 46 | ||
| 47 | - [ ] `C019` ProviderRuntimeIngestion processes events for wrong thread on race | |
| 48 | - Status: `TODO` | |
| 49 | - Verdict: `partially-valid` | |
| 50 | - Severity: `Medium` | |
| 51 | - Area: `Runtime resilience and failure handling` | |
| 52 | - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:178` | |
| 53 | - Threads: PRRT_kwDORLtfbc5wkPaL | |
| 54 | - Audit note: SessionId-only routing can misassociate events under races/rebinds. | |
| 55 | ||
| 56 | - [x] `C020` On `message.completed`, the message ID is added to the set and `thread.message.assistant.complete` is dispatched. On `turn.completed`, the same set is iterated and `thread.message.assistant.complete` is dispatched again for each ID—including already-completed ones. Consider removing message IDs from the set after dispatching on `message.completed`, or filtering out already-completed IDs before the `turn.completed` loop. | |
| 57 | - Status: `DONE` | |
| 58 | - Verdict: `partially-valid` | |
| 59 | - Severity: `Medium` | |
| 60 | - Area: `Runtime resilience and failure handling` | |
| 61 | - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:266` | |
| 62 | - Threads: PRRT_kwDORLtfbc5w1GPr | |
| 63 | - Audit note: Duplicate complete dispatch exists; downstream impact often idempotent. | |
| 64 | ||
| 65 | - [x] `C026` Consider adding `.catch(() => {})` after `Effect.runPromise(handleMessage(ws, raw))` to prevent unhandled rejections from crashing the server if `encodeResponse` or setup logic fails. | |
| 66 | - Status: `DONE` | |
| 67 | - Verdict: `valid` | |
| 68 | - Severity: `Medium` | |
| 69 | - Area: `WebSocket robustness` | |
| 70 | - File: `apps/server/src/wsServer.ts:545` | |
| 71 | - Threads: PRRT_kwDORLtfbc5wj4cE | |
| 72 | - Audit note: runPromise result still not caught; rejection can surface unhandled. | |
| 73 | ||
| 74 | - [x] `C027` WS message handler can cause unhandled promise rejection | |
| 75 | - Status: `DONE` | |
| 76 | - Verdict: `valid` | |
| 77 | - Severity: `Medium` | |
| 78 | - Area: `Runtime resilience and failure handling` | |
| 79 | - File: `apps/server/src/wsServer.ts:545` | |
| 80 | - Threads: PRRT_kwDORLtfbc5wyTaW, PRRT_kwDORLtfbc5wzli3 (+1 duplicate thread(s)) | |
| 81 | - Audit note: Same unhandled rejection path remains in WS message handler. | |
| 82 | ||
| 83 | - [x] `C042` Duplicated `resolveThreadWorkspaceCwd` across three files | |
| 84 | - Status: `DONE` | |
| 85 | - Verdict: `partially-valid` | |
| 86 | - Severity: `Low` | |
| 87 | - Area: `Runtime resilience and failure handling` | |
| 88 | - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` | |
| 89 | - Threads: PRRT_kwDORLtfbc5wzli2 | |
| 90 | - Audit note: Duplication exists but one instance is variant logic, so impact is moderate. | |
| 91 | ||
| 92 | - [x] `C043` Duplicated workspace CWD resolution logic across reactor modules | |
| 93 | - Status: `DONE` | |
| 94 | - Verdict: `valid` | |
| 95 | - Severity: `Low` | |
| 96 | - Area: `Runtime resilience and failure handling` | |
| 97 | - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:62` | |
| 98 | - Threads: PRRT_kwDORLtfbc5wnWwM, PRRT_kwDORLtfbc5w1C3-, PRRT_kwDORLtfbc5w1HGZ (+2 duplicate thread(s)) | |
| 99 | - Audit note: Workspace CWD resolution duplication still present across modules. | |
| 100 | ||
| 101 | - [x] `C044` Checkpoint reactor swallows diff errors silently for `turn.completed` | |
| 102 | - Status: `DONE` | |
| 103 | - Verdict: `partially-valid` | |
| 104 | - Severity: `Low` | |
| 105 | - Area: `Runtime resilience and failure handling` | |
| 106 | - File: `apps/server/src/orchestration/Layers/CheckpointReactor.ts:274` | |
| 107 | - Threads: PRRT_kwDORLtfbc5wkPaO | |
| 108 | - Audit note: Errors are swallowed to empty diff with warning; not fully silent but still lossy. | |
| 109 | ||
| 110 | - [x] `C045` `truncateDetail` slices to `limit - 1` then appends `"..."` (3 chars), producing strings of length `limit + 2`. Consider slicing to `limit - 3` instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 111 | - Status: `DONE` | |
| 112 | - Verdict: `valid` | |
| 113 | - Severity: `Low` | |
| 114 | - Area: `Runtime resilience and failure handling` | |
| 115 | - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:29` | |
| 116 | - Threads: PRRT_kwDORLtfbc5wzp4R | |
| 117 | - Audit note: truncateDetail still overshoots limit. | |
| 118 | ||
| 119 | - [x] `C046` `latestMessageIdByTurnKey` is written to but never read, and `clearAssistantMessageIdsForTurn` doesn't clear its entries—only `clearTurnStateForSession` does. Consider removing this map entirely if unused, or clearing it alongside `turnMessageIdsByTurnKey` in `clearAssistantMessageIdsForTurn`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 120 | - Status: `DONE` | |
| 121 | - Verdict: `valid` | |
| 122 | - Severity: `Low` | |
| 123 | - Area: `Runtime resilience and failure handling` | |
| 124 | - File: `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:133` | |
| 125 | - Threads: PRRT_kwDORLtfbc5wxvIQ | |
| 126 | - Audit note: latestMessageIdByTurnKey still unused/unpruned in per-turn clear path. | |
| 127 | ||
| 128 | - [x] `C053` Consider using `socket.end(response)` instead of `socket.write(response)` + `socket.destroy()` to ensure the HTTP error response is fully flushed before closing the connection. | |
| 129 | - Status: `DONE` | |
| 130 | - Verdict: `valid` | |
| 131 | - Severity: `Low` | |
| 132 | - Area: `WebSocket robustness` | |
| 133 | - File: `apps/server/src/wsServer.ts:83` | |
| 134 | - Threads: PRRT_kwDORLtfbc5v-WPD | |
| 135 | - Audit note: Still uses write+destroy rather than end() for rejection response. | |
| 136 | ||
| 137 | - [ ] `C054` When array chunks contain a multi-byte UTF-8 character split across boundaries, decoding each chunk separately produces replacement characters. Consider using `Buffer.concat()` on all chunks before calling `.toString("utf8")`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 138 | - Status: `TODO` | |
| 139 | - Verdict: `valid` | |
| 140 | - Severity: `Low` | |
| 141 | - Area: `WebSocket robustness` | |
| 142 | - File: `apps/server/src/wsServer.ts:104` | |
| 143 | - Threads: PRRT_kwDORLtfbc5whtrR | |
| 144 | - Audit note: Array chunk UTF-8 decode remains vulnerable to split multibyte corruption. | |
| 145 | ||
| 146 | - [x] `C059` Suggestion: don’t spread `params` into `body`; it can override `_tag` and mishandle non-object values. Keep `_tag` separate and nest `params` under a single key (e.g., `data`), or validate `params` is a plain object. | |
| 147 | - Status: `DONE` | |
| 148 | - Verdict: `partially-valid` | |
| 149 | - Severity: `Low` | |
| 150 | - Area: `WebSocket robustness` | |
| 151 | - File: `apps/web/src/wsTransport.ts:59` | |
| 152 | - Threads: PRRT_kwDORLtfbc5whtrN | |
| 153 | - Audit note: Transport _tag override risk exists but current callsites are constrained. | |
| 154 | ||
| 155 | ### Phase 2 | |
| 156 | ||
| 157 | - [x] `C001` Non-atomic event appending can corrupt state on retry. If an error occurs mid-loop (lines 96-102) after some events are persisted but before the receipt is written, the command appears to fail. A retry generates new UUIDs via `crypto.randomUUID()` in the decider, appending duplicate events. Consider wrapping the loop in a transaction or using deterministic event IDs derived from `commandId`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 158 | - Status: `DONE` | |
| 159 | - Verdict: `valid` | |
| 160 | - Severity: `High` | |
| 161 | - Area: `Event ordering and state consistency` | |
| 162 | - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:96` | |
| 163 | - Threads: PRRT_kwDORLtfbc5wzp4T | |
| 164 | - Audit note: Append/project/receipt are non-atomic; retry can duplicate events. | |
| 165 | ||
| 166 | - [x] `C013` If `projectionPipeline.projectEvent` fails after `eventStore.append` succeeds, the event is persisted but `readModel` isn't updated, causing desync. Consider updating the in-memory `readModel` immediately after append (before the external projection), so local state stays consistent regardless of downstream failures. | |
| 167 | - Status: `DONE` | |
| 168 | - Verdict: `valid` | |
| 169 | - Severity: `Medium` | |
| 170 | - Area: `Event ordering and state consistency` | |
| 171 | - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:99` | |
| 172 | - Threads: PRRT_kwDORLtfbc5whtrM | |
| 173 | - Audit note: Persisted event can outpace in-memory projection on mid-flight failure. | |
| 174 | ||
| 175 | - [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 176 | - Status: `DONE` | |
| 177 | - Verdict: `partially-valid` | |
| 178 | - Severity: `Medium` | |
| 179 | - Area: `Event ordering and state consistency` | |
| 180 | - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:99` | |
| 181 | - Threads: PRRT_kwDORLtfbc5whxJO | |
| 182 | - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated. | |
| 183 | ||
| 184 | - [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 185 | - Status: `DONE` | |
| 186 | - Verdict: `valid` | |
| 187 | - Severity: `Medium` | |
| 188 | - Area: `Event ordering and state consistency` | |
| 189 | - File: `apps/server/src/orchestration/Layers/ProjectionPipeline.ts:490` | |
| 190 | - Threads: PRRT_kwDORLtfbc5wxvH8 | |
| 191 | - Audit note: Pending turn-start map is in-memory only and not rebuilt on bootstrap. | |
| 192 | ||
| 193 | ### Phase 3 | |
| 194 | ||
| 195 | - [x] `C008` Inconsistent input normalization across CheckpointStore methods | |
| 196 | - Status: `DONE` | |
| 197 | - Verdict: `partially-valid` | |
| 198 | - Severity: `Medium` | |
| 199 | - Area: `Checkpointing correctness` | |
| 200 | - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94` | |
| 201 | - Threads: PRRT_kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv_, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s)) | |
| 202 | - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary. | |
| 203 | ||
| 204 | - [x] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 205 | - Status: `DONE` | |
| 206 | - Verdict: `partially-valid` | |
| 207 | - Severity: `Medium` | |
| 208 | - Area: `Checkpointing correctness` | |
| 209 | - File: `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:71` | |
| 210 | - Threads: PRRT_kwDORLtfbc5wiLhQ | |
| 211 | - Audit note: Snapshot sequence can under-report due to extra projectors, but replay impact is lower now. | |
| 212 | ||
| 213 | - [x] `C033` Three error classes defined but never instantiated anywhere | |
| 214 | - Status: `DONE` | |
| 215 | - Verdict: `partially-valid` | |
| 216 | - Severity: `Low` | |
| 217 | - Area: `Checkpointing correctness` | |
| 218 | - File: `apps/server/src/checkpointing/Errors.ts:51` | |
| 219 | - Threads: PRRT_kwDORLtfbc5wlYgo | |
| 220 | - Audit note: Original claim overstated; some errors used, others appear unused. | |
| 221 | ||
| 222 | - [x] `C034` Redundant `CheckpointInvariantError` in `CheckpointServiceError` union type | |
| 223 | - Status: `DONE` | |
| 224 | - Verdict: `valid` | |
| 225 | - Severity: `Low` | |
| 226 | - Area: `Checkpointing correctness` | |
| 227 | - File: `apps/server/src/checkpointing/Errors.ts:79` | |
| 228 | - Threads: PRRT_kwDORLtfbc5wj5fn | |
| 229 | - Audit note: CheckpointInvariantError remains redundantly included in service union. | |
| 230 | ||
| 231 | - [x] `C035` Redundant error type in CheckpointServiceError union definition | |
| 232 | - Status: `DONE` | |
| 233 | - Verdict: `valid` | |
| 234 | - Severity: `Low` | |
| 235 | - Area: `Checkpointing correctness` | |
| 236 | - File: `apps/server/src/checkpointing/Errors.ts:79` | |
| 237 | - Threads: PRRT_kwDORLtfbc5wlYgs, PRRT_kwDORLtfbc5wxsO6, PRRT_kwDORLtfbc5w1C4B (+2 duplicate thread(s)) | |
| 238 | - Audit note: Same as C034. | |
| 239 | ||
| 240 | ### Phase 4 | |
| 241 | ||
| 242 | - [ ] `C018` Unbounded memory growth in turn start deduplication set | |
| 243 | - Status: `TODO` | |
| 244 | - Verdict: `valid` | |
| 245 | - Severity: `Medium` | |
| 246 | - Area: `Memory/resource growth` | |
| 247 | - File: `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:84` | |
| 248 | - Threads: PRRT_kwDORLtfbc5whszQ, PRRT_kwDORLtfbc5wl2A8, PRRT_kwDORLtfbc5wyTaT, PRRT_kwDORLtfbc5wzliz, PRRT_kwDORLtfbc5w0_g-, PRRT_kwDORLtfbc5w1HGW (+5 duplicate thread(s)) | |
| 249 | - Audit note: handledTurnStartKeys still grows without pruning. | |
| 250 | ||
| 251 | ### Phase 5 | |
| 252 | ||
| 253 | - [ ] `C009` Git's braced rename syntax (e.g., `src/{old => new}/file.ts`) isn't handled correctly. The current slice after ` => ` produces invalid paths like `new}/file.ts`. Consider expanding the braces to construct the full destination path. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 254 | - Status: `TODO` | |
| 255 | - Verdict: `valid` | |
| 256 | - Severity: `Medium` | |
| 257 | - Area: `Edge-case parsing/platform behavior` | |
| 258 | - File: `apps/server/src/git/Layers/GitCore.ts:41` | |
| 259 | - Threads: PRRT_kwDORLtfbc5w1CxT | |
| 260 | - Audit note: Braced rename parsing still breaks paths like src/{old => new}/file.ts. | |
| 261 | ||
| 262 | - [ ] `C010` `loadCustomKeybindingsConfig` fails when the config file doesn't exist, which is expected for new users. Consider catching `ENOENT` and returning an empty array instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 263 | - Status: `TODO` | |
| 264 | - Verdict: `valid` | |
| 265 | - Severity: `Medium` | |
| 266 | - Area: `Edge-case parsing/platform behavior` | |
| 267 | - File: `apps/server/src/keybindings.ts:418` | |
| 268 | - Threads: PRRT_kwDORLtfbc5wxvIJ | |
| 269 | - Audit note: ENOENT for missing keybindings config still not handled as empty/default. | |
| 270 | ||
| 271 | - [ ] `C022` Fish shell outputs `$PATH` as space-separated, not colon-separated. Consider checking if the shell is fish and using `string join : $PATH` instead, or validating the result contains colons before assigning. | |
| 272 | - Status: `TODO` | |
| 273 | - Verdict: `valid` | |
| 274 | - Severity: `Medium` | |
| 275 | - Area: `Edge-case parsing/platform behavior` | |
| 276 | - File: `apps/server/src/os-jank.ts:10` | |
| 277 | - Threads: PRRT_kwDORLtfbc5wkRZM | |
| 278 | - Audit note: fish PATH formatting risk still exists in os-jank path recovery. | |
| 279 | ||
| 280 | - [ ] `C023` Using `-il` flags causes the shell to source profile scripts that may print banners or other text, polluting the captured `PATH`. Consider using `-lc` (login only, non-interactive) to reduce unwanted output. | |
| 281 | - Status: `TODO` | |
| 282 | - Verdict: `valid` | |
| 283 | - Severity: `Medium` | |
| 284 | - Area: `Edge-case parsing/platform behavior` | |
| 285 | - File: `apps/server/src/os-jank.ts:10` | |
| 286 | - Threads: PRRT_kwDORLtfbc5wj4cM | |
| 287 | - Audit note: -ilc shell invocation can pollute captured PATH output. | |
| 288 | ||
| 289 | - [x] `C029` `parseFileUrlHref` already decodes the path (line 46), but `safeDecode` is called again here, corrupting filenames containing `%` sequences. Consider skipping the decode when `fileUrlTarget` is non-null. | |
| 290 | - Status: `DONE` | |
| 291 | - Verdict: `valid` | |
| 292 | - Severity: `Medium` | |
| 293 | - Area: `Edge-case parsing/platform behavior` | |
| 294 | - File: `apps/web/src/markdown-links.ts:105` | |
| 295 | - Threads: PRRT_kwDORLtfbc5wnVsU | |
| 296 | - Audit note: file URL decoding still double-decodes in one path. | |
| 297 | ||
| 298 | - [x] `C030` `EXTERNAL_SCHEME_PATTERN` matches `script.ts:10` as a scheme because `.ts:` looks like `scheme:`. Consider requiring `://` after the colon, or checking that what follows the colon is not just digits. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 299 | - Status: `DONE` | |
| 300 | - Verdict: `valid` | |
| 301 | - Severity: `Medium` | |
| 302 | - Area: `Edge-case parsing/platform behavior` | |
| 303 | - File: `apps/web/src/markdown-links.ts:111` | |
| 304 | - Threads: PRRT_kwDORLtfbc5wnVsK | |
| 305 | - Audit note: Scheme regex still misclassifies script.ts:10 as external scheme. | |
| 306 | ||
| 307 | - [ ] `C038` Multi-byte UTF-8 characters split across chunks will be corrupted when decoding each chunk separately. Consider accumulating all chunks first, then decoding once, or use `TextDecoder` with `stream: true`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 308 | - Status: `TODO` | |
| 309 | - Verdict: `valid` | |
| 310 | - Severity: `Low` | |
| 311 | - Area: `Edge-case parsing/platform behavior` | |
| 312 | - File: `apps/server/src/git/Layers/CodexTextGeneration.ts:136` | |
| 313 | - Threads: PRRT_kwDORLtfbc5w1GPo | |
| 314 | - Audit note: Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters. | |
| 315 | ||
| 316 | - [x] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 317 | - Status: `DONE` | |
| 318 | - Verdict: `partially-valid` | |
| 319 | - Severity: `Low` | |
| 320 | - Area: `Edge-case parsing/platform behavior` | |
| 321 | - File: `apps/server/src/keybindings.ts:352` | |
| 322 | - Threads: PRRT_kwDORLtfbc5wxvIB | |
| 323 | - Audit note: Parser/encoder mismatch remains, but encoder path currently low-use. | |
| 324 | ||
| 325 | - [x] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 326 | - Status: `DONE` | |
| 327 | - Verdict: `valid` | |
| 328 | - Severity: `Low` | |
| 329 | - Area: `Edge-case parsing/platform behavior` | |
| 330 | - File: `apps/server/src/keybindings.ts:488` | |
| 331 | - Threads: PRRT_kwDORLtfbc5wxvIA | |
| 332 | - Audit note: upsertKeybindingRule read-modify-write remains race-prone. | |
| 333 | ||
| 334 | ### Phase 6 | |
| 335 | ||
| 336 | - [ ] `C028` Branch sync dispatches both server and stale local update | |
| 337 | - Status: `TODO` | |
| 338 | - Verdict: `partially-valid` | |
| 339 | - Severity: `Medium` | |
| 340 | - Area: `Other` | |
| 341 | - File: `apps/web/src/components/BranchToolbar.tsx:102` | |
| 342 | - Threads: PRRT_kwDORLtfbc5v-XCu | |
| 343 | - Audit note: Optimistic local+server dual update is intentional but can temporarily diverge. | |
| 344 | ||
| 345 | - [x] `C037` `Effect.callback` should return a cleanup function to close the server(s) on fiber interruption. Without it, the `Net.Server` handles keep the process alive and leak the port if the effect is cancelled. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 346 | - Status: `DONE` | |
| 347 | - Verdict: `partially-valid` | |
| 348 | - Severity: `Low` | |
| 349 | - Area: `Other` | |
| 350 | - File: `apps/server/src/config.ts:41` | |
| 351 | - Threads: PRRT_kwDORLtfbc5wj4cO | |
| 352 | - Audit note: Callback cleanup missing, but practical exposure is low in one-shot startup path. | |
| 353 | ||
| 354 | - [ ] `C047` `SqlSchema.findOneOption` can produce both SQL errors and decode errors, but `mapError` wraps all as `PersistenceSqlError`. Consider distinguishing `ParseError` from SQL errors and mapping decode failures to `PersistenceDecodeError` instead. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 355 | - Status: `TODO` | |
| 356 | - Verdict: `valid` | |
| 357 | - Severity: `Low` | |
| 358 | - Area: `Other` | |
| 359 | - File: `apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts:75` | |
| 360 | - Threads: PRRT_kwDORLtfbc5wiaR- | |
| 361 | - Audit note: Decode and SQL errors still collapsed into one persistence error kind. | |
| 362 | ||
| 363 | - [x] `C049` `JSON.stringify(cause)` returns `undefined` for `undefined`, functions, or symbols, violating the `string` return type. Consider coercing the result to a string (e.g., `String(JSON.stringify(cause))`) or adding a fallback. | |
| 364 | - Status: `DONE` | |
| 365 | - Verdict: `valid` | |
| 366 | - Severity: `Low` | |
| 367 | - Area: `Other` | |
| 368 | - File: `apps/server/src/provider/Layers/ProviderService.ts:59` | |
| 369 | - Threads: PRRT_kwDORLtfbc5wnVsI | |
| 370 | - Audit note: JSON.stringify(cause) may return undefined despite string expectations. | |
| 371 | ||
| 372 | - [ ] `C050` The read-modify-write pattern (`getBySessionId` → merge → `upsert`) is susceptible to lost updates under concurrent writes. Consider wrapping in a transaction or adding optimistic concurrency control (e.g., version field) if concurrent session updates are expected. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 373 | - Status: `TODO` | |
| 374 | - Verdict: `valid` | |
| 375 | - Severity: `Low` | |
| 376 | - Area: `Other` | |
| 377 | - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:94` | |
| 378 | - Threads: PRRT_kwDORLtfbc5wiLhY | |
| 379 | - Audit note: ProviderSessionDirectory upsert remains read-merge-write without concurrency control. | |
| 380 | ||
| 381 | - [x] `C051` Using `??` for `providerThreadId` and `adapterKey` makes it impossible to clear these fields by passing `null`, since `null ?? existing` evaluates to `existing`. Consider using explicit `undefined` checks (like `resumeCursor` does) if clearing should be supported. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 382 | - Status: `DONE` | |
| 383 | - Verdict: `partially-valid` | |
| 384 | - Severity: `Low` | |
| 385 | - Area: `Other` | |
| 386 | - File: `apps/server/src/provider/Layers/ProviderSessionDirectory.ts:119` | |
| 387 | - Threads: PRRT_kwDORLtfbc5wxvH9 | |
| 388 | - Audit note: Null-clearing issue is real for providerThreadId; adapterKey part overstated. | |
| 389 | ||
| 390 | - [ ] `C052` Race condition: `processHandle` may be `null` when `data` callback fires, since it's assigned after `Bun.spawn` returns. Consider initializing `BunPtyProcess` first, then passing it to the callback to avoid losing initial output. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 391 | - Status: `TODO` | |
| 392 | - Verdict: `valid` | |
| 393 | - Severity: `Low` | |
| 394 | - Area: `Other` | |
| 395 | - File: `apps/server/src/terminal/Layers/BunPTY.ts:97` | |
| 396 | - Threads: PRRT_kwDORLtfbc5w1CxE | |
| 397 | - Audit note: Data callback may race before processHandle assignment. | |
| 398 | ||
| 399 | - [ ] `C056` When `onOpenChange` is provided without `open`, the internal `_open` state never updates because `setOpenProp` takes precedence. Consider calling `_setOpen` when `openProp === undefined`, regardless of whether `setOpenProp` exists. | |
| 400 | - Status: `TODO` | |
| 401 | - Verdict: `partially-valid` | |
| 402 | - Severity: `Low` | |
| 403 | - Area: `Other` | |
| 404 | - File: `apps/web/src/components/ui/sidebar.tsx:114` | |
| 405 | - Threads: PRRT_kwDORLtfbc5wxvIq | |
| 406 | - Audit note: Bug pattern exists, but current callsites mostly avoid triggering it. | |
| 407 | ||
| 408 | - [ ] `C057` The `resizable` object is recreated on every render, causing `SidebarRail`'s `useEffect` to repeatedly read localStorage and update the DOM. Consider memoizing the object with `useMemo`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 409 | - Status: `TODO` | |
| 410 | - Verdict: `valid` | |
| 411 | - Severity: `Low` | |
| 412 | - Area: `Other` | |
| 413 | - File: `apps/web/src/routes/_chat.$threadId.tsx:105` | |
| 414 | - Threads: PRRT_kwDORLtfbc5wyWz4 | |
| 415 | - Audit note: Resizable object recreation still retriggers effect/storage reads. | |
| 416 | ||
| 417 | - [ ] `C058` When `localStorage.getItem()` returns `null`, `Number(null)` evaluates to `0`, which passes `Number.isFinite(0)`. This causes the sidebar to clamp to `minWidth` on first load, overriding the `DIFF_INLINE_DEFAULT_WIDTH` CSS clamp. Consider checking for `null` or empty string before parsing, e.g. guard with `storedWidth === null || storedWidth === ''`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 418 | - Status: `TODO` | |
| 419 | - Verdict: `valid` | |
| 420 | - Severity: `Low` | |
| 421 | - Area: `Other` | |
| 422 | - File: `apps/web/src/routes/_chat.$threadId.tsx:122` | |
| 423 | - Threads: PRRT_kwDORLtfbc5wnVsX | |
| 424 | - Audit note: Number(null) -> 0 path still forces min width on initial load. | |
| 425 | ||
| 426 | - [ ] `C060` `defaultModel` should be `Schema.optional(Schema.NullOr(Schema.String))` to allow clearing the value. Currently there's no way to reset it to `null` since omitting means "no change" in patch semantics. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 427 | - Status: `TODO` | |
| 428 | - Verdict: `valid` | |
| 429 | - Severity: `Low` | |
| 430 | - Area: `Other` | |
| 431 | - File: `packages/contracts/src/orchestration.ts:253` | |
| 432 | - Threads: PRRT_kwDORLtfbc5whxJC | |
| 433 | - Audit note: Schema still cannot express null clear for defaultModel patch. | |
| 434 | ||
| 435 | ## Closed Invalid Items | |
| 436 | ||
| 437 | - [x] `C014` Engine error handler catches all errors including non-invariant ones | |
| 438 | - Status: `CLOSED_INVALID` | |
| 439 | - Severity: `Medium` | |
| 440 | - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144` | |
| 441 | - Threads: PRRT_kwDORLtfbc5wkPaJ | |
| 442 | - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design. | |
| 443 | ||
| 444 | - [x] `C021` Shared mutable default metadata object causes stale eventId | |
| 445 | - Status: `CLOSED_INVALID` | |
| 446 | - Severity: `Medium` | |
| 447 | - File: `apps/server/src/orchestration/decider.ts:27` | |
| 448 | - Threads: PRRT_kwDORLtfbc5wkPaA | |
| 449 | - Rationale: Stale-eventId claim no longer applies; eventId is regenerated per event. | |
| 450 | ||
| 451 | - [x] `C025` Duplicated checkpoint ref computation across two files | |
| 452 | - Status: `CLOSED_INVALID` | |
| 453 | - Severity: `Medium` | |
| 454 | - File: `apps/server/src/wsServer.ts:128` | |
| 455 | - Threads: PRRT_kwDORLtfbc5wvwag | |
| 456 | - Rationale: No longer duplicated; checkpoint ref helper now centralized. | |
| 457 | ||
| 458 | - [x] `C031` Revert uses wrong turn count from positional inference | |
| 459 | - Status: `CLOSED_INVALID` | |
| 460 | - Severity: `Medium` | |
| 461 | - File: `apps/web/src/session-logic.ts:127` | |
| 462 | - Threads: PRRT_kwDORLtfbc5v9SCp | |
| 463 | - Rationale: Revert now uses explicit checkpointTurnCount first; positional fallback is non-primary. | |
| 464 | ||
| 465 | - [x] `C036` Duplicate `checkpointRefForThreadTurn` function in two production files | |
| 466 | - Status: `CLOSED_INVALID` | |
| 467 | - Severity: `Low` | |
| 468 | - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:284` | |
| 469 | - Threads: PRRT_kwDORLtfbc5wiqFX | |
| 470 | - Rationale: No longer duplicated; single production source via Refs.ts. | |
| 471 | ||
| 472 | - [x] `C055` Duplicate `checkpointRefForThreadTurn` function across files | |
| 473 | - Status: `CLOSED_INVALID` | |
| 474 | - Severity: `Low` | |
| 475 | - File: `apps/server/src/wsServer.ts:128` | |
| 476 | - Threads: PRRT_kwDORLtfbc5wkPaG | |
| 477 | - Rationale: No longer duplicated; helper is centralized. |
.plans/17-claude-code.md created+546| ... | ... | @@ -0,0 +1,546 @@ |
| 1 | # Plan: Claude Code Integration (Orchestration Architecture) | |
| 2 | ||
| 3 | ## Why this plan was rewritten | |
| 4 | ||
| 5 | The previous plan targeted a pre-orchestration architecture (`ProviderManager`, provider-native WS event methods, and direct provider UI wiring). The current app now routes everything through: | |
| 6 | ||
| 7 | 1. `orchestration.dispatchCommand` (client intent) | |
| 8 | 2. `OrchestrationEngine` (decide + persist + publish domain events) | |
| 9 | 3. `ProviderCommandReactor` (domain intent -> `ProviderService`) | |
| 10 | 4. `ProviderService` (adapter routing + canonical runtime stream) | |
| 11 | 5. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands) | |
| 12 | 6. `orchestration.domainEvent` (single push channel consumed by web) | |
| 13 | ||
| 14 | Claude integration must plug into this path instead of reintroducing legacy provider-specific flows. | |
| 15 | ||
| 16 | --- | |
| 17 | ||
| 18 | ## Current constraints to design around | |
| 19 | ||
| 20 | 1. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads. | |
| 21 | 2. `ProviderService.startSession` currently defaults to `provider: "codex"` unless explicitly provided. | |
| 22 | 3. `thread.turn.start` has no provider field today, so first-turn provider selection cannot be explicitly requested. | |
| 23 | 4. `ProviderService` requires adapter `startSession()` to return a `ProviderSession` with `threadId`. | |
| 24 | 5. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior. | |
| 25 | 6. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only. | |
| 26 | ||
| 27 | --- | |
| 28 | ||
| 29 | ## Architecture target | |
| 30 | ||
| 31 | Add Claude as a first-class provider adapter that emits canonical runtime events and works with existing orchestration reactors without adding new WS channels or bypass paths. | |
| 32 | ||
| 33 | Key decisions: | |
| 34 | ||
| 35 | 1. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries. | |
| 36 | 2. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract. | |
| 37 | 3. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`. | |
| 38 | 4. Add explicit provider selection to turn-start intent so first turn can start Claude session intentionally. | |
| 39 | ||
| 40 | --- | |
| 41 | ||
| 42 | ## Phase 1: Contracts and command shape updates | |
| 43 | ||
| 44 | ### 1.1 Provider-aware model contract | |
| 45 | ||
| 46 | Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only. | |
| 47 | ||
| 48 | Expected outcomes: | |
| 49 | ||
| 50 | 1. Introduce provider-scoped model lists (Codex + Claude). | |
| 51 | 2. Add helpers that resolve model by provider. | |
| 52 | 3. Preserve backwards compatibility for existing Codex defaults. | |
| 53 | ||
| 54 | ### 1.2 Turn-start provider intent | |
| 55 | ||
| 56 | Update `packages/contracts/src/orchestration.ts`: | |
| 57 | ||
| 58 | 1. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`. | |
| 59 | 2. Carry provider through `ThreadTurnStartRequestedPayload`. | |
| 60 | 3. Keep existing command valid when provider is omitted. | |
| 61 | ||
| 62 | This removes the implicit “Codex unless session already exists” behavior as the only path. | |
| 63 | ||
| 64 | ### 1.3 Provider session start input for Claude runtime knobs | |
| 65 | ||
| 66 | Update `packages/contracts/src/provider.ts`: | |
| 67 | ||
| 68 | 1. Extend `ProviderSessionStartInput` with optional Claude-specific fields (for example `claudeBinaryPath`, `permissionMode`, `maxThinkingTokens`). | |
| 69 | 2. Keep fields optional so current call sites remain valid. | |
| 70 | 3. Continue using generic `resumeThreadId` + `resumeCursor` as the cross-provider recovery mechanism. | |
| 71 | ||
| 72 | ### 1.4 Contract tests | |
| 73 | ||
| 74 | Update/add tests in `packages/contracts/src/*.test.ts` for: | |
| 75 | ||
| 76 | 1. New command payload shape. | |
| 77 | 2. Provider-aware model resolution behavior. | |
| 78 | 3. Backward compatibility of existing command/schema decoding. | |
| 79 | ||
| 80 | --- | |
| 81 | ||
| 82 | ## Phase 2: Claude adapter implementation | |
| 83 | ||
| 84 | ### 2.1 Add adapter service + layer | |
| 85 | ||
| 86 | Create: | |
| 87 | ||
| 88 | 1. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` | |
| 89 | 2. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` | |
| 90 | ||
| 91 | Adapter must implement `ProviderAdapterShape<ProviderAdapterError>`. | |
| 92 | ||
| 93 | ### 2.1.a SDK dependency and baseline config | |
| 94 | ||
| 95 | Add server dependency: | |
| 96 | ||
| 97 | 1. `@anthropic-ai/claude-agent-sdk` | |
| 98 | ||
| 99 | Baseline adapter options to support from day one: | |
| 100 | ||
| 101 | 1. `cwd` | |
| 102 | 2. `model` | |
| 103 | 3. `pathToClaudeCodeExecutable` (from `claudeBinaryPath`) | |
| 104 | 4. `permissionMode` | |
| 105 | 5. `maxThinkingTokens` | |
| 106 | 6. `resume` | |
| 107 | 7. `resumeSessionAt` | |
| 108 | 8. `includePartialMessages` | |
| 109 | 9. `canUseTool` | |
| 110 | 10. `hooks` | |
| 111 | 11. `env` and `additionalDirectories` (if needed for sandbox/workspace parity) | |
| 112 | ||
| 113 | ### 2.2 Claude runtime bridge | |
| 114 | ||
| 115 | Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle. | |
| 116 | ||
| 117 | Required capabilities: | |
| 118 | ||
| 119 | 1. Long-lived session context per adapter session. | |
| 120 | 2. Multi-turn input queue. | |
| 121 | 3. Interrupt support. | |
| 122 | 4. Approval request/response bridge. | |
| 123 | 5. Resume support via `resumeThreadId` / `resumeCursor`. | |
| 124 | ||
| 125 | #### 2.2.a Agent SDK details to preserve | |
| 126 | ||
| 127 | The adapter should explicitly rely on these SDK capabilities: | |
| 128 | ||
| 129 | 1. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers). | |
| 130 | 2. Multi-turn input is supported via async-iterable prompt input. | |
| 131 | 3. Tool approval decisions are provided via `canUseTool`. | |
| 132 | 4. Resume support uses `resume` and optional `resumeSessionAt`. | |
| 133 | 5. Hooks can be used for lifecycle signals (`Stop`, `PostToolUse`, etc.) when we need adapter-originated checkpoint/runtime events. | |
| 134 | ||
| 135 | #### 2.2.b Effect-native session lifecycle skeleton | |
| 136 | ||
| 137 | ```ts | |
| 138 | import { query } from "@anthropic-ai/claude-agent-sdk"; | |
| 139 | import { Effect } from "effect"; | |
| 140 | ||
| 141 | const acquireSession = (input: ProviderSessionStartInput) => | |
| 142 | Effect.acquireRelease( | |
| 143 | Effect.tryPromise({ | |
| 144 | try: async () => { | |
| 145 | const abortController = new AbortController(); | |
| 146 | const result = query({ | |
| 147 | prompt: makePromptAsyncIterable(input.sessionId), | |
| 148 | options: { | |
| 149 | cwd: input.cwd, | |
| 150 | model: input.model, | |
| 151 | permissionMode: input.permissionMode, | |
| 152 | maxThinkingTokens: input.maxThinkingTokens, | |
| 153 | pathToClaudeCodeExecutable: input.claudeBinaryPath, | |
| 154 | resume: input.resumeThreadId, | |
| 155 | resumeSessionAt: readResumeCursor(input.resumeCursor), | |
| 156 | signal: abortController.signal, | |
| 157 | includePartialMessages: true, | |
| 158 | canUseTool: makeCanUseTool(input.sessionId), | |
| 159 | hooks: makeClaudeHooks(input.sessionId), | |
| 160 | }, | |
| 161 | }); | |
| 162 | return { abortController, result }; | |
| 163 | }, | |
| 164 | catch: (cause) => | |
| 165 | new ProviderAdapterProcessError({ | |
| 166 | provider: "claudeCode", | |
| 167 | sessionId: "pending", | |
| 168 | detail: "Failed to start Claude runtime session.", | |
| 169 | cause, | |
| 170 | }), | |
| 171 | }), | |
| 172 | ({ abortController }) => Effect.sync(() => abortController.abort()), | |
| 173 | ); | |
| 174 | ``` | |
| 175 | ||
| 176 | #### 2.2.c AsyncIterable -> Effect Stream integration | |
| 177 | ||
| 178 | Preferred when available in the pinned Effect version: | |
| 179 | ||
| 180 | ```ts | |
| 181 | const sdkMessageStream = Stream.fromAsyncIterable( | |
| 182 | session.result, | |
| 183 | (cause) => | |
| 184 | new ProviderAdapterProcessError({ | |
| 185 | provider: "claudeCode", | |
| 186 | sessionId, | |
| 187 | detail: "Claude runtime stream failed.", | |
| 188 | cause, | |
| 189 | }), | |
| 190 | ); | |
| 191 | ``` | |
| 192 | ||
| 193 | Portable fallback (already aligned with current server patterns): | |
| 194 | ||
| 195 | ```ts | |
| 196 | const queue = yield* Queue.unbounded<ClaudeSdkMessage>(); | |
| 197 | yield* Effect.forkScoped( | |
| 198 | Effect.tryPromise({ | |
| 199 | try: async () => { | |
| 200 | for await (const message of session.result) { | |
| 201 | Queue.offerAllUnsafe(queue, [message]); | |
| 202 | } | |
| 203 | }, | |
| 204 | catch: (cause) => | |
| 205 | new ProviderAdapterProcessError({ | |
| 206 | provider: "claudeCode", | |
| 207 | sessionId, | |
| 208 | detail: "Claude runtime stream pump failed.", | |
| 209 | cause, | |
| 210 | }), | |
| 211 | }), | |
| 212 | ); | |
| 213 | const sdkMessageStream = Stream.fromQueue(queue); | |
| 214 | ``` | |
| 215 | ||
| 216 | #### 2.2.d Multi-turn prompt queue pattern (Effect) | |
| 217 | ||
| 218 | Use an Effect queue as the single input boundary: | |
| 219 | ||
| 220 | ```ts | |
| 221 | const promptQueue = yield* Queue.unbounded<ClaudePromptEnvelope>(); | |
| 222 | ||
| 223 | const prompt: AsyncIterable<ClaudePromptEnvelope> = { | |
| 224 | [Symbol.asyncIterator]() { | |
| 225 | return { | |
| 226 | next: async () => { | |
| 227 | const item = await Effect.runPromise(Queue.take(promptQueue)); | |
| 228 | if (item.type === "terminate") { | |
| 229 | return { done: true, value: undefined }; | |
| 230 | } | |
| 231 | return { done: false, value: item }; | |
| 232 | }, | |
| 233 | }; | |
| 234 | }, | |
| 235 | }; | |
| 236 | ``` | |
| 237 | ||
| 238 | `sendTurn()` enqueues a user envelope, while `stopSession()` enqueues terminate and aborts. | |
| 239 | ||
| 240 | #### 2.2.e Approval bridge with `canUseTool` | |
| 241 | ||
| 242 | Map SDK approval checks to existing orchestration approval flows: | |
| 243 | ||
| 244 | ```ts | |
| 245 | const canUseTool = async (toolName: string, toolInput: Record<string, unknown>) => { | |
| 246 | if (approvalModeIsFullAccess(sessionId)) { | |
| 247 | return { behavior: "allow", updatedInput: toolInput }; | |
| 248 | } | |
| 249 | ||
| 250 | const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); | |
| 251 | emitRuntimeEvent({ | |
| 252 | type: "approval.requested", | |
| 253 | provider: "claudeCode", | |
| 254 | sessionId, | |
| 255 | requestId, | |
| 256 | requestKind: classifyTool(toolName), | |
| 257 | detail: summarizeToolRequest(toolName, toolInput), | |
| 258 | // ... eventId/createdAt | |
| 259 | }); | |
| 260 | ||
| 261 | const decision = await waitForApprovalDecision(requestId); | |
| 262 | emitRuntimeEvent({ | |
| 263 | type: "approval.resolved", | |
| 264 | provider: "claudeCode", | |
| 265 | sessionId, | |
| 266 | requestId, | |
| 267 | decision, | |
| 268 | // ... eventId/createdAt | |
| 269 | }); | |
| 270 | ||
| 271 | return decision === "accept" || decision === "acceptForSession" | |
| 272 | ? { behavior: "allow", updatedInput: toolInput } | |
| 273 | : { behavior: "deny", message: "User declined tool execution." }; | |
| 274 | }; | |
| 275 | ``` | |
| 276 | ||
| 277 | #### 2.2.f Hooks and checkpoint signals | |
| 278 | ||
| 279 | If Claude hooks provide cleaner turn boundaries, convert them into canonical runtime events (`turn.completed` and optionally `checkpoint.captured`) so `CheckpointReactor` remains unchanged: | |
| 280 | ||
| 281 | ```ts | |
| 282 | hooks: { | |
| 283 | Stop: [ | |
| 284 | { | |
| 285 | matcher: {}, | |
| 286 | hooks: [ | |
| 287 | async () => { | |
| 288 | emitRuntimeEvent({ | |
| 289 | type: "checkpoint.captured", | |
| 290 | provider: "claudeCode", | |
| 291 | sessionId, | |
| 292 | threadId, | |
| 293 | turnId: activeTurnId, | |
| 294 | turnCount: nextTurnCount(), | |
| 295 | // ... eventId/createdAt | |
| 296 | }); | |
| 297 | }, | |
| 298 | ], | |
| 299 | }, | |
| 300 | ], | |
| 301 | } | |
| 302 | ``` | |
| 303 | ||
| 304 | #### 2.2.g Runtime control method wiring | |
| 305 | ||
| 306 | Adapter should expose SDK controls through existing service methods and runtime payload updates: | |
| 307 | ||
| 308 | 1. `interruptTurn()` -> `result.interrupt()` | |
| 309 | 2. Model override on send turn -> `result.setModel(model)` before enqueuing prompt | |
| 310 | 3. Runtime mode changes -> `result.setPermissionMode(mode)` if we support live mode switching | |
| 311 | 4. Thinking budget updates -> `result.setMaxThinkingTokens(tokens)` when configured | |
| 312 | ||
| 313 | ### 2.3 Canonical event mapping | |
| 314 | ||
| 315 | Map Claude SDK events to `ProviderRuntimeEvent`: | |
| 316 | ||
| 317 | 1. `session.started` / `session.exited` | |
| 318 | 2. `turn.started` / `turn.completed` | |
| 319 | 3. `message.delta` / `message.completed` | |
| 320 | 4. `tool.started` / `tool.completed` | |
| 321 | 5. `approval.requested` / `approval.resolved` | |
| 322 | 6. `runtime.error` | |
| 323 | ||
| 324 | No provider-native event methods should leak beyond the adapter boundary. | |
| 325 | ||
| 326 | Reference mapping table: | |
| 327 | ||
| 328 | | Claude SDK message/callback | Canonical runtime event(s) | Notes | | |
| 329 | |---|---|---| | |
| 330 | | assistant partial text | `message.delta` | Emit deltas only (do not resend full content each chunk). | | |
| 331 | | assistant final message | `message.completed` | Must include stable `itemId` for dedupe/finalize behavior. | | |
| 332 | | result (success) | `turn.completed` (`status: "completed"`) | This is the main turn boundary for checkpointing. | | |
| 333 | | result (interrupted/cancelled) | `turn.completed` (`status: "interrupted"`/`"cancelled"`) | Preserve state semantics used by ingestion + UI. | | |
| 334 | | result (error) | `runtime.error` and `turn.completed` (`status: "failed"`) | Include concise error message. | | |
| 335 | | tool start | `tool.started` | Set `toolKind` using shared classifier (`command`, `file-change`, `other`). | | |
| 336 | | tool result | `tool.completed` | Include detail summary used by activity feed. | | |
| 337 | | canUseTool prompt | `approval.requested` | Source of request id shown in UI. | | |
| 338 | | approval decision returned | `approval.resolved` | Must correlate using same request id. | | |
| 339 | | session initialization | `session.started`, optional `thread.started` | Ensure `threadId` is emitted early and consistently. | | |
| 340 | | process exit/close | `session.exited` | Required for session status projection cleanup. | | |
| 341 | ||
| 342 | ### 2.4 Session/thread/resume identifiers | |
| 343 | ||
| 344 | Define explicit adapter semantics: | |
| 345 | ||
| 346 | 1. `sessionId`: adapter-owned stable session id. | |
| 347 | 2. `threadId`: Claude conversation/session identifier returned as `ProviderThreadId`. | |
| 348 | 3. `resumeCursor`: provider-specific cursor (for example message id) needed for precise recovery/rollback. | |
| 349 | ||
| 350 | ### 2.5 Rollback/read strategy | |
| 351 | ||
| 352 | Implement `readThread()` and `rollbackThread()` in a way compatible with `CheckpointReactor`. | |
| 353 | ||
| 354 | Preferred: | |
| 355 | ||
| 356 | 1. Track per-turn resume cursors and reconstruct session state on rollback. | |
| 357 | 2. Keep adapter `sessionId` stable across internal rehydration/restart. | |
| 358 | ||
| 359 | Fallback (explicitly documented if chosen): | |
| 360 | ||
| 361 | 1. Filesystem revert succeeds even when conversation rewind is partial. | |
| 362 | 2. Reactor behavior is updated to surface a clear warning activity instead of silent drift. | |
| 363 | ||
| 364 | ### 2.6 Adapter unit tests | |
| 365 | ||
| 366 | Add tests mirroring Codex adapter coverage: | |
| 367 | ||
| 368 | 1. Provider validation (`provider === "claudeCode"`). | |
| 369 | 2. Event mapping to canonical runtime events. | |
| 370 | 3. Approval request lifecycle. | |
| 371 | 4. Resume/recovery behavior. | |
| 372 | 5. Rollback behavior. | |
| 373 | ||
| 374 | --- | |
| 375 | ||
| 376 | ## Phase 3: Register adapter in runtime composition | |
| 377 | ||
| 378 | ### 3.1 Adapter registry wiring | |
| 379 | ||
| 380 | Update: | |
| 381 | ||
| 382 | 1. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts` | |
| 383 | ||
| 384 | Register both Codex and Claude adapters. | |
| 385 | ||
| 386 | ### 3.2 Server layer composition | |
| 387 | ||
| 388 | Update: | |
| 389 | ||
| 390 | 1. `apps/server/src/serverLayers.ts` | |
| 391 | ||
| 392 | Provide `ClaudeCodeAdapterLive` alongside Codex in `makeServerProviderLayer()`. | |
| 393 | ||
| 394 | ### 3.3 Composition tests | |
| 395 | ||
| 396 | Update integration tests to ensure: | |
| 397 | ||
| 398 | 1. Registry exposes Claude provider. | |
| 399 | 2. Provider service can route Claude sessions. | |
| 400 | ||
| 401 | --- | |
| 402 | ||
| 403 | ## Phase 4: Orchestration command/reactor updates | |
| 404 | ||
| 405 | ### 4.1 Decider propagation | |
| 406 | ||
| 407 | Update `apps/server/src/orchestration/decider.ts`: | |
| 408 | ||
| 409 | 1. Carry optional `provider` from `thread.turn.start` command into `thread.turn-start-requested` event payload. | |
| 410 | ||
| 411 | ### 4.2 ProviderCommandReactor provider selection | |
| 412 | ||
| 413 | Update `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`: | |
| 414 | ||
| 415 | 1. Prefer provider from turn-start event payload when starting a new session. | |
| 416 | 2. Fallback to existing thread session provider when payload omitted. | |
| 417 | 3. Fallback to default provider only when neither is present. | |
| 418 | ||
| 419 | Switch behavior policy (explicit in implementation): | |
| 420 | ||
| 421 | 1. If active session provider differs from requested provider, stop and recreate session before sending turn. | |
| 422 | 2. Keep current provider when request omits provider. | |
| 423 | ||
| 424 | ### 4.3 Reactor/invariant tests | |
| 425 | ||
| 426 | Update/add tests in: | |
| 427 | ||
| 428 | 1. `apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts` | |
| 429 | 2. `apps/server/src/orchestration/*.test.ts` as needed | |
| 430 | ||
| 431 | Validate first-turn provider choice, provider switching semantics, and backward compatibility when provider is omitted. | |
| 432 | ||
| 433 | --- | |
| 434 | ||
| 435 | ## Phase 5: Web provider UX enablement | |
| 436 | ||
| 437 | ### 5.1 Enable Claude option | |
| 438 | ||
| 439 | Update `apps/web/src/session-logic.ts`: | |
| 440 | ||
| 441 | 1. Mark Claude provider option as available. | |
| 442 | ||
| 443 | ### 5.2 Provider selection in composer | |
| 444 | ||
| 445 | Update `apps/web/src/components/ChatView.tsx`: | |
| 446 | ||
| 447 | 1. Add provider picker near model/runtime controls. | |
| 448 | 2. Include selected provider in `thread.turn.start` command payload. | |
| 449 | 3. Keep selected provider sticky per thread in client state (or derive from active session provider when present). | |
| 450 | ||
| 451 | ### 5.3 Provider-aware model picker | |
| 452 | ||
| 453 | Update model picker usage so model options reflect selected provider. | |
| 454 | ||
| 455 | ### 5.4 Web tests | |
| 456 | ||
| 457 | Update/add tests for: | |
| 458 | ||
| 459 | 1. Provider picker interactions. | |
| 460 | 2. Turn-start command includes provider. | |
| 461 | 3. Model list switches by provider. | |
| 462 | ||
| 463 | --- | |
| 464 | ||
| 465 | ## Phase 6: Checkpoint and revert compatibility | |
| 466 | ||
| 467 | ### 6.1 Validate checkpoint reactor expectations | |
| 468 | ||
| 469 | Exercise `apps/server/src/orchestration/Layers/CheckpointReactor.ts` flows with Claude: | |
| 470 | ||
| 471 | 1. Turn baseline capture. | |
| 472 | 2. Turn completion checkpoint capture. | |
| 473 | 3. Revert + provider rollback coordination. | |
| 474 | ||
| 475 | ### 6.2 Policy for partial rollback support | |
| 476 | ||
| 477 | If Claude cannot support full conversation rewind at parity with Codex: | |
| 478 | ||
| 479 | 1. Document behavior clearly. | |
| 480 | 2. Emit explicit thread activity for degraded rollback behavior. | |
| 481 | 3. Ensure filesystem state remains correct and predictable. | |
| 482 | ||
| 483 | ### 6.3 Checkpoint tests | |
| 484 | ||
| 485 | Add/update tests in checkpoint + provider service integration suites for Claude revert behavior. | |
| 486 | ||
| 487 | --- | |
| 488 | ||
| 489 | ## Phase 7: End-to-end hardening and observability | |
| 490 | ||
| 491 | ### 7.1 Integration tests | |
| 492 | ||
| 493 | Add integration coverage for: | |
| 494 | ||
| 495 | 1. `thread.turn.start` with `provider: "claudeCode"` from fresh thread. | |
| 496 | 2. Session recovery after restart (provider session alias/recovery path). | |
| 497 | 3. Approval request/respond flow. | |
| 498 | 4. Interrupt behavior. | |
| 499 | 5. Revert flow with rollback. | |
| 500 | ||
| 501 | ### 7.2 WS behavior regression check | |
| 502 | ||
| 503 | Ensure no contract regressions: | |
| 504 | ||
| 505 | 1. Client still consumes only `orchestration.domainEvent`. | |
| 506 | 2. No new provider-specific WS channels introduced. | |
| 507 | ||
| 508 | ### 7.3 Logging | |
| 509 | ||
| 510 | Confirm both native and canonical provider logs remain useful with multi-adapter setup: | |
| 511 | ||
| 512 | 1. `provider-native.ndjson` | |
| 513 | 2. `provider-canonical.ndjson` | |
| 514 | ||
| 515 | --- | |
| 516 | ||
| 517 | ## File checklist | |
| 518 | ||
| 519 | Likely files to touch: | |
| 520 | ||
| 521 | 1. `packages/contracts/src/model.ts` | |
| 522 | 2. `packages/contracts/src/orchestration.ts` | |
| 523 | 3. `packages/contracts/src/provider.ts` | |
| 524 | 4. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` (new) | |
| 525 | 5. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` (new) | |
| 526 | 6. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts` | |
| 527 | 7. `apps/server/src/serverLayers.ts` | |
| 528 | 8. `apps/server/src/orchestration/decider.ts` | |
| 529 | 9. `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | |
| 530 | 10. `apps/web/src/session-logic.ts` | |
| 531 | 11. `apps/web/src/components/ChatView.tsx` | |
| 532 | 12. Related tests under `packages/contracts/src`, `apps/server/src/provider/Layers`, `apps/server/src/orchestration/Layers`, `apps/server/integration`, and `apps/web/src`. | |
| 533 | ||
| 534 | --- | |
| 535 | ||
| 536 | ## Delivery order | |
| 537 | ||
| 538 | 1. Contracts for provider selection + models. | |
| 539 | 2. Claude adapter + unit tests. | |
| 540 | 3. Registry/layer wiring. | |
| 541 | 4. Reactor updates for provider-aware session start. | |
| 542 | 5. Web provider picker + provider-aware models. | |
| 543 | 6. Checkpoint/revert compatibility. | |
| 544 | 7. End-to-end integration tests and stabilization. | |
| 545 | ||
| 546 | This order keeps risk isolated and maintains a working orchestrated path at each stage. |
.plans/spec-1-1-cutover-plan.md created+201| ... | ... | @@ -0,0 +1,201 @@ |
| 1 | # Spec 1:1 Cutover Plan | |
| 2 | ||
| 3 | Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. | |
| 4 | ||
| 5 | Execution mode for this plan: | |
| 6 | - Hard cutover only. Existing DB and migration history are disposable. | |
| 7 | - Intermediate steps are allowed to break runtime, tests, typecheck, and lint. | |
| 8 | - We optimize for small, reviewable work units, not continuous app operability. | |
| 9 | - Only the final gate requires everything to run cleanly. | |
| 10 | ||
| 11 | ## 1. Freeze SPEC contract as source of truth | |
| 12 | Work units: | |
| 13 | - Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. | |
| 14 | - Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. | |
| 15 | - Add app-level requirements per row: writer path, reader path, and owning module. | |
| 16 | - Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`. | |
| 17 | - Identify any ambiguous spec lines and record a concrete interpretation in the matrix. | |
| 18 | ||
| 19 | Deliverables: | |
| 20 | - Complete matrix file with no unclassified rows. | |
| 21 | - Single source checklist used by all later steps. | |
| 22 | ||
| 23 | Breakage allowed: | |
| 24 | - No code changes required yet. | |
| 25 | ||
| 26 | Exit criteria: | |
| 27 | - Every requirement in `7.1`-`7.4` has exactly one matrix row. | |
| 28 | ||
| 29 | ## 2. Hard cutover migrations (replace current migration set) | |
| 30 | Work units: | |
| 31 | - Delete the current legacy migration files and rewrite migration loader ordering. | |
| 32 | - Create `001_orchestration_events.ts` with full envelope columns and required event indexes. | |
| 33 | - Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. | |
| 34 | - Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`. | |
| 35 | - Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes. | |
| 36 | - Create `005_projections.ts` with all projection tables: | |
| 37 | - `projection_projects` | |
| 38 | - `projection_threads` | |
| 39 | - `projection_thread_messages` | |
| 40 | - `projection_thread_activities` | |
| 41 | - `projection_thread_sessions` | |
| 42 | - `projection_thread_turns` | |
| 43 | - `projection_checkpoints` | |
| 44 | - `projection_pending_approvals` | |
| 45 | - `projection_state` | |
| 46 | - Add all required indexes/constraints in `005_projections.ts`. | |
| 47 | - Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. | |
| 48 | ||
| 49 | Deliverables: | |
| 50 | - New 5-file migration chain. | |
| 51 | - Updated migration loader references only new migrations. | |
| 52 | ||
| 53 | Breakage allowed: | |
| 54 | - Repositories/services can be temporarily broken due to removed old tables. | |
| 55 | ||
| 56 | Exit criteria: | |
| 57 | - Fresh DB initializes with only canonical tables plus migration bookkeeping. | |
| 58 | ||
| 59 | ## 3. Align persistence row/request schemas to DB 1:1 | |
| 60 | Work units: | |
| 61 | - Define row schemas for each canonical table (contracts or persistence layer module). | |
| 62 | - Define request schemas for every insert/update/query operation touching canonical tables. | |
| 63 | - Remove or deprecate row/request schemas tied to deleted legacy tables. | |
| 64 | - Normalize enum and null semantics to match contracts exactly. | |
| 65 | - Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). | |
| 66 | ||
| 67 | Deliverables: | |
| 68 | - Canonical row/request schemas committed. | |
| 69 | - Zero references to legacy row schemas in active code paths. | |
| 70 | ||
| 71 | Breakage allowed: | |
| 72 | - Runtime can still fail while query layers are being rewired. | |
| 73 | ||
| 74 | Exit criteria: | |
| 75 | - Every canonical table used in code has a typed row schema and typed request schema. | |
| 76 | ||
| 77 | ## 4. Rewrite event store for full persisted envelope | |
| 78 | Work units: | |
| 79 | - Refactor append path to write full envelope fields: | |
| 80 | - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json` | |
| 81 | - Implement stream version assignment/checking per aggregate stream. | |
| 82 | - Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently. | |
| 83 | - Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor). | |
| 84 | - Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). | |
| 85 | ||
| 86 | Deliverables: | |
| 87 | - Event store append/replay fully aligned with canonical envelope. | |
| 88 | ||
| 89 | Breakage allowed: | |
| 90 | - Command dispatch flow can be partially broken until receipts/projectors are updated. | |
| 91 | ||
| 92 | Exit criteria: | |
| 93 | - Event store no longer depends on legacy event table shape. | |
| 94 | ||
| 95 | ## 5. Add command receipt idempotency | |
| 96 | Work units: | |
| 97 | - Introduce persistence access layer for `orchestration_command_receipts`. | |
| 98 | - In command dispatch flow, check existing receipt by `commandId` before append. | |
| 99 | - On first execution, persist accepted receipt with `resultSequence`. | |
| 100 | - On domain rejection, persist rejected receipt with error payload. | |
| 101 | - On duplicate command, return prior result from receipt without re-appending event. | |
| 102 | - Ensure receipt write and event append ordering is deterministic. | |
| 103 | ||
| 104 | Deliverables: | |
| 105 | - Dispatch path with idempotency behavior wired through receipts. | |
| 106 | ||
| 107 | Breakage allowed: | |
| 108 | - Snapshot/read model may still be inconsistent until projectors are fully wired. | |
| 109 | ||
| 110 | Exit criteria: | |
| 111 | - Duplicate command IDs no longer create duplicate events. | |
| 112 | ||
| 113 | ## 6. Build DB-backed projection pipeline | |
| 114 | Work units: | |
| 115 | - Create projector runner that consumes events and applies table-specific projections. | |
| 116 | - Implement projector handlers for each projection table. | |
| 117 | - For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. | |
| 118 | - Define projector names used in `projection_state` and make them stable constants. | |
| 119 | - Add replay bootstrap from event store to bring projections up to latest sequence on startup. | |
| 120 | - Add safe resume logic from projector `last_applied_sequence`. | |
| 121 | ||
| 122 | Deliverables: | |
| 123 | - Persistent projector pipeline writing all `projection_*` tables. | |
| 124 | ||
| 125 | Breakage allowed: | |
| 126 | - Web/API layer may still read old in-memory model until step 7. | |
| 127 | ||
| 128 | Exit criteria: | |
| 129 | - Events drive projection rows in DB; projection state advances transactionally. | |
| 130 | ||
| 131 | ## 7. Move RPC reads to projections and diff blobs | |
| 132 | Work units: | |
| 133 | - Implement snapshot query service reading only projection tables. | |
| 134 | - Build thread hydration from projection rows: messages, activities, checkpoints, session. | |
| 135 | - Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. | |
| 136 | - Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only. | |
| 137 | - Remove or bypass in-memory snapshot construction for RPC responses. | |
| 138 | - Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. | |
| 139 | ||
| 140 | Deliverables: | |
| 141 | - `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. | |
| 142 | ||
| 143 | Breakage allowed: | |
| 144 | - Provider runtime persistence may still be partially legacy until step 8. | |
| 145 | ||
| 146 | Exit criteria: | |
| 147 | - No orchestration read RPC depends on legacy tables or in-memory-only state. | |
| 148 | ||
| 149 | ## 8. Migrate provider runtime persistence to canonical table | |
| 150 | Work units: | |
| 151 | - Create repository/service for `provider_session_runtime`. | |
| 152 | - Update adapter/session manager to persist runtime/resume cursor in new table. | |
| 153 | - Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. | |
| 154 | - Remove writes to legacy provider session tables. | |
| 155 | - Verify restart/resume path reads runtime state from canonical table only. | |
| 156 | ||
| 157 | Deliverables: | |
| 158 | - Provider runtime state entirely backed by `provider_session_runtime`. | |
| 159 | ||
| 160 | Breakage allowed: | |
| 161 | - Some legacy interfaces may still exist but should be disconnected. | |
| 162 | ||
| 163 | Exit criteria: | |
| 164 | - Runtime restore no longer reads/writes legacy provider session persistence. | |
| 165 | ||
| 166 | ## 9. Remove old cruft aggressively | |
| 167 | Work units: | |
| 168 | - Delete legacy repositories/services that map to removed tables. | |
| 169 | - Remove dead migration imports and obsolete persistence service interfaces. | |
| 170 | - Remove compatibility code paths that translate legacy row shapes. | |
| 171 | - Remove unused contracts/types linked to deprecated persistence model. | |
| 172 | - Update internal docs/comments to reference canonical projection/event model only. | |
| 173 | ||
| 174 | Deliverables: | |
| 175 | - Legacy persistence and translation layers removed from active codebase. | |
| 176 | ||
| 177 | Breakage allowed: | |
| 178 | - Temporary compile failures acceptable while deletion/refactor is in progress. | |
| 179 | ||
| 180 | Exit criteria: | |
| 181 | - No production code path references deleted legacy tables/services. | |
| 182 | ||
| 183 | ## 10. Final verification gate (first point where green is required) | |
| 184 | Work units: | |
| 185 | - Add migration tests that assert canonical tables, columns, constraints, and indexes. | |
| 186 | - Add event store tests for envelope persistence, metadata, actor kind, and replay. | |
| 187 | - Add receipt idempotency tests for accept/reject/duplicate paths. | |
| 188 | - Add projector tests for transactional row updates + `projection_state` updates. | |
| 189 | - Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics. | |
| 190 | - Add turn diff tests verifying `checkpoint_diff_blobs` source of truth. | |
| 191 | - Add provider runtime tests for persist + restart + resume behavior. | |
| 192 | - Run project lint/typecheck/tests and fix failures. | |
| 193 | ||
| 194 | Deliverables: | |
| 195 | - Green checks with canonical schema + persistence model in place. | |
| 196 | ||
| 197 | Breakage allowed: | |
| 198 | - None at end of step. | |
| 199 | ||
| 200 | Exit criteria: | |
| 201 | - SPEC `7.1`-`7.4` requirements satisfied and validated by tests. |
.plans/spec-contract-matrix.md created+401| ... | ... | @@ -0,0 +1,401 @@ |
| 1 | # SPEC Contract Matrix (Sections 7.1-7.4) | |
| 2 | ||
| 3 | Status legend: | |
| 4 | - `required`: requirement acknowledged, no current implementation claim yet. | |
| 5 | - `implemented`: requirement currently satisfied in code + schema. | |
| 6 | - `to-replace`: partial/misaligned implementation exists and must be replaced. | |
| 7 | - `delete`: current path actively conflicts with SPEC and should be removed. | |
| 8 | ||
| 9 | ## 7.1 Write-Side Persisted Tables | |
| 10 | ||
| 11 | ### W1 | |
| 12 | - Spec ref: `7.1.1 orchestration_events` | |
| 13 | - Requirement: append-only event store with canonical envelope columns. | |
| 14 | - SQL contract: | |
| 15 | - `sequence INTEGER PRIMARY KEY` (global monotonic) | |
| 16 | - `event_id TEXT UNIQUE NOT NULL` | |
| 17 | - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` | |
| 18 | - `stream_id TEXT NOT NULL` | |
| 19 | - `stream_version INTEGER NOT NULL` | |
| 20 | - `event_type TEXT NOT NULL` | |
| 21 | - `occurred_at TEXT NOT NULL` | |
| 22 | - `command_id TEXT NULL` | |
| 23 | - `causation_event_id TEXT NULL` | |
| 24 | - `correlation_id TEXT NULL` | |
| 25 | - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')` | |
| 26 | - `payload_json TEXT NOT NULL` | |
| 27 | - `metadata_json TEXT NOT NULL` | |
| 28 | - Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` | |
| 29 | - Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` | |
| 30 | - Owner module: `apps/server/src/persistence` (event store + migrations) | |
| 31 | - Status: `to-replace` | |
| 32 | - Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. | |
| 33 | ||
| 34 | ### W2 | |
| 35 | - Spec ref: `7.1.2 orchestration_command_receipts` | |
| 36 | - Requirement: command idempotency + ack replay receipts table. | |
| 37 | - SQL contract: | |
| 38 | - `command_id TEXT PRIMARY KEY` | |
| 39 | - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` | |
| 40 | - `aggregate_id TEXT NOT NULL` | |
| 41 | - `accepted_at TEXT NOT NULL` | |
| 42 | - `result_sequence INTEGER NOT NULL` | |
| 43 | - `status TEXT NOT NULL CHECK IN ('accepted','rejected')` | |
| 44 | - `error TEXT NULL` | |
| 45 | - Current writer path: none | |
| 46 | - Current reader path: none | |
| 47 | - Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence` | |
| 48 | - Status: `to-replace` | |
| 49 | - Notes: missing table and missing idempotency flow. | |
| 50 | ||
| 51 | ### W3 | |
| 52 | - Spec ref: `7.1.3 checkpoint_diff_blobs` | |
| 53 | - Requirement: store large plaintext diffs separate from checkpoint summaries. | |
| 54 | - SQL contract: | |
| 55 | - `thread_id TEXT NOT NULL` | |
| 56 | - `from_turn_count INTEGER NOT NULL` | |
| 57 | - `to_turn_count INTEGER NOT NULL` | |
| 58 | - `diff TEXT NOT NULL` | |
| 59 | - `created_at TEXT NOT NULL` | |
| 60 | - `UNIQUE(thread_id, from_turn_count, to_turn_count)` | |
| 61 | - Current writer path: none | |
| 62 | - Current reader path: none | |
| 63 | - Owner module: `apps/server/src/persistence` + turn diff query service | |
| 64 | - Status: `to-replace` | |
| 65 | - Notes: no canonical diff blob table yet. | |
| 66 | ||
| 67 | ### W4 | |
| 68 | - Spec ref: `7.1.4 provider_session_runtime` | |
| 69 | - Requirement: server-internal provider runtime/resume state. | |
| 70 | - SQL contract: | |
| 71 | - `provider_session_id TEXT PRIMARY KEY` | |
| 72 | - `thread_id TEXT NOT NULL` | |
| 73 | - `provider_name TEXT NOT NULL` | |
| 74 | - `adapter_key TEXT NOT NULL` | |
| 75 | - `provider_thread_id TEXT NULL` | |
| 76 | - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')` | |
| 77 | - `last_seen_at TEXT NOT NULL` | |
| 78 | - `resume_cursor_json TEXT NULL` | |
| 79 | - `runtime_payload_json TEXT NULL` | |
| 80 | - Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) | |
| 81 | - Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) | |
| 82 | - Owner module: provider runtime manager + persistence runtime repository | |
| 83 | - Status: `to-replace` | |
| 84 | - Notes: existing `provider_sessions` schema is incompatible and too small. | |
| 85 | ||
| 86 | ## 7.2 Canonical Persisted Event Schema | |
| 87 | ||
| 88 | ### E1 | |
| 89 | - Spec ref: `7.2 OrchestrationPersistedEventSchema` | |
| 90 | - Requirement: full typed persisted event envelope in shared contracts. | |
| 91 | - SQL contract: envelope fields in W1 must map 1:1 to contracts schema. | |
| 92 | - Current writer path: contracts defined in `packages/contracts/src/orchestration.ts` | |
| 93 | - Current reader path: used by persistence decode boundaries (partial) | |
| 94 | - Owner module: `packages/contracts` | |
| 95 | - Status: `implemented` | |
| 96 | - Notes: contract schema exists; DB + store mapping still incomplete. | |
| 97 | ||
| 98 | ### E2 | |
| 99 | - Spec ref: `7.2 Rules/payload discriminated by eventType` | |
| 100 | - Requirement: `payload` validation keyed by `eventType`. | |
| 101 | - SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. | |
| 102 | - Current writer path: `packages/contracts/src/orchestration.ts` | |
| 103 | - Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path | |
| 104 | - Owner module: contracts + event store | |
| 105 | - Status: `to-replace` | |
| 106 | - Notes: decode is present but DB does not persist full envelope columns. | |
| 107 | ||
| 108 | ### E3 | |
| 109 | - Spec ref: `7.2 Rules/provider ids scope` | |
| 110 | - Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. | |
| 111 | - SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. | |
| 112 | - Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty) | |
| 113 | - Current reader path: projector/event consumers | |
| 114 | - Owner module: decider + provider ingestion + event store | |
| 115 | - Status: `to-replace` | |
| 116 | - Notes: metadata plumbing is incomplete in persistence path. | |
| 117 | ||
| 118 | ### E4 | |
| 119 | - Spec ref: `7.2 Rules/streamVersion concurrency guard` | |
| 120 | - Requirement: stream version monotonic per aggregate stream; enforced on write. | |
| 121 | - SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. | |
| 122 | - Current writer path: none | |
| 123 | - Current reader path: none | |
| 124 | - Owner module: event store append logic + DB constraints | |
| 125 | - Status: `to-replace` | |
| 126 | - Notes: no stream version assignment/checking today. | |
| 127 | ||
| 128 | ## 7.3 Required Projected Tables (Read Models) | |
| 129 | ||
| 130 | ### P1 | |
| 131 | - Spec ref: `7.3.1 projection_projects` | |
| 132 | - Requirement: persisted project projection table. | |
| 133 | - SQL contract: | |
| 134 | - `project_id TEXT PRIMARY KEY` | |
| 135 | - `title TEXT NOT NULL` | |
| 136 | - `workspace_root TEXT NOT NULL` | |
| 137 | - `default_model TEXT NULL` | |
| 138 | - `created_at TEXT NOT NULL` | |
| 139 | - `updated_at TEXT NOT NULL` | |
| 140 | - `deleted_at TEXT NULL` | |
| 141 | - Current writer path: none (in-memory projector only) | |
| 142 | - Current reader path: none (snapshot not DB-projected) | |
| 143 | - Owner module: projector pipeline + snapshot query | |
| 144 | - Status: `to-replace` | |
| 145 | - Notes: legacy `projects` table is separate concept and should be removed from orchestration model. | |
| 146 | ||
| 147 | ### P2 | |
| 148 | - Spec ref: `7.3.2 projection_threads` | |
| 149 | - Requirement: persisted thread projection table. | |
| 150 | - SQL contract: | |
| 151 | - `thread_id TEXT PRIMARY KEY` | |
| 152 | - `project_id TEXT NOT NULL` | |
| 153 | - `title TEXT NOT NULL` | |
| 154 | - `model TEXT NOT NULL` | |
| 155 | - `branch TEXT NULL` | |
| 156 | - `worktree_path TEXT NULL` | |
| 157 | - `latest_turn_id TEXT NULL` | |
| 158 | - `created_at TEXT NOT NULL` | |
| 159 | - `updated_at TEXT NOT NULL` | |
| 160 | - `deleted_at TEXT NULL` | |
| 161 | - Current writer path: none (in-memory projector only) | |
| 162 | - Current reader path: none (snapshot not DB-projected) | |
| 163 | - Owner module: projector pipeline + snapshot query | |
| 164 | - Status: `to-replace` | |
| 165 | - Notes: missing table and projector writes. | |
| 166 | ||
| 167 | ### P3 | |
| 168 | - Spec ref: `7.3.3 projection_thread_messages` | |
| 169 | - Requirement: persisted thread message projection table. | |
| 170 | - SQL contract: | |
| 171 | - `message_id TEXT PRIMARY KEY` | |
| 172 | - `thread_id TEXT NOT NULL` | |
| 173 | - `turn_id TEXT NULL` | |
| 174 | - `role TEXT NOT NULL CHECK IN ('user','assistant','system')` | |
| 175 | - `text TEXT NOT NULL` | |
| 176 | - `is_streaming INTEGER/BOOLEAN NOT NULL` | |
| 177 | - `created_at TEXT NOT NULL` | |
| 178 | - `updated_at TEXT NOT NULL` | |
| 179 | - Current writer path: none (in-memory projector only) | |
| 180 | - Current reader path: none (snapshot not DB-projected) | |
| 181 | - Owner module: projector pipeline + snapshot query | |
| 182 | - Status: `to-replace` | |
| 183 | - Notes: missing table and message projection writes. | |
| 184 | ||
| 185 | ### P4 | |
| 186 | - Spec ref: `7.3.4 projection_thread_activities` | |
| 187 | - Requirement: persisted thread activity projection table. | |
| 188 | - SQL contract: | |
| 189 | - `activity_id TEXT PRIMARY KEY` | |
| 190 | - `thread_id TEXT NOT NULL` | |
| 191 | - `turn_id TEXT NULL` | |
| 192 | - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')` | |
| 193 | - `kind TEXT NOT NULL` | |
| 194 | - `summary TEXT NOT NULL` | |
| 195 | - `payload_json TEXT NOT NULL` | |
| 196 | - `created_at TEXT NOT NULL` | |
| 197 | - Current writer path: none (in-memory projector only) | |
| 198 | - Current reader path: none (snapshot not DB-projected) | |
| 199 | - Owner module: projector pipeline + snapshot query | |
| 200 | - Status: `to-replace` | |
| 201 | - Notes: no canonical activity projection persistence. | |
| 202 | ||
| 203 | ### P5 | |
| 204 | - Spec ref: `7.3.5 projection_thread_sessions` | |
| 205 | - Requirement: persisted thread session projection table. | |
| 206 | - SQL contract: | |
| 207 | - `thread_id TEXT PRIMARY KEY` | |
| 208 | - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')` | |
| 209 | - `provider_name TEXT NULL` | |
| 210 | - `provider_session_id TEXT NULL` | |
| 211 | - `provider_thread_id TEXT NULL` | |
| 212 | - `active_turn_id TEXT NULL` | |
| 213 | - `last_error TEXT NULL` | |
| 214 | - `updated_at TEXT NOT NULL` | |
| 215 | - Current writer path: none (in-memory projector only) | |
| 216 | - Current reader path: none (snapshot not DB-projected) | |
| 217 | - Owner module: projector pipeline + snapshot query | |
| 218 | - Status: `to-replace` | |
| 219 | - Notes: current provider session table is not this domain projection. | |
| 220 | ||
| 221 | ### P6 | |
| 222 | - Spec ref: `7.3.6 projection_thread_turns` | |
| 223 | - Requirement: persisted thread turn projection table. | |
| 224 | - SQL contract: | |
| 225 | - `turn_id TEXT PRIMARY KEY` | |
| 226 | - `thread_id TEXT NOT NULL` | |
| 227 | - `turn_count INTEGER NOT NULL` | |
| 228 | - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')` | |
| 229 | - `user_message_id TEXT NULL` | |
| 230 | - `assistant_message_id TEXT NULL` | |
| 231 | - `started_at TEXT NOT NULL` | |
| 232 | - `completed_at TEXT NULL` | |
| 233 | - Current writer path: none | |
| 234 | - Current reader path: none | |
| 235 | - Owner module: projector pipeline + session/turn query helpers | |
| 236 | - Status: `to-replace` | |
| 237 | - Notes: missing table and projection logic. | |
| 238 | ||
| 239 | ### P7 | |
| 240 | - Spec ref: `7.3.7 projection_checkpoints` | |
| 241 | - Requirement: persisted checkpoint summary projection table. | |
| 242 | - SQL contract: | |
| 243 | - `thread_id TEXT NOT NULL` | |
| 244 | - `turn_id TEXT NOT NULL` | |
| 245 | - `checkpoint_turn_count INTEGER NOT NULL` | |
| 246 | - `checkpoint_ref TEXT NOT NULL` | |
| 247 | - `status TEXT NOT NULL CHECK IN ('ready','missing','error')` | |
| 248 | - `files_json TEXT NOT NULL` | |
| 249 | - `assistant_message_id TEXT NULL` | |
| 250 | - `completed_at TEXT NOT NULL` | |
| 251 | - `UNIQUE(thread_id, checkpoint_turn_count)` | |
| 252 | - Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts` | |
| 253 | - Current reader path: legacy checkpoint repository | |
| 254 | - Owner module: projector pipeline + checkpoint query layer | |
| 255 | - Status: `to-replace` | |
| 256 | - Notes: current table semantics do not match canonical checkpoint projection schema. | |
| 257 | ||
| 258 | ### P8 | |
| 259 | - Spec ref: `7.3.8 projection_pending_approvals` | |
| 260 | - Requirement: persisted pending-approval projection table. | |
| 261 | - SQL contract: | |
| 262 | - `request_id TEXT PRIMARY KEY` | |
| 263 | - `thread_id TEXT NOT NULL` | |
| 264 | - `turn_id TEXT NULL` | |
| 265 | - `status TEXT NOT NULL CHECK IN ('pending','resolved')` | |
| 266 | - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')` | |
| 267 | - `created_at TEXT NOT NULL` | |
| 268 | - `resolved_at TEXT NULL` | |
| 269 | - Current writer path: none | |
| 270 | - Current reader path: none | |
| 271 | - Owner module: projector pipeline + approval query layer | |
| 272 | - Status: `to-replace` | |
| 273 | - Notes: missing table and projection logic. | |
| 274 | ||
| 275 | ### P9 | |
| 276 | - Spec ref: `7.3.9 projection_state` | |
| 277 | - Requirement: projector progress tracking table. | |
| 278 | - SQL contract: | |
| 279 | - `projector TEXT PRIMARY KEY` | |
| 280 | - `last_applied_sequence INTEGER NOT NULL` | |
| 281 | - `updated_at TEXT NOT NULL` | |
| 282 | - Current writer path: none | |
| 283 | - Current reader path: none | |
| 284 | - Owner module: projector runner/checkpointing | |
| 285 | - Status: `to-replace` | |
| 286 | - Notes: missing table and projector bookkeeping. | |
| 287 | ||
| 288 | ### P10 | |
| 289 | - Spec ref: `7.3 Projection consistency rules` | |
| 290 | - Requirement: projector row updates and `projection_state` update must be atomic per event. | |
| 291 | - SQL contract: per-projector transaction boundary covering both projection write and state update. | |
| 292 | - Current writer path: none (in-memory projector has no SQL transaction) | |
| 293 | - Current reader path: none | |
| 294 | - Owner module: projector runner | |
| 295 | - Status: `to-replace` | |
| 296 | - Notes: requires transactional projection executor. | |
| 297 | ||
| 298 | ### P11 | |
| 299 | - Spec ref: `7.3 Optional debug field` | |
| 300 | - Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. | |
| 301 | - SQL contract: optional; not required in baseline schema. | |
| 302 | - Current writer path: none | |
| 303 | - Current reader path: none | |
| 304 | - Owner module: projector runner | |
| 305 | - Status: `required` | |
| 306 | - Notes: interpretation: exclude from first cutover unless debugging requires it. | |
| 307 | ||
| 308 | ## 7.4 Snapshot and RPC Requirements | |
| 309 | ||
| 310 | ### R1 | |
| 311 | - Spec ref: `7.4.1` | |
| 312 | - Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. | |
| 313 | - SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. | |
| 314 | - Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts` | |
| 315 | - Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel` | |
| 316 | - Owner module: snapshot query service + ws RPC handler | |
| 317 | - Status: `delete` | |
| 318 | - Notes: current in-memory read model path must be removed for SPEC compliance. | |
| 319 | ||
| 320 | ### R2 | |
| 321 | - Spec ref: `7.4.2` | |
| 322 | - Requirement: snapshot `projects[]` source is `projection_projects`. | |
| 323 | - SQL contract: `projects` collection assembled from `projection_projects` rows. | |
| 324 | - Current writer path: none | |
| 325 | - Current reader path: in-memory thread/project arrays | |
| 326 | - Owner module: snapshot query service | |
| 327 | - Status: `to-replace` | |
| 328 | - Notes: no DB project projection reader exists yet. | |
| 329 | ||
| 330 | ### R3 | |
| 331 | - Spec ref: `7.4.3` | |
| 332 | - Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. | |
| 333 | - SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. | |
| 334 | - Current writer path: legacy checkpoint repo data model | |
| 335 | - Current reader path: in-memory checkpoints from orchestration events | |
| 336 | - Owner module: snapshot query service + checkpoint projector | |
| 337 | - Status: `to-replace` | |
| 338 | - Notes: canonical projection table and reader not implemented. | |
| 339 | ||
| 340 | ### R4 | |
| 341 | - Spec ref: `7.4.4` | |
| 342 | - Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. | |
| 343 | - SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. | |
| 344 | - Current writer path: none for diff blobs | |
| 345 | - Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete | |
| 346 | - Owner module: ws RPC handler + diff query service | |
| 347 | - Status: `to-replace` | |
| 348 | - Notes: current checkpoint repository is not canonical source. | |
| 349 | ||
| 350 | ### R5 | |
| 351 | - Spec ref: `7.4.5` | |
| 352 | - Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. | |
| 353 | - SQL contract: session lookup by `thread_id` from projection table. | |
| 354 | - Current writer path: mixed provider/session handling paths | |
| 355 | - Current reader path: legacy provider session persistence lookups | |
| 356 | - Owner module: provider dispatch/session resolution | |
| 357 | - Status: `to-replace` | |
| 358 | - Notes: remove provider-session-as-routing-key behavior. | |
| 359 | ||
| 360 | ### R6 | |
| 361 | - Spec ref: `7.4.6` | |
| 362 | - Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. | |
| 363 | - SQL contract: `MIN(last_applied_sequence)` across required projector keys. | |
| 364 | - Current writer path: none | |
| 365 | - Current reader path: currently from in-memory event projection sequence | |
| 366 | - Owner module: snapshot query service | |
| 367 | - Status: `to-replace` | |
| 368 | - Notes: must move from in-memory sequence to DB projection-state semantics. | |
| 369 | ||
| 370 | ### R7 | |
| 371 | - Spec ref: `7.4.7` | |
| 372 | - Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). | |
| 373 | - SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. | |
| 374 | - Current writer path: event stream via `OrchestrationEventStore.readFromSequence` | |
| 375 | - Current reader path: ws replay flow in `apps/server/src/wsServer.ts` | |
| 376 | - Owner module: ws RPC + event stream handoff layer | |
| 377 | - Status: `to-replace` | |
| 378 | - Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent). | |
| 379 | ||
| 380 | ## Ambiguous/Interpretation Decisions (tracked upfront) | |
| 381 | ||
| 382 | ### A1 | |
| 383 | - Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. | |
| 384 | - Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. | |
| 385 | ||
| 386 | ### A2 | |
| 387 | - Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. | |
| 388 | - Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. | |
| 389 | ||
| 390 | ### A3 | |
| 391 | - Topic: `snapshotSequence` dependency set for min-sequence computation. | |
| 392 | - Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). | |
| 393 | ||
| 394 | ### A4 | |
| 395 | - Topic: no-gap handoff mechanism in `7.4.7`. | |
| 396 | - Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. | |
| 397 | ||
| 398 | ## Checklist Completeness Statement | |
| 399 | - Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. | |
| 400 | - Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. | |
| 401 | - Unclassified rows: `0`. |
.test-favicon.mjs created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import path from "path"; | |
| 2 | import fs from "fs"; | |
| 3 | ||
| 4 | const FAVICON_CANDIDATES = [ | |
| 5 | "favicon.svg", "favicon.ico", "favicon.png", | |
| 6 | "public/favicon.svg", "public/favicon.ico", "public/favicon.png", | |
| 7 | "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico", | |
| 8 | "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", | |
| 9 | ]; | |
| 10 | ||
| 11 | const ICON_SOURCE_FILES = [ | |
| 12 | "index.html", "public/index.html", | |
| 13 | "app/routes/__root.tsx", "src/routes/__root.tsx", | |
| 14 | "app/root.tsx", "src/root.tsx", "src/index.html", | |
| 15 | ]; | |
| 16 | ||
| 17 | const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | |
| 18 | const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | |
| 19 | ||
| 20 | function test(projectCwd) { | |
| 21 | console.log("--- Testing:", projectCwd, "---"); | |
| 22 | ||
| 23 | for (const c of FAVICON_CANDIDATES) { | |
| 24 | const full = path.join(projectCwd, c); | |
| 25 | if (fs.existsSync(full) && fs.statSync(full).isFile()) { | |
| 26 | console.log(" Phase 1 FOUND:", c); | |
| 27 | return; | |
| 28 | } | |
| 29 | } | |
| 30 | console.log(" Phase 1: no match"); | |
| 31 | ||
| 32 | for (const sf of ICON_SOURCE_FILES) { | |
| 33 | const full = path.join(projectCwd, sf); | |
| 34 | if (!fs.existsSync(full)) continue; | |
| 35 | const content = fs.readFileSync(full, "utf8"); | |
| 36 | const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1]; | |
| 37 | if (href) { | |
| 38 | const clean = href.replace(/^\//, ""); | |
| 39 | const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | |
| 40 | for (const p of tryPaths) { | |
| 41 | if (fs.existsSync(p)) { | |
| 42 | console.log(" Phase 2 FOUND:", sf, "->", href, "->", p); | |
| 43 | return; | |
| 44 | } | |
| 45 | } | |
| 46 | console.log(" Phase 2 href found but file missing:", href); | |
| 47 | } | |
| 48 | } | |
| 49 | console.log(" Phase 2: no match"); | |
| 50 | } | |
| 51 | ||
| 52 | function getTestPaths() { | |
| 53 | const cliPaths = process.argv.slice(2).map((p) => p.trim()).filter(Boolean); | |
| 54 | if (cliPaths.length > 0) return cliPaths; | |
| 55 | ||
| 56 | const envPaths = process.env.TEST_PATHS?.split(path.delimiter).map((p) => p.trim()).filter(Boolean); | |
| 57 | if (envPaths?.length) return envPaths; | |
| 58 | ||
| 59 | return [process.cwd()]; | |
| 60 | } | |
| 61 | ||
| 62 | for (const p of getTestPaths()) { | |
| 63 | test(path.resolve(p)); | |
| 64 | } |
.vscode/settings.json+2-1| ... | ... | @@ -4,5 +4,6 @@ |
| 4 | 4 | "editor.codeActionsOnSave": { |
| 5 | 5 | "source.fixAll.oxc": "always" |
| 6 | 6 | }, |
| 7 | "oxc.unusedDisableDirectives": "warn" | |
| 7 | "oxc.unusedDisableDirectives": "warn", | |
| 8 | "typescript.tsdk": "node_modules/typescript/lib" | |
| 8 | 9 | } |
AGENTS.md+1-1| ... | ... | @@ -29,7 +29,7 @@ How we use it in this codebase: |
| 29 | 29 | - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. |
| 30 | 30 | - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. |
| 31 | 31 | - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. |
| 32 | - Web app consumes provider event streams via WebSocket push on channel `providers.event`. | |
| 32 | - Web app consumes orchestration domain events via WebSocket push on channel `orchestration.domainEvent` (provider runtime activity is projected into orchestration events server-side). | |
| 33 | 33 | |
| 34 | 34 | Docs: |
| 35 | 35 |
README.md+6-3| ... | ... | @@ -68,6 +68,9 @@ npx t3 |
| 68 | 68 | - `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. |
| 69 | 69 | - `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). |
| 70 | 70 | - `bun run dev:web` — Starts just the Vite dev server for the web app. |
| 71 | - Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. | |
| 72 | - Override server CLI-equivalent flags from root dev commands with `--`, for example: | |
| 73 | `bun run dev -- --state-dir ~/.t3/another-dev-state` | |
| 71 | 74 | - `bun run start` — Runs the production server (serves built web app as static files). |
| 72 | 75 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 73 | 76 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| ... | ... | @@ -88,7 +91,7 @@ npx t3 |
| 88 | 91 | |
| 89 | 92 | Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. |
| 90 | 93 | |
| 91 | - Default ports: server `3773`, web `5173` | |
| 94 | - Default ports: server `3773`, web `5733` | |
| 92 | 95 | - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) |
| 93 | 96 | - Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` |
| 94 | 97 | |
| ... | ... | @@ -106,12 +109,12 @@ T3 Code has a global runtime mode switch in the chat toolbar: |
| 106 | 109 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: |
| 107 | 110 | |
| 108 | 111 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` |
| 109 | - **Push events**: `{ type: "push", channel, data }` for streaming provider events | |
| 112 | - **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates | |
| 110 | 113 | |
| 111 | 114 | Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: |
| 112 | 115 | |
| 113 | 116 | - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` |
| 114 | - `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` | |
| 117 | - `providers.respondToRequest`, `providers.stopSession` | |
| 115 | 118 | - `shell.openInEditor`, `server.getConfig` |
| 116 | 119 | |
| 117 | 120 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. |
REMOTE.md created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | # Remote Access Setup | |
| 2 | ||
| 3 | Use this when you want to open T3 Code from another device (phone, tablet, another laptop). | |
| 4 | ||
| 5 | ## CLI ↔ Env option map | |
| 6 | ||
| 7 | The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: | |
| 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 | | `--state-dir <path>` | `T3CODE_STATE_DIR` | State 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. | | |
| 18 | ||
| 19 | > TIP: Use the `--help` flag to see all available options and their descriptions. | |
| 20 | ||
| 21 | ## Security First | |
| 22 | ||
| 23 | - Always set `--auth-token` before exposing the server outside localhost. | |
| 24 | - Treat the token like a password. | |
| 25 | - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. | |
| 26 | ||
| 27 | ## 1) Build + run server for remote access | |
| 28 | ||
| 29 | Remote access should use the built web app (not local Vite redirect mode). | |
| 30 | ||
| 31 | ```bash | |
| 32 | bun run build | |
| 33 | TOKEN="$(openssl rand -hex 24)" | |
| 34 | bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser | |
| 35 | ``` | |
| 36 | ||
| 37 | Then open on your phone: | |
| 38 | ||
| 39 | `http://<your-machine-ip>:3773` | |
| 40 | ||
| 41 | Example: | |
| 42 | ||
| 43 | `http://192.168.1.42:3773` | |
| 44 | ||
| 45 | Notes: | |
| 46 | ||
| 47 | - `--host 0.0.0.0` listens on all IPv4 interfaces. | |
| 48 | - `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. | |
| 49 | - Ensure your OS firewall allows inbound TCP on the selected port. | |
| 50 | ||
| 51 | ## 2) Tailnet / Tailscale access | |
| 52 | ||
| 53 | If you use Tailscale, you can bind directly to your Tailnet address. | |
| 54 | ||
| 55 | ```bash | |
| 56 | TAILNET_IP="$(tailscale ip -4)" | |
| 57 | TOKEN="$(openssl rand -hex 24)" | |
| 58 | bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser | |
| 59 | ``` | |
| 60 | ||
| 61 | Open from any device in your tailnet: | |
| 62 | ||
| 63 | `http://<tailnet-ip>:3773` | |
| 64 | ||
| 65 | You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. |
SPEC.md created+582| ... | ... | @@ -0,0 +1,582 @@ |
| 1 | # Orchestration Interface Spec (Required State) | |
| 2 | ||
| 3 | Status: Required target architecture (not a description of current behavior). | |
| 4 | ||
| 5 | ## 1. Identifier Schema (Branded) | |
| 6 | ||
| 7 | All identifiers are branded and never plain `string` in schema definitions. | |
| 8 | ||
| 9 | ```ts | |
| 10 | const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); | |
| 11 | const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); | |
| 12 | const CommandId = Schema.String.pipe(Schema.brand("CommandId")); | |
| 13 | const EventId = Schema.String.pipe(Schema.brand("EventId")); | |
| 14 | const MessageId = Schema.String.pipe(Schema.brand("MessageId")); | |
| 15 | const TurnId = Schema.String.pipe(Schema.brand("TurnId")); | |
| 16 | ||
| 17 | const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); | |
| 18 | const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); | |
| 19 | const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); | |
| 20 | const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); | |
| 21 | const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); | |
| 22 | ||
| 23 | const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); | |
| 24 | ``` | |
| 25 | ||
| 26 | Rules: | |
| 27 | ||
| 28 | - `ThreadId` is app/orchestration thread identity. | |
| 29 | - `ProviderThreadId` is provider runtime thread identity. | |
| 30 | - They are never assigned to each other. | |
| 31 | - `ProviderSessionId` is internal server/provider routing identity; clients do not send it. | |
| 32 | - use e.g. `ThreadId.make(randomUUID())` to create new entity ids at the source (e.g. client create thread id and send to server, provider adapter creates the provider thread id from the codex app server etc) | |
| 33 | ||
| 34 | ## 2. Client Commands (Domain Commands) | |
| 35 | ||
| 36 | All commands are sent through one RPC: `orchestration.dispatchCommand`. | |
| 37 | ||
| 38 | Output for every command: `DispatchResultSchema = { sequence: number }`. | |
| 39 | ||
| 40 | ### 2.1 Client-Dispatchable Commands | |
| 41 | ||
| 42 | 1. `project.create` | |
| 43 | ||
| 44 | - Input: | |
| 45 | - `commandId: CommandId` | |
| 46 | - `projectId: ProjectId` | |
| 47 | - `title: string` | |
| 48 | - `workspaceRoot: string` | |
| 49 | - `defaultModel?: string` | |
| 50 | - `createdAt: IsoDateTime` | |
| 51 | - Output: `DispatchResult` | |
| 52 | ||
| 53 | 2. `project.meta.update` | |
| 54 | ||
| 55 | - Input: | |
| 56 | - `commandId: CommandId` | |
| 57 | - `projectId: ProjectId` | |
| 58 | - optional `{ title, workspaceRoot, defaultModel, scripts }` | |
| 59 | - Output: `DispatchResult` | |
| 60 | ||
| 61 | 3. `project.delete` | |
| 62 | ||
| 63 | - Input: `commandId`, `projectId` | |
| 64 | - Output: `DispatchResult` | |
| 65 | ||
| 66 | 4. `thread.create` | |
| 67 | ||
| 68 | - Input: | |
| 69 | - `commandId: CommandId` | |
| 70 | - `threadId: ThreadId` | |
| 71 | - `projectId: ProjectId` | |
| 72 | - `title: string` | |
| 73 | - `model: string` | |
| 74 | - `branch: string | null` | |
| 75 | - `worktreePath: string | null` | |
| 76 | - `createdAt: IsoDateTime` | |
| 77 | - Output: `DispatchResult` | |
| 78 | ||
| 79 | 5. `thread.delete` | |
| 80 | ||
| 81 | - Input: `commandId`, `threadId` | |
| 82 | - Output: `DispatchResult` | |
| 83 | ||
| 84 | 6. `thread.meta.update` | |
| 85 | ||
| 86 | - Input: `commandId`, `threadId`, optional `{ title, model, branch, worktreePath }` | |
| 87 | - Output: `DispatchResult` | |
| 88 | ||
| 89 | 7. `thread.turn.start` | |
| 90 | ||
| 91 | - Input: | |
| 92 | - `commandId` | |
| 93 | - `threadId` | |
| 94 | - `message`: | |
| 95 | - `messageId: MessageId` | |
| 96 | - `role: "user"` | |
| 97 | - `text: string` | |
| 98 | - `attachments: ChatAttachment[]` (schema-shared) | |
| 99 | - `model?: string` | |
| 100 | - `effort?: string` | |
| 101 | - `createdAt` | |
| 102 | - Output: `DispatchResult` | |
| 103 | ||
| 104 | 8. `thread.turn.interrupt` | |
| 105 | ||
| 106 | - Input: `commandId`, `threadId`, optional `turnId: TurnId`, `createdAt` | |
| 107 | - Output: `DispatchResult` | |
| 108 | ||
| 109 | 9. `thread.approval.respond` | |
| 110 | ||
| 111 | - Input: | |
| 112 | - `commandId` | |
| 113 | - `threadId` | |
| 114 | - `requestId: ApprovalRequestId` | |
| 115 | - `decision: "accept" | "acceptForSession" | "decline" | "cancel"` | |
| 116 | - `createdAt` | |
| 117 | - Output: `DispatchResult` | |
| 118 | ||
| 119 | 10. `thread.checkpoint.revert` | |
| 120 | ||
| 121 | - Input: `commandId`, `threadId`, `turnCount: number`, `createdAt` | |
| 122 | - Output: `DispatchResult` | |
| 123 | ||
| 124 | 11. `thread.session.stop` | |
| 125 | ||
| 126 | - Input: `commandId`, `threadId`, `createdAt` | |
| 127 | - Output: `DispatchResult` | |
| 128 | ||
| 129 | ### 2.2 Internal-Only Commands (Not Client Dispatchable) | |
| 130 | ||
| 131 | 1. `thread.session.set` | |
| 132 | ||
| 133 | - Server-owned projection update for session lifecycle. | |
| 134 | ||
| 135 | 2. `thread.message.assistant.delta` | |
| 136 | ||
| 137 | - Server-owned incremental assistant content append. | |
| 138 | ||
| 139 | 3. `thread.message.assistant.complete` | |
| 140 | ||
| 141 | - Server-owned assistant message completion marker. | |
| 142 | ||
| 143 | 4. `thread.turn.diff.complete` | |
| 144 | ||
| 145 | - Server-owned checkpoint diff summary write. | |
| 146 | ||
| 147 | 5. `thread.activity.append` | |
| 148 | ||
| 149 | - Server-owned activity feed append. | |
| 150 | ||
| 151 | ## 3. Client RPC (Required) | |
| 152 | ||
| 153 | These are the required client-facing RPC methods. | |
| 154 | ||
| 155 | 1. `orchestration.getSnapshot` | |
| 156 | ||
| 157 | - Input: `{}` | |
| 158 | - Output: `OrchestrationReadModel` | |
| 159 | ||
| 160 | 2. `orchestration.dispatchCommand` | |
| 161 | ||
| 162 | - Input: `ClientOrchestrationCommand` (`Schema.Union` of all client-dispatchable commands) | |
| 163 | - Output: `DispatchResult` | |
| 164 | ||
| 165 | 3. `orchestration.getTurnDiff` | |
| 166 | ||
| 167 | - Input: | |
| 168 | - `threadId: ThreadId` | |
| 169 | - `fromTurnCount: number` | |
| 170 | - `toTurnCount: number` | |
| 171 | - Output: | |
| 172 | - `threadId: ThreadId` | |
| 173 | - `fromTurnCount: number` | |
| 174 | - `toTurnCount: number` | |
| 175 | - `diff: string` | |
| 176 | ||
| 177 | 4. `orchestration.replayEvents` (optional operational/debug API) | |
| 178 | ||
| 179 | - Input: `{ fromSequenceExclusive: number }` | |
| 180 | - Output: `OrchestrationEvent[]` | |
| 181 | ||
| 182 | ### 3.1 RPC To Remove | |
| 183 | ||
| 184 | Remove from client boundary: | |
| 185 | ||
| 186 | - `providers.startSession` | |
| 187 | - `providers.sendTurn` | |
| 188 | - `providers.interruptTurn` | |
| 189 | - `providers.respondToRequest` | |
| 190 | - `providers.stopSession` | |
| 191 | - `providers.listCheckpoints` | |
| 192 | - `providers.getCheckpointDiff` | |
| 193 | - `providers.revertToCheckpoint` | |
| 194 | ||
| 195 | Rationale: | |
| 196 | ||
| 197 | - Session/turn/checkpoint control belongs to orchestrator command path. | |
| 198 | - Snapshot already includes checkpoint summaries (`checkpoints`); only full textual diff needs explicit query RPC. | |
| 199 | ||
| 200 | ## 4. Server Orchestrator Command <-> Event Schemas | |
| 201 | ||
| 202 | Each command deterministically yields one or more domain events that are persisted to the event store. | |
| 203 | ||
| 204 | 1. `project.create` -> `project.created` | |
| 205 | 2. `project.meta.update` -> `project.meta-updated` | |
| 206 | 3. `project.delete` -> `project.deleted` | |
| 207 | 4. `thread.create` -> `thread.created` | |
| 208 | 5. `thread.delete` -> `thread.deleted` | |
| 209 | 6. `thread.meta.update` -> `thread.meta-updated` | |
| 210 | 7. `thread.turn.start` -> | |
| 211 | ||
| 212 | - `thread.message-sent` (user message append) | |
| 213 | - `thread.turn-start-requested` | |
| 214 | - then (async side effect success) `thread.session-set` + provider-driven message/turn events | |
| 215 | ||
| 216 | 8. `thread.turn.interrupt` -> `thread.turn-interrupt-requested` | |
| 217 | 9. `thread.approval.respond` -> `thread.approval-response-requested` | |
| 218 | 10. `thread.checkpoint.revert` -> `thread.checkpoint-revert-requested` then `thread.reverted` | |
| 219 | 11. `thread.session.stop` -> `thread.session-stop-requested` then `thread.session-set` | |
| 220 | ||
| 221 | Internal command/event path: | |
| 222 | ||
| 223 | - `thread.session.set` -> `thread.session-set` | |
| 224 | - `thread.message.assistant.delta` -> `thread.message-sent` (assistant streaming) | |
| 225 | - `thread.message.assistant.complete` -> `thread.message-sent` (streaming false) | |
| 226 | - `thread.turn.diff.complete` -> `thread.turn-diff-completed` | |
| 227 | - `thread.activity.append` -> `thread.activity-appended` | |
| 228 | ||
| 229 | ## 5. Orchestration Events <-> Generic Provider Events <-> Raw Provider Events | |
| 230 | ||
| 231 | Required mapping pipeline: | |
| 232 | ||
| 233 | `Raw Provider Event (adapter-specific)` | |
| 234 | -> `GenericProviderRuntimeEvent` (provider-agnostic schema) | |
| 235 | -> `Internal Orchestration Command` | |
| 236 | -> `Orchestration Domain Event` | |
| 237 | ||
| 238 | ### 5.1 Mapping Table for Codex Adapter | |
| 239 | ||
| 240 | 1. Raw `thread/started` | |
| 241 | ||
| 242 | - Generic: `thread.started` | |
| 243 | - Internal command: `thread.session.set` (attach `ProviderThreadId`, status `ready|running`) | |
| 244 | - Domain event: `thread.session-set` | |
| 245 | ||
| 246 | 2. Raw `turn/started` | |
| 247 | ||
| 248 | - Generic: `turn.started` | |
| 249 | - Internal command: `thread.session.set` (status `running`, active turn) | |
| 250 | - Domain event: `thread.session-set` | |
| 251 | ||
| 252 | 3. Raw `item/agentMessage/delta` | |
| 253 | ||
| 254 | - Generic: `message.delta` | |
| 255 | - Internal command: `thread.message.assistant.delta` | |
| 256 | - Domain event: `thread.message-sent` (assistant, streaming) | |
| 257 | ||
| 258 | 4. Raw `item/completed` (agent message) | |
| 259 | ||
| 260 | - Generic: `message.completed` | |
| 261 | - Internal command: `thread.message.assistant.complete` | |
| 262 | - Domain event: `thread.message-sent` (assistant, completed) | |
| 263 | ||
| 264 | 5. Raw `turn/completed` | |
| 265 | ||
| 266 | - Generic: `turn.completed` | |
| 267 | - Internal commands: | |
| 268 | - `thread.session.set` (ready/error) | |
| 269 | - `thread.activity.append` (optional summary) | |
| 270 | - Domain events: `thread.session-set`, `thread.activity-appended` | |
| 271 | ||
| 272 | 6. Raw approval request | |
| 273 | ||
| 274 | - Generic: `approval.requested` | |
| 275 | - Internal command: `thread.activity.append` (typed `requestId`, `requestKind`) | |
| 276 | - Domain event: `thread.activity-appended` | |
| 277 | ||
| 278 | 7. Raw approval decision ack | |
| 279 | ||
| 280 | - Generic: `approval.resolved` | |
| 281 | - Internal command: `thread.activity.append` | |
| 282 | - Domain event: `thread.activity-appended` | |
| 283 | ||
| 284 | 8. Raw tool start/complete | |
| 285 | ||
| 286 | - Generic: `tool.started` / `tool.completed` | |
| 287 | - Internal command: `thread.activity.append` | |
| 288 | - Domain event: `thread.activity-appended` | |
| 289 | ||
| 290 | 9. Checkpoint capture (server-produced) | |
| 291 | ||
| 292 | - Generic: `checkpoint.captured` | |
| 293 | - Internal command: `thread.turn.diff.complete` | |
| 294 | - Domain event: `thread.turn-diff-completed` | |
| 295 | ||
| 296 | 10. Raw/runtime error | |
| 297 | ||
| 298 | - Generic: `runtime.error` | |
| 299 | - Internal commands: | |
| 300 | - `thread.session.set` (status `error`) | |
| 301 | - `thread.activity.append` (`tone=error`) | |
| 302 | - Domain events: `thread.session-set`, `thread.activity-appended` | |
| 303 | ||
| 304 | ## 6. Schema Section (Boundary + Reuse) | |
| 305 | ||
| 306 | ### 6.1 Shared Schemas (contracts package) | |
| 307 | ||
| 308 | Expose to both web and server: | |
| 309 | ||
| 310 | - `OrchestrationReadModelSchema` | |
| 311 | - `ClientOrchestrationCommandSchema` | |
| 312 | - `OrchestrationEventSchema` | |
| 313 | - `OrchestrationRpcSchemas` (`getSnapshot`, `dispatchCommand`, `getTurnDiff`, optional replay) | |
| 314 | - `GenericProviderRuntimeEventSchema` (used for diagnostics/ingestion tests) | |
| 315 | - Shared message/activity/checkpoint summary schemas used in snapshot | |
| 316 | ||
| 317 | ### 6.2 Server-Internal Schemas | |
| 318 | ||
| 319 | Not exposed to web: | |
| 320 | ||
| 321 | - `InternalOrchestrationCommandSchema` | |
| 322 | - Adapter raw-event schemas and provider-specific payload schemas | |
| 323 | - Provider session directory records and persistence-only row schemas | |
| 324 | - Git service cache/query schemas (non-orchestration read model) | |
| 325 | ||
| 326 | ### 6.3 Reuse Requirements | |
| 327 | ||
| 328 | - Snapshot checkpoint summary schema is source of truth for checkpoint list UI. | |
| 329 | - `getTurnDiff` response reuses `ThreadId`/turn count brands and textual `diff` only. | |
| 330 | - Activity schemas are reused for approval/tool/runtime annotations; no separate UI-only structure. | |
| 331 | - Session schema must include both `threadId: ThreadId` and optional `providerThreadId: ProviderThreadId` (explicitly distinct). | |
| 332 | ||
| 333 | ## 7. Persistence Model (Required) | |
| 334 | ||
| 335 | This section defines required persisted tables, the canonical persisted event envelope, and required projected/read tables. | |
| 336 | ||
| 337 | ### 7.1 Write-Side Persisted Tables | |
| 338 | ||
| 339 | 1. `orchestration_events` (append-only event store) | |
| 340 | ||
| 341 | - `sequence: number` (global monotonic sequence, primary key) | |
| 342 | - `eventId: EventId` (unique) | |
| 343 | - `aggregateKind: "project" | "thread"` | |
| 344 | - `streamId: ProjectId | ThreadId` (aggregate stream id) | |
| 345 | - `streamVersion: number` (per-aggregate monotonic version) | |
| 346 | - `eventType: OrchestrationEventType` | |
| 347 | - `occurredAt: IsoDateTime` | |
| 348 | - `commandId: CommandId | null` | |
| 349 | - `causationEventId: EventId | null` | |
| 350 | - `correlationId: CommandId | null` | |
| 351 | - `actorKind: "client" | "server" | "provider"` | |
| 352 | - `payload: OrchestrationEventPayload` (type-validated by `eventType`) | |
| 353 | - `metadata: OrchestrationEventMetadata` | |
| 354 | ||
| 355 | 2. `orchestration_command_receipts` (idempotency + ack replay) | |
| 356 | ||
| 357 | - `commandId: CommandId` (primary key) | |
| 358 | - `aggregateKind: "project" | "thread"` | |
| 359 | - `aggregateId: ProjectId | ThreadId` | |
| 360 | - `acceptedAt: IsoDateTime` | |
| 361 | - `resultSequence: number` | |
| 362 | - `status: "accepted" | "rejected"` | |
| 363 | - `error: string | null` | |
| 364 | ||
| 365 | 3. `checkpoint_diff_blobs` (large plaintext diffs; separate from summaries) | |
| 366 | ||
| 367 | - `threadId: ThreadId` | |
| 368 | - `fromTurnCount: number` | |
| 369 | - `toTurnCount: number` | |
| 370 | - `diff: string` | |
| 371 | - `createdAt: IsoDateTime` | |
| 372 | - unique key: `(threadId, fromTurnCount, toTurnCount)` | |
| 373 | ||
| 374 | 4. `provider_session_runtime` (server-internal adapter resume state) | |
| 375 | ||
| 376 | - `providerSessionId: ProviderSessionId` (primary key) | |
| 377 | - `threadId: ThreadId` | |
| 378 | - `providerName: string` | |
| 379 | - `adapterKey: string` | |
| 380 | - `providerThreadId: ProviderThreadId | null` | |
| 381 | - `status: "starting" | "running" | "stopped" | "error"` | |
| 382 | - `lastSeenAt: IsoDateTime` | |
| 383 | - `resumeCursor: JsonValue | null` (adapter-specific opaque state) | |
| 384 | - `runtimePayload: JsonValue | null` (adapter-specific opaque state) | |
| 385 | ||
| 386 | ### 7.2 Canonical Persisted Event Schema | |
| 387 | ||
| 388 | `OrchestrationPersistedEventSchema` (full envelope): | |
| 389 | ||
| 390 | ```ts | |
| 391 | type OrchestrationPersistedEvent = { | |
| 392 | sequence: number; | |
| 393 | eventId: EventId; | |
| 394 | aggregateKind: "project" | "thread"; | |
| 395 | streamId: ProjectId | ThreadId; | |
| 396 | streamVersion: number; | |
| 397 | eventType: OrchestrationEventType; | |
| 398 | occurredAt: IsoDateTime; | |
| 399 | commandId: CommandId | null; | |
| 400 | causationEventId: EventId | null; | |
| 401 | correlationId: CommandId | null; | |
| 402 | actorKind: "client" | "server" | "provider"; | |
| 403 | payload: OrchestrationEventPayload; | |
| 404 | metadata: { | |
| 405 | providerSessionId?: ProviderSessionId; | |
| 406 | providerThreadId?: ProviderThreadId; | |
| 407 | providerTurnId?: ProviderTurnId; | |
| 408 | providerItemId?: ProviderItemId; | |
| 409 | adapterKey?: string; | |
| 410 | requestId?: ApprovalRequestId; | |
| 411 | ingestedAt?: IsoDateTime; | |
| 412 | }; | |
| 413 | }; | |
| 414 | ``` | |
| 415 | ||
| 416 | Rules: | |
| 417 | ||
| 418 | - `payload` must be schema-discriminated by `eventType`. | |
| 419 | - provider identifiers only appear in `metadata` and provider-specific payload sub-shapes; they do not replace `ThreadId`. | |
| 420 | - `streamVersion` is concurrency guard for aggregate writes. | |
| 421 | ||
| 422 | ### 7.3 Required Projected Tables (Read Models) | |
| 423 | ||
| 424 | 1. `projection_projects` | |
| 425 | ||
| 426 | - `projectId: ProjectId` (primary key) | |
| 427 | - `title: string` | |
| 428 | - `workspaceRoot: string` | |
| 429 | - `defaultModel: string | null` | |
| 430 | - `scripts: ProjectScript[]` | |
| 431 | - `createdAt: IsoDateTime` | |
| 432 | - `updatedAt: IsoDateTime` | |
| 433 | - `deletedAt: IsoDateTime | null` | |
| 434 | ||
| 435 | 2. `projection_threads` | |
| 436 | ||
| 437 | - `threadId: ThreadId` (primary key) | |
| 438 | - `projectId: ProjectId` | |
| 439 | - `title: string` | |
| 440 | - `model: string` | |
| 441 | - `branch: string | null` | |
| 442 | - `worktreePath: string | null` | |
| 443 | - `latestTurnId: TurnId | null` | |
| 444 | - `createdAt: IsoDateTime` | |
| 445 | - `updatedAt: IsoDateTime` | |
| 446 | - `deletedAt: IsoDateTime | null` | |
| 447 | ||
| 448 | 3. `projection_thread_messages` | |
| 449 | ||
| 450 | - `messageId: MessageId` (primary key) | |
| 451 | - `threadId: ThreadId` | |
| 452 | - `turnId: TurnId | null` | |
| 453 | - `role: "user" | "assistant" | "system"` | |
| 454 | - `text: string` | |
| 455 | - `isStreaming: boolean` | |
| 456 | - `createdAt: IsoDateTime` | |
| 457 | - `updatedAt: IsoDateTime` | |
| 458 | ||
| 459 | 4. `projection_thread_activities` | |
| 460 | ||
| 461 | - `activityId: EventId` (primary key; derived from source event) | |
| 462 | - `threadId: ThreadId` | |
| 463 | - `turnId: TurnId | null` | |
| 464 | - `tone: "info" | "tool" | "approval" | "error"` | |
| 465 | - `kind: string` | |
| 466 | - `summary: string` | |
| 467 | - `payload: JsonValue` | |
| 468 | - `createdAt: IsoDateTime` | |
| 469 | ||
| 470 | 5. `projection_thread_sessions` | |
| 471 | ||
| 472 | - `threadId: ThreadId` (primary key) | |
| 473 | - `status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"` | |
| 474 | - `providerName: string | null` | |
| 475 | - `providerSessionId: ProviderSessionId | null` | |
| 476 | - `providerThreadId: ProviderThreadId | null` | |
| 477 | - `activeTurnId: TurnId | null` | |
| 478 | - `lastError: string | null` | |
| 479 | - `updatedAt: IsoDateTime` | |
| 480 | ||
| 481 | 6. `projection_thread_turns` | |
| 482 | ||
| 483 | - `turnId: TurnId` (primary key) | |
| 484 | - `threadId: ThreadId` | |
| 485 | - `turnCount: number` | |
| 486 | - `status: "running" | "completed" | "interrupted" | "error"` | |
| 487 | - `userMessageId: MessageId | null` | |
| 488 | - `assistantMessageId: MessageId | null` | |
| 489 | - `startedAt: IsoDateTime` | |
| 490 | - `completedAt: IsoDateTime | null` | |
| 491 | ||
| 492 | 7. `projection_checkpoints` | |
| 493 | ||
| 494 | - `threadId: ThreadId` | |
| 495 | - `turnId: TurnId` | |
| 496 | - `checkpointTurnCount: number` | |
| 497 | - `checkpointRef: CheckpointRef` | |
| 498 | - `status: "ready" | "missing" | "error"` | |
| 499 | - `files: JsonArray` (typed as file diff summary schema) | |
| 500 | - `assistantMessageId: MessageId | null` | |
| 501 | - `completedAt: IsoDateTime` | |
| 502 | - unique key: `(threadId, checkpointTurnCount)` | |
| 503 | ||
| 504 | 8. `projection_pending_approvals` | |
| 505 | ||
| 506 | - `requestId: ApprovalRequestId` (primary key) | |
| 507 | - `threadId: ThreadId` | |
| 508 | - `turnId: TurnId | null` | |
| 509 | - `status: "pending" | "resolved"` | |
| 510 | - `decision: "accept" | "acceptForSession" | "decline" | "cancel" | null` | |
| 511 | - `createdAt: IsoDateTime` | |
| 512 | - `resolvedAt: IsoDateTime | null` | |
| 513 | ||
| 514 | 9. `projection_state` | |
| 515 | ||
| 516 | - `projector: string` (primary key; e.g. `threads`, `messages`, `sessions`) | |
| 517 | - `lastAppliedSequence: number` | |
| 518 | - `updatedAt: IsoDateTime` | |
| 519 | ||
| 520 | Projection consistency rules: | |
| 521 | ||
| 522 | - Every projector applies read-model row updates and `projection_state.lastAppliedSequence` in the same database transaction. | |
| 523 | - Optional debug field on projection rows: `lastEventSequence: number` (not required for correctness). | |
| 524 | ||
| 525 | ### 7.4 Snapshot and RPC Requirements | |
| 526 | ||
| 527 | 1. `orchestration.getSnapshot` is fully served from projection tables and returns `snapshotSequence: number`. | |
| 528 | 2. Snapshot must include `projects[]` from `projection_projects`. | |
| 529 | 3. Thread snapshot must include `checkpoints[]` from `projection_checkpoints`: | |
| 530 | ||
| 531 | - `turnId: TurnId` | |
| 532 | - `completedAt: IsoDateTime` | |
| 533 | - `status` | |
| 534 | - `files[]` (`path`, `kind`, `additions`, `deletions`) | |
| 535 | - `checkpointRef: CheckpointRef` | |
| 536 | - `assistantMessageId?: MessageId` | |
| 537 | - `checkpointTurnCount: number` | |
| 538 | ||
| 539 | 4. Client does not require `listCheckpoints` RPC: | |
| 540 | ||
| 541 | - checkpoint list comes from snapshot projections | |
| 542 | - full diff text comes from `orchestration.getTurnDiff` backed by `checkpoint_diff_blobs` | |
| 543 | ||
| 544 | 5. Provider session identity is not a client routing key: | |
| 545 | ||
| 546 | - client acts on `ThreadId` | |
| 547 | - server resolves provider session internally via `projection_thread_sessions` | |
| 548 | ||
| 549 | 6. `snapshotSequence` is derived from `projection_state`: | |
| 550 | ||
| 551 | - if snapshot depends on multiple projectors, use the minimum `lastAppliedSequence` across those projectors. | |
| 552 | ||
| 553 | 7. Event subscription handoff contract: | |
| 554 | ||
| 555 | - client performs `getSnapshot` and reads `snapshotSequence` | |
| 556 | - client subscribes/replays with `fromSequenceExclusive = snapshotSequence` | |
| 557 | - server guarantees no gap between snapshot visibility and subsequent event stream from that sequence. | |
| 558 | ||
| 559 | ### 7.5 External Derived State (Non-Orchestration) | |
| 560 | ||
| 561 | 1. Current git/worktree state is not projected from orchestration events. | |
| 562 | 2. If persisted, it belongs in a separate git cache/read model (for example `git_state_cache`) owned by a git service. | |
| 563 | 3. Orchestration may embed git metadata only when captured as a domain fact (for example checkpoint metadata at turn completion). | |
| 564 | 4. Any RPC for current git status should be outside orchestration RPC (for example `git.getThreadState`). | |
| 565 | ||
| 566 | ### 7.6 Existing Repository Placement | |
| 567 | ||
| 568 | 1. `ProjectsRepository` | |
| 569 | ||
| 570 | - Fits as projection/query access on top of `projection_projects` plus orchestration command dispatch for writes. | |
| 571 | - Must not bypass command->event append path for mutations. | |
| 572 | ||
| 573 | 2. `CheckpointsRepository` | |
| 574 | ||
| 575 | - Fits as projection/query access on top of `projection_checkpoints` plus `checkpoint_diff_blobs` (or git-service on-demand diff implementation). | |
| 576 | - Represents the same concept as prior `turn_diff_summary`; canonical naming is `checkpoint`. | |
| 577 | - Must not be an independent source of truth outside orchestration events. | |
| 578 | ||
| 579 | 3. `ProviderSessionsRepository` | |
| 580 | ||
| 581 | - Fits as server-internal runtime persistence on top of `provider_session_runtime`, with domain-visible state projected into `projection_thread_sessions`. | |
| 582 | - Not part of client RPC/domain aggregate boundary. |
TODO.md+2-2| ... | ... | @@ -15,11 +15,11 @@ |
| 15 | 15 | - [x] "Response" bar should ONLY appear above the FINAL message in a series |
| 16 | 16 | - [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) |
| 17 | 17 | - [x] Insta focus input on new thread creation |
| 18 | - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it | |
| 18 | - [x] Disable "streaming" (wait until whole paragraph/step finishes before showing it | |
| 19 | 19 | - [x] Making new thread should auto-focus input box |
| 20 | 20 | - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) |
| 21 | 21 | |
| 22 | 22 | ## Bigger things |
| 23 | 23 | |
| 24 | 24 | - [ ] Queueing messages |
| 25 | - [ ] External runners (i.e. running the cli on a mac mini) | |
| 25 | - [x] External runners (i.e. running the cli on a mac mini) |
apps/desktop/package.json+4-3| ... | ... | @@ -2,7 +2,6 @@ |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "private": true, |
| 5 | "productName": "T3 Code (Alpha)", | |
| 6 | 5 | "main": "dist-electron/main.js", |
| 7 | 6 | "scripts": { |
| 8 | 7 | "dev": "bun run --parallel dev:bundle dev:electron", |
| ... | ... | @@ -15,14 +14,16 @@ |
| 15 | 14 | "smoke-test": "node scripts/smoke-test.mjs" |
| 16 | 15 | }, |
| 17 | 16 | "dependencies": { |
| 18 | "electron": "33.4.11" | |
| 17 | "electron": "40.6.0" | |
| 19 | 18 | }, |
| 20 | 19 | "devDependencies": { |
| 20 | "@t3tools/contracts": "workspace:*", | |
| 21 | 21 | "@types/node": "^22.10.2", |
| 22 | 22 | "electronmon": "^2.0.2", |
| 23 | 23 | "tsdown": "^0.20.3", |
| 24 | 24 | "typescript": "^5.7.3", |
| 25 | 25 | "vitest": "^4.0.0", |
| 26 | 26 | "wait-on": "^8.0.2" |
| 27 | } | |
| 27 | }, | |
| 28 | "productName": "T3 Code (Alpha)" | |
| 28 | 29 | } |
apps/desktop/scripts/dev-electron.mjs+2-5| ... | ... | @@ -5,7 +5,7 @@ import waitOn from "wait-on"; |
| 5 | 5 | |
| 6 | 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 7 | 7 | |
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); | |
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | |
| 9 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | 10 | |
| 11 | 11 | await waitOn({ |
| ... | ... | @@ -46,10 +46,7 @@ async function shutdown(exitCode) { |
| 46 | 46 | shuttingDown = true; |
| 47 | 47 | |
| 48 | 48 | try { |
| 49 | await Promise.race([ | |
| 50 | monitor.destroy(), | |
| 51 | new Promise((resolve) => setTimeout(resolve, 1_500)), | |
| 52 | ]); | |
| 49 | await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); | |
| 53 | 50 | } catch { |
| 54 | 51 | // Best effort only; fallback process kill below handles stubborn children. |
| 55 | 52 | } |
apps/desktop/scripts/electron-launcher.mjs+12-41| ... | ... | @@ -25,34 +25,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 25 | 25 | export const desktopDir = resolve(__dirname, ".."); |
| 26 | 26 | |
| 27 | 27 | function setPlistString(plistPath, key, value) { |
| 28 | const replaceResult = spawnSync( | |
| 29 | "plutil", | |
| 30 | ["-replace", key, "-string", value, plistPath], | |
| 31 | { | |
| 32 | encoding: "utf8", | |
| 33 | }, | |
| 34 | ); | |
| 28 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { | |
| 29 | encoding: "utf8", | |
| 30 | }); | |
| 35 | 31 | if (replaceResult.status === 0) { |
| 36 | 32 | return; |
| 37 | 33 | } |
| 38 | 34 | |
| 39 | const insertResult = spawnSync( | |
| 40 | "plutil", | |
| 41 | ["-insert", key, "-string", value, plistPath], | |
| 42 | { | |
| 43 | encoding: "utf8", | |
| 44 | }, | |
| 45 | ); | |
| 35 | const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { | |
| 36 | encoding: "utf8", | |
| 37 | }); | |
| 46 | 38 | if (insertResult.status === 0) { |
| 47 | 39 | return; |
| 48 | 40 | } |
| 49 | 41 | |
| 50 | const details = [replaceResult.stderr, insertResult.stderr] | |
| 51 | .filter(Boolean) | |
| 52 | .join("\n"); | |
| 53 | throw new Error( | |
| 54 | `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), | |
| 55 | ); | |
| 42 | const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); | |
| 43 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); | |
| 56 | 44 | } |
| 57 | 45 | |
| 58 | 46 | function patchMainBundleInfoPlist(appBundlePath, iconPath) { |
| ... | ... | @@ -81,28 +69,16 @@ function patchHelperBundleInfoPlists(appBundlePath) { |
| 81 | 69 | continue; |
| 82 | 70 | } |
| 83 | 71 | |
| 84 | const helperPlistPath = join( | |
| 85 | frameworksDir, | |
| 86 | entry.name, | |
| 87 | "Contents", | |
| 88 | "Info.plist", | |
| 89 | ); | |
| 72 | const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); | |
| 90 | 73 | if (!existsSync(helperPlistPath)) { |
| 91 | 74 | continue; |
| 92 | 75 | } |
| 93 | 76 | |
| 94 | const suffix = entry.name | |
| 95 | .replace("Electron Helper", "") | |
| 96 | .replace(".app", "") | |
| 97 | .trim(); | |
| 77 | const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); | |
| 98 | 78 | const helperName = suffix |
| 99 | 79 | ? `${APP_DISPLAY_NAME} Helper ${suffix}` |
| 100 | 80 | : `${APP_DISPLAY_NAME} Helper`; |
| 101 | const helperIdSuffix = suffix | |
| 102 | .replace(/[()]/g, "") | |
| 103 | .trim() | |
| 104 | .toLowerCase() | |
| 105 | .replace(/\s+/g, "-"); | |
| 81 | const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); | |
| 106 | 82 | const helperBundleId = helperIdSuffix |
| 107 | 83 | ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` |
| 108 | 84 | : `${APP_BUNDLE_ID}.helper`; |
| ... | ... | @@ -125,12 +101,7 @@ function buildMacLauncher(electronBinaryPath) { |
| 125 | 101 | const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); |
| 126 | 102 | const runtimeDir = join(desktopDir, ".electron-runtime"); |
| 127 | 103 | const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); |
| 128 | const targetBinaryPath = join( | |
| 129 | targetAppBundlePath, | |
| 130 | "Contents", | |
| 131 | "MacOS", | |
| 132 | "Electron", | |
| 133 | ); | |
| 104 | const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); | |
| 134 | 105 | const iconPath = join(desktopDir, "resources", "icon.icns"); |
| 135 | 106 | const metadataPath = join(runtimeDir, "metadata.json"); |
| 136 | 107 |
apps/desktop/src/main.ts+9-13| ... | ... | @@ -5,16 +5,8 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { | |
| 9 | app, | |
| 10 | BrowserWindow, | |
| 11 | dialog, | |
| 12 | ipcMain, | |
| 13 | Menu, | |
| 14 | type MenuItemConstructorOptions, | |
| 15 | protocol, | |
| 16 | shell, | |
| 17 | } from "electron"; | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron"; | |
| 9 | import type { MenuItemConstructorOptions } from "electron"; | |
| 18 | 10 | |
| 19 | 11 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 20 | 12 | import { fixPath } from "./fixPath"; |
| ... | ... | @@ -26,7 +18,8 @@ const CONFIRM_CHANNEL = "desktop:confirm"; |
| 26 | 18 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 27 | 19 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 28 | 20 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 29 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); | |
| 21 | const STATE_DIR = | |
| 22 | process.env.T3CODE_STATE_DIR?.trim() || path.join(os.homedir(), ".t3", "userdata"); | |
| 30 | 23 | const DESKTOP_SCHEME = "t3"; |
| 31 | 24 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 32 | 25 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| ... | ... | @@ -126,7 +119,9 @@ function registerDesktopProtocol(): void { |
| 126 | 119 | |
| 127 | 120 | const staticRoot = resolveDesktopStaticDir(); |
| 128 | 121 | if (!staticRoot) { |
| 129 | throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); | |
| 122 | throw new Error( | |
| 123 | "Desktop static bundle missing. Build apps/server (with bundled client) first.", | |
| 124 | ); | |
| 130 | 125 | } |
| 131 | 126 | |
| 132 | 127 | const staticRootResolved = path.resolve(staticRoot); |
| ... | ... | @@ -160,7 +155,8 @@ function registerDesktopProtocol(): void { |
| 160 | 155 | } |
| 161 | 156 | |
| 162 | 157 | function dispatchMenuAction(action: string): void { |
| 163 | const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 158 | const existingWindow = | |
| 159 | BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 164 | 160 | const targetWindow = existingWindow ?? createWindow(); |
| 165 | 161 | if (!existingWindow) { |
| 166 | 162 | mainWindow = targetWindow; |
apps/desktop/src/preload.ts+7-7| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 5 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| ... | ... | @@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 9 | 10 | |
| 10 | 11 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 11 | 12 | getWsUrl: () => wsUrl, |
| 12 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | |
| 13 | confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>, | |
| 14 | showContextMenu: (items: { id: string; label: string }[]) => | |
| 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, | |
| 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>, | |
| 17 | onMenuAction: (listener: (action: string) => void) => { | |
| 13 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | |
| 14 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | |
| 15 | showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items), | |
| 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), | |
| 17 | onMenuAction: (listener) => { | |
| 18 | 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 19 | 19 | if (typeof action !== "string") return; |
| 20 | 20 | listener(action); |
| ... | ... | @@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 25 | 25 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); |
| 26 | 26 | }; |
| 27 | 27 | }, |
| 28 | }); | |
| 28 | } satisfies DesktopBridge); |
apps/server/integration/OrchestrationEngineHarness.integration.ts created+401| ... | ... | @@ -0,0 +1,401 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { execFileSync } from "node:child_process"; | |
| 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { | |
| 8 | ApprovalRequestId, | |
| 9 | type OrchestrationEvent, | |
| 10 | type OrchestrationThread, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { | |
| 13 | Effect, | |
| 14 | Exit, | |
| 15 | Layer, | |
| 16 | ManagedRuntime, | |
| 17 | Option, | |
| 18 | Schedule, | |
| 19 | Schema, | |
| 20 | Scope, | |
| 21 | Stream, | |
| 22 | } from "effect"; | |
| 23 | ||
| 24 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; | |
| 25 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; | |
| 26 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 27 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; | |
| 28 | import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; | |
| 29 | import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; | |
| 30 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; | |
| 31 | import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; | |
| 32 | import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; | |
| 33 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; | |
| 34 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | |
| 35 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | |
| 36 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | |
| 37 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | |
| 38 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | |
| 39 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | |
| 40 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | |
| 41 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | |
| 42 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | |
| 43 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; | |
| 44 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; | |
| 45 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; | |
| 46 | import { | |
| 47 | OrchestrationEngineService, | |
| 48 | type OrchestrationEngineShape, | |
| 49 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; | |
| 50 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; | |
| 51 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 52 | ||
| 53 | import { | |
| 54 | makeTestProviderAdapterHarness, | |
| 55 | type TestProviderAdapterHarness, | |
| 56 | } from "./TestProviderAdapter.integration.ts"; | |
| 57 | ||
| 58 | function runGit(cwd: string, args: ReadonlyArray<string>) { | |
| 59 | return execFileSync("git", args, { | |
| 60 | cwd, | |
| 61 | stdio: ["ignore", "pipe", "pipe"], | |
| 62 | encoding: "utf8", | |
| 63 | }); | |
| 64 | } | |
| 65 | ||
| 66 | function initializeGitWorkspace(cwd: string) { | |
| 67 | runGit(cwd, ["init", "--initial-branch=main"]); | |
| 68 | runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 69 | runGit(cwd, ["config", "user.name", "Test User"]); | |
| 70 | fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); | |
| 71 | runGit(cwd, ["add", "."]); | |
| 72 | runGit(cwd, ["commit", "-m", "Initial"]); | |
| 73 | } | |
| 74 | ||
| 75 | export function gitRefExists(cwd: string, ref: string): boolean { | |
| 76 | try { | |
| 77 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); | |
| 78 | return true; | |
| 79 | } catch { | |
| 80 | return false; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { | |
| 85 | return runGit(cwd, ["show", `${ref}:${filePath}`]); | |
| 86 | } | |
| 87 | ||
| 88 | class WaitForTimeoutError extends Schema.TaggedErrorClass<WaitForTimeoutError>()( | |
| 89 | "WaitForTimeoutError", | |
| 90 | { | |
| 91 | description: Schema.String, | |
| 92 | }, | |
| 93 | ) {} | |
| 94 | ||
| 95 | function waitFor<A, E>( | |
| 96 | read: Effect.Effect<A, E>, | |
| 97 | predicate: (value: A) => boolean, | |
| 98 | description: string, | |
| 99 | timeoutMs?: number, | |
| 100 | ): Effect.Effect<A, never>; | |
| 101 | function waitFor<A, B extends A, E>( | |
| 102 | read: Effect.Effect<A, E>, | |
| 103 | predicate: (value: A) => value is B, | |
| 104 | description: string, | |
| 105 | timeoutMs?: number, | |
| 106 | ): Effect.Effect<B, never>; | |
| 107 | function waitFor<A, E>( | |
| 108 | read: Effect.Effect<A, E>, | |
| 109 | predicate: (value: A) => boolean, | |
| 110 | description: string, | |
| 111 | timeoutMs = 3000, | |
| 112 | ): Effect.Effect<A, never> { | |
| 113 | const RETRY_SIGNAL = "wait_for_retry"; | |
| 114 | const retryIntervalMs = 10; | |
| 115 | const maxRetries = Math.max(0, Math.floor(timeoutMs / retryIntervalMs)); | |
| 116 | const retrySchedule = Schedule.spaced(`${retryIntervalMs} millis`); | |
| 117 | ||
| 118 | return read.pipe( | |
| 119 | Effect.filterOrFail(predicate, () => RETRY_SIGNAL), | |
| 120 | Effect.retry({ | |
| 121 | schedule: retrySchedule, | |
| 122 | times: maxRetries, | |
| 123 | while: (error) => error === RETRY_SIGNAL, | |
| 124 | }), | |
| 125 | Effect.mapError((error) => | |
| 126 | error === RETRY_SIGNAL ? new WaitForTimeoutError({ description }) : error, | |
| 127 | ), | |
| 128 | Effect.orDie, | |
| 129 | ); | |
| 130 | } | |
| 131 | ||
| 132 | class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<OrchestrationHarnessRuntimeError>()( | |
| 133 | "OrchestrationHarnessRuntimeError", | |
| 134 | { | |
| 135 | operation: Schema.String, | |
| 136 | cause: Schema.optional(Schema.Defect), | |
| 137 | }, | |
| 138 | ) {} | |
| 139 | ||
| 140 | const tryRuntimePromise = <A>(operation: string, run: () => Promise<A>) => | |
| 141 | Effect.tryPromise({ | |
| 142 | try: run, | |
| 143 | catch: (cause) => new OrchestrationHarnessRuntimeError({ operation, cause }), | |
| 144 | }); | |
| 145 | ||
| 146 | export interface OrchestrationIntegrationHarness { | |
| 147 | readonly rootDir: string; | |
| 148 | readonly workspaceDir: string; | |
| 149 | readonly dbPath: string; | |
| 150 | readonly adapterHarness: TestProviderAdapterHarness; | |
| 151 | readonly engine: OrchestrationEngineShape; | |
| 152 | readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; | |
| 153 | readonly providerService: ProviderService["Service"]; | |
| 154 | readonly checkpointStore: CheckpointStore["Service"]; | |
| 155 | readonly checkpointRepository: ProjectionCheckpointRepository["Service"]; | |
| 156 | readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; | |
| 157 | readonly waitForThread: ( | |
| 158 | threadId: string, | |
| 159 | predicate: (thread: OrchestrationThread) => boolean, | |
| 160 | timeoutMs?: number, | |
| 161 | ) => Effect.Effect<OrchestrationThread, never>; | |
| 162 | readonly waitForDomainEvent: ( | |
| 163 | predicate: (event: OrchestrationEvent) => boolean, | |
| 164 | timeoutMs?: number, | |
| 165 | ) => Effect.Effect<ReadonlyArray<OrchestrationEvent>, never>; | |
| 166 | readonly waitForPendingApproval: ( | |
| 167 | requestId: string, | |
| 168 | predicate: (row: { | |
| 169 | readonly status: "pending" | "resolved"; | |
| 170 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 171 | readonly resolvedAt: string | null; | |
| 172 | }) => boolean, | |
| 173 | timeoutMs?: number, | |
| 174 | ) => Effect.Effect< | |
| 175 | { | |
| 176 | readonly status: "pending" | "resolved"; | |
| 177 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 178 | readonly resolvedAt: string | null; | |
| 179 | }, | |
| 180 | never | |
| 181 | >; | |
| 182 | readonly dispose: Effect.Effect<void, never>; | |
| 183 | } | |
| 184 | ||
| 185 | export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { | |
| 186 | const sleep = (ms: number) => Effect.sleep(ms); | |
| 187 | const adapterHarness = yield* makeTestProviderAdapterHarness; | |
| 188 | ||
| 189 | const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); | |
| 190 | const workspaceDir = path.join(rootDir, "workspace"); | |
| 191 | const stateDir = path.join(rootDir, "state"); | |
| 192 | const dbPath = path.join(stateDir, "state.sqlite"); | |
| 193 | fs.mkdirSync(workspaceDir, { recursive: true }); | |
| 194 | fs.mkdirSync(stateDir, { recursive: true }); | |
| 195 | initializeGitWorkspace(workspaceDir); | |
| 196 | ||
| 197 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 198 | getByProvider: (provider) => | |
| 199 | provider === "codex" | |
| 200 | ? Effect.succeed(adapterHarness.adapter) | |
| 201 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 202 | listProviders: () => Effect.succeed(["codex"]), | |
| 203 | }; | |
| 204 | ||
| 205 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 206 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 207 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 208 | Layer.provide(OrchestrationEventStoreLive), | |
| 209 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 210 | ); | |
| 211 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 212 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 213 | ); | |
| 214 | const providerLayer = makeProviderServiceLive().pipe( | |
| 215 | Layer.provide(providerSessionDirectoryLayer), | |
| 216 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | |
| 217 | ); | |
| 218 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | |
| 219 | ||
| 220 | const runtimeServicesLayer = Layer.mergeAll( | |
| 221 | orchestrationLayer, | |
| 222 | OrchestrationProjectionSnapshotQueryLive, | |
| 223 | ProjectionCheckpointRepositoryLive, | |
| 224 | ProjectionPendingApprovalRepositoryLive, | |
| 225 | checkpointStoreLayer, | |
| 226 | providerLayer, | |
| 227 | ); | |
| 228 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | |
| 229 | Layer.provideMerge(runtimeServicesLayer), | |
| 230 | ); | |
| 231 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | |
| 232 | Layer.provideMerge(runtimeServicesLayer), | |
| 233 | ); | |
| 234 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | |
| 235 | Layer.provideMerge(runtimeServicesLayer), | |
| 236 | ); | |
| 237 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | |
| 238 | Layer.provideMerge(runtimeIngestionLayer), | |
| 239 | Layer.provideMerge(providerCommandReactorLayer), | |
| 240 | Layer.provideMerge(checkpointReactorLayer), | |
| 241 | ); | |
| 242 | const layer = orchestrationReactorLayer.pipe( | |
| 243 | Layer.provide(persistenceLayer), | |
| 244 | Layer.provideMerge(NodeServices.layer), | |
| 245 | ); | |
| 246 | ||
| 247 | const runtime = ManagedRuntime.make(layer); | |
| 248 | const engine = yield* tryRuntimePromise("load OrchestrationEngine service", () => | |
| 249 | runtime.runPromise(Effect.service(OrchestrationEngineService)), | |
| 250 | ).pipe(Effect.orDie); | |
| 251 | const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => | |
| 252 | runtime.runPromise(Effect.service(OrchestrationReactor)), | |
| 253 | ).pipe(Effect.orDie); | |
| 254 | const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => | |
| 255 | runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), | |
| 256 | ).pipe(Effect.orDie); | |
| 257 | const providerService = yield* tryRuntimePromise("load ProviderService service", () => | |
| 258 | runtime.runPromise(Effect.service(ProviderService)), | |
| 259 | ).pipe(Effect.orDie); | |
| 260 | const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () => | |
| 261 | runtime.runPromise(Effect.service(CheckpointStore)), | |
| 262 | ).pipe(Effect.orDie); | |
| 263 | const checkpointRepository = yield* tryRuntimePromise( | |
| 264 | "load ProjectionCheckpointRepository service", | |
| 265 | () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), | |
| 266 | ).pipe(Effect.orDie); | |
| 267 | const pendingApprovalRepository = yield* tryRuntimePromise( | |
| 268 | "load ProjectionPendingApprovalRepository service", | |
| 269 | () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), | |
| 270 | ).pipe(Effect.orDie); | |
| 271 | ||
| 272 | const scope = yield* Scope.make("sequential"); | |
| 273 | yield* tryRuntimePromise("start OrchestrationReactor", () => | |
| 274 | runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), | |
| 275 | ).pipe(Effect.orDie); | |
| 276 | yield* sleep(10); | |
| 277 | ||
| 278 | const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( | |
| 279 | threadId, | |
| 280 | predicate, | |
| 281 | timeoutMs, | |
| 282 | ) => | |
| 283 | waitFor( | |
| 284 | snapshotQuery | |
| 285 | .getSnapshot() | |
| 286 | .pipe( | |
| 287 | Effect.map( | |
| 288 | (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, | |
| 289 | ), | |
| 290 | ), | |
| 291 | (thread): thread is OrchestrationThread => thread !== null && predicate(thread), | |
| 292 | `projected thread '${threadId}'`, | |
| 293 | timeoutMs, | |
| 294 | ) as Effect.Effect<OrchestrationThread, never>; | |
| 295 | ||
| 296 | const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( | |
| 297 | predicate, | |
| 298 | timeoutMs, | |
| 299 | ) => | |
| 300 | waitFor( | |
| 301 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 302 | Effect.map((chunk): ReadonlyArray<OrchestrationEvent> => Array.from(chunk)), | |
| 303 | ), | |
| 304 | (events) => events.some(predicate), | |
| 305 | "domain event", | |
| 306 | timeoutMs, | |
| 307 | ); | |
| 308 | ||
| 309 | const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( | |
| 310 | requestId, | |
| 311 | predicate, | |
| 312 | timeoutMs, | |
| 313 | ) => | |
| 314 | waitFor( | |
| 315 | pendingApprovalRepository | |
| 316 | .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) | |
| 317 | .pipe( | |
| 318 | Effect.map((row) => | |
| 319 | Option.match(row, { | |
| 320 | onNone: () => null, | |
| 321 | onSome: (value) => ({ | |
| 322 | status: value.status, | |
| 323 | decision: value.decision, | |
| 324 | resolvedAt: value.resolvedAt, | |
| 325 | }), | |
| 326 | }), | |
| 327 | ), | |
| 328 | ), | |
| 329 | ( | |
| 330 | row, | |
| 331 | ): row is { | |
| 332 | readonly status: "pending" | "resolved"; | |
| 333 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 334 | readonly resolvedAt: string | null; | |
| 335 | } => row !== null && predicate(row), | |
| 336 | `pending approval '${requestId}'`, | |
| 337 | timeoutMs, | |
| 338 | ) as Effect.Effect< | |
| 339 | { | |
| 340 | readonly status: "pending" | "resolved"; | |
| 341 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 342 | readonly resolvedAt: string | null; | |
| 343 | }, | |
| 344 | never | |
| 345 | >; | |
| 346 | ||
| 347 | let disposed = false; | |
| 348 | const dispose = Effect.gen(function* () { | |
| 349 | if (disposed) { | |
| 350 | return; | |
| 351 | } | |
| 352 | disposed = true; | |
| 353 | ||
| 354 | const shutdown = Effect.gen(function* () { | |
| 355 | const stopAllExit = yield* Effect.exit( | |
| 356 | Effect.promise(() => runtime.runPromise(providerService.stopAll())), | |
| 357 | ); | |
| 358 | const closeScopeExit = yield* Effect.exit( | |
| 359 | Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))), | |
| 360 | ); | |
| 361 | const disposeRuntimeExit = yield* Effect.exit(Effect.promise(() => runtime.dispose())); | |
| 362 | ||
| 363 | const failureCause = Exit.isFailure(stopAllExit) | |
| 364 | ? stopAllExit.cause | |
| 365 | : Exit.isFailure(closeScopeExit) | |
| 366 | ? closeScopeExit.cause | |
| 367 | : Exit.isFailure(disposeRuntimeExit) | |
| 368 | ? disposeRuntimeExit.cause | |
| 369 | : null; | |
| 370 | ||
| 371 | if (failureCause) { | |
| 372 | return yield* Effect.failCause(failureCause); | |
| 373 | } | |
| 374 | }); | |
| 375 | ||
| 376 | yield* shutdown.pipe( | |
| 377 | Effect.ensuring( | |
| 378 | Effect.sync(() => { | |
| 379 | fs.rmSync(rootDir, { recursive: true, force: true }); | |
| 380 | }), | |
| 381 | ), | |
| 382 | ); | |
| 383 | }); | |
| 384 | ||
| 385 | return { | |
| 386 | rootDir, | |
| 387 | workspaceDir, | |
| 388 | dbPath, | |
| 389 | adapterHarness, | |
| 390 | engine, | |
| 391 | snapshotQuery, | |
| 392 | providerService, | |
| 393 | checkpointStore, | |
| 394 | checkpointRepository, | |
| 395 | pendingApprovalRepository, | |
| 396 | waitForThread, | |
| 397 | waitForDomainEvent, | |
| 398 | waitForPendingApproval, | |
| 399 | dispose, | |
| 400 | } satisfies OrchestrationIntegrationHarness; | |
| 401 | }); |
apps/server/integration/TestProviderAdapter.integration.ts created+368| ... | ... | @@ -0,0 +1,368 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import { | |
| 4 | ApprovalRequestId, | |
| 5 | EventId, | |
| 6 | ProviderApprovalDecision, | |
| 7 | ProviderRuntimeEvent, | |
| 8 | ProviderSession, | |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | ProviderTurnStartResult, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import { Effect, Queue, Stream } from "effect"; | |
| 15 | ||
| 16 | import { | |
| 17 | ProviderAdapterSessionNotFoundError, | |
| 18 | ProviderAdapterValidationError, | |
| 19 | type ProviderAdapterError, | |
| 20 | } from "../src/provider/Errors.ts"; | |
| 21 | import type { | |
| 22 | ProviderAdapterShape, | |
| 23 | ProviderThreadSnapshot, | |
| 24 | ProviderThreadTurnSnapshot, | |
| 25 | } from "../src/provider/Services/ProviderAdapter.ts"; | |
| 26 | ||
| 27 | export interface TestTurnResponse { | |
| 28 | readonly events: ReadonlyArray<ProviderRuntimeEvent>; | |
| 29 | readonly mutateWorkspace?: (input: { | |
| 30 | readonly cwd: string; | |
| 31 | readonly turnCount: number; | |
| 32 | }) => Effect.Effect<void, never>; | |
| 33 | } | |
| 34 | ||
| 35 | interface SessionState { | |
| 36 | readonly session: ProviderSession; | |
| 37 | snapshot: ProviderThreadSnapshot; | |
| 38 | turnCount: number; | |
| 39 | readonly queuedResponses: Array<TestTurnResponse>; | |
| 40 | readonly rollbackCalls: Array<number>; | |
| 41 | } | |
| 42 | ||
| 43 | export interface TestProviderAdapterHarness { | |
| 44 | readonly adapter: ProviderAdapterShape<ProviderAdapterError>; | |
| 45 | readonly queueTurnResponse: ( | |
| 46 | sessionId: string, | |
| 47 | response: TestTurnResponse, | |
| 48 | ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>; | |
| 49 | readonly queueTurnResponseForNextSession: ( | |
| 50 | response: TestTurnResponse, | |
| 51 | ) => Effect.Effect<void, never>; | |
| 52 | readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>; | |
| 53 | readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{ | |
| 54 | readonly sessionId: ProviderSessionId; | |
| 55 | readonly requestId: ApprovalRequestId; | |
| 56 | readonly decision: ProviderApprovalDecision; | |
| 57 | }>; | |
| 58 | } | |
| 59 | ||
| 60 | const PROVIDER = "codex" as const; | |
| 61 | ||
| 62 | function nowIso(): string { | |
| 63 | return new Date().toISOString(); | |
| 64 | } | |
| 65 | ||
| 66 | function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError { | |
| 67 | return new ProviderAdapterSessionNotFoundError({ | |
| 68 | provider: PROVIDER, | |
| 69 | sessionId, | |
| 70 | }); | |
| 71 | } | |
| 72 | ||
| 73 | function missingSessionEffect(sessionId: string): Effect.Effect<never, ProviderAdapterError> { | |
| 74 | return Effect.fail(sessionNotFound(sessionId)); | |
| 75 | } | |
| 76 | ||
| 77 | export const makeTestProviderAdapterHarness = Effect.gen(function* () { | |
| 78 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); | |
| 79 | let sessionCount = 0; | |
| 80 | const sessions = new Map<string, SessionState>(); | |
| 81 | const queuedResponsesForNextSession: TestTurnResponse[] = []; | |
| 82 | const approvalResponsesBySession = new Map< | |
| 83 | string, | |
| 84 | Array<{ | |
| 85 | readonly sessionId: ProviderSessionId; | |
| 86 | readonly requestId: ApprovalRequestId; | |
| 87 | readonly decision: ProviderApprovalDecision; | |
| 88 | }> | |
| 89 | >(); | |
| 90 | ||
| 91 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); | |
| 92 | ||
| 93 | const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) => | |
| 94 | Effect.gen(function* () { | |
| 95 | if (input.provider !== undefined && input.provider !== PROVIDER) { | |
| 96 | return yield* new ProviderAdapterValidationError({ | |
| 97 | provider: PROVIDER, | |
| 98 | operation: "startSession", | |
| 99 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | |
| 100 | }); | |
| 101 | } | |
| 102 | ||
| 103 | sessionCount += 1; | |
| 104 | const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); | |
| 105 | const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); | |
| 106 | const createdAt = nowIso(); | |
| 107 | ||
| 108 | const session: ProviderSession = { | |
| 109 | sessionId, | |
| 110 | provider: PROVIDER, | |
| 111 | status: "ready", | |
| 112 | threadId, | |
| 113 | cwd: input.cwd, | |
| 114 | createdAt, | |
| 115 | updatedAt: createdAt, | |
| 116 | }; | |
| 117 | ||
| 118 | sessions.set(sessionId, { | |
| 119 | session, | |
| 120 | snapshot: { | |
| 121 | threadId, | |
| 122 | turns: [], | |
| 123 | }, | |
| 124 | turnCount: 0, | |
| 125 | queuedResponses: queuedResponsesForNextSession.splice(0), | |
| 126 | rollbackCalls: [], | |
| 127 | }); | |
| 128 | ||
| 129 | return session; | |
| 130 | }); | |
| 131 | ||
| 132 | const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) => | |
| 133 | Effect.gen(function* () { | |
| 134 | const state = sessions.get(input.sessionId); | |
| 135 | if (!state) { | |
| 136 | return yield* missingSessionEffect(input.sessionId); | |
| 137 | } | |
| 138 | ||
| 139 | state.turnCount += 1; | |
| 140 | const turnCount = state.turnCount; | |
| 141 | const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); | |
| 142 | ||
| 143 | const response = state.queuedResponses.shift(); | |
| 144 | if (!response) { | |
| 145 | return yield* new ProviderAdapterValidationError({ | |
| 146 | provider: PROVIDER, | |
| 147 | operation: "sendTurn", | |
| 148 | issue: `No queued turn response for session ${input.sessionId}.`, | |
| 149 | }); | |
| 150 | } | |
| 151 | ||
| 152 | const assistantDeltas: string[] = []; | |
| 153 | const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = []; | |
| 154 | for (const fixtureEvent of response.events) { | |
| 155 | const rawEvent: Record<string, unknown> = { | |
| 156 | ...(fixtureEvent as Record<string, unknown>), | |
| 157 | eventId: randomUUID(), | |
| 158 | provider: PROVIDER, | |
| 159 | sessionId: input.sessionId, | |
| 160 | createdAt: nowIso(), | |
| 161 | }; | |
| 162 | if (Object.hasOwn(rawEvent, "threadId")) { | |
| 163 | rawEvent.threadId = state.snapshot.threadId; | |
| 164 | } | |
| 165 | if (Object.hasOwn(rawEvent, "turnId")) { | |
| 166 | rawEvent.turnId = turnId; | |
| 167 | } | |
| 168 | ||
| 169 | const runtimeEvent = rawEvent as ProviderRuntimeEvent; | |
| 170 | if (runtimeEvent.type === "message.delta") { | |
| 171 | assistantDeltas.push(runtimeEvent.delta); | |
| 172 | } | |
| 173 | if (runtimeEvent.type === "turn.completed") { | |
| 174 | deferredTurnCompletedEvents.push(runtimeEvent); | |
| 175 | continue; | |
| 176 | } | |
| 177 | ||
| 178 | yield* emit(runtimeEvent); | |
| 179 | } | |
| 180 | ||
| 181 | if (response.mutateWorkspace && state.session.cwd) { | |
| 182 | yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount }); | |
| 183 | } | |
| 184 | ||
| 185 | const userItem = { | |
| 186 | type: "userMessage", | |
| 187 | content: [{ type: "text", text: input.input }], | |
| 188 | } as const; | |
| 189 | const assistantText = assistantDeltas.join(""); | |
| 190 | const nextItems: Array<unknown> = | |
| 191 | assistantText.length > 0 | |
| 192 | ? [userItem, { type: "agentMessage", text: assistantText }] | |
| 193 | : [userItem]; | |
| 194 | ||
| 195 | const nextTurn: ProviderThreadTurnSnapshot = { | |
| 196 | id: turnId, | |
| 197 | items: nextItems, | |
| 198 | }; | |
| 199 | ||
| 200 | state.snapshot = { | |
| 201 | threadId: state.snapshot.threadId, | |
| 202 | turns: [...state.snapshot.turns, nextTurn], | |
| 203 | }; | |
| 204 | ||
| 205 | if (deferredTurnCompletedEvents.length === 0) { | |
| 206 | yield* emit({ | |
| 207 | type: "turn.completed", | |
| 208 | eventId: EventId.makeUnsafe(randomUUID()), | |
| 209 | provider: PROVIDER, | |
| 210 | sessionId: input.sessionId, | |
| 211 | createdAt: nowIso(), | |
| 212 | threadId: state.snapshot.threadId, | |
| 213 | turnId, | |
| 214 | status: "completed", | |
| 215 | }); | |
| 216 | } else { | |
| 217 | for (const completedEvent of deferredTurnCompletedEvents) { | |
| 218 | yield* emit(completedEvent); | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | return { | |
| 223 | threadId: state.snapshot.threadId, | |
| 224 | turnId, | |
| 225 | } satisfies ProviderTurnStartResult; | |
| 226 | }); | |
| 227 | ||
| 228 | const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = ( | |
| 229 | sessionId, | |
| 230 | _turnId, | |
| 231 | ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); | |
| 232 | ||
| 233 | const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = ( | |
| 234 | sessionId, | |
| 235 | requestId, | |
| 236 | decision, | |
| 237 | ) => | |
| 238 | sessions.has(sessionId) | |
| 239 | ? Effect.sync(() => { | |
| 240 | const existing = approvalResponsesBySession.get(sessionId) ?? []; | |
| 241 | existing.push({ | |
| 242 | sessionId, | |
| 243 | requestId, | |
| 244 | decision, | |
| 245 | }); | |
| 246 | approvalResponsesBySession.set(sessionId, existing); | |
| 247 | }) | |
| 248 | : missingSessionEffect(sessionId); | |
| 249 | ||
| 250 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) => | |
| 251 | Effect.sync(() => { | |
| 252 | sessions.delete(sessionId); | |
| 253 | }); | |
| 254 | ||
| 255 | const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () => | |
| 256 | Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); | |
| 257 | ||
| 258 | const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (sessionId) => | |
| 259 | Effect.succeed(sessions.has(sessionId)); | |
| 260 | ||
| 261 | const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => { | |
| 262 | const state = sessions.get(sessionId); | |
| 263 | if (!state) { | |
| 264 | return missingSessionEffect(sessionId); | |
| 265 | } | |
| 266 | return Effect.succeed(state.snapshot); | |
| 267 | }; | |
| 268 | ||
| 269 | const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = ( | |
| 270 | sessionId, | |
| 271 | numTurns, | |
| 272 | ) => { | |
| 273 | const state = sessions.get(sessionId); | |
| 274 | if (!state) { | |
| 275 | return missingSessionEffect(sessionId); | |
| 276 | } | |
| 277 | if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { | |
| 278 | return Effect.fail( | |
| 279 | new ProviderAdapterValidationError({ | |
| 280 | provider: PROVIDER, | |
| 281 | operation: "rollbackThread", | |
| 282 | issue: "numTurns must be an integer between 0 and current turn count.", | |
| 283 | }), | |
| 284 | ); | |
| 285 | } | |
| 286 | ||
| 287 | return Effect.sync(() => { | |
| 288 | state.rollbackCalls.push(numTurns); | |
| 289 | state.snapshot = { | |
| 290 | threadId: state.snapshot.threadId, | |
| 291 | turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), | |
| 292 | }; | |
| 293 | state.turnCount = state.snapshot.turns.length; | |
| 294 | return state.snapshot; | |
| 295 | }); | |
| 296 | }; | |
| 297 | ||
| 298 | const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () => | |
| 299 | Effect.sync(() => { | |
| 300 | sessions.clear(); | |
| 301 | }); | |
| 302 | ||
| 303 | const adapter: ProviderAdapterShape<ProviderAdapterError> = { | |
| 304 | provider: PROVIDER, | |
| 305 | startSession, | |
| 306 | sendTurn, | |
| 307 | interruptTurn, | |
| 308 | respondToRequest, | |
| 309 | stopSession, | |
| 310 | listSessions, | |
| 311 | hasSession, | |
| 312 | readThread, | |
| 313 | rollbackThread, | |
| 314 | stopAll, | |
| 315 | streamEvents: Stream.fromQueue(runtimeEvents), | |
| 316 | }; | |
| 317 | ||
| 318 | const queueTurnResponse = ( | |
| 319 | sessionId: string, | |
| 320 | response: TestTurnResponse, | |
| 321 | ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> => | |
| 322 | Effect.sync(() => sessions.get(sessionId)).pipe( | |
| 323 | Effect.flatMap((state) => | |
| 324 | state | |
| 325 | ? Effect.sync(() => { | |
| 326 | state.queuedResponses.push(response); | |
| 327 | }) | |
| 328 | : Effect.fail(sessionNotFound(sessionId)), | |
| 329 | ), | |
| 330 | ); | |
| 331 | ||
| 332 | const queueTurnResponseForNextSession = ( | |
| 333 | response: TestTurnResponse, | |
| 334 | ): Effect.Effect<void, never> => | |
| 335 | Effect.sync(() => { | |
| 336 | queuedResponsesForNextSession.push(response); | |
| 337 | }); | |
| 338 | ||
| 339 | const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => { | |
| 340 | const state = sessions.get(sessionId); | |
| 341 | if (!state) { | |
| 342 | return []; | |
| 343 | } | |
| 344 | return [...state.rollbackCalls]; | |
| 345 | }; | |
| 346 | ||
| 347 | const getApprovalResponses = ( | |
| 348 | sessionId: string, | |
| 349 | ): ReadonlyArray<{ | |
| 350 | readonly sessionId: ProviderSessionId; | |
| 351 | readonly requestId: ApprovalRequestId; | |
| 352 | readonly decision: ProviderApprovalDecision; | |
| 353 | }> => { | |
| 354 | const responses = approvalResponsesBySession.get(sessionId); | |
| 355 | if (!responses) { | |
| 356 | return []; | |
| 357 | } | |
| 358 | return [...responses]; | |
| 359 | }; | |
| 360 | ||
| 361 | return { | |
| 362 | adapter, | |
| 363 | queueTurnResponse, | |
| 364 | queueTurnResponseForNextSession, | |
| 365 | getRollbackCalls, | |
| 366 | getApprovalResponses, | |
| 367 | } satisfies TestProviderAdapterHarness; | |
| 368 | }); |
apps/server/integration/fixtures/providerRuntime.ts created+138| ... | ... | @@ -0,0 +1,138 @@ |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | EventId, | |
| 4 | ProviderSessionId, | |
| 5 | ProviderThreadId, | |
| 6 | ProviderTurnId, | |
| 7 | type ProviderRuntimeEvent, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | ||
| 10 | const PROVIDER = "codex" as const; | |
| 11 | const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session"); | |
| 12 | const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); | |
| 13 | const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); | |
| 14 | const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); | |
| 15 | ||
| 16 | function baseEvent( | |
| 17 | eventId: string, | |
| 18 | createdAt: string, | |
| 19 | ): Pick<ProviderRuntimeEvent, "eventId" | "provider" | "sessionId" | "createdAt"> { | |
| 20 | return { | |
| 21 | eventId: EventId.makeUnsafe(eventId), | |
| 22 | provider: PROVIDER, | |
| 23 | sessionId: SESSION_ID, | |
| 24 | createdAt, | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | export const codexTurnTextFixture = [ | |
| 29 | { | |
| 30 | type: "turn.started", | |
| 31 | ...baseEvent("evt-1", "2026-02-23T00:00:00.000Z"), | |
| 32 | threadId: THREAD_ID, | |
| 33 | turnId: TURN_ID, | |
| 34 | }, | |
| 35 | { | |
| 36 | type: "message.delta", | |
| 37 | ...baseEvent("evt-2", "2026-02-23T00:00:00.100Z"), | |
| 38 | threadId: THREAD_ID, | |
| 39 | turnId: TURN_ID, | |
| 40 | delta: "I will make a small update.\n", | |
| 41 | }, | |
| 42 | { | |
| 43 | type: "message.delta", | |
| 44 | ...baseEvent("evt-3", "2026-02-23T00:00:00.200Z"), | |
| 45 | threadId: THREAD_ID, | |
| 46 | turnId: TURN_ID, | |
| 47 | delta: "Done.\n", | |
| 48 | }, | |
| 49 | { | |
| 50 | type: "turn.completed", | |
| 51 | ...baseEvent("evt-4", "2026-02-23T00:00:00.300Z"), | |
| 52 | threadId: THREAD_ID, | |
| 53 | turnId: TURN_ID, | |
| 54 | status: "completed", | |
| 55 | }, | |
| 56 | ] satisfies ReadonlyArray<ProviderRuntimeEvent>; | |
| 57 | ||
| 58 | export const codexTurnToolFixture = [ | |
| 59 | { | |
| 60 | type: "turn.started", | |
| 61 | ...baseEvent("evt-11", "2026-02-23T00:01:00.000Z"), | |
| 62 | threadId: THREAD_ID, | |
| 63 | turnId: TURN_ID, | |
| 64 | }, | |
| 65 | { | |
| 66 | type: "tool.started", | |
| 67 | ...baseEvent("evt-12", "2026-02-23T00:01:00.100Z"), | |
| 68 | threadId: THREAD_ID, | |
| 69 | turnId: TURN_ID, | |
| 70 | toolKind: "command", | |
| 71 | title: "Command run", | |
| 72 | detail: "echo integration", | |
| 73 | }, | |
| 74 | { | |
| 75 | type: "tool.completed", | |
| 76 | ...baseEvent("evt-13", "2026-02-23T00:01:00.200Z"), | |
| 77 | threadId: THREAD_ID, | |
| 78 | turnId: TURN_ID, | |
| 79 | toolKind: "command", | |
| 80 | title: "Command run", | |
| 81 | detail: "echo integration", | |
| 82 | }, | |
| 83 | { | |
| 84 | type: "message.delta", | |
| 85 | ...baseEvent("evt-14", "2026-02-23T00:01:00.300Z"), | |
| 86 | threadId: THREAD_ID, | |
| 87 | turnId: TURN_ID, | |
| 88 | delta: "Applied the requested edit.\n", | |
| 89 | }, | |
| 90 | { | |
| 91 | type: "turn.completed", | |
| 92 | ...baseEvent("evt-15", "2026-02-23T00:01:00.400Z"), | |
| 93 | threadId: THREAD_ID, | |
| 94 | turnId: TURN_ID, | |
| 95 | status: "completed", | |
| 96 | }, | |
| 97 | ] satisfies ReadonlyArray<ProviderRuntimeEvent>; | |
| 98 | ||
| 99 | export const codexTurnApprovalFixture = [ | |
| 100 | { | |
| 101 | type: "turn.started", | |
| 102 | ...baseEvent("evt-21", "2026-02-23T00:02:00.000Z"), | |
| 103 | threadId: THREAD_ID, | |
| 104 | turnId: TURN_ID, | |
| 105 | }, | |
| 106 | { | |
| 107 | type: "approval.requested", | |
| 108 | ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), | |
| 109 | threadId: THREAD_ID, | |
| 110 | turnId: TURN_ID, | |
| 111 | requestId: REQUEST_ID, | |
| 112 | requestKind: "command", | |
| 113 | detail: "Please approve command", | |
| 114 | }, | |
| 115 | { | |
| 116 | type: "approval.resolved", | |
| 117 | ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), | |
| 118 | threadId: THREAD_ID, | |
| 119 | turnId: TURN_ID, | |
| 120 | requestId: REQUEST_ID, | |
| 121 | requestKind: "command", | |
| 122 | decision: "accept", | |
| 123 | }, | |
| 124 | { | |
| 125 | type: "message.delta", | |
| 126 | ...baseEvent("evt-24", "2026-02-23T00:02:00.300Z"), | |
| 127 | threadId: THREAD_ID, | |
| 128 | turnId: TURN_ID, | |
| 129 | delta: "Approval received and command executed.\n", | |
| 130 | }, | |
| 131 | { | |
| 132 | type: "turn.completed", | |
| 133 | ...baseEvent("evt-25", "2026-02-23T00:02:00.400Z"), | |
| 134 | threadId: THREAD_ID, | |
| 135 | turnId: TURN_ID, | |
| 136 | status: "completed", | |
| 137 | }, | |
| 138 | ] satisfies ReadonlyArray<ProviderRuntimeEvent>; |
apps/server/integration/orchestrationEngine.integration.test.ts created+750| ... | ... | @@ -0,0 +1,750 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import { | |
| 5 | ApprovalRequestId, | |
| 6 | CommandId, | |
| 7 | EventId, | |
| 8 | MessageId, | |
| 9 | ProjectId, | |
| 10 | ProviderSessionId, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { assert, it } from "@effect/vitest"; | |
| 16 | import { Effect, Option, Schema } from "effect"; | |
| 17 | ||
| 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; | |
| 19 | import { | |
| 20 | gitRefExists, | |
| 21 | gitShowFileAtRef, | |
| 22 | makeOrchestrationIntegrationHarness, | |
| 23 | type OrchestrationIntegrationHarness, | |
| 24 | } from "./OrchestrationEngineHarness.integration.ts"; | |
| 25 | import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; | |
| 26 | ||
| 27 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 28 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 29 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 30 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 31 | ProviderSessionId.makeUnsafe(value); | |
| 32 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 34 | const asApprovalRequestId = (value: string): ApprovalRequestId => | |
| 35 | ApprovalRequestId.makeUnsafe(value); | |
| 36 | ||
| 37 | const PROJECT_ID = asProjectId("project-1"); | |
| 38 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); | |
| 39 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); | |
| 40 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); | |
| 41 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); | |
| 42 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); | |
| 43 | ||
| 44 | function nowIso() { | |
| 45 | return new Date().toISOString(); | |
| 46 | } | |
| 47 | ||
| 48 | class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWaitTimeoutError>()( | |
| 49 | "IntegrationWaitTimeoutError", | |
| 50 | { | |
| 51 | description: Schema.String, | |
| 52 | }, | |
| 53 | ) {} | |
| 54 | ||
| 55 | const sleep = (ms: number) => Effect.sleep(ms); | |
| 56 | ||
| 57 | function waitForSync<A>( | |
| 58 | read: () => A, | |
| 59 | predicate: (value: A) => boolean, | |
| 60 | description: string, | |
| 61 | timeoutMs = 3000, | |
| 62 | ): Effect.Effect<A, never> { | |
| 63 | return Effect.gen(function* () { | |
| 64 | const deadline = Date.now() + timeoutMs; | |
| 65 | ||
| 66 | while (true) { | |
| 67 | const value = read(); | |
| 68 | if (predicate(value)) { | |
| 69 | return value; | |
| 70 | } | |
| 71 | if (Date.now() >= deadline) { | |
| 72 | return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); | |
| 73 | } | |
| 74 | yield* sleep(10); | |
| 75 | } | |
| 76 | }); | |
| 77 | } | |
| 78 | ||
| 79 | function runtimeBase(eventId: string, createdAt: string) { | |
| 80 | return { | |
| 81 | eventId: asEventId(eventId), | |
| 82 | provider: "codex" as const, | |
| 83 | sessionId: FIXTURE_SESSION_ID, | |
| 84 | createdAt, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | function withHarness<A, E>(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>) { | |
| 89 | return Effect.acquireUseRelease( | |
| 90 | makeOrchestrationIntegrationHarness, | |
| 91 | use, | |
| 92 | (harness) => harness.dispose, | |
| 93 | ); | |
| 94 | } | |
| 95 | ||
| 96 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | |
| 97 | Effect.gen(function* () { | |
| 98 | const createdAt = nowIso(); | |
| 99 | ||
| 100 | yield* harness.engine.dispatch({ | |
| 101 | type: "project.create", | |
| 102 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 103 | projectId: PROJECT_ID, | |
| 104 | title: "Integration Project", | |
| 105 | workspaceRoot: harness.workspaceDir, | |
| 106 | defaultModel: "gpt-5-codex", | |
| 107 | createdAt, | |
| 108 | }); | |
| 109 | ||
| 110 | yield* harness.engine.dispatch({ | |
| 111 | type: "thread.create", | |
| 112 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 113 | threadId: THREAD_ID, | |
| 114 | projectId: PROJECT_ID, | |
| 115 | title: "Integration Thread", | |
| 116 | model: "gpt-5-codex", | |
| 117 | branch: null, | |
| 118 | worktreePath: harness.workspaceDir, | |
| 119 | createdAt, | |
| 120 | }); | |
| 121 | }); | |
| 122 | ||
| 123 | const startTurn = (input: { | |
| 124 | readonly harness: OrchestrationIntegrationHarness; | |
| 125 | readonly commandId: string; | |
| 126 | readonly messageId: string; | |
| 127 | readonly text: string; | |
| 128 | }) => | |
| 129 | input.harness.engine.dispatch({ | |
| 130 | type: "thread.turn.start", | |
| 131 | commandId: CommandId.makeUnsafe(input.commandId), | |
| 132 | threadId: THREAD_ID, | |
| 133 | message: { | |
| 134 | messageId: asMessageId(input.messageId), | |
| 135 | role: "user", | |
| 136 | text: input.text, | |
| 137 | attachments: [], | |
| 138 | }, | |
| 139 | approvalPolicy: "on-request", | |
| 140 | sandboxMode: "workspace-write", | |
| 141 | createdAt: nowIso(), | |
| 142 | }); | |
| 143 | ||
| 144 | it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => | |
| 145 | withHarness((harness) => | |
| 146 | Effect.gen(function* () { | |
| 147 | yield* seedProjectAndThread(harness); | |
| 148 | ||
| 149 | const turnResponse: TestTurnResponse = { | |
| 150 | events: [ | |
| 151 | { | |
| 152 | type: "turn.started", | |
| 153 | ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), | |
| 154 | threadId: FIXTURE_THREAD_ID, | |
| 155 | turnId: FIXTURE_TURN_ID, | |
| 156 | }, | |
| 157 | { | |
| 158 | type: "message.delta", | |
| 159 | ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), | |
| 160 | threadId: FIXTURE_THREAD_ID, | |
| 161 | turnId: FIXTURE_TURN_ID, | |
| 162 | delta: "Single turn response.\n", | |
| 163 | }, | |
| 164 | { | |
| 165 | type: "turn.completed", | |
| 166 | ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), | |
| 167 | threadId: FIXTURE_THREAD_ID, | |
| 168 | turnId: FIXTURE_TURN_ID, | |
| 169 | status: "completed", | |
| 170 | }, | |
| 171 | ], | |
| 172 | }; | |
| 173 | ||
| 174 | yield* harness.adapterHarness.queueTurnResponseForNextSession(turnResponse); | |
| 175 | yield* startTurn({ | |
| 176 | harness, | |
| 177 | commandId: "cmd-turn-start-single", | |
| 178 | messageId: "msg-user-single", | |
| 179 | text: "Say hello", | |
| 180 | }); | |
| 181 | ||
| 182 | const thread = yield* harness.waitForThread( | |
| 183 | THREAD_ID, | |
| 184 | (entry) => | |
| 185 | entry.session?.status === "ready" && | |
| 186 | entry.messages.some( | |
| 187 | (message) => message.role === "assistant" && message.streaming === false, | |
| 188 | ) && | |
| 189 | entry.checkpoints.length === 1, | |
| 190 | ); | |
| 191 | assert.equal(thread.checkpoints[0]?.status, "ready"); | |
| 192 | assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); | |
| 193 | ||
| 194 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 195 | threadId: THREAD_ID, | |
| 196 | }); | |
| 197 | assert.equal(checkpointRows.length, 1); | |
| 198 | assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); | |
| 199 | assert.equal(checkpointRows[0]?.status, "ready"); | |
| 200 | assert.deepEqual(checkpointRows[0]?.files, []); | |
| 201 | ||
| 202 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); | |
| 203 | ||
| 204 | const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); | |
| 205 | const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); | |
| 206 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); | |
| 207 | assert.equal(gitRefExists(harness.workspaceDir, ref1), true); | |
| 208 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); | |
| 209 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), "v1\n"); | |
| 210 | }), | |
| 211 | ), | |
| 212 | ); | |
| 213 | ||
| 214 | it.live("runs multi-turn file edits and persists checkpoint diffs", () => | |
| 215 | withHarness((harness) => | |
| 216 | Effect.gen(function* () { | |
| 217 | yield* seedProjectAndThread(harness); | |
| 218 | ||
| 219 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 220 | events: [ | |
| 221 | { | |
| 222 | type: "turn.started", | |
| 223 | ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), | |
| 224 | threadId: FIXTURE_THREAD_ID, | |
| 225 | turnId: FIXTURE_TURN_ID, | |
| 226 | }, | |
| 227 | { | |
| 228 | type: "tool.started", | |
| 229 | ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), | |
| 230 | threadId: FIXTURE_THREAD_ID, | |
| 231 | turnId: FIXTURE_TURN_ID, | |
| 232 | toolKind: "command", | |
| 233 | title: "Edit file", | |
| 234 | detail: "README.md", | |
| 235 | }, | |
| 236 | { | |
| 237 | type: "tool.completed", | |
| 238 | ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), | |
| 239 | threadId: FIXTURE_THREAD_ID, | |
| 240 | turnId: FIXTURE_TURN_ID, | |
| 241 | toolKind: "command", | |
| 242 | title: "Edit file", | |
| 243 | detail: "README.md", | |
| 244 | }, | |
| 245 | { | |
| 246 | type: "message.delta", | |
| 247 | ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), | |
| 248 | threadId: FIXTURE_THREAD_ID, | |
| 249 | turnId: FIXTURE_TURN_ID, | |
| 250 | delta: "Updated README to v2.\n", | |
| 251 | }, | |
| 252 | { | |
| 253 | type: "turn.completed", | |
| 254 | ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), | |
| 255 | threadId: FIXTURE_THREAD_ID, | |
| 256 | turnId: FIXTURE_TURN_ID, | |
| 257 | status: "completed", | |
| 258 | }, | |
| 259 | ], | |
| 260 | mutateWorkspace: ({ cwd }) => | |
| 261 | Effect.sync(() => { | |
| 262 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | |
| 263 | }), | |
| 264 | }); | |
| 265 | ||
| 266 | yield* startTurn({ | |
| 267 | harness, | |
| 268 | commandId: "cmd-turn-start-multi-1", | |
| 269 | messageId: "msg-user-multi-1", | |
| 270 | text: "Make first edit", | |
| 271 | }); | |
| 272 | ||
| 273 | const firstTurnThread = yield* harness.waitForThread( | |
| 274 | THREAD_ID, | |
| 275 | (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null, | |
| 276 | ); | |
| 277 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 278 | assert.equal(sessionId !== null, true); | |
| 279 | if (!sessionId) { | |
| 280 | return; | |
| 281 | } | |
| 282 | ||
| 283 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 284 | events: [ | |
| 285 | { | |
| 286 | type: "turn.started", | |
| 287 | ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), | |
| 288 | threadId: FIXTURE_THREAD_ID, | |
| 289 | turnId: FIXTURE_TURN_ID, | |
| 290 | }, | |
| 291 | { | |
| 292 | type: "message.delta", | |
| 293 | ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), | |
| 294 | threadId: FIXTURE_THREAD_ID, | |
| 295 | turnId: FIXTURE_TURN_ID, | |
| 296 | delta: "Updated README to v3.\n", | |
| 297 | }, | |
| 298 | { | |
| 299 | type: "turn.completed", | |
| 300 | ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), | |
| 301 | threadId: FIXTURE_THREAD_ID, | |
| 302 | turnId: FIXTURE_TURN_ID, | |
| 303 | status: "completed", | |
| 304 | }, | |
| 305 | ], | |
| 306 | mutateWorkspace: ({ cwd }) => | |
| 307 | Effect.sync(() => { | |
| 308 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | |
| 309 | }), | |
| 310 | }); | |
| 311 | ||
| 312 | yield* startTurn({ | |
| 313 | harness, | |
| 314 | commandId: "cmd-turn-start-multi-2", | |
| 315 | messageId: "msg-user-multi-2", | |
| 316 | text: "Make second edit", | |
| 317 | }); | |
| 318 | ||
| 319 | const secondTurnThread = yield* harness.waitForThread( | |
| 320 | THREAD_ID, | |
| 321 | (entry) => | |
| 322 | entry.latestTurn?.turnId === "turn-2" && | |
| 323 | entry.checkpoints.length === 2 && | |
| 324 | entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), | |
| 325 | ); | |
| 326 | const secondCheckpoint = secondTurnThread.checkpoints.find( | |
| 327 | (checkpoint) => checkpoint.checkpointTurnCount === 2, | |
| 328 | ); | |
| 329 | assert.equal( | |
| 330 | secondCheckpoint?.files.some((file) => file.path === "README.md"), | |
| 331 | true, | |
| 332 | ); | |
| 333 | ||
| 334 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 335 | threadId: THREAD_ID, | |
| 336 | }); | |
| 337 | assert.deepEqual( | |
| 338 | checkpointRows.map((row) => row.checkpointTurnCount), | |
| 339 | [1, 2], | |
| 340 | ); | |
| 341 | ||
| 342 | const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ | |
| 343 | cwd: harness.workspaceDir, | |
| 344 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), | |
| 345 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 346 | fallbackFromToHead: false, | |
| 347 | }); | |
| 348 | assert.equal(incrementalDiff.includes("README.md"), true); | |
| 349 | ||
| 350 | const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ | |
| 351 | cwd: harness.workspaceDir, | |
| 352 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), | |
| 353 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 354 | fallbackFromToHead: false, | |
| 355 | }); | |
| 356 | assert.equal(fullDiff.includes("README.md"), true); | |
| 357 | ||
| 358 | assert.equal( | |
| 359 | gitShowFileAtRef( | |
| 360 | harness.workspaceDir, | |
| 361 | checkpointRefForThreadTurn(THREAD_ID, 1), | |
| 362 | "README.md", | |
| 363 | ), | |
| 364 | "v2\n", | |
| 365 | ); | |
| 366 | assert.equal( | |
| 367 | gitShowFileAtRef( | |
| 368 | harness.workspaceDir, | |
| 369 | checkpointRefForThreadTurn(THREAD_ID, 2), | |
| 370 | "README.md", | |
| 371 | ), | |
| 372 | "v3\n", | |
| 373 | ); | |
| 374 | }), | |
| 375 | ), | |
| 376 | ); | |
| 377 | ||
| 378 | it.live("tracks approval requests and resolves pending approvals on user response", () => | |
| 379 | withHarness((harness) => | |
| 380 | Effect.gen(function* () { | |
| 381 | yield* seedProjectAndThread(harness); | |
| 382 | ||
| 383 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 384 | events: [ | |
| 385 | { | |
| 386 | type: "turn.started", | |
| 387 | ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), | |
| 388 | threadId: FIXTURE_THREAD_ID, | |
| 389 | turnId: FIXTURE_TURN_ID, | |
| 390 | }, | |
| 391 | { | |
| 392 | type: "approval.requested", | |
| 393 | ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), | |
| 394 | threadId: FIXTURE_THREAD_ID, | |
| 395 | turnId: FIXTURE_TURN_ID, | |
| 396 | requestId: APPROVAL_REQUEST_ID, | |
| 397 | requestKind: "command", | |
| 398 | detail: "Approve command execution", | |
| 399 | }, | |
| 400 | { | |
| 401 | type: "turn.completed", | |
| 402 | ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), | |
| 403 | threadId: FIXTURE_THREAD_ID, | |
| 404 | turnId: FIXTURE_TURN_ID, | |
| 405 | status: "completed", | |
| 406 | }, | |
| 407 | ], | |
| 408 | }); | |
| 409 | ||
| 410 | yield* startTurn({ | |
| 411 | harness, | |
| 412 | commandId: "cmd-turn-start-approval", | |
| 413 | messageId: "msg-user-approval", | |
| 414 | text: "Run command needing approval", | |
| 415 | }); | |
| 416 | ||
| 417 | const thread = yield* harness.waitForThread(THREAD_ID, (entry) => | |
| 418 | entry.activities.some((activity) => activity.kind === "approval.requested"), | |
| 419 | ); | |
| 420 | assert.equal( | |
| 421 | thread.activities.some((activity) => activity.kind === "approval.requested"), | |
| 422 | true, | |
| 423 | ); | |
| 424 | ||
| 425 | const pendingRow = yield* harness.waitForPendingApproval( | |
| 426 | "req-approval-1", | |
| 427 | (row) => row.status === "pending" && row.decision === null, | |
| 428 | ); | |
| 429 | assert.equal(pendingRow.status, "pending"); | |
| 430 | ||
| 431 | yield* harness.engine.dispatch({ | |
| 432 | type: "thread.approval.respond", | |
| 433 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), | |
| 434 | threadId: THREAD_ID, | |
| 435 | requestId: APPROVAL_REQUEST_ID, | |
| 436 | decision: "accept", | |
| 437 | createdAt: nowIso(), | |
| 438 | }); | |
| 439 | ||
| 440 | const resolvedRow = yield* harness.waitForPendingApproval( | |
| 441 | "req-approval-1", | |
| 442 | (row) => row.status === "resolved" && row.decision === "accept", | |
| 443 | ); | |
| 444 | assert.equal(resolvedRow.status, "resolved"); | |
| 445 | assert.equal(resolvedRow.decision, "accept"); | |
| 446 | ||
| 447 | const providerSessionId = thread.session?.providerSessionId ?? "test-session-1"; | |
| 448 | const approvalResponses = yield* waitForSync( | |
| 449 | () => harness.adapterHarness.getApprovalResponses(providerSessionId), | |
| 450 | (responses) => responses.length === 1, | |
| 451 | "provider approval response", | |
| 452 | ); | |
| 453 | assert.equal(approvalResponses.length, 1); | |
| 454 | assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); | |
| 455 | assert.equal(approvalResponses[0]?.decision, "accept"); | |
| 456 | }), | |
| 457 | ), | |
| 458 | ); | |
| 459 | ||
| 460 | it.live("records failed turn runtime state and checkpoint status as error", () => | |
| 461 | withHarness((harness) => | |
| 462 | Effect.gen(function* () { | |
| 463 | yield* seedProjectAndThread(harness); | |
| 464 | ||
| 465 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 466 | events: [ | |
| 467 | { | |
| 468 | type: "turn.started", | |
| 469 | ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), | |
| 470 | threadId: FIXTURE_THREAD_ID, | |
| 471 | turnId: FIXTURE_TURN_ID, | |
| 472 | }, | |
| 473 | { | |
| 474 | type: "message.delta", | |
| 475 | ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), | |
| 476 | threadId: FIXTURE_THREAD_ID, | |
| 477 | turnId: FIXTURE_TURN_ID, | |
| 478 | delta: "Partial output before failure.\n", | |
| 479 | }, | |
| 480 | { | |
| 481 | type: "runtime.error", | |
| 482 | ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), | |
| 483 | threadId: FIXTURE_THREAD_ID, | |
| 484 | turnId: FIXTURE_TURN_ID, | |
| 485 | message: "Sandbox command failed.", | |
| 486 | }, | |
| 487 | { | |
| 488 | type: "turn.completed", | |
| 489 | ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), | |
| 490 | threadId: FIXTURE_THREAD_ID, | |
| 491 | turnId: FIXTURE_TURN_ID, | |
| 492 | status: "failed", | |
| 493 | errorMessage: "Sandbox command failed.", | |
| 494 | }, | |
| 495 | ], | |
| 496 | }); | |
| 497 | ||
| 498 | yield* startTurn({ | |
| 499 | harness, | |
| 500 | commandId: "cmd-turn-start-failure", | |
| 501 | messageId: "msg-user-failure", | |
| 502 | text: "Run risky command", | |
| 503 | }); | |
| 504 | ||
| 505 | const thread = yield* harness.waitForThread( | |
| 506 | THREAD_ID, | |
| 507 | (entry) => | |
| 508 | entry.session?.status === "error" && | |
| 509 | entry.session?.lastError === "Sandbox command failed." && | |
| 510 | entry.activities.some((activity) => activity.kind === "runtime.error") && | |
| 511 | entry.checkpoints.length === 1, | |
| 512 | ); | |
| 513 | assert.equal(thread.session?.status, "error"); | |
| 514 | assert.equal(thread.checkpoints[0]?.status, "error"); | |
| 515 | ||
| 516 | const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ | |
| 517 | threadId: THREAD_ID, | |
| 518 | checkpointTurnCount: 1, | |
| 519 | }); | |
| 520 | assert.equal(Option.isSome(checkpointRow), true); | |
| 521 | if (Option.isSome(checkpointRow)) { | |
| 522 | assert.equal(checkpointRow.value.status, "error"); | |
| 523 | } | |
| 524 | assert.equal( | |
| 525 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), | |
| 526 | true, | |
| 527 | ); | |
| 528 | }), | |
| 529 | ), | |
| 530 | ); | |
| 531 | ||
| 532 | it.live("reverts to an earlier checkpoint and trims checkpoint projections + git refs", () => | |
| 533 | withHarness((harness) => | |
| 534 | Effect.gen(function* () { | |
| 535 | yield* seedProjectAndThread(harness); | |
| 536 | ||
| 537 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 538 | events: [ | |
| 539 | { | |
| 540 | type: "turn.started", | |
| 541 | ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), | |
| 542 | threadId: FIXTURE_THREAD_ID, | |
| 543 | turnId: FIXTURE_TURN_ID, | |
| 544 | }, | |
| 545 | { | |
| 546 | type: "tool.started", | |
| 547 | ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"), | |
| 548 | threadId: FIXTURE_THREAD_ID, | |
| 549 | turnId: FIXTURE_TURN_ID, | |
| 550 | toolKind: "command", | |
| 551 | title: "Edit file", | |
| 552 | detail: "README.md", | |
| 553 | }, | |
| 554 | { | |
| 555 | type: "tool.completed", | |
| 556 | ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"), | |
| 557 | threadId: FIXTURE_THREAD_ID, | |
| 558 | turnId: FIXTURE_TURN_ID, | |
| 559 | toolKind: "command", | |
| 560 | title: "Edit file", | |
| 561 | detail: "README.md", | |
| 562 | }, | |
| 563 | { | |
| 564 | type: "message.delta", | |
| 565 | ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"), | |
| 566 | threadId: FIXTURE_THREAD_ID, | |
| 567 | turnId: FIXTURE_TURN_ID, | |
| 568 | delta: "Updated README to v2.\n", | |
| 569 | }, | |
| 570 | { | |
| 571 | type: "turn.completed", | |
| 572 | ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), | |
| 573 | threadId: FIXTURE_THREAD_ID, | |
| 574 | turnId: FIXTURE_TURN_ID, | |
| 575 | status: "completed", | |
| 576 | }, | |
| 577 | ], | |
| 578 | mutateWorkspace: ({ cwd }) => | |
| 579 | Effect.sync(() => { | |
| 580 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | |
| 581 | }), | |
| 582 | }); | |
| 583 | yield* startTurn({ | |
| 584 | harness, | |
| 585 | commandId: "cmd-turn-start-revert-1", | |
| 586 | messageId: "msg-user-revert-1", | |
| 587 | text: "First edit", | |
| 588 | }); | |
| 589 | ||
| 590 | const firstTurnThread = yield* harness.waitForThread( | |
| 591 | THREAD_ID, | |
| 592 | (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1, | |
| 593 | ); | |
| 594 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 595 | assert.equal(sessionId !== null, true); | |
| 596 | if (!sessionId) { | |
| 597 | return; | |
| 598 | } | |
| 599 | ||
| 600 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 601 | events: [ | |
| 602 | { | |
| 603 | type: "turn.started", | |
| 604 | ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), | |
| 605 | threadId: FIXTURE_THREAD_ID, | |
| 606 | turnId: FIXTURE_TURN_ID, | |
| 607 | }, | |
| 608 | { | |
| 609 | type: "tool.started", | |
| 610 | ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"), | |
| 611 | threadId: FIXTURE_THREAD_ID, | |
| 612 | turnId: FIXTURE_TURN_ID, | |
| 613 | toolKind: "command", | |
| 614 | title: "Edit file", | |
| 615 | detail: "README.md", | |
| 616 | }, | |
| 617 | { | |
| 618 | type: "tool.completed", | |
| 619 | ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"), | |
| 620 | threadId: FIXTURE_THREAD_ID, | |
| 621 | turnId: FIXTURE_TURN_ID, | |
| 622 | toolKind: "command", | |
| 623 | title: "Edit file", | |
| 624 | detail: "README.md", | |
| 625 | }, | |
| 626 | { | |
| 627 | type: "message.delta", | |
| 628 | ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"), | |
| 629 | threadId: FIXTURE_THREAD_ID, | |
| 630 | turnId: FIXTURE_TURN_ID, | |
| 631 | delta: "Updated README to v3.\n", | |
| 632 | }, | |
| 633 | { | |
| 634 | type: "turn.completed", | |
| 635 | ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), | |
| 636 | threadId: FIXTURE_THREAD_ID, | |
| 637 | turnId: FIXTURE_TURN_ID, | |
| 638 | status: "completed", | |
| 639 | }, | |
| 640 | ], | |
| 641 | mutateWorkspace: ({ cwd }) => | |
| 642 | Effect.sync(() => { | |
| 643 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | |
| 644 | }), | |
| 645 | }); | |
| 646 | yield* startTurn({ | |
| 647 | harness, | |
| 648 | commandId: "cmd-turn-start-revert-2", | |
| 649 | messageId: "msg-user-revert-2", | |
| 650 | text: "Second edit", | |
| 651 | }); | |
| 652 | ||
| 653 | yield* harness.waitForThread( | |
| 654 | THREAD_ID, | |
| 655 | (entry) => | |
| 656 | entry.latestTurn?.turnId === "turn-2" && | |
| 657 | entry.checkpoints.length === 2 && | |
| 658 | entry.activities.some((activity) => activity.turnId === "turn-2"), | |
| 659 | 8000, | |
| 660 | ); | |
| 661 | ||
| 662 | yield* harness.engine.dispatch({ | |
| 663 | type: "thread.checkpoint.revert", | |
| 664 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), | |
| 665 | threadId: THREAD_ID, | |
| 666 | turnCount: 1, | |
| 667 | createdAt: nowIso(), | |
| 668 | }); | |
| 669 | ||
| 670 | yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); | |
| 671 | const revertedThread = yield* harness.waitForThread( | |
| 672 | THREAD_ID, | |
| 673 | (entry) => | |
| 674 | entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, | |
| 675 | ); | |
| 676 | assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); | |
| 677 | assert.deepEqual( | |
| 678 | revertedThread.messages.map((message) => ({ role: message.role, text: message.text })), | |
| 679 | [ | |
| 680 | { role: "user", text: "First edit" }, | |
| 681 | { role: "assistant", text: "Updated README to v2.\n" }, | |
| 682 | ], | |
| 683 | ); | |
| 684 | assert.equal( | |
| 685 | revertedThread.activities.some((activity) => activity.turnId === "turn-2"), | |
| 686 | false, | |
| 687 | ); | |
| 688 | assert.equal( | |
| 689 | revertedThread.activities.some( | |
| 690 | (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started", | |
| 691 | ), | |
| 692 | true, | |
| 693 | ); | |
| 694 | assert.equal( | |
| 695 | revertedThread.activities.some( | |
| 696 | (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed", | |
| 697 | ), | |
| 698 | true, | |
| 699 | ); | |
| 700 | assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); | |
| 701 | assert.equal( | |
| 702 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), | |
| 703 | false, | |
| 704 | ); | |
| 705 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); | |
| 706 | ||
| 707 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 708 | threadId: THREAD_ID, | |
| 709 | }); | |
| 710 | assert.equal(checkpointRows.length, 1); | |
| 711 | }), | |
| 712 | ), | |
| 713 | ); | |
| 714 | ||
| 715 | it.live( | |
| 716 | "appends checkpoint.revert.failed activity when revert is requested without an active session", | |
| 717 | () => | |
| 718 | withHarness((harness) => | |
| 719 | Effect.gen(function* () { | |
| 720 | yield* seedProjectAndThread(harness); | |
| 721 | ||
| 722 | yield* harness.engine.dispatch({ | |
| 723 | type: "thread.checkpoint.revert", | |
| 724 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), | |
| 725 | threadId: THREAD_ID, | |
| 726 | turnCount: 0, | |
| 727 | createdAt: nowIso(), | |
| 728 | }); | |
| 729 | ||
| 730 | const thread = yield* harness.waitForThread(THREAD_ID, (entry) => | |
| 731 | entry.activities.some( | |
| 732 | (activity) => | |
| 733 | activity.kind === "checkpoint.revert.failed" && | |
| 734 | typeof activity.payload === "object" && | |
| 735 | activity.payload !== null, | |
| 736 | ), | |
| 737 | ); | |
| 738 | const failureActivity = thread.activities.find( | |
| 739 | (activity) => activity.kind === "checkpoint.revert.failed", | |
| 740 | ); | |
| 741 | assert.equal(failureActivity !== undefined, true); | |
| 742 | assert.equal( | |
| 743 | String( | |
| 744 | (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, | |
| 745 | ).includes("No active provider session"), | |
| 746 | true, | |
| 747 | ); | |
| 748 | }), | |
| 749 | ), | |
| 750 | ); |
apps/server/integration/providerService.integration.test.ts created+287| ... | ... | @@ -0,0 +1,287 @@ |
| 1 | import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | import { ThreadId } from "@t3tools/contracts"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it, assert } from "@effect/vitest"; | |
| 5 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; | |
| 6 | ||
| 7 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | |
| 8 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | |
| 9 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | |
| 10 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | |
| 11 | import { | |
| 12 | ProviderService, | |
| 13 | type ProviderServiceShape, | |
| 14 | } from "../src/provider/Services/ProviderService.ts"; | |
| 15 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; | |
| 16 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; | |
| 17 | ||
| 18 | import { | |
| 19 | makeTestProviderAdapterHarness, | |
| 20 | type TestProviderAdapterHarness, | |
| 21 | type TestTurnResponse, | |
| 22 | } from "./TestProviderAdapter.integration.ts"; | |
| 23 | import { | |
| 24 | codexTurnApprovalFixture, | |
| 25 | codexTurnToolFixture, | |
| 26 | codexTurnTextFixture, | |
| 27 | } from "./fixtures/providerRuntime.ts"; | |
| 28 | ||
| 29 | const makeWorkspaceDirectory = Effect.gen(function* () { | |
| 30 | const fs = yield* FileSystem.FileSystem; | |
| 31 | const pathService = yield* Path.Path; | |
| 32 | const cwd = yield* fs.makeTempDirectory(); | |
| 33 | yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); | |
| 34 | return cwd; | |
| 35 | }).pipe(Effect.provide(NodeServices.layer)); | |
| 36 | ||
| 37 | interface IntegrationFixture { | |
| 38 | readonly cwd: string; | |
| 39 | readonly harness: TestProviderAdapterHarness; | |
| 40 | readonly layer: Layer.Layer<ProviderService, unknown, never>; | |
| 41 | } | |
| 42 | ||
| 43 | const makeIntegrationFixture = Effect.gen(function* () { | |
| 44 | const cwd = yield* makeWorkspaceDirectory; | |
| 45 | const harness = yield* makeTestProviderAdapterHarness; | |
| 46 | ||
| 47 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 48 | getByProvider: (provider) => | |
| 49 | provider === "codex" | |
| 50 | ? Effect.succeed(harness.adapter) | |
| 51 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 52 | listProviders: () => Effect.succeed(["codex"]), | |
| 53 | }; | |
| 54 | ||
| 55 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 56 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 57 | ); | |
| 58 | ||
| 59 | const shared = Layer.mergeAll( | |
| 60 | directoryLayer, | |
| 61 | Layer.succeed(ProviderAdapterRegistry, registry), | |
| 62 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | |
| 63 | ||
| 64 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); | |
| 65 | ||
| 66 | return { | |
| 67 | cwd, | |
| 68 | harness, | |
| 69 | layer, | |
| 70 | } satisfies IntegrationFixture; | |
| 71 | }); | |
| 72 | ||
| 73 | const collectEventsDuring = <A, E, R>( | |
| 74 | stream: Stream.Stream<ProviderRuntimeEvent>, | |
| 75 | count: number, | |
| 76 | action: Effect.Effect<A, E, R>, | |
| 77 | ) => | |
| 78 | Effect.gen(function* () { | |
| 79 | const queue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | |
| 80 | yield* Stream.runForEach(stream, (event) => Queue.offer(queue, event).pipe(Effect.asVoid)).pipe( | |
| 81 | Effect.forkScoped, | |
| 82 | ); | |
| 83 | ||
| 84 | yield* action; | |
| 85 | ||
| 86 | return yield* Effect.forEach( | |
| 87 | Array.from({ length: count }, () => undefined), | |
| 88 | () => Queue.take(queue), | |
| 89 | { discard: false }, | |
| 90 | ); | |
| 91 | }); | |
| 92 | ||
| 93 | const runTurn = (input: { | |
| 94 | readonly provider: ProviderServiceShape; | |
| 95 | readonly harness: TestProviderAdapterHarness; | |
| 96 | readonly sessionId: ProviderSessionId; | |
| 97 | readonly userText: string; | |
| 98 | readonly response: TestTurnResponse; | |
| 99 | }) => | |
| 100 | Effect.gen(function* () { | |
| 101 | yield* input.harness.queueTurnResponse(input.sessionId, input.response); | |
| 102 | ||
| 103 | return yield* collectEventsDuring( | |
| 104 | input.provider.streamEvents, | |
| 105 | input.response.events.length, | |
| 106 | input.provider.sendTurn({ | |
| 107 | sessionId: input.sessionId, | |
| 108 | input: input.userText, | |
| 109 | attachments: [], | |
| 110 | }), | |
| 111 | ); | |
| 112 | }); | |
| 113 | ||
| 114 | it.effect("replays typed runtime fixture events", () => | |
| 115 | Effect.gen(function* () { | |
| 116 | const fixture = yield* makeIntegrationFixture; | |
| 117 | ||
| 118 | yield* Effect.gen(function* () { | |
| 119 | const provider = yield* ProviderService; | |
| 120 | const session = yield* provider.startSession( | |
| 121 | ThreadId.makeUnsafe("thread-integration-typed"), | |
| 122 | { | |
| 123 | provider: "codex", | |
| 124 | cwd: fixture.cwd, | |
| 125 | }, | |
| 126 | ); | |
| 127 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 128 | ||
| 129 | const observedEvents = yield* runTurn({ | |
| 130 | provider, | |
| 131 | harness: fixture.harness, | |
| 132 | sessionId: session.sessionId, | |
| 133 | userText: "hello", | |
| 134 | response: { events: codexTurnTextFixture }, | |
| 135 | }); | |
| 136 | ||
| 137 | assert.deepEqual( | |
| 138 | observedEvents.map((event) => event.type), | |
| 139 | codexTurnTextFixture.map((event) => event.type), | |
| 140 | ); | |
| 141 | }).pipe(Effect.provide(fixture.layer)); | |
| 142 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 143 | ); | |
| 144 | ||
| 145 | it.effect("replays file-changing fixture turn events", () => | |
| 146 | Effect.gen(function* () { | |
| 147 | const fixture = yield* makeIntegrationFixture; | |
| 148 | const { join } = yield* Path.Path; | |
| 149 | const { writeFileString } = yield* FileSystem.FileSystem; | |
| 150 | ||
| 151 | yield* Effect.gen(function* () { | |
| 152 | const provider = yield* ProviderService; | |
| 153 | const session = yield* provider.startSession( | |
| 154 | ThreadId.makeUnsafe("thread-integration-tools"), | |
| 155 | { | |
| 156 | provider: "codex", | |
| 157 | cwd: fixture.cwd, | |
| 158 | }, | |
| 159 | ); | |
| 160 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 161 | ||
| 162 | const observedEvents = yield* runTurn({ | |
| 163 | provider, | |
| 164 | harness: fixture.harness, | |
| 165 | sessionId: session.sessionId, | |
| 166 | userText: "make a small change", | |
| 167 | response: { | |
| 168 | events: codexTurnToolFixture, | |
| 169 | mutateWorkspace: ({ cwd }) => | |
| 170 | writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), | |
| 171 | }, | |
| 172 | }); | |
| 173 | ||
| 174 | assert.deepEqual( | |
| 175 | observedEvents.map((event) => event.type), | |
| 176 | codexTurnToolFixture.map((event) => event.type), | |
| 177 | ); | |
| 178 | }).pipe(Effect.provide(fixture.layer)); | |
| 179 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 180 | ); | |
| 181 | ||
| 182 | it.effect("runs multi-turn tool/approval flow", () => | |
| 183 | Effect.gen(function* () { | |
| 184 | const fixture = yield* makeIntegrationFixture; | |
| 185 | const { join } = yield* Path.Path; | |
| 186 | const { writeFileString } = yield* FileSystem.FileSystem; | |
| 187 | ||
| 188 | yield* Effect.gen(function* () { | |
| 189 | const provider = yield* ProviderService; | |
| 190 | const session = yield* provider.startSession( | |
| 191 | ThreadId.makeUnsafe("thread-integration-multi"), | |
| 192 | { | |
| 193 | provider: "codex", | |
| 194 | cwd: fixture.cwd, | |
| 195 | }, | |
| 196 | ); | |
| 197 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 198 | ||
| 199 | const firstTurnEvents = yield* runTurn({ | |
| 200 | provider, | |
| 201 | harness: fixture.harness, | |
| 202 | sessionId: session.sessionId, | |
| 203 | userText: "turn 1", | |
| 204 | response: { | |
| 205 | events: codexTurnToolFixture, | |
| 206 | mutateWorkspace: ({ cwd }) => | |
| 207 | writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), | |
| 208 | }, | |
| 209 | }); | |
| 210 | assert.deepEqual( | |
| 211 | firstTurnEvents.map((event) => event.type), | |
| 212 | codexTurnToolFixture.map((event) => event.type), | |
| 213 | ); | |
| 214 | ||
| 215 | const secondTurnEvents = yield* runTurn({ | |
| 216 | provider, | |
| 217 | harness: fixture.harness, | |
| 218 | sessionId: session.sessionId, | |
| 219 | userText: "turn 2 approval", | |
| 220 | response: { | |
| 221 | events: codexTurnApprovalFixture, | |
| 222 | mutateWorkspace: ({ cwd }) => | |
| 223 | writeFileString(join(cwd, "README.md"), "v3\n").pipe(Effect.asVoid, Effect.ignore), | |
| 224 | }, | |
| 225 | }); | |
| 226 | assert.deepEqual( | |
| 227 | secondTurnEvents.map((event) => event.type), | |
| 228 | codexTurnApprovalFixture.map((event) => event.type), | |
| 229 | ); | |
| 230 | }).pipe(Effect.provide(fixture.layer)); | |
| 231 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 232 | ); | |
| 233 | ||
| 234 | it.effect("rolls back provider conversation state only", () => | |
| 235 | Effect.gen(function* () { | |
| 236 | const fixture = yield* makeIntegrationFixture; | |
| 237 | const { join } = yield* Path.Path; | |
| 238 | const { writeFileString, readFileString } = yield* FileSystem.FileSystem; | |
| 239 | ||
| 240 | yield* Effect.gen(function* () { | |
| 241 | const provider = yield* ProviderService; | |
| 242 | const session = yield* provider.startSession( | |
| 243 | ThreadId.makeUnsafe("thread-integration-rollback"), | |
| 244 | { | |
| 245 | provider: "codex", | |
| 246 | cwd: fixture.cwd, | |
| 247 | }, | |
| 248 | ); | |
| 249 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 250 | ||
| 251 | yield* runTurn({ | |
| 252 | provider, | |
| 253 | harness: fixture.harness, | |
| 254 | sessionId: session.sessionId, | |
| 255 | userText: "turn 1", | |
| 256 | response: { | |
| 257 | events: codexTurnToolFixture, | |
| 258 | mutateWorkspace: ({ cwd }) => | |
| 259 | writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), | |
| 260 | }, | |
| 261 | }); | |
| 262 | ||
| 263 | yield* runTurn({ | |
| 264 | provider, | |
| 265 | harness: fixture.harness, | |
| 266 | sessionId: session.sessionId, | |
| 267 | userText: "turn 2 approval", | |
| 268 | response: { | |
| 269 | events: codexTurnApprovalFixture, | |
| 270 | mutateWorkspace: ({ cwd }) => | |
| 271 | writeFileString(join(cwd, "README.md"), "v3\n").pipe(Effect.asVoid, Effect.ignore), | |
| 272 | }, | |
| 273 | }); | |
| 274 | ||
| 275 | yield* provider.rollbackConversation({ | |
| 276 | sessionId: session.sessionId, | |
| 277 | numTurns: 1, | |
| 278 | }); | |
| 279 | ||
| 280 | const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); | |
| 281 | assert.deepEqual(rollbackCalls, [1]); | |
| 282 | ||
| 283 | const readme = yield* readFileString(join(fixture.cwd, "README.md")); | |
| 284 | assert.equal(readme, "v3\n"); | |
| 285 | }).pipe(Effect.provide(fixture.layer)); | |
| 286 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 287 | ); |
apps/server/package.json+12-3| ... | ... | @@ -10,23 +10,32 @@ |
| 10 | 10 | "type": "module", |
| 11 | 11 | "main": "./dist/index.mjs", |
| 12 | 12 | "scripts": { |
| 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", | |
| 14 | "build": "tsdown && node scripts/bundle-client.mjs", | |
| 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts", | |
| 14 | "build": "tsdown && node scripts/bundle-client.ts", | |
| 15 | 15 | "start": "node dist/index.mjs", |
| 16 | "prepare": "effect-language-service patch", | |
| 16 | 17 | "typecheck": "tsc --noEmit", |
| 17 | 18 | "test": "vitest run" |
| 18 | 19 | }, |
| 19 | 20 | "dependencies": { |
| 21 | "@effect/platform-node": "catalog:", | |
| 22 | "@effect/sql-sqlite-bun": "catalog:", | |
| 23 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 24 | "effect": "catalog:", | |
| 20 | 25 | "node-pty": "^1.1.0", |
| 21 | 26 | "open": "^10.1.0", |
| 22 | 27 | "ws": "^8.18.0" |
| 23 | 28 | }, |
| 24 | 29 | "devDependencies": { |
| 30 | "@effect/language-service": "0.75.1", | |
| 31 | "@effect/vitest": "catalog:", | |
| 25 | 32 | "@t3tools/contracts": "workspace:*", |
| 26 | 33 | "@t3tools/web": "workspace:*", |
| 34 | "@types/bun": "^1.3.9", | |
| 27 | 35 | "@types/node": "^22.10.2", |
| 28 | 36 | "@types/ws": "^8.5.13", |
| 29 | 37 | "tsdown": "^0.20.3", |
| 30 | "typescript": "^5.7.3" | |
| 38 | "typescript": "^5.7.3", | |
| 39 | "vitest": "^4.0.0" | |
| 31 | 40 | } |
| 32 | 41 | } |
apps/server/scripts/bundle-client.mjs deleted-21| ... | ... | @@ -1,21 +0,0 @@ |
| 1 | /** | |
| 2 | * Copies the built web app into dist/client/ so the published npm package | |
| 3 | * includes the web UI. This runs as a post-build step. | |
| 4 | */ | |
| 5 | import fs from "node:fs"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const webDist = path.resolve(__dirname, "../../web/dist"); | |
| 11 | const target = path.resolve(__dirname, "../dist/client"); | |
| 12 | ||
| 13 | if (!fs.existsSync(webDist)) { | |
| 14 | console.log( | |
| 15 | "âš Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.", | |
| 16 | ); | |
| 17 | process.exit(0); | |
| 18 | } | |
| 19 | ||
| 20 | fs.cpSync(webDist, target, { recursive: true }); | |
| 21 | console.log("✓ Bundled web app into dist/client"); |
apps/server/scripts/bundle-client.ts created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | /** | |
| 2 | * Copies the built web app into dist/client/ so the published npm package | |
| 3 | * includes the web UI. This runs as a post-build step. | |
| 4 | */ | |
| 5 | import * as fs from "node:fs"; | |
| 6 | import * as path from "node:path"; | |
| 7 | ||
| 8 | const webDist = path.resolve(import.meta.dirname, "../../web/dist"); | |
| 9 | const target = path.resolve(import.meta.dirname, "../dist/client"); | |
| 10 | ||
| 11 | if (!fs.existsSync(webDist)) { | |
| 12 | console.log( | |
| 13 | "âš Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.", | |
| 14 | ); | |
| 15 | process.exit(0); | |
| 16 | } | |
| 17 | ||
| 18 | fs.cpSync(webDist, target, { recursive: true }); | |
| 19 | console.log("✓ Bundled web app into dist/client"); |
apps/server/src/checkpointing/Diffs.test.ts created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts"; | |
| 4 | ||
| 5 | describe("parseTurnDiffFilesFromUnifiedDiff", () => { | |
| 6 | it("returns empty list for empty diff", () => { | |
| 7 | expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]); | |
| 8 | }); | |
| 9 | ||
| 10 | it("parses per-file additions and deletions", () => { | |
| 11 | const diff = [ | |
| 12 | "diff --git a/a.txt b/a.txt", | |
| 13 | "index 1111111..2222222 100644", | |
| 14 | "--- a/a.txt", | |
| 15 | "+++ b/a.txt", | |
| 16 | "@@ -1,2 +1,3 @@", | |
| 17 | " one", | |
| 18 | "-two", | |
| 19 | "+two updated", | |
| 20 | "+three", | |
| 21 | "diff --git a/src/b.ts b/src/b.ts", | |
| 22 | "index 3333333..4444444 100644", | |
| 23 | "--- a/src/b.ts", | |
| 24 | "+++ b/src/b.ts", | |
| 25 | "@@ -3,2 +3,0 @@", | |
| 26 | "-old", | |
| 27 | "-stale", | |
| 28 | "", | |
| 29 | ].join("\n"); | |
| 30 | ||
| 31 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | |
| 32 | { path: "a.txt", additions: 2, deletions: 1 }, | |
| 33 | { path: "src/b.ts", additions: 0, deletions: 2 }, | |
| 34 | ]); | |
| 35 | }); | |
| 36 | ||
| 37 | it("parses rename-only diffs with zero line changes", () => { | |
| 38 | const diff = [ | |
| 39 | "diff --git a/src/old.ts b/src/new.ts", | |
| 40 | "similarity index 100%", | |
| 41 | "rename from src/old.ts", | |
| 42 | "rename to src/new.ts", | |
| 43 | "", | |
| 44 | ].join("\n"); | |
| 45 | ||
| 46 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | |
| 47 | { path: "src/new.ts", additions: 0, deletions: 0 }, | |
| 48 | ]); | |
| 49 | }); | |
| 50 | ||
| 51 | it("normalizes CRLF input before parsing", () => { | |
| 52 | const diff = [ | |
| 53 | "diff --git a/a.txt b/a.txt", | |
| 54 | "index 1111111..2222222 100644", | |
| 55 | "--- a/a.txt", | |
| 56 | "+++ b/a.txt", | |
| 57 | "@@ -1 +1,2 @@", | |
| 58 | "-one", | |
| 59 | "+one updated", | |
| 60 | "+two", | |
| 61 | "", | |
| 62 | ].join("\r\n"); | |
| 63 | ||
| 64 | expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | |
| 65 | { path: "a.txt", additions: 2, deletions: 1 }, | |
| 66 | ]); | |
| 67 | }); | |
| 68 | }); |
apps/server/src/checkpointing/Diffs.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | |
| 2 | ||
| 3 | export interface TurnDiffFileSummary { | |
| 4 | readonly path: string; | |
| 5 | readonly additions: number; | |
| 6 | readonly deletions: number; | |
| 7 | } | |
| 8 | ||
| 9 | export function parseTurnDiffFilesFromUnifiedDiff( | |
| 10 | diff: string, | |
| 11 | ): ReadonlyArray<TurnDiffFileSummary> { | |
| 12 | const normalized = diff.replace(/\r\n/g, "\n").trim(); | |
| 13 | if (normalized.length === 0) { | |
| 14 | return []; | |
| 15 | } | |
| 16 | ||
| 17 | const parsedPatches = parsePatchFiles(normalized); | |
| 18 | const files = parsedPatches.flatMap((patch) => | |
| 19 | patch.files.map((file) => ({ | |
| 20 | path: file.name, | |
| 21 | additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), | |
| 22 | deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), | |
| 23 | })), | |
| 24 | ); | |
| 25 | ||
| 26 | return files.toSorted((left, right) => left.path.localeCompare(right.path)); | |
| 27 | } |
apps/server/src/checkpointing/Errors.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; | |
| 3 | import { GitCommandError } from "../git/Errors.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. | |
| 7 | */ | |
| 8 | export class CheckpointUnavailableError extends Schema.TaggedErrorClass<CheckpointUnavailableError>()( | |
| 9 | "CheckpointUnavailableError", | |
| 10 | { | |
| 11 | threadId: Schema.String, | |
| 12 | turnCount: Schema.Number, | |
| 13 | detail: Schema.String, | |
| 14 | cause: Schema.optional(Schema.Defect), | |
| 15 | }, | |
| 16 | ) { | |
| 17 | override get message(): string { | |
| 18 | return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. | |
| 24 | */ | |
| 25 | export class CheckpointInvariantError extends Schema.TaggedErrorClass<CheckpointInvariantError>()( | |
| 26 | "CheckpointInvariantError", | |
| 27 | { | |
| 28 | operation: Schema.String, | |
| 29 | detail: Schema.String, | |
| 30 | cause: Schema.optional(Schema.Defect), | |
| 31 | }, | |
| 32 | ) { | |
| 33 | override get message(): string { | |
| 34 | return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | export type CheckpointStoreError = | |
| 39 | | GitCommandError | |
| 40 | | CheckpointInvariantError | |
| 41 | | CheckpointUnavailableError; | |
| 42 | ||
| 43 | export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | ProjectId, | |
| 4 | ThreadId, | |
| 5 | TurnId, | |
| 6 | type OrchestrationReadModel, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Effect, Layer } from "effect"; | |
| 9 | import { describe, expect, it } from "vitest"; | |
| 10 | ||
| 11 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 12 | import { checkpointRefForThreadTurn } from "../Utils.ts"; | |
| 13 | import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; | |
| 14 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | |
| 15 | import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; | |
| 16 | ||
| 17 | function makeSnapshot(input: { | |
| 18 | readonly projectId: ProjectId; | |
| 19 | readonly threadId: ThreadId; | |
| 20 | readonly workspaceRoot: string; | |
| 21 | readonly worktreePath: string | null; | |
| 22 | readonly checkpointTurnCount: number; | |
| 23 | readonly checkpointRef: CheckpointRef; | |
| 24 | }): OrchestrationReadModel { | |
| 25 | return { | |
| 26 | snapshotSequence: 0, | |
| 27 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 28 | projects: [ | |
| 29 | { | |
| 30 | id: input.projectId, | |
| 31 | title: "Project", | |
| 32 | workspaceRoot: input.workspaceRoot, | |
| 33 | defaultModel: null, | |
| 34 | scripts: [], | |
| 35 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 36 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 37 | deletedAt: null, | |
| 38 | }, | |
| 39 | ], | |
| 40 | threads: [ | |
| 41 | { | |
| 42 | id: input.threadId, | |
| 43 | projectId: input.projectId, | |
| 44 | title: "Thread", | |
| 45 | model: "gpt-5-codex", | |
| 46 | branch: null, | |
| 47 | worktreePath: input.worktreePath, | |
| 48 | latestTurn: { | |
| 49 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 50 | state: "completed", | |
| 51 | requestedAt: "2026-01-01T00:00:00.000Z", | |
| 52 | startedAt: "2026-01-01T00:00:00.000Z", | |
| 53 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 54 | assistantMessageId: null, | |
| 55 | }, | |
| 56 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 57 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 58 | deletedAt: null, | |
| 59 | messages: [], | |
| 60 | activities: [], | |
| 61 | checkpoints: [ | |
| 62 | { | |
| 63 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 64 | checkpointTurnCount: input.checkpointTurnCount, | |
| 65 | checkpointRef: input.checkpointRef, | |
| 66 | status: "ready", | |
| 67 | files: [], | |
| 68 | assistantMessageId: null, | |
| 69 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 70 | }, | |
| 71 | ], | |
| 72 | session: null, | |
| 73 | }, | |
| 74 | ], | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 78 | describe("CheckpointDiffQueryLive", () => { | |
| 79 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { | |
| 80 | const projectId = ProjectId.makeUnsafe("project-1"); | |
| 81 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 82 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); | |
| 83 | const hasCheckpointRefCalls: Array<CheckpointRef> = []; | |
| 84 | const diffCheckpointsCalls: Array<{ | |
| 85 | readonly fromCheckpointRef: CheckpointRef; | |
| 86 | readonly toCheckpointRef: CheckpointRef; | |
| 87 | readonly cwd: string; | |
| 88 | }> = []; | |
| 89 | ||
| 90 | const snapshot = makeSnapshot({ | |
| 91 | projectId, | |
| 92 | threadId, | |
| 93 | workspaceRoot: "/tmp/workspace", | |
| 94 | worktreePath: null, | |
| 95 | checkpointTurnCount: 1, | |
| 96 | checkpointRef: toCheckpointRef, | |
| 97 | }); | |
| 98 | ||
| 99 | const checkpointStore: CheckpointStoreShape = { | |
| 100 | isGitRepository: () => Effect.succeed(true), | |
| 101 | captureCheckpoint: () => Effect.void, | |
| 102 | hasCheckpointRef: ({ checkpointRef }) => | |
| 103 | Effect.sync(() => { | |
| 104 | hasCheckpointRefCalls.push(checkpointRef); | |
| 105 | return true; | |
| 106 | }), | |
| 107 | restoreCheckpoint: () => Effect.succeed(true), | |
| 108 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) => | |
| 109 | Effect.sync(() => { | |
| 110 | diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd }); | |
| 111 | return "diff patch"; | |
| 112 | }), | |
| 113 | deleteCheckpointRefs: () => Effect.void, | |
| 114 | }; | |
| 115 | ||
| 116 | const layer = CheckpointDiffQueryLive.pipe( | |
| 117 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 118 | Layer.provideMerge( | |
| 119 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 120 | getSnapshot: () => Effect.succeed(snapshot), | |
| 121 | }), | |
| 122 | ), | |
| 123 | ); | |
| 124 | ||
| 125 | const result = await Effect.runPromise( | |
| 126 | Effect.gen(function* () { | |
| 127 | const query = yield* CheckpointDiffQuery; | |
| 128 | return yield* query.getTurnDiff({ | |
| 129 | threadId, | |
| 130 | fromTurnCount: 0, | |
| 131 | toTurnCount: 1, | |
| 132 | }); | |
| 133 | }).pipe(Effect.provide(layer)), | |
| 134 | ); | |
| 135 | ||
| 136 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | |
| 137 | expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); | |
| 138 | expect(diffCheckpointsCalls).toEqual([ | |
| 139 | { | |
| 140 | cwd: "/tmp/workspace", | |
| 141 | fromCheckpointRef: expectedFromRef, | |
| 142 | toCheckpointRef, | |
| 143 | }, | |
| 144 | ]); | |
| 145 | expect(result).toEqual({ | |
| 146 | threadId, | |
| 147 | fromTurnCount: 0, | |
| 148 | toTurnCount: 1, | |
| 149 | diff: "diff patch", | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 153 | it("fails when the thread is missing from the snapshot", async () => { | |
| 154 | const threadId = ThreadId.makeUnsafe("thread-missing"); | |
| 155 | ||
| 156 | const checkpointStore: CheckpointStoreShape = { | |
| 157 | isGitRepository: () => Effect.succeed(true), | |
| 158 | captureCheckpoint: () => Effect.void, | |
| 159 | hasCheckpointRef: () => Effect.succeed(true), | |
| 160 | restoreCheckpoint: () => Effect.succeed(true), | |
| 161 | diffCheckpoints: () => Effect.succeed(""), | |
| 162 | deleteCheckpointRefs: () => Effect.void, | |
| 163 | }; | |
| 164 | ||
| 165 | const layer = CheckpointDiffQueryLive.pipe( | |
| 166 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | |
| 167 | Layer.provideMerge( | |
| 168 | Layer.succeed(ProjectionSnapshotQuery, { | |
| 169 | getSnapshot: () => | |
| 170 | Effect.succeed({ | |
| 171 | snapshotSequence: 0, | |
| 172 | projects: [], | |
| 173 | threads: [], | |
| 174 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 175 | } satisfies OrchestrationReadModel), | |
| 176 | }), | |
| 177 | ), | |
| 178 | ); | |
| 179 | ||
| 180 | await expect( | |
| 181 | Effect.runPromise( | |
| 182 | Effect.gen(function* () { | |
| 183 | const query = yield* CheckpointDiffQuery; | |
| 184 | return yield* query.getTurnDiff({ | |
| 185 | threadId, | |
| 186 | fromTurnCount: 0, | |
| 187 | toTurnCount: 1, | |
| 188 | }); | |
| 189 | }).pipe(Effect.provide(layer)), | |
| 190 | ), | |
| 191 | ).rejects.toThrow("Thread 'thread-missing' not found."); | |
| 192 | }); | |
| 193 | }); |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | import { | |
| 2 | OrchestrationGetTurnDiffResult, | |
| 3 | type OrchestrationGetFullThreadDiffInput, | |
| 4 | type OrchestrationGetFullThreadDiffResult, | |
| 5 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Effect, Layer, Schema } from "effect"; | |
| 8 | ||
| 9 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 10 | import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; | |
| 11 | import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts"; | |
| 12 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 13 | import { | |
| 14 | CheckpointDiffQuery, | |
| 15 | type CheckpointDiffQueryShape, | |
| 16 | } from "../Services/CheckpointDiffQuery.ts"; | |
| 17 | ||
| 18 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | |
| 19 | ||
| 20 | const make = Effect.gen(function* () { | |
| 21 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 22 | const checkpointStore = yield* CheckpointStore; | |
| 23 | ||
| 24 | const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) => | |
| 25 | Effect.gen(function* () { | |
| 26 | const operation = "CheckpointDiffQuery.getTurnDiff"; | |
| 27 | ||
| 28 | if (input.fromTurnCount === input.toTurnCount) { | |
| 29 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | |
| 30 | threadId: input.threadId, | |
| 31 | fromTurnCount: input.fromTurnCount, | |
| 32 | toTurnCount: input.toTurnCount, | |
| 33 | diff: "", | |
| 34 | }; | |
| 35 | if (!isTurnDiffResult(emptyDiff)) { | |
| 36 | return yield* new CheckpointInvariantError({ | |
| 37 | operation, | |
| 38 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 39 | }); | |
| 40 | } | |
| 41 | return emptyDiff; | |
| 42 | } | |
| 43 | ||
| 44 | const snapshot = yield* projectionSnapshotQuery.getSnapshot(); | |
| 45 | const thread = snapshot.threads.find((entry) => entry.id === input.threadId); | |
| 46 | if (!thread) { | |
| 47 | return yield* new CheckpointInvariantError({ | |
| 48 | operation, | |
| 49 | detail: `Thread '${input.threadId}' not found.`, | |
| 50 | }); | |
| 51 | } | |
| 52 | ||
| 53 | const maxTurnCount = thread.checkpoints.reduce( | |
| 54 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | |
| 55 | 0, | |
| 56 | ); | |
| 57 | if (input.toTurnCount > maxTurnCount) { | |
| 58 | return yield* new CheckpointUnavailableError({ | |
| 59 | threadId: input.threadId, | |
| 60 | turnCount: input.toTurnCount, | |
| 61 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | |
| 62 | }); | |
| 63 | } | |
| 64 | ||
| 65 | const workspaceCwd = resolveThreadWorkspaceCwd({ | |
| 66 | thread, | |
| 67 | projects: snapshot.projects, | |
| 68 | }); | |
| 69 | if (!workspaceCwd) { | |
| 70 | return yield* new CheckpointInvariantError({ | |
| 71 | operation, | |
| 72 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | const fromCheckpointRef = | |
| 77 | input.fromTurnCount === 0 | |
| 78 | ? checkpointRefForThreadTurn(input.threadId, 0) | |
| 79 | : thread.checkpoints.find( | |
| 80 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | |
| 81 | )?.checkpointRef; | |
| 82 | if (!fromCheckpointRef) { | |
| 83 | return yield* new CheckpointUnavailableError({ | |
| 84 | threadId: input.threadId, | |
| 85 | turnCount: input.fromTurnCount, | |
| 86 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | |
| 87 | }); | |
| 88 | } | |
| 89 | ||
| 90 | const toCheckpointRef = thread.checkpoints.find( | |
| 91 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | |
| 92 | )?.checkpointRef; | |
| 93 | if (!toCheckpointRef) { | |
| 94 | return yield* new CheckpointUnavailableError({ | |
| 95 | threadId: input.threadId, | |
| 96 | turnCount: input.toTurnCount, | |
| 97 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | const [fromExists, toExists] = yield* Effect.all( | |
| 102 | [ | |
| 103 | checkpointStore.hasCheckpointRef({ | |
| 104 | cwd: workspaceCwd, | |
| 105 | checkpointRef: fromCheckpointRef, | |
| 106 | }), | |
| 107 | checkpointStore.hasCheckpointRef({ | |
| 108 | cwd: workspaceCwd, | |
| 109 | checkpointRef: toCheckpointRef, | |
| 110 | }), | |
| 111 | ], | |
| 112 | { concurrency: "unbounded" }, | |
| 113 | ); | |
| 114 | ||
| 115 | if (!fromExists) { | |
| 116 | return yield* new CheckpointUnavailableError({ | |
| 117 | threadId: input.threadId, | |
| 118 | turnCount: input.fromTurnCount, | |
| 119 | detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`, | |
| 120 | }); | |
| 121 | } | |
| 122 | ||
| 123 | if (!toExists) { | |
| 124 | return yield* new CheckpointUnavailableError({ | |
| 125 | threadId: input.threadId, | |
| 126 | turnCount: input.toTurnCount, | |
| 127 | detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`, | |
| 128 | }); | |
| 129 | } | |
| 130 | ||
| 131 | const diff = yield* checkpointStore.diffCheckpoints({ | |
| 132 | cwd: workspaceCwd, | |
| 133 | fromCheckpointRef, | |
| 134 | toCheckpointRef, | |
| 135 | fallbackFromToHead: false, | |
| 136 | }); | |
| 137 | ||
| 138 | const turnDiff: OrchestrationGetTurnDiffResultType = { | |
| 139 | threadId: input.threadId, | |
| 140 | fromTurnCount: input.fromTurnCount, | |
| 141 | toTurnCount: input.toTurnCount, | |
| 142 | diff, | |
| 143 | }; | |
| 144 | if (!isTurnDiffResult(turnDiff)) { | |
| 145 | return yield* new CheckpointInvariantError({ | |
| 146 | operation, | |
| 147 | detail: "Computed turn diff result does not satisfy contract schema.", | |
| 148 | }); | |
| 149 | } | |
| 150 | ||
| 151 | return turnDiff; | |
| 152 | }); | |
| 153 | ||
| 154 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( | |
| 155 | input: OrchestrationGetFullThreadDiffInput, | |
| 156 | ) => | |
| 157 | getTurnDiff({ | |
| 158 | threadId: input.threadId, | |
| 159 | fromTurnCount: 0, | |
| 160 | toTurnCount: input.toTurnCount, | |
| 161 | }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); | |
| 162 | ||
| 163 | return { | |
| 164 | getTurnDiff, | |
| 165 | getFullThreadDiff, | |
| 166 | } satisfies CheckpointDiffQueryShape; | |
| 167 | }); | |
| 168 | ||
| 169 | export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); |
apps/server/src/checkpointing/Layers/CheckpointStore.ts created+282| ... | ... | @@ -0,0 +1,282 @@ |
| 1 | /** | |
| 2 | * CheckpointStoreLive - Filesystem checkpoint store adapter layer. | |
| 3 | * | |
| 4 | * Implements hidden Git-ref checkpoint capture/restore directly with | |
| 5 | * Effect-native child process execution (`effect/unstable/process`). | |
| 6 | * | |
| 7 | * This layer owns filesystem/Git interactions only; it does not persist | |
| 8 | * checkpoint metadata and does not coordinate provider rollback semantics. | |
| 9 | * | |
| 10 | * @module CheckpointStoreLive | |
| 11 | */ | |
| 12 | import { randomUUID } from "node:crypto"; | |
| 13 | ||
| 14 | import { Effect, Layer, FileSystem, Path } from "effect"; | |
| 15 | ||
| 16 | import { CheckpointInvariantError } from "../Errors.ts"; | |
| 17 | import { GitCommandError } from "../../git/Errors.ts"; | |
| 18 | import { GitServiceLive } from "../../git/Layers/GitService.ts"; | |
| 19 | import { GitService } from "../../git/Services/GitService.ts"; | |
| 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | |
| 21 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 22 | ||
| 23 | const makeCheckpointStore = Effect.gen(function* () { | |
| 24 | const fs = yield* FileSystem.FileSystem; | |
| 25 | const path = yield* Path.Path; | |
| 26 | const git = yield* GitService; | |
| 27 | ||
| 28 | const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> => | |
| 29 | git | |
| 30 | .execute({ | |
| 31 | operation: "CheckpointStore.resolveHeadCommit", | |
| 32 | cwd, | |
| 33 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], | |
| 34 | allowNonZeroExit: true, | |
| 35 | }) | |
| 36 | .pipe( | |
| 37 | Effect.map((result) => { | |
| 38 | if (result.code !== 0) { | |
| 39 | return null; | |
| 40 | } | |
| 41 | const commit = result.stdout.trim(); | |
| 42 | return commit.length > 0 ? commit : null; | |
| 43 | }), | |
| 44 | ); | |
| 45 | ||
| 46 | const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> => | |
| 47 | git | |
| 48 | .execute({ | |
| 49 | operation: "CheckpointStore.hasHeadCommit", | |
| 50 | cwd, | |
| 51 | args: ["rev-parse", "--verify", "HEAD"], | |
| 52 | allowNonZeroExit: true, | |
| 53 | }) | |
| 54 | .pipe(Effect.map((result) => result.code === 0)); | |
| 55 | ||
| 56 | const resolveCheckpointCommit = ( | |
| 57 | cwd: string, | |
| 58 | checkpointRef: CheckpointRef, | |
| 59 | ): Effect.Effect<string | null, GitCommandError> => | |
| 60 | git | |
| 61 | .execute({ | |
| 62 | operation: "CheckpointStore.resolveCheckpointCommit", | |
| 63 | cwd, | |
| 64 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | |
| 65 | allowNonZeroExit: true, | |
| 66 | }) | |
| 67 | .pipe( | |
| 68 | Effect.map((result) => { | |
| 69 | if (result.code !== 0) { | |
| 70 | return null; | |
| 71 | } | |
| 72 | const commit = result.stdout.trim(); | |
| 73 | return commit.length > 0 ? commit : null; | |
| 74 | }), | |
| 75 | ); | |
| 76 | ||
| 77 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | |
| 78 | git | |
| 79 | .execute({ | |
| 80 | operation: "CheckpointStore.isGitRepository", | |
| 81 | cwd, | |
| 82 | args: ["rev-parse", "--is-inside-work-tree"], | |
| 83 | allowNonZeroExit: true, | |
| 84 | }) | |
| 85 | .pipe( | |
| 86 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), | |
| 87 | Effect.catch(() => Effect.succeed(false)), | |
| 88 | ); | |
| 89 | ||
| 90 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => | |
| 91 | Effect.gen(function* () { | |
| 92 | const operation = "CheckpointStore.captureCheckpoint"; | |
| 93 | ||
| 94 | yield* Effect.acquireUseRelease( | |
| 95 | fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), | |
| 96 | (tempDir) => | |
| 97 | Effect.gen(function* () { | |
| 98 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | |
| 99 | const commitEnv: NodeJS.ProcessEnv = { | |
| 100 | ...process.env, | |
| 101 | GIT_INDEX_FILE: tempIndexPath, | |
| 102 | GIT_AUTHOR_NAME: "T3 Code", | |
| 103 | GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", | |
| 104 | GIT_COMMITTER_NAME: "T3 Code", | |
| 105 | GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", | |
| 106 | }; | |
| 107 | ||
| 108 | const headExists = yield* hasHeadCommit(input.cwd); | |
| 109 | if (headExists) { | |
| 110 | yield* git.execute({ | |
| 111 | operation, | |
| 112 | cwd: input.cwd, | |
| 113 | args: ["read-tree", "HEAD"], | |
| 114 | env: commitEnv, | |
| 115 | }); | |
| 116 | } | |
| 117 | ||
| 118 | yield* git.execute({ | |
| 119 | operation, | |
| 120 | cwd: input.cwd, | |
| 121 | args: ["add", "-A", "--", "."], | |
| 122 | env: commitEnv, | |
| 123 | }); | |
| 124 | ||
| 125 | const writeTreeResult = yield* git.execute({ | |
| 126 | operation, | |
| 127 | cwd: input.cwd, | |
| 128 | args: ["write-tree"], | |
| 129 | env: commitEnv, | |
| 130 | }); | |
| 131 | const treeOid = writeTreeResult.stdout.trim(); | |
| 132 | if (treeOid.length === 0) { | |
| 133 | return yield* new GitCommandError({ | |
| 134 | operation, | |
| 135 | command: "git write-tree", | |
| 136 | cwd: input.cwd, | |
| 137 | detail: "git write-tree returned an empty tree oid.", | |
| 138 | }); | |
| 139 | } | |
| 140 | ||
| 141 | const message = `t3 checkpoint ref=${input.checkpointRef}`; | |
| 142 | const commitTreeResult = yield* git.execute({ | |
| 143 | operation, | |
| 144 | cwd: input.cwd, | |
| 145 | args: ["commit-tree", treeOid, "-m", message], | |
| 146 | env: commitEnv, | |
| 147 | }); | |
| 148 | const commitOid = commitTreeResult.stdout.trim(); | |
| 149 | if (commitOid.length === 0) { | |
| 150 | return yield* new GitCommandError({ | |
| 151 | operation, | |
| 152 | command: "git commit-tree", | |
| 153 | cwd: input.cwd, | |
| 154 | detail: "git commit-tree returned an empty commit oid.", | |
| 155 | }); | |
| 156 | } | |
| 157 | ||
| 158 | yield* git.execute({ | |
| 159 | operation, | |
| 160 | cwd: input.cwd, | |
| 161 | args: ["update-ref", input.checkpointRef, commitOid], | |
| 162 | }); | |
| 163 | }), | |
| 164 | (tempDir) => fs.remove(tempDir, { recursive: true }), | |
| 165 | ).pipe( | |
| 166 | Effect.catchTags({ | |
| 167 | PlatformError: (error) => | |
| 168 | Effect.fail( | |
| 169 | new CheckpointInvariantError({ | |
| 170 | operation: "CheckpointStore.captureCheckpoint", | |
| 171 | detail: "Failed to capture checkpoint.", | |
| 172 | cause: error, | |
| 173 | }), | |
| 174 | ), | |
| 175 | }), | |
| 176 | ); | |
| 177 | }); | |
| 178 | ||
| 179 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => | |
| 180 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | |
| 181 | Effect.map((commit) => commit !== null), | |
| 182 | ); | |
| 183 | ||
| 184 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => | |
| 185 | Effect.gen(function* () { | |
| 186 | const operation = "CheckpointStore.restoreCheckpoint"; | |
| 187 | ||
| 188 | let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); | |
| 189 | ||
| 190 | if (!commitOid && input.fallbackToHead === true) { | |
| 191 | commitOid = yield* resolveHeadCommit(input.cwd); | |
| 192 | } | |
| 193 | ||
| 194 | if (!commitOid) { | |
| 195 | return false; | |
| 196 | } | |
| 197 | ||
| 198 | yield* git.execute({ | |
| 199 | operation, | |
| 200 | cwd: input.cwd, | |
| 201 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | |
| 202 | }); | |
| 203 | yield* git.execute({ | |
| 204 | operation, | |
| 205 | cwd: input.cwd, | |
| 206 | args: ["clean", "-fd", "--", "."], | |
| 207 | }); | |
| 208 | ||
| 209 | const headExists = yield* hasHeadCommit(input.cwd); | |
| 210 | if (headExists) { | |
| 211 | yield* git.execute({ | |
| 212 | operation, | |
| 213 | cwd: input.cwd, | |
| 214 | args: ["reset", "--quiet", "--", "."], | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | return true; | |
| 219 | }); | |
| 220 | ||
| 221 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => | |
| 222 | Effect.gen(function* () { | |
| 223 | const operation = "CheckpointStore.diffCheckpoints"; | |
| 224 | ||
| 225 | let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); | |
| 226 | const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); | |
| 227 | ||
| 228 | if (!fromCommitOid && input.fallbackFromToHead === true) { | |
| 229 | const headCommit = yield* resolveHeadCommit(input.cwd); | |
| 230 | if (headCommit) { | |
| 231 | fromCommitOid = headCommit; | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | if (!fromCommitOid || !toCommitOid) { | |
| 236 | return yield* new GitCommandError({ | |
| 237 | operation, | |
| 238 | command: "git diff", | |
| 239 | cwd: input.cwd, | |
| 240 | detail: "Checkpoint ref is unavailable for diff operation.", | |
| 241 | }); | |
| 242 | } | |
| 243 | ||
| 244 | const result = yield* git.execute({ | |
| 245 | operation, | |
| 246 | cwd: input.cwd, | |
| 247 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], | |
| 248 | }); | |
| 249 | ||
| 250 | return result.stdout; | |
| 251 | }); | |
| 252 | ||
| 253 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => | |
| 254 | Effect.gen(function* () { | |
| 255 | const operation = "CheckpointStore.deleteCheckpointRefs"; | |
| 256 | ||
| 257 | yield* Effect.forEach( | |
| 258 | input.checkpointRefs, | |
| 259 | (checkpointRef) => | |
| 260 | git.execute({ | |
| 261 | operation, | |
| 262 | cwd: input.cwd, | |
| 263 | args: ["update-ref", "-d", checkpointRef], | |
| 264 | allowNonZeroExit: true, | |
| 265 | }), | |
| 266 | { discard: true }, | |
| 267 | ); | |
| 268 | }); | |
| 269 | ||
| 270 | return { | |
| 271 | isGitRepository, | |
| 272 | captureCheckpoint, | |
| 273 | hasCheckpointRef, | |
| 274 | restoreCheckpoint, | |
| 275 | diffCheckpoints, | |
| 276 | deleteCheckpointRefs, | |
| 277 | } satisfies CheckpointStoreShape; | |
| 278 | }); | |
| 279 | ||
| 280 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe( | |
| 281 | Layer.provideMerge(GitServiceLive), | |
| 282 | ); |
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | /** | |
| 2 | * CheckpointDiffQuery - Query interface for computed checkpoint diffs. | |
| 3 | * | |
| 4 | * Provides read-only diff operations across checkpoint snapshots used by | |
| 5 | * orchestration APIs. | |
| 6 | * | |
| 7 | * @module CheckpointDiffQuery | |
| 8 | */ | |
| 9 | import type { | |
| 10 | OrchestrationGetFullThreadDiffInput, | |
| 11 | OrchestrationGetFullThreadDiffResult, | |
| 12 | OrchestrationGetTurnDiffInput, | |
| 13 | OrchestrationGetTurnDiffResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { ServiceMap } from "effect"; | |
| 16 | import type { Effect } from "effect"; | |
| 17 | ||
| 18 | import type { CheckpointServiceError } from "../Errors.ts"; | |
| 19 | ||
| 20 | /** | |
| 21 | * CheckpointDiffQueryShape - Service API for checkpoint diff queries. | |
| 22 | */ | |
| 23 | export interface CheckpointDiffQueryShape { | |
| 24 | /** | |
| 25 | * Read the patch diff for a single turn checkpoint transition. | |
| 26 | * | |
| 27 | * Verifies checkpoint availability in both projection state and filesystem. | |
| 28 | */ | |
| 29 | readonly getTurnDiff: ( | |
| 30 | input: OrchestrationGetTurnDiffInput, | |
| 31 | ) => Effect.Effect<OrchestrationGetTurnDiffResult, CheckpointServiceError>; | |
| 32 | ||
| 33 | /** | |
| 34 | * Read the full patch diff across a thread range of checkpoints. | |
| 35 | * | |
| 36 | * Delegates to turn diff with `fromTurnCount = 0`. | |
| 37 | */ | |
| 38 | readonly getFullThreadDiff: ( | |
| 39 | input: OrchestrationGetFullThreadDiffInput, | |
| 40 | ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * CheckpointDiffQuery - Service tag for checkpoint diff queries. | |
| 45 | */ | |
| 46 | export class CheckpointDiffQuery extends ServiceMap.Service< | |
| 47 | CheckpointDiffQuery, | |
| 48 | CheckpointDiffQueryShape | |
| 49 | >()("t3/checkpointing/Services/CheckpointDiffQuery") {} |
apps/server/src/checkpointing/Services/CheckpointStore.ts created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | /** | |
| 2 | * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. | |
| 3 | * | |
| 4 | * Owns hidden Git-ref checkpoint capture/restore and diff computation for a | |
| 5 | * workspace thread timeline. It does not store user-facing checkpoint metadata | |
| 6 | * and does not coordinate provider conversation rollback. | |
| 7 | * | |
| 8 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 9 | * domain errors for checkpoint storage operations. | |
| 10 | * | |
| 11 | * @module CheckpointStore | |
| 12 | */ | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | import type { Effect } from "effect"; | |
| 15 | ||
| 16 | import type { CheckpointStoreError } from "../Errors.ts"; | |
| 17 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 18 | ||
| 19 | export interface CaptureCheckpointInput { | |
| 20 | readonly cwd: string; | |
| 21 | readonly checkpointRef: CheckpointRef; | |
| 22 | } | |
| 23 | ||
| 24 | export interface RestoreCheckpointInput { | |
| 25 | readonly cwd: string; | |
| 26 | readonly checkpointRef: CheckpointRef; | |
| 27 | readonly fallbackToHead?: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | export interface DiffCheckpointsInput { | |
| 31 | readonly cwd: string; | |
| 32 | readonly fromCheckpointRef: CheckpointRef; | |
| 33 | readonly toCheckpointRef: CheckpointRef; | |
| 34 | readonly fallbackFromToHead?: boolean; | |
| 35 | } | |
| 36 | ||
| 37 | export interface DeleteCheckpointRefsInput { | |
| 38 | readonly cwd: string; | |
| 39 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * CheckpointStoreShape - Service API for checkpoint capture/restore and diff access. | |
| 44 | */ | |
| 45 | export interface CheckpointStoreShape { | |
| 46 | /** | |
| 47 | * Check whether cwd is inside a Git worktree. | |
| 48 | */ | |
| 49 | readonly isGitRepository: (cwd: string) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 50 | ||
| 51 | /** | |
| 52 | * Capture a checkpoint commit and store it at the provided checkpoint ref. | |
| 53 | * | |
| 54 | * Uses an isolated temporary Git index and writes a hidden ref. | |
| 55 | */ | |
| 56 | readonly captureCheckpoint: ( | |
| 57 | input: CaptureCheckpointInput, | |
| 58 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 59 | ||
| 60 | /** | |
| 61 | * Check whether a checkpoint ref exists. | |
| 62 | */ | |
| 63 | readonly hasCheckpointRef: ( | |
| 64 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, | |
| 65 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 66 | ||
| 67 | /** | |
| 68 | * Restore workspace/staging state to a checkpoint. | |
| 69 | * | |
| 70 | * Optionally falls back to current `HEAD` when the checkpoint ref is missing. | |
| 71 | */ | |
| 72 | readonly restoreCheckpoint: ( | |
| 73 | input: RestoreCheckpointInput, | |
| 74 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 75 | ||
| 76 | /** | |
| 77 | * Compute patch diff between two checkpoint refs. | |
| 78 | * | |
| 79 | * Can optionally treat missing "from" ref as `HEAD`. | |
| 80 | */ | |
| 81 | readonly diffCheckpoints: ( | |
| 82 | input: DiffCheckpointsInput, | |
| 83 | ) => Effect.Effect<string, CheckpointStoreError>; | |
| 84 | ||
| 85 | /** | |
| 86 | * Delete the provided checkpoint refs. | |
| 87 | * | |
| 88 | * Best-effort delete: missing refs are tolerated. | |
| 89 | */ | |
| 90 | readonly deleteCheckpointRefs: ( | |
| 91 | input: DeleteCheckpointRefsInput, | |
| 92 | ) => Effect.Effect<void, CheckpointStoreError>; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * CheckpointStore - Service tag for checkpoint persistence and restore operations. | |
| 97 | */ | |
| 98 | export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()( | |
| 99 | "t3/checkpointing/Services/CheckpointStore", | |
| 100 | ) {} |
apps/server/src/checkpointing/Utils.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { Encoding } from "effect"; | |
| 2 | import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 5 | ||
| 6 | export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | |
| 7 | return CheckpointRef.makeUnsafe( | |
| 8 | `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, | |
| 9 | ); | |
| 10 | } | |
| 11 | ||
| 12 | export function resolveThreadWorkspaceCwd(input: { | |
| 13 | readonly thread: { | |
| 14 | readonly projectId: ProjectId; | |
| 15 | readonly worktreePath: string | null; | |
| 16 | }; | |
| 17 | readonly projects: ReadonlyArray<{ | |
| 18 | readonly id: ProjectId; | |
| 19 | readonly workspaceRoot: string; | |
| 20 | }>; | |
| 21 | }): string | undefined { | |
| 22 | const worktreeCwd = input.thread.worktreePath ?? undefined; | |
| 23 | if (worktreeCwd) { | |
| 24 | return worktreeCwd; | |
| 25 | } | |
| 26 | ||
| 27 | return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; | |
| 28 | } |
apps/server/src/codexAppServerManager.test.ts+15-6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | CodexAppServerManager, |
| ... | ... | @@ -7,6 +8,8 @@ import { |
| 7 | 8 | normalizeCodexModelSlug, |
| 8 | 9 | } from "./codexAppServerManager"; |
| 9 | 10 | |
| 11 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 12 | ||
| 10 | 13 | function createSendTurnHarness() { |
| 11 | 14 | const manager = new CodexAppServerManager(); |
| 12 | 15 | const context = { |
| ... | ... | @@ -183,7 +186,7 @@ describe("sendTurn", () => { |
| 183 | 186 | createSendTurnHarness(); |
| 184 | 187 | |
| 185 | 188 | const result = await manager.sendTurn({ |
| 186 | sessionId: "sess_1", | |
| 189 | sessionId: asSessionId("sess_1"), | |
| 187 | 190 | input: "Inspect this image", |
| 188 | 191 | attachments: [ |
| 189 | 192 | { |
| ... | ... | @@ -201,6 +204,9 @@ describe("sendTurn", () => { |
| 201 | 204 | expect(result).toEqual({ |
| 202 | 205 | threadId: "thread_1", |
| 203 | 206 | turnId: "turn_1", |
| 207 | resumeCursor: { | |
| 208 | threadId: "thread_1", | |
| 209 | }, | |
| 204 | 210 | }); |
| 205 | 211 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 206 | 212 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| ... | ... | @@ -222,6 +228,9 @@ describe("sendTurn", () => { |
| 222 | 228 | expect(updateSession).toHaveBeenCalledWith(context, { |
| 223 | 229 | status: "running", |
| 224 | 230 | activeTurnId: "turn_1", |
| 231 | resumeCursor: { | |
| 232 | threadId: "thread_1", | |
| 233 | }, | |
| 225 | 234 | }); |
| 226 | 235 | }); |
| 227 | 236 | |
| ... | ... | @@ -229,7 +238,7 @@ describe("sendTurn", () => { |
| 229 | 238 | const { manager, context, sendRequest } = createSendTurnHarness(); |
| 230 | 239 | |
| 231 | 240 | await manager.sendTurn({ |
| 232 | sessionId: "sess_1", | |
| 241 | sessionId: asSessionId("sess_1"), | |
| 233 | 242 | attachments: [ |
| 234 | 243 | { |
| 235 | 244 | type: "image", |
| ... | ... | @@ -257,7 +266,7 @@ describe("sendTurn", () => { |
| 257 | 266 | |
| 258 | 267 | await expect( |
| 259 | 268 | manager.sendTurn({ |
| 260 | sessionId: "sess_1", | |
| 269 | sessionId: asSessionId("sess_1"), | |
| 261 | 270 | }), |
| 262 | 271 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 263 | 272 | }); |
| ... | ... | @@ -278,7 +287,7 @@ describe("thread checkpoint control", () => { |
| 278 | 287 | }, |
| 279 | 288 | }); |
| 280 | 289 | |
| 281 | const result = await manager.readThread("sess_1"); | |
| 290 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 282 | 291 | |
| 283 | 292 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 284 | 293 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| ... | ... | @@ -308,7 +317,7 @@ describe("thread checkpoint control", () => { |
| 308 | 317 | ], |
| 309 | 318 | }); |
| 310 | 319 | |
| 311 | const result = await manager.readThread("sess_1"); | |
| 320 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 312 | 321 | |
| 313 | 322 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| 314 | 323 | threadId: "thread_1", |
| ... | ... | @@ -334,7 +343,7 @@ describe("thread checkpoint control", () => { |
| 334 | 343 | }, |
| 335 | 344 | }); |
| 336 | 345 | |
| 337 | const result = await manager.rollbackThread("sess_1", 2); | |
| 346 | const result = await manager.rollbackThread(asSessionId("sess_1"), 2); | |
| 338 | 347 | |
| 339 | 348 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { |
| 340 | 349 | threadId: "thread_1", |
apps/server/src/codexAppServerManager.ts+119-61| ... | ... | @@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { EventEmitter } from "node:events"; |
| 4 | 4 | import readline from "node:readline"; |
| 5 | 5 | |
| 6 | import type { | |
| 7 | ProviderApprovalDecision, | |
| 8 | ProviderEvent, | |
| 6 | import { | |
| 7 | ApprovalRequestId, | |
| 8 | EventId, | |
| 9 | ProviderItemId, | |
| 9 | 10 | ProviderRequestKind, |
| 10 | ProviderSendTurnInput, | |
| 11 | ProviderSession, | |
| 12 | ProviderSessionStartInput, | |
| 13 | ProviderTurnStartResult, | |
| 11 | ProviderSessionId, | |
| 12 | ProviderThreadId, | |
| 13 | ProviderTurnId, | |
| 14 | normalizeModelSlug, | |
| 15 | type ProviderApprovalDecision, | |
| 16 | type ProviderEvent, | |
| 17 | type ProviderSendTurnInput, | |
| 18 | type ProviderSession, | |
| 19 | type ProviderSessionStartInput, | |
| 20 | type ProviderTurnStartResult, | |
| 14 | 21 | } from "@t3tools/contracts"; |
| 15 | import { normalizeModelSlug } from "@t3tools/contracts"; | |
| 16 | 22 | |
| 17 | 23 | type PendingRequestKey = string; |
| 18 | 24 | |
| ... | ... | @@ -24,13 +30,13 @@ interface PendingRequest { |
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | interface PendingApprovalRequest { |
| 27 | requestId: string; | |
| 33 | requestId: ApprovalRequestId; | |
| 28 | 34 | jsonRpcId: string | number; |
| 29 | 35 | method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; |
| 30 | 36 | requestKind: ProviderRequestKind; |
| 31 | threadId?: string; | |
| 32 | turnId?: string; | |
| 33 | itemId?: string; | |
| 37 | threadId?: ProviderThreadId; | |
| 38 | turnId?: ProviderTurnId; | |
| 39 | itemId?: ProviderItemId; | |
| 34 | 40 | } |
| 35 | 41 | |
| 36 | 42 | interface CodexSessionContext { |
| ... | ... | @@ -38,7 +44,7 @@ interface CodexSessionContext { |
| 38 | 44 | child: ChildProcessWithoutNullStreams; |
| 39 | 45 | output: readline.Interface; |
| 40 | 46 | pending: Map<PendingRequestKey, PendingRequest>; |
| 41 | pendingApprovals: Map<string, PendingApprovalRequest>; | |
| 47 | pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>; | |
| 42 | 48 | nextRequestId: number; |
| 43 | 49 | stopping: boolean; |
| 44 | 50 | } |
| ... | ... | @@ -66,12 +72,12 @@ interface JsonRpcNotification { |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | export interface CodexThreadTurnSnapshot { |
| 69 | id: string; | |
| 75 | id: ProviderTurnId; | |
| 70 | 76 | items: unknown[]; |
| 71 | 77 | } |
| 72 | 78 | |
| 73 | 79 | export interface CodexThreadSnapshot { |
| 74 | threadId: string; | |
| 80 | threadId: ProviderThreadId; | |
| 75 | 81 | turns: CodexThreadTurnSnapshot[]; |
| 76 | 82 | } |
| 77 | 83 | |
| ... | ... | @@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents { |
| 143 | 149 | } |
| 144 | 150 | |
| 145 | 151 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { |
| 146 | private readonly sessions = new Map<string, CodexSessionContext>(); | |
| 152 | private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>(); | |
| 147 | 153 | |
| 148 | 154 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 149 | const sessionId = randomUUID(); | |
| 155 | const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); | |
| 150 | 156 | const now = new Date().toISOString(); |
| 151 | 157 | let context: CodexSessionContext | undefined; |
| 152 | 158 | |
| ... | ... | @@ -163,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 163 | 169 | updatedAt: now, |
| 164 | 170 | }; |
| 165 | 171 | |
| 166 | const codexBinaryPath = input.codexBinaryPath?.trim() || "codex"; | |
| 167 | const codexHomePath = input.codexHomePath?.trim(); | |
| 172 | const codexBinaryPath = input.codexBinaryPath ?? "codex"; | |
| 173 | const codexHomePath = input.codexHomePath; | |
| 168 | 174 | const child = spawn(codexBinaryPath, ["app-server"], { |
| 169 | 175 | cwd: resolvedCwd, |
| 170 | 176 | env: { |
| ... | ... | @@ -215,15 +221,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 215 | 221 | ...sessionOverrides, |
| 216 | 222 | experimentalRawEvents: false, |
| 217 | 223 | }; |
| 224 | const resumeThreadId = readResumeThreadId(input); | |
| 218 | 225 | |
| 219 | 226 | let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; |
| 220 | 227 | let threadOpenResponse: unknown; |
| 221 | if (input.resumeThreadId) { | |
| 228 | if (resumeThreadId) { | |
| 222 | 229 | try { |
| 223 | 230 | threadOpenMethod = "thread/resume"; |
| 224 | 231 | threadOpenResponse = await this.sendRequest(context, "thread/resume", { |
| 225 | 232 | ...sessionOverrides, |
| 226 | threadId: input.resumeThreadId, | |
| 233 | threadId: resumeThreadId, | |
| 227 | 234 | }); |
| 228 | 235 | } catch (error) { |
| 229 | 236 | if (!isRecoverableThreadResumeError(error)) { |
| ... | ... | @@ -234,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 234 | 241 | this.emitLifecycleEvent( |
| 235 | 242 | context, |
| 236 | 243 | "session/threadResumeFallback", |
| 237 | `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`, | |
| 244 | `Could not resume thread ${resumeThreadId}; started a new thread instead.`, | |
| 238 | 245 | ); |
| 239 | 246 | threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); |
| 240 | 247 | } |
| ... | ... | @@ -244,16 +251,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 244 | 251 | } |
| 245 | 252 | |
| 246 | 253 | const threadOpenRecord = this.readObject(threadOpenResponse); |
| 247 | const threadId = | |
| 254 | const threadIdRaw = | |
| 248 | 255 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? |
| 249 | 256 | this.readString(threadOpenRecord, "threadId"); |
| 250 | if (!threadId) { | |
| 257 | if (!threadIdRaw) { | |
| 251 | 258 | throw new Error(`${threadOpenMethod} response did not include a thread id.`); |
| 252 | 259 | } |
| 260 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 253 | 261 | |
| 254 | 262 | this.updateSession(context, { |
| 255 | 263 | status: "ready", |
| 256 | 264 | threadId, |
| 265 | resumeCursor: { threadId }, | |
| 257 | 266 | }); |
| 258 | 267 | this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); |
| 259 | 268 | return { ...context.session }; |
| ... | ... | @@ -268,7 +277,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 268 | 277 | this.stopSession(sessionId); |
| 269 | 278 | } else { |
| 270 | 279 | this.emitEvent({ |
| 271 | id: randomUUID(), | |
| 280 | id: EventId.makeUnsafe(randomUUID()), | |
| 272 | 281 | kind: "error", |
| 273 | 282 | provider: "codex", |
| 274 | 283 | sessionId, |
| ... | ... | @@ -310,7 +319,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 310 | 319 | } |
| 311 | 320 | |
| 312 | 321 | const turnStartParams: { |
| 313 | threadId: string; | |
| 322 | threadId: ProviderThreadId; | |
| 314 | 323 | input: Array< |
| 315 | 324 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| 316 | 325 | >; |
| ... | ... | @@ -331,23 +340,26 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 331 | 340 | const response = await this.sendRequest(context, "turn/start", turnStartParams); |
| 332 | 341 | |
| 333 | 342 | const turn = this.readObject(this.readObject(response), "turn"); |
| 334 | const turnId = this.readString(turn, "id"); | |
| 335 | if (!turnId) { | |
| 343 | const turnIdRaw = this.readString(turn, "id"); | |
| 344 | if (!turnIdRaw) { | |
| 336 | 345 | throw new Error("turn/start response did not include a turn id."); |
| 337 | 346 | } |
| 347 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | |
| 338 | 348 | |
| 339 | 349 | this.updateSession(context, { |
| 340 | 350 | status: "running", |
| 341 | 351 | activeTurnId: turnId, |
| 352 | resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 342 | 353 | }); |
| 343 | 354 | |
| 344 | 355 | return { |
| 345 | 356 | threadId: context.session.threadId, |
| 346 | 357 | turnId, |
| 358 | resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 347 | 359 | }; |
| 348 | 360 | } |
| 349 | 361 | |
| 350 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 362 | async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> { | |
| 351 | 363 | const context = this.requireSession(sessionId); |
| 352 | 364 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| 353 | 365 | |
| ... | ... | @@ -361,7 +373,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 361 | 373 | }); |
| 362 | 374 | } |
| 363 | 375 | |
| 364 | async readThread(sessionId: string): Promise<CodexThreadSnapshot> { | |
| 376 | async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> { | |
| 365 | 377 | const context = this.requireSession(sessionId); |
| 366 | 378 | const threadId = context.session.threadId; |
| 367 | 379 | if (!threadId) { |
| ... | ... | @@ -375,7 +387,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 375 | 387 | return this.parseThreadSnapshot("thread/read", response); |
| 376 | 388 | } |
| 377 | 389 | |
| 378 | async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> { | |
| 390 | async rollbackThread( | |
| 391 | sessionId: ProviderSessionId, | |
| 392 | numTurns: number, | |
| 393 | ): Promise<CodexThreadSnapshot> { | |
| 379 | 394 | const context = this.requireSession(sessionId); |
| 380 | 395 | const threadId = context.session.threadId; |
| 381 | 396 | if (!threadId) { |
| ... | ... | @@ -397,8 +412,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 397 | 412 | } |
| 398 | 413 | |
| 399 | 414 | async respondToRequest( |
| 400 | sessionId: string, | |
| 401 | requestId: string, | |
| 415 | sessionId: ProviderSessionId, | |
| 416 | requestId: ApprovalRequestId, | |
| 402 | 417 | decision: ProviderApprovalDecision, |
| 403 | 418 | ): Promise<void> { |
| 404 | 419 | const context = this.requireSession(sessionId); |
| ... | ... | @@ -416,7 +431,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 416 | 431 | }); |
| 417 | 432 | |
| 418 | 433 | this.emitEvent({ |
| 419 | id: randomUUID(), | |
| 434 | id: EventId.makeUnsafe(randomUUID()), | |
| 420 | 435 | kind: "notification", |
| 421 | 436 | provider: "codex", |
| 422 | 437 | sessionId: context.session.sessionId, |
| ... | ... | @@ -435,7 +450,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 435 | 450 | }); |
| 436 | 451 | } |
| 437 | 452 | |
| 438 | stopSession(sessionId: string): void { | |
| 453 | stopSession(sessionId: ProviderSessionId): void { | |
| 439 | 454 | const context = this.sessions.get(sessionId); |
| 440 | 455 | if (!context) { |
| 441 | 456 | return; |
| ... | ... | @@ -470,7 +485,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 470 | 485 | })); |
| 471 | 486 | } |
| 472 | 487 | |
| 473 | hasSession(sessionId: string): boolean { | |
| 488 | hasSession(sessionId: ProviderSessionId): boolean { | |
| 474 | 489 | return this.sessions.has(sessionId); |
| 475 | 490 | } |
| 476 | 491 | |
| ... | ... | @@ -480,7 +495,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 480 | 495 | } |
| 481 | 496 | } |
| 482 | 497 | |
| 483 | private requireSession(sessionId: string): CodexSessionContext { | |
| 498 | private requireSession(sessionId: ProviderSessionId): CodexSessionContext { | |
| 484 | 499 | const context = this.sessions.get(sessionId); |
| 485 | 500 | if (!context) { |
| 486 | 501 | throw new Error(`Unknown session: ${sessionId}`); |
| ... | ... | @@ -591,7 +606,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 591 | 606 | : undefined; |
| 592 | 607 | |
| 593 | 608 | this.emitEvent({ |
| 594 | id: randomUUID(), | |
| 609 | id: EventId.makeUnsafe(randomUUID()), | |
| 595 | 610 | kind: "notification", |
| 596 | 611 | provider: "codex", |
| 597 | 612 | sessionId: context.session.sessionId, |
| ... | ... | @@ -605,7 +620,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 605 | 620 | }); |
| 606 | 621 | |
| 607 | 622 | if (notification.method === "thread/started") { |
| 608 | const threadId = this.readString(this.readObject(notification.params)?.thread, "id"); | |
| 623 | const threadId = toProviderThreadId( | |
| 624 | this.readString(this.readObject(notification.params)?.thread, "id"), | |
| 625 | ); | |
| 609 | 626 | if (threadId) { |
| 610 | 627 | this.updateSession(context, { threadId }); |
| 611 | 628 | } |
| ... | ... | @@ -613,7 +630,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 613 | 630 | } |
| 614 | 631 | |
| 615 | 632 | if (notification.method === "turn/started") { |
| 616 | const turnId = this.readString(this.readObject(notification.params)?.turn, "id"); | |
| 633 | const turnId = toProviderTurnId( | |
| 634 | this.readString(this.readObject(notification.params)?.turn, "id"), | |
| 635 | ); | |
| 617 | 636 | this.updateSession(context, { |
| 618 | 637 | status: "running", |
| 619 | 638 | activeTurnId: turnId, |
| ... | ... | @@ -647,9 +666,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 647 | 666 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { |
| 648 | 667 | const route = this.readRouteFields(request.params); |
| 649 | 668 | const requestKind = this.requestKindForMethod(request.method); |
| 650 | let requestId: string | undefined; | |
| 669 | let requestId: ApprovalRequestId | undefined; | |
| 651 | 670 | if (requestKind) { |
| 652 | requestId = randomUUID(); | |
| 671 | requestId = ApprovalRequestId.makeUnsafe(randomUUID()); | |
| 653 | 672 | const pendingRequest: PendingApprovalRequest = { |
| 654 | 673 | requestId, |
| 655 | 674 | jsonRpcId: request.id, |
| ... | ... | @@ -666,7 +685,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 666 | 685 | } |
| 667 | 686 | |
| 668 | 687 | this.emitEvent({ |
| 669 | id: randomUUID(), | |
| 688 | id: EventId.makeUnsafe(randomUUID()), | |
| 670 | 689 | kind: "request", |
| 671 | 690 | provider: "codex", |
| 672 | 691 | sessionId: context.session.sessionId, |
| ... | ... | @@ -761,7 +780,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 761 | 780 | |
| 762 | 781 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { |
| 763 | 782 | this.emitEvent({ |
| 764 | id: randomUUID(), | |
| 783 | id: EventId.makeUnsafe(randomUUID()), | |
| 765 | 784 | kind: "session", |
| 766 | 785 | provider: "codex", |
| 767 | 786 | sessionId: context.session.sessionId, |
| ... | ... | @@ -773,7 +792,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 773 | 792 | |
| 774 | 793 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { |
| 775 | 794 | this.emitEvent({ |
| 776 | id: randomUUID(), | |
| 795 | id: EventId.makeUnsafe(randomUUID()), | |
| 777 | 796 | kind: "error", |
| 778 | 797 | provider: "codex", |
| 779 | 798 | sessionId: context.session.sessionId, |
| ... | ... | @@ -810,15 +829,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 810 | 829 | private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { |
| 811 | 830 | const responseRecord = this.readObject(response); |
| 812 | 831 | const thread = this.readObject(responseRecord, "thread"); |
| 813 | const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); | |
| 814 | if (!threadId) { | |
| 832 | const threadIdRaw = | |
| 833 | this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); | |
| 834 | if (!threadIdRaw) { | |
| 815 | 835 | throw new Error(`${method} response did not include a thread id.`); |
| 816 | 836 | } |
| 837 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 817 | 838 | |
| 818 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; | |
| 839 | const turnsRaw = | |
| 840 | this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; | |
| 819 | 841 | const turns = turnsRaw.map((turnValue, index) => { |
| 820 | 842 | const turn = this.readObject(turnValue); |
| 821 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; | |
| 843 | const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; | |
| 844 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | |
| 822 | 845 | const items = this.readArray(turn, "items") ?? []; |
| 823 | 846 | return { |
| 824 | 847 | id: turnId, |
| ... | ... | @@ -865,23 +888,26 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 865 | 888 | } |
| 866 | 889 | |
| 867 | 890 | private readRouteFields(params: unknown): { |
| 868 | threadId?: string; | |
| 869 | turnId?: string; | |
| 870 | itemId?: string; | |
| 891 | threadId?: ProviderThreadId; | |
| 892 | turnId?: ProviderTurnId; | |
| 893 | itemId?: ProviderItemId; | |
| 871 | 894 | } { |
| 872 | 895 | const route: { |
| 873 | threadId?: string; | |
| 874 | turnId?: string; | |
| 875 | itemId?: string; | |
| 896 | threadId?: ProviderThreadId; | |
| 897 | turnId?: ProviderTurnId; | |
| 898 | itemId?: ProviderItemId; | |
| 876 | 899 | } = {}; |
| 877 | 900 | |
| 878 | const threadId = | |
| 901 | const threadId = toProviderThreadId( | |
| 879 | 902 | this.readString(params, "threadId") ?? |
| 880 | this.readString(this.readObject(params, "thread"), "id"); | |
| 881 | const turnId = | |
| 882 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); | |
| 883 | const itemId = | |
| 884 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); | |
| 903 | this.readString(this.readObject(params, "thread"), "id"), | |
| 904 | ); | |
| 905 | const turnId = toProviderTurnId( | |
| 906 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), | |
| 907 | ); | |
| 908 | const itemId = toProviderItemId( | |
| 909 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"), | |
| 910 | ); | |
| 885 | 911 | |
| 886 | 912 | if (threadId) { |
| 887 | 913 | route.threadId = threadId; |
| ... | ... | @@ -941,3 +967,35 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 941 | 967 | return typeof candidate === "boolean" ? candidate : undefined; |
| 942 | 968 | } |
| 943 | 969 | } |
| 970 | ||
| 971 | function brandIfNonEmpty<T extends string>( | |
| 972 | value: string | undefined, | |
| 973 | maker: (value: string) => T, | |
| 974 | ): T | undefined { | |
| 975 | const normalized = value?.trim(); | |
| 976 | return normalized?.length ? maker(normalized) : undefined; | |
| 977 | } | |
| 978 | ||
| 979 | function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { | |
| 980 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); | |
| 981 | } | |
| 982 | ||
| 983 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { | |
| 984 | if ( | |
| 985 | !input.resumeCursor || | |
| 986 | typeof input.resumeCursor !== "object" || | |
| 987 | Array.isArray(input.resumeCursor) | |
| 988 | ) { | |
| 989 | return input.resumeThreadId; | |
| 990 | } | |
| 991 | const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId; | |
| 992 | return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; | |
| 993 | } | |
| 994 | ||
| 995 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { | |
| 996 | return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); | |
| 997 | } | |
| 998 | ||
| 999 | function toProviderItemId(value: string | undefined): ProviderItemId | undefined { | |
| 1000 | return brandIfNonEmpty(value, ProviderItemId.makeUnsafe); | |
| 1001 | } |
apps/server/src/codexTextGenerator.ts deleted-252| ... | ... | @@ -1,252 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import type { | |
| 7 | CommitMessageGenerationInput, | |
| 8 | CommitMessageGenerationResult, | |
| 9 | PrContentGenerationInput, | |
| 10 | PrContentGenerationResult, | |
| 11 | TextGenerationService, | |
| 12 | } from "./coreServices"; | |
| 13 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 14 | ||
| 15 | type ProcessRunner = ( | |
| 16 | command: string, | |
| 17 | args: readonly string[], | |
| 18 | options?: ProcessRunOptions, | |
| 19 | ) => Promise<ProcessRunResult>; | |
| 20 | ||
| 21 | const CODEX_MODEL = "gpt-5.3-codex"; | |
| 22 | const CODEX_REASONING_EFFORT = "low"; | |
| 23 | ||
| 24 | const COMMIT_OUTPUT_SCHEMA_JSON = { | |
| 25 | type: "object", | |
| 26 | properties: { | |
| 27 | subject: { type: "string" }, | |
| 28 | body: { type: "string" }, | |
| 29 | }, | |
| 30 | required: ["subject", "body"], | |
| 31 | additionalProperties: false, | |
| 32 | } as const; | |
| 33 | ||
| 34 | const PR_OUTPUT_SCHEMA_JSON = { | |
| 35 | type: "object", | |
| 36 | properties: { | |
| 37 | title: { type: "string" }, | |
| 38 | body: { type: "string" }, | |
| 39 | }, | |
| 40 | required: ["title", "body"], | |
| 41 | additionalProperties: false, | |
| 42 | } as const; | |
| 43 | ||
| 44 | function parseCommitOutput(raw: unknown): { subject: string; body: string } { | |
| 45 | if (!raw || typeof raw !== "object") { | |
| 46 | throw new Error("Codex returned a non-object commit message payload."); | |
| 47 | } | |
| 48 | const record = raw as Record<string, unknown>; | |
| 49 | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | |
| 50 | const body = typeof record.body === "string" ? record.body : ""; | |
| 51 | if (subject.length === 0) { | |
| 52 | throw new Error("Codex returned an empty commit subject."); | |
| 53 | } | |
| 54 | return { subject, body }; | |
| 55 | } | |
| 56 | ||
| 57 | function parsePrOutput(raw: unknown): { title: string; body: string } { | |
| 58 | if (!raw || typeof raw !== "object") { | |
| 59 | throw new Error("Codex returned a non-object PR payload."); | |
| 60 | } | |
| 61 | const record = raw as Record<string, unknown>; | |
| 62 | const title = typeof record.title === "string" ? record.title.trim() : ""; | |
| 63 | const body = typeof record.body === "string" ? record.body.trim() : ""; | |
| 64 | if (title.length === 0 || body.length === 0) { | |
| 65 | throw new Error("Codex returned an invalid PR title/body payload."); | |
| 66 | } | |
| 67 | return { title, body }; | |
| 68 | } | |
| 69 | ||
| 70 | function limitSection(value: string, maxChars: number): string { | |
| 71 | if (value.length <= maxChars) return value; | |
| 72 | const truncated = value.slice(0, maxChars); | |
| 73 | return `${truncated}\n\n[truncated]`; | |
| 74 | } | |
| 75 | ||
| 76 | async function writeTempFile(prefix: string, content: string): Promise<string> { | |
| 77 | const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); | |
| 78 | await fs.writeFile(filePath, content, "utf8"); | |
| 79 | return filePath; | |
| 80 | } | |
| 81 | ||
| 82 | async function safeUnlink(filePath: string): Promise<void> { | |
| 83 | try { | |
| 84 | await fs.unlink(filePath); | |
| 85 | } catch { | |
| 86 | // Best-effort cleanup. | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | function sanitizeCommitSubject(raw: string): string { | |
| 91 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 92 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | |
| 93 | if (withoutTrailingPeriod.length === 0) { | |
| 94 | return "Update project files"; | |
| 95 | } | |
| 96 | ||
| 97 | if (withoutTrailingPeriod.length <= 72) { | |
| 98 | return withoutTrailingPeriod; | |
| 99 | } | |
| 100 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | |
| 101 | } | |
| 102 | ||
| 103 | function sanitizePrTitle(raw: string): string { | |
| 104 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 105 | if (singleLine.length > 0) { | |
| 106 | return singleLine; | |
| 107 | } | |
| 108 | return "Update project changes"; | |
| 109 | } | |
| 110 | ||
| 111 | async function runCodexJson<T>({ | |
| 112 | cwd, | |
| 113 | prompt, | |
| 114 | outputSchemaJson, | |
| 115 | parse, | |
| 116 | run, | |
| 117 | }: { | |
| 118 | cwd: string; | |
| 119 | prompt: string; | |
| 120 | outputSchemaJson: object; | |
| 121 | parse: (raw: unknown) => T; | |
| 122 | run: ProcessRunner; | |
| 123 | }): Promise<T> { | |
| 124 | const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson)); | |
| 125 | let outputPath: string | null = null; | |
| 126 | ||
| 127 | try { | |
| 128 | outputPath = await writeTempFile("codex-output", ""); | |
| 129 | await run( | |
| 130 | "codex", | |
| 131 | [ | |
| 132 | "exec", | |
| 133 | "--ephemeral", | |
| 134 | "-s", | |
| 135 | "read-only", | |
| 136 | "--model", | |
| 137 | CODEX_MODEL, | |
| 138 | "--config", | |
| 139 | `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, | |
| 140 | "--output-schema", | |
| 141 | schemaPath, | |
| 142 | "--output-last-message", | |
| 143 | outputPath, | |
| 144 | "-", | |
| 145 | ], | |
| 146 | { | |
| 147 | cwd, | |
| 148 | timeoutMs: 180_000, | |
| 149 | stdin: prompt, | |
| 150 | }, | |
| 151 | ); | |
| 152 | ||
| 153 | const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); | |
| 154 | if (rawOutput.length === 0) { | |
| 155 | throw new Error("Codex returned an empty response."); | |
| 156 | } | |
| 157 | ||
| 158 | let parsedJson: unknown; | |
| 159 | try { | |
| 160 | parsedJson = JSON.parse(rawOutput); | |
| 161 | } catch { | |
| 162 | throw new Error("Codex returned invalid JSON output."); | |
| 163 | } | |
| 164 | ||
| 165 | return parse(parsedJson); | |
| 166 | } finally { | |
| 167 | await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | interface CodexTextGeneratorDeps { | |
| 172 | runProcess?: ProcessRunner; | |
| 173 | } | |
| 174 | ||
| 175 | export class CodexTextGenerator implements TextGenerationService { | |
| 176 | private readonly run: ProcessRunner; | |
| 177 | ||
| 178 | constructor(deps: CodexTextGeneratorDeps = {}) { | |
| 179 | this.run = deps.runProcess ?? runProcess; | |
| 180 | } | |
| 181 | ||
| 182 | async generateCommitMessage( | |
| 183 | input: CommitMessageGenerationInput, | |
| 184 | ): Promise<CommitMessageGenerationResult> { | |
| 185 | const prompt = [ | |
| 186 | "You write concise git commit messages.", | |
| 187 | "Return a JSON object with keys: subject, body.", | |
| 188 | "Rules:", | |
| 189 | "- subject must be imperative, <= 72 chars, and no trailing period", | |
| 190 | "- body can be empty string or short bullet points", | |
| 191 | "- capture the primary user-visible or developer-visible change", | |
| 192 | "", | |
| 193 | `Branch: ${input.branch ?? "(detached)"}`, | |
| 194 | "", | |
| 195 | "Staged files:", | |
| 196 | limitSection(input.stagedSummary, 6_000), | |
| 197 | "", | |
| 198 | "Staged patch:", | |
| 199 | limitSection(input.stagedPatch, 40_000), | |
| 200 | ].join("\n"); | |
| 201 | ||
| 202 | const generated = await runCodexJson({ | |
| 203 | cwd: input.cwd, | |
| 204 | prompt, | |
| 205 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | |
| 206 | parse: (raw) => parseCommitOutput(raw), | |
| 207 | run: this.run, | |
| 208 | }); | |
| 209 | ||
| 210 | return { | |
| 211 | subject: sanitizeCommitSubject(generated.subject), | |
| 212 | body: generated.body.trim(), | |
| 213 | }; | |
| 214 | } | |
| 215 | ||
| 216 | async generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult> { | |
| 217 | const prompt = [ | |
| 218 | "You write GitHub pull request content.", | |
| 219 | "Return a JSON object with keys: title, body.", | |
| 220 | "Rules:", | |
| 221 | "- title should be concise and specific", | |
| 222 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 223 | "- under Summary, provide short bullet points", | |
| 224 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 225 | "", | |
| 226 | `Base branch: ${input.baseBranch}`, | |
| 227 | `Head branch: ${input.headBranch}`, | |
| 228 | "", | |
| 229 | "Commits:", | |
| 230 | limitSection(input.commitSummary, 12_000), | |
| 231 | "", | |
| 232 | "Diff stat:", | |
| 233 | limitSection(input.diffSummary, 12_000), | |
| 234 | "", | |
| 235 | "Diff patch:", | |
| 236 | limitSection(input.diffPatch, 40_000), | |
| 237 | ].join("\n"); | |
| 238 | ||
| 239 | const generated = await runCodexJson({ | |
| 240 | cwd: input.cwd, | |
| 241 | prompt, | |
| 242 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | |
| 243 | parse: (raw) => parsePrOutput(raw), | |
| 244 | run: this.run, | |
| 245 | }); | |
| 246 | ||
| 247 | return { | |
| 248 | title: sanitizePrTitle(generated.title), | |
| 249 | body: generated.body.trim(), | |
| 250 | }; | |
| 251 | } | |
| 252 | } |
apps/server/src/config.ts created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | /** | |
| 2 | * ServerConfig and NetService - Runtime configuration services. | |
| 3 | * | |
| 4 | * Defines process-level server configuration and networking helpers used by | |
| 5 | * startup and runtime layers. | |
| 6 | * | |
| 7 | * @module ServerConfig | |
| 8 | */ | |
| 9 | import * as Net from "node:net"; | |
| 10 | import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; | |
| 11 | ||
| 12 | export const DEFAULT_PORT = 3773; | |
| 13 | ||
| 14 | export type RuntimeMode = "web" | "desktop"; | |
| 15 | ||
| 16 | /** | |
| 17 | * ServerConfigShape - Process/runtime configuration required by the server. | |
| 18 | */ | |
| 19 | export interface ServerConfigShape { | |
| 20 | readonly mode: RuntimeMode; | |
| 21 | readonly port: number; | |
| 22 | readonly host: string | undefined; | |
| 23 | readonly cwd: string; | |
| 24 | readonly keybindingsConfigPath: string; | |
| 25 | readonly stateDir: string; | |
| 26 | readonly staticDir: string | undefined; | |
| 27 | readonly devUrl: URL | undefined; | |
| 28 | readonly noBrowser: boolean; | |
| 29 | readonly authToken: string | undefined; | |
| 30 | readonly autoBootstrapProjectFromCwd: boolean; | |
| 31 | readonly logWebSocketEvents: boolean; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * ServerConfig - Service tag for server runtime configuration. | |
| 36 | */ | |
| 37 | export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()( | |
| 38 | "t3/config/ServerConfig", | |
| 39 | ) {} | |
| 40 | ||
| 41 | // Helpers | |
| 42 | ||
| 43 | export class NetError extends Data.TaggedError("NetError")<{ | |
| 44 | readonly message: string; | |
| 45 | readonly cause?: unknown; | |
| 46 | }> {} | |
| 47 | ||
| 48 | /** | |
| 49 | * NetServiceShape - Networking helper operations used during startup. | |
| 50 | */ | |
| 51 | export interface NetServiceShape { | |
| 52 | /** | |
| 53 | * Resolve an available listening port, preferring the provided port first. | |
| 54 | * | |
| 55 | * Falls back to an ephemeral OS-assigned port when the preferred port is in use. | |
| 56 | */ | |
| 57 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * NetService - Service tag for startup networking helpers. | |
| 62 | */ | |
| 63 | export class NetService extends ServiceMap.Service<NetService, NetServiceShape>()( | |
| 64 | "t3/config/NetService", | |
| 65 | ) { | |
| 66 | static readonly layer = Layer.succeed(NetService, { | |
| 67 | findAvailablePort: (preferred) => | |
| 68 | Effect.callback<number, NetError>((resume) => { | |
| 69 | let fallbackServer: Net.Server | null = null; | |
| 70 | const server = Net.createServer(); | |
| 71 | server.listen(preferred, () => { | |
| 72 | server.close(() => resume(Effect.succeed(preferred))); | |
| 73 | }); | |
| 74 | server.on("error", () => { | |
| 75 | const fallback = Net.createServer(); | |
| 76 | fallback.listen(0, () => { | |
| 77 | const addr = fallback.address(); | |
| 78 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 79 | fallback.close(() => { | |
| 80 | resume( | |
| 81 | port > 0 | |
| 82 | ? Effect.succeed(port) | |
| 83 | : Effect.fail(new NetError({ message: "Could not find an available port." })), | |
| 84 | ); | |
| 85 | }); | |
| 86 | }); | |
| 87 | fallback.on("error", (cause) => { | |
| 88 | resume( | |
| 89 | Effect.fail(new NetError({ message: "Failed to find an available port", cause })), | |
| 90 | ); | |
| 91 | }); | |
| 92 | fallbackServer = fallback; | |
| 93 | }); | |
| 94 | ||
| 95 | return Effect.sync(() => { | |
| 96 | server.close(); | |
| 97 | fallbackServer?.close(); | |
| 98 | }); | |
| 99 | }), | |
| 100 | }); | |
| 101 | } | |
| 102 | ||
| 103 | export const resolveStaticDir = Effect.fn(function* () { | |
| 104 | const { join, resolve } = yield* Path.Path; | |
| 105 | const { exists } = yield* FileSystem.FileSystem; | |
| 106 | const bundledClient = resolve(join(import.meta.dirname, "client")); | |
| 107 | const bundledStat = yield* exists(join(bundledClient, "index.html")).pipe( | |
| 108 | Effect.orElseSucceed(() => false), | |
| 109 | ); | |
| 110 | if (bundledStat) { | |
| 111 | return bundledClient; | |
| 112 | } | |
| 113 | ||
| 114 | const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); | |
| 115 | const monorepoStat = yield* exists(join(monorepoClient, "index.html")).pipe( | |
| 116 | Effect.orElseSucceed(() => false), | |
| 117 | ); | |
| 118 | if (monorepoStat) { | |
| 119 | return monorepoClient; | |
| 120 | } | |
| 121 | return undefined; | |
| 122 | }); |
apps/server/src/coreServices.ts deleted-32| ... | ... | @@ -1,32 +0,0 @@ |
| 1 | export interface CommitMessageGenerationInput { | |
| 2 | cwd: string; | |
| 3 | branch: string | null; | |
| 4 | stagedSummary: string; | |
| 5 | stagedPatch: string; | |
| 6 | } | |
| 7 | ||
| 8 | export interface CommitMessageGenerationResult { | |
| 9 | subject: string; | |
| 10 | body: string; | |
| 11 | } | |
| 12 | ||
| 13 | export interface PrContentGenerationInput { | |
| 14 | cwd: string; | |
| 15 | baseBranch: string; | |
| 16 | headBranch: string; | |
| 17 | commitSummary: string; | |
| 18 | diffSummary: string; | |
| 19 | diffPatch: string; | |
| 20 | } | |
| 21 | ||
| 22 | export interface PrContentGenerationResult { | |
| 23 | title: string; | |
| 24 | body: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface TextGenerationService { | |
| 28 | generateCommitMessage( | |
| 29 | input: CommitMessageGenerationInput, | |
| 30 | ): Promise<CommitMessageGenerationResult>; | |
| 31 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | |
| 32 | } |
apps/server/src/filesystemCheckpointStore.test.ts deleted-243| ... | ... | @@ -1,243 +0,0 @@ |
| 1 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { describe, expect, it } from "vitest"; | |
| 7 | ||
| 8 | import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 9 | import { runProcess } from "./processRunner"; | |
| 10 | ||
| 11 | async function runGit(cwd: string, args: readonly string[]): Promise<string> { | |
| 12 | const result = await runProcess("git", args, { | |
| 13 | cwd, | |
| 14 | allowNonZeroExit: true, | |
| 15 | }); | |
| 16 | if (result.code !== 0) { | |
| 17 | throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); | |
| 18 | } | |
| 19 | return result.stdout.trim(); | |
| 20 | } | |
| 21 | ||
| 22 | function exists(filePath: string): boolean { | |
| 23 | return fs.existsSync(filePath); | |
| 24 | } | |
| 25 | ||
| 26 | async function createRepo(): Promise<string> { | |
| 27 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-")); | |
| 28 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 29 | await runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 30 | await runGit(cwd, ["config", "user.name", "Test User"]); | |
| 31 | await writeFile(path.join(cwd, "README.md"), "v1\n"); | |
| 32 | await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n"); | |
| 33 | await runGit(cwd, ["add", "."]); | |
| 34 | await runGit(cwd, ["commit", "-m", "Initial"]); | |
| 35 | return cwd; | |
| 36 | } | |
| 37 | ||
| 38 | async function createRepoWithoutHead(): Promise<string> { | |
| 39 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-")); | |
| 40 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 41 | return cwd; | |
| 42 | } | |
| 43 | ||
| 44 | describe("FilesystemCheckpointStore", () => { | |
| 45 | it("captures refs without mutating branch history and restores workspace state", async () => { | |
| 46 | const cwd = await createRepo(); | |
| 47 | const store = new FilesystemCheckpointStore(); | |
| 48 | const threadId = "thr_1"; | |
| 49 | ||
| 50 | try { | |
| 51 | const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]); | |
| 52 | ||
| 53 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 54 | const rootRef = checkpointRefForThreadTurn(threadId, 0); | |
| 55 | const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]); | |
| 56 | expect(rootCommit.length).toBeGreaterThan(0); | |
| 57 | expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore); | |
| 58 | ||
| 59 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 60 | await rm(path.join(cwd, "tracked.txt")); | |
| 61 | await writeFile(path.join(cwd, "notes.md"), "notes\n"); | |
| 62 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 63 | ||
| 64 | await writeFile(path.join(cwd, "README.md"), "v3\n"); | |
| 65 | await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); | |
| 66 | await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n"); | |
| 67 | ||
| 68 | const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 69 | expect(restoredRoot).toBe(true); | |
| 70 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 71 | expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n"); | |
| 72 | expect(exists(path.join(cwd, "notes.md"))).toBe(false); | |
| 73 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 74 | ||
| 75 | const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 76 | expect(restoredTurn1).toBe(true); | |
| 77 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n"); | |
| 78 | expect(exists(path.join(cwd, "tracked.txt"))).toBe(false); | |
| 79 | expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n"); | |
| 80 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 81 | } finally { | |
| 82 | await rm(cwd, { recursive: true, force: true }); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | it("returns false when restoring a missing checkpoint", async () => { | |
| 87 | const cwd = await createRepo(); | |
| 88 | const store = new FilesystemCheckpointStore(); | |
| 89 | ||
| 90 | try { | |
| 91 | const restored = await store.restoreCheckpoint({ | |
| 92 | cwd, | |
| 93 | threadId: "thr_2", | |
| 94 | turnCount: 999, | |
| 95 | }); | |
| 96 | expect(restored).toBe(false); | |
| 97 | } finally { | |
| 98 | await rm(cwd, { recursive: true, force: true }); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => { | |
| 103 | const cwd = await createRepo(); | |
| 104 | const store = new FilesystemCheckpointStore(); | |
| 105 | const threadId = "thr_2b"; | |
| 106 | ||
| 107 | try { | |
| 108 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 109 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 110 | ||
| 111 | await writeFile(path.join(cwd, "README.md"), "v3\n"); | |
| 112 | await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); | |
| 113 | ||
| 114 | const restored = await store.restoreCheckpoint({ | |
| 115 | cwd, | |
| 116 | threadId, | |
| 117 | turnCount: 0, | |
| 118 | }); | |
| 119 | ||
| 120 | expect(restored).toBe(true); | |
| 121 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 122 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 123 | } finally { | |
| 124 | await rm(cwd, { recursive: true, force: true }); | |
| 125 | } | |
| 126 | }); | |
| 127 | ||
| 128 | it("prunes refs newer than the retained turn", async () => { | |
| 129 | const cwd = await createRepo(); | |
| 130 | const store = new FilesystemCheckpointStore(); | |
| 131 | const threadId = "thr_3"; | |
| 132 | ||
| 133 | try { | |
| 134 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 135 | await writeFile(path.join(cwd, "README.md"), "turn1\n"); | |
| 136 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 137 | await writeFile(path.join(cwd, "README.md"), "turn2\n"); | |
| 138 | await store.captureCheckpoint({ cwd, threadId, turnCount: 2 }); | |
| 139 | ||
| 140 | await store.pruneAfterTurn({ | |
| 141 | cwd, | |
| 142 | threadId, | |
| 143 | maxTurnCount: 1, | |
| 144 | }); | |
| 145 | ||
| 146 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true); | |
| 147 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true); | |
| 148 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false); | |
| 149 | } finally { | |
| 150 | await rm(cwd, { recursive: true, force: true }); | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 154 | it("returns a unified patch between checkpoint turns", async () => { | |
| 155 | const cwd = await createRepo(); | |
| 156 | const store = new FilesystemCheckpointStore(); | |
| 157 | const threadId = "thr_4"; | |
| 158 | ||
| 159 | try { | |
| 160 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 161 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 162 | await writeFile(path.join(cwd, "new.txt"), "new file\n"); | |
| 163 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 164 | ||
| 165 | const diff = await store.diffCheckpoints({ | |
| 166 | cwd, | |
| 167 | threadId, | |
| 168 | fromTurnCount: 0, | |
| 169 | toTurnCount: 1, | |
| 170 | }); | |
| 171 | ||
| 172 | expect(diff).toContain("diff --git a/README.md b/README.md"); | |
| 173 | expect(diff).toContain("diff --git a/new.txt b/new.txt"); | |
| 174 | } finally { | |
| 175 | await rm(cwd, { recursive: true, force: true }); | |
| 176 | } | |
| 177 | }); | |
| 178 | ||
| 179 | it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => { | |
| 180 | const cwd = await createRepo(); | |
| 181 | const store = new FilesystemCheckpointStore(); | |
| 182 | const threadId = "thr_5"; | |
| 183 | ||
| 184 | try { | |
| 185 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 186 | await writeFile(path.join(cwd, "new.txt"), "new file\n"); | |
| 187 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 188 | ||
| 189 | const diff = await store.diffCheckpoints({ | |
| 190 | cwd, | |
| 191 | threadId, | |
| 192 | fromTurnCount: 0, | |
| 193 | toTurnCount: 1, | |
| 194 | }); | |
| 195 | ||
| 196 | expect(diff).toContain("diff --git a/README.md b/README.md"); | |
| 197 | expect(diff).toContain("diff --git a/new.txt b/new.txt"); | |
| 198 | } finally { | |
| 199 | await rm(cwd, { recursive: true, force: true }); | |
| 200 | } | |
| 201 | }); | |
| 202 | ||
| 203 | it("detects non-repo folders", async () => { | |
| 204 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); | |
| 205 | const store = new FilesystemCheckpointStore(); | |
| 206 | ||
| 207 | try { | |
| 208 | expect(await store.isGitRepository(cwd)).toBe(false); | |
| 209 | } finally { | |
| 210 | await rm(cwd, { recursive: true, force: true }); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | it("returns false when repository path does not exist", async () => { | |
| 215 | const store = new FilesystemCheckpointStore(); | |
| 216 | const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`); | |
| 217 | ||
| 218 | expect(await store.isGitRepository(cwd)).toBe(false); | |
| 219 | }); | |
| 220 | ||
| 221 | it("restores checkpoint without reset when repository has no HEAD commit", async () => { | |
| 222 | const cwd = await createRepoWithoutHead(); | |
| 223 | const store = new FilesystemCheckpointStore(); | |
| 224 | const threadId = "thr-no-head"; | |
| 225 | ||
| 226 | try { | |
| 227 | await writeFile(path.join(cwd, "README.md"), "first\n"); | |
| 228 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 229 | ||
| 230 | await writeFile(path.join(cwd, "README.md"), "second\n"); | |
| 231 | const restored = await store.restoreCheckpoint({ | |
| 232 | cwd, | |
| 233 | threadId, | |
| 234 | turnCount: 0, | |
| 235 | }); | |
| 236 | ||
| 237 | expect(restored).toBe(true); | |
| 238 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n"); | |
| 239 | } finally { | |
| 240 | await rm(cwd, { recursive: true, force: true }); | |
| 241 | } | |
| 242 | }); | |
| 243 | }); |
apps/server/src/filesystemCheckpointStore.ts deleted-322| ... | ... | @@ -1,322 +0,0 @@ |
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { randomUUID } from "node:crypto"; | |
| 5 | ||
| 6 | import { runProcess } from "./processRunner"; | |
| 7 | ||
| 8 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 9 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 10 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 11 | ||
| 12 | function quoteGitCommand(args: readonly string[]): string { | |
| 13 | return `git ${args.join(" ")}`; | |
| 14 | } | |
| 15 | ||
| 16 | function checkpointRefThreadSegment(threadId: string): string { | |
| 17 | return Buffer.from(threadId, "utf8").toString("base64url"); | |
| 18 | } | |
| 19 | ||
| 20 | export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { | |
| 21 | if (!Number.isInteger(turnCount) || turnCount < 0) { | |
| 22 | throw new Error(`Invalid checkpoint turn count: ${turnCount}`); | |
| 23 | } | |
| 24 | return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; | |
| 25 | } | |
| 26 | ||
| 27 | interface GitOptions { | |
| 28 | env?: NodeJS.ProcessEnv; | |
| 29 | stdin?: string; | |
| 30 | allowNonZeroExit?: boolean; | |
| 31 | } | |
| 32 | ||
| 33 | export class FilesystemCheckpointStore { | |
| 34 | async isGitRepository(cwd: string): Promise<boolean> { | |
| 35 | try { | |
| 36 | const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { | |
| 37 | allowNonZeroExit: true, | |
| 38 | }); | |
| 39 | return result.code === 0 && result.stdout.trim() === "true"; | |
| 40 | } catch { | |
| 41 | return false; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | async captureCheckpoint(input: { | |
| 46 | cwd: string; | |
| 47 | threadId: string; | |
| 48 | turnCount: number; | |
| 49 | }): Promise<void> { | |
| 50 | const { cwd, threadId, turnCount } = input; | |
| 51 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 52 | ||
| 53 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); | |
| 54 | ||
| 55 | try { | |
| 56 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | |
| 57 | const commitEnv: NodeJS.ProcessEnv = { | |
| 58 | ...process.env, | |
| 59 | GIT_INDEX_FILE: tempIndexPath, | |
| 60 | GIT_AUTHOR_NAME: "T3 Code", | |
| 61 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | |
| 62 | GIT_COMMITTER_NAME: "T3 Code", | |
| 63 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | |
| 64 | }; | |
| 65 | ||
| 66 | const hasHead = await this.hasHeadCommit(cwd); | |
| 67 | if (hasHead) { | |
| 68 | await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv }); | |
| 69 | } | |
| 70 | ||
| 71 | await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv }); | |
| 72 | const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv }); | |
| 73 | const treeOid = writeTreeResult.stdout.trim(); | |
| 74 | if (!treeOid) { | |
| 75 | throw new Error("git write-tree returned an empty tree oid."); | |
| 76 | } | |
| 77 | ||
| 78 | const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`; | |
| 79 | const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], { | |
| 80 | env: commitEnv, | |
| 81 | stdin: `${message}\n`, | |
| 82 | }); | |
| 83 | const commitOid = commitTreeResult.stdout.trim(); | |
| 84 | if (!commitOid) { | |
| 85 | throw new Error("git commit-tree returned an empty commit oid."); | |
| 86 | } | |
| 87 | ||
| 88 | await this.runGit(cwd, ["update-ref", ref, commitOid]); | |
| 89 | } finally { | |
| 90 | await rm(tempDir, { recursive: true, force: true }); | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | async hasCheckpoint(input: { | |
| 95 | cwd: string; | |
| 96 | threadId: string; | |
| 97 | turnCount: number; | |
| 98 | }): Promise<boolean> { | |
| 99 | const { cwd, threadId, turnCount } = input; | |
| 100 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 101 | const commit = await this.resolveCheckpointCommit(cwd, ref); | |
| 102 | return commit !== null; | |
| 103 | } | |
| 104 | ||
| 105 | async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise<boolean> { | |
| 106 | const { cwd, threadId } = input; | |
| 107 | const rootRef = checkpointRefForThreadTurn(threadId, 0); | |
| 108 | const existing = await this.resolveCheckpointCommit(cwd, rootRef); | |
| 109 | if (existing) { | |
| 110 | return true; | |
| 111 | } | |
| 112 | ||
| 113 | const headCommit = await this.resolveHeadCommit(cwd); | |
| 114 | if (!headCommit) { | |
| 115 | return false; | |
| 116 | } | |
| 117 | ||
| 118 | await this.runGit(cwd, ["update-ref", rootRef, headCommit]); | |
| 119 | return true; | |
| 120 | } | |
| 121 | ||
| 122 | async restoreCheckpoint(input: { | |
| 123 | cwd: string; | |
| 124 | threadId: string; | |
| 125 | turnCount: number; | |
| 126 | }): Promise<boolean> { | |
| 127 | const { cwd, threadId, turnCount } = input; | |
| 128 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 129 | let commitOid = await this.resolveCheckpointCommit(cwd, ref); | |
| 130 | if (!commitOid && turnCount === 0) { | |
| 131 | commitOid = await this.resolveHeadCommit(cwd); | |
| 132 | } | |
| 133 | if (!commitOid) { | |
| 134 | return false; | |
| 135 | } | |
| 136 | ||
| 137 | await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); | |
| 138 | await this.runGit(cwd, ["clean", "-fd", "--", "."]); | |
| 139 | if (await this.hasHeadCommit(cwd)) { | |
| 140 | await this.runGit(cwd, ["reset", "--quiet", "--", "."]); | |
| 141 | } | |
| 142 | return true; | |
| 143 | } | |
| 144 | ||
| 145 | async diffCheckpoints(input: { | |
| 146 | cwd: string; | |
| 147 | threadId: string; | |
| 148 | fromTurnCount: number; | |
| 149 | toTurnCount: number; | |
| 150 | }): Promise<string> { | |
| 151 | const { cwd, threadId, fromTurnCount, toTurnCount } = input; | |
| 152 | if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) { | |
| 153 | throw new Error(`Invalid from turn count: ${fromTurnCount}`); | |
| 154 | } | |
| 155 | if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { | |
| 156 | throw new Error(`Invalid to turn count: ${toTurnCount}`); | |
| 157 | } | |
| 158 | if (fromTurnCount > toTurnCount) { | |
| 159 | throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`); | |
| 160 | } | |
| 161 | ||
| 162 | const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount); | |
| 163 | const toRef = checkpointRefForThreadTurn(threadId, toTurnCount); | |
| 164 | let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef); | |
| 165 | const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef); | |
| 166 | if (!fromCommitOid && fromTurnCount === 0) { | |
| 167 | const headCommit = await this.resolveHeadCommit(cwd); | |
| 168 | if (headCommit) { | |
| 169 | fromCommitOid = headCommit; | |
| 170 | } | |
| 171 | } | |
| 172 | if (!fromCommitOid) { | |
| 173 | throw new Error( | |
| 174 | `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`, | |
| 175 | ); | |
| 176 | } | |
| 177 | if (!toCommitOid) { | |
| 178 | throw new Error( | |
| 179 | `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 183 | const result = await this.runGit(cwd, [ | |
| 184 | "diff", | |
| 185 | "--patch", | |
| 186 | "--minimal", | |
| 187 | "--no-color", | |
| 188 | fromCommitOid, | |
| 189 | toCommitOid, | |
| 190 | ]); | |
| 191 | return result.stdout; | |
| 192 | } | |
| 193 | ||
| 194 | private async resolveHeadCommit(cwd: string): Promise<string | null> { | |
| 195 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { | |
| 196 | allowNonZeroExit: true, | |
| 197 | }); | |
| 198 | if (result.code !== 0) { | |
| 199 | return null; | |
| 200 | } | |
| 201 | const commit = result.stdout.trim(); | |
| 202 | return commit.length > 0 ? commit : null; | |
| 203 | } | |
| 204 | ||
| 205 | async pruneAfterTurn(input: { | |
| 206 | cwd: string; | |
| 207 | threadId: string; | |
| 208 | maxTurnCount: number; | |
| 209 | }): Promise<void> { | |
| 210 | const { cwd, threadId, maxTurnCount } = input; | |
| 211 | if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { | |
| 212 | throw new Error(`Invalid max turn count: ${maxTurnCount}`); | |
| 213 | } | |
| 214 | ||
| 215 | const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; | |
| 216 | const result = await this.runGit( | |
| 217 | cwd, | |
| 218 | ["for-each-ref", "--format=%(refname)", threadRefPrefix], | |
| 219 | { | |
| 220 | allowNonZeroExit: true, | |
| 221 | }, | |
| 222 | ); | |
| 223 | if (result.code !== 0) { | |
| 224 | return; | |
| 225 | } | |
| 226 | ||
| 227 | const refsToDelete = result.stdout | |
| 228 | .split(/\r?\n/g) | |
| 229 | .map((line) => line.trim()) | |
| 230 | .filter((line) => line.length > 0) | |
| 231 | .filter((refName) => { | |
| 232 | const turn = this.readRefTurnCount(refName); | |
| 233 | return turn !== null && turn > maxTurnCount; | |
| 234 | }); | |
| 235 | ||
| 236 | const batchSize = 32; | |
| 237 | const batches = Array.from( | |
| 238 | { length: Math.ceil(refsToDelete.length / batchSize) }, | |
| 239 | (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), | |
| 240 | ); | |
| 241 | await batches.reduce<Promise<void>>((pending, batch) => { | |
| 242 | return pending.then(async () => { | |
| 243 | await Promise.all( | |
| 244 | batch.map((refName) => | |
| 245 | this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), | |
| 246 | ), | |
| 247 | ); | |
| 248 | }); | |
| 249 | }, Promise.resolve()); | |
| 250 | } | |
| 251 | ||
| 252 | private async hasHeadCommit(cwd: string): Promise<boolean> { | |
| 253 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], { | |
| 254 | allowNonZeroExit: true, | |
| 255 | }); | |
| 256 | return result.code === 0; | |
| 257 | } | |
| 258 | ||
| 259 | private async resolveCheckpointCommit(cwd: string, ref: string): Promise<string | null> { | |
| 260 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { | |
| 261 | allowNonZeroExit: true, | |
| 262 | }); | |
| 263 | if (result.code !== 0) { | |
| 264 | return null; | |
| 265 | } | |
| 266 | const commit = result.stdout.trim(); | |
| 267 | return commit.length > 0 ? commit : null; | |
| 268 | } | |
| 269 | ||
| 270 | private readRefTurnCount(ref: string): number | null { | |
| 271 | const segments = ref.split("/"); | |
| 272 | const rawTurn = segments.at(-1); | |
| 273 | if (!rawTurn) { | |
| 274 | return null; | |
| 275 | } | |
| 276 | const turn = Number.parseInt(rawTurn, 10); | |
| 277 | return Number.isInteger(turn) && turn >= 0 ? turn : null; | |
| 278 | } | |
| 279 | ||
| 280 | private async runGit( | |
| 281 | cwd: string, | |
| 282 | args: readonly string[], | |
| 283 | options: GitOptions = {}, | |
| 284 | ): Promise<Awaited<ReturnType<typeof runProcess>>> { | |
| 285 | let result: Awaited<ReturnType<typeof runProcess>>; | |
| 286 | try { | |
| 287 | result = await runProcess("git", args, { | |
| 288 | cwd, | |
| 289 | env: options.env, | |
| 290 | stdin: options.stdin, | |
| 291 | timeoutMs: DEFAULT_TIMEOUT_MS, | |
| 292 | allowNonZeroExit: true, | |
| 293 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 294 | outputMode: "truncate", | |
| 295 | }); | |
| 296 | } catch (error) { | |
| 297 | const message = error instanceof Error ? error.message : String(error); | |
| 298 | throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error }); | |
| 299 | } | |
| 300 | ||
| 301 | if (result.timedOut) { | |
| 302 | throw new Error(`${quoteGitCommand(args)} timed out.`); | |
| 303 | } | |
| 304 | ||
| 305 | if (result.stdoutTruncated || result.stderrTruncated) { | |
| 306 | throw new Error( | |
| 307 | `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, | |
| 308 | ); | |
| 309 | } | |
| 310 | ||
| 311 | if (!options.allowNonZeroExit && result.code !== 0) { | |
| 312 | const stderr = result.stderr.trim(); | |
| 313 | throw new Error( | |
| 314 | stderr.length > 0 | |
| 315 | ? `${quoteGitCommand(args)} failed: ${stderr}` | |
| 316 | : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`, | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | return result; | |
| 321 | } | |
| 322 | } |
apps/server/src/fixPath.ts deleted-18| ... | ... | @@ -1,18 +0,0 @@ |
| 1 | import { execFileSync } from "node:child_process"; | |
| 2 | ||
| 3 | export function fixPath(): void { | |
| 4 | if (process.platform !== "darwin") return; | |
| 5 | ||
| 6 | try { | |
| 7 | const shell = process.env.SHELL ?? "/bin/zsh"; | |
| 8 | const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { | |
| 9 | encoding: "utf8", | |
| 10 | timeout: 5000, | |
| 11 | }); | |
| 12 | if (result) { | |
| 13 | process.env.PATH = result; | |
| 14 | } | |
| 15 | } catch { | |
| 16 | // Silently ignore — keep default PATH | |
| 17 | } | |
| 18 | } |
apps/server/src/git.test.ts deleted-930| ... | ... | @@ -1,930 +0,0 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { describe, expect, it, vi } from "vitest"; | |
| 6 | ||
| 7 | import { | |
| 8 | GitCoreService, | |
| 9 | checkoutGitBranch, | |
| 10 | createGitBranch, | |
| 11 | createGitWorktree, | |
| 12 | initGitRepo, | |
| 13 | listGitBranches, | |
| 14 | pullGitBranch, | |
| 15 | removeGitWorktree, | |
| 16 | runTerminalCommand, | |
| 17 | } from "./git"; | |
| 18 | ||
| 19 | // ── Helpers ── | |
| 20 | ||
| 21 | /** Run a raw git command for test setup (not under test). */ | |
| 22 | async function git(cwd: string, command: string): Promise<string> { | |
| 23 | const result = await runTerminalCommand({ | |
| 24 | command: `git ${command}`, | |
| 25 | cwd, | |
| 26 | timeoutMs: 10_000, | |
| 27 | }); | |
| 28 | if (result.code !== 0) { | |
| 29 | throw new Error(`git ${command} failed: ${result.stderr}`); | |
| 30 | } | |
| 31 | return result.stdout.trim(); | |
| 32 | } | |
| 33 | ||
| 34 | /** Create a disposable temp directory that cleans up automatically. */ | |
| 35 | async function makeTmpDir() { | |
| 36 | const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); | |
| 37 | return { | |
| 38 | path: dir, | |
| 39 | [Symbol.asyncDispose]: async () => { | |
| 40 | await rm(dir, { recursive: true, force: true }); | |
| 41 | }, | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | /** Create a repo with an initial commit so branches work. */ | |
| 46 | async function initRepoWithCommit(cwd: string): Promise<void> { | |
| 47 | await initGitRepo({ cwd }); | |
| 48 | await git(cwd, "config user.email 'test@test.com'"); | |
| 49 | await git(cwd, "config user.name 'Test'"); | |
| 50 | await writeFile(path.join(cwd, "README.md"), "# test\n"); | |
| 51 | await git(cwd, "add ."); | |
| 52 | await git(cwd, "commit -m 'initial commit'"); | |
| 53 | } | |
| 54 | ||
| 55 | async function commitWithDate( | |
| 56 | cwd: string, | |
| 57 | fileName: string, | |
| 58 | fileContents: string, | |
| 59 | dateIsoString: string, | |
| 60 | message: string, | |
| 61 | ): Promise<void> { | |
| 62 | await writeFile(path.join(cwd, fileName), fileContents); | |
| 63 | await git(cwd, `add ${fileName}`); | |
| 64 | const commitResult = await runTerminalCommand({ | |
| 65 | command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`, | |
| 66 | cwd, | |
| 67 | timeoutMs: 10_000, | |
| 68 | }); | |
| 69 | if (commitResult.code !== 0) { | |
| 70 | throw new Error(`git dated commit failed: ${commitResult.stderr}`); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | // ── Tests ── | |
| 75 | ||
| 76 | describe("git integration", () => { | |
| 77 | describe("runTerminalCommand", () => { | |
| 78 | it("caps captured output when maxOutputBytes is exceeded", async () => { | |
| 79 | const result = await runTerminalCommand({ | |
| 80 | command: `node -e "process.stdout.write('x'.repeat(2000))"`, | |
| 81 | cwd: process.cwd(), | |
| 82 | timeoutMs: 10_000, | |
| 83 | maxOutputBytes: 128, | |
| 84 | }); | |
| 85 | ||
| 86 | expect(result.code).toBe(0); | |
| 87 | expect(result.stdout.length).toBeLessThanOrEqual(128); | |
| 88 | expect(result.stderr).toContain("output truncated"); | |
| 89 | }); | |
| 90 | }); | |
| 91 | ||
| 92 | // ── initGitRepo ── | |
| 93 | ||
| 94 | describe("initGitRepo", () => { | |
| 95 | it("creates a valid git repo", async () => { | |
| 96 | await using tmp = await makeTmpDir(); | |
| 97 | await initGitRepo({ cwd: tmp.path }); | |
| 98 | expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); | |
| 99 | }); | |
| 100 | ||
| 101 | it("listGitBranches reports isRepo: true after init + commit", async () => { | |
| 102 | await using tmp = await makeTmpDir(); | |
| 103 | await initRepoWithCommit(tmp.path); | |
| 104 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 105 | expect(result.isRepo).toBe(true); | |
| 106 | expect(result.branches.length).toBeGreaterThanOrEqual(1); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | // ── listGitBranches ── | |
| 111 | ||
| 112 | describe("listGitBranches", () => { | |
| 113 | it("returns isRepo: false for non-git directory", async () => { | |
| 114 | await using tmp = await makeTmpDir(); | |
| 115 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 116 | expect(result.isRepo).toBe(false); | |
| 117 | expect(result.branches).toEqual([]); | |
| 118 | }); | |
| 119 | ||
| 120 | it("returns the current branch with current: true", async () => { | |
| 121 | await using tmp = await makeTmpDir(); | |
| 122 | await initRepoWithCommit(tmp.path); | |
| 123 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 124 | const current = result.branches.find((b) => b.current); | |
| 125 | expect(current).toBeDefined(); | |
| 126 | expect(current!.current).toBe(true); | |
| 127 | }); | |
| 128 | ||
| 129 | it("keeps current branch first and sorts the remaining branches by recency", async () => { | |
| 130 | await using tmp = await makeTmpDir(); | |
| 131 | await initRepoWithCommit(tmp.path); | |
| 132 | const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 133 | (branch) => branch.current, | |
| 134 | )!.name; | |
| 135 | ||
| 136 | await createGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 137 | await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 138 | await commitWithDate( | |
| 139 | tmp.path, | |
| 140 | "older.txt", | |
| 141 | "older branch change\n", | |
| 142 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 143 | "older branch change", | |
| 144 | ); | |
| 145 | ||
| 146 | await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); | |
| 147 | await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 148 | await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 149 | await commitWithDate( | |
| 150 | tmp.path, | |
| 151 | "newer.txt", | |
| 152 | "newer branch change\n", | |
| 153 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 154 | "newer branch change", | |
| 155 | ); | |
| 156 | ||
| 157 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. | |
| 158 | await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); | |
| 159 | ||
| 160 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 161 | expect(result.branches[0]!.name).toBe("older-branch"); | |
| 162 | expect(result.branches[1]!.name).toBe("newer-branch"); | |
| 163 | }); | |
| 164 | ||
| 165 | it("keeps default branch right after current branch", async () => { | |
| 166 | await using tmp = await makeTmpDir(); | |
| 167 | await using remote = await makeTmpDir(); | |
| 168 | await initRepoWithCommit(tmp.path); | |
| 169 | const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 170 | (branch) => branch.current, | |
| 171 | )!.name; | |
| 172 | ||
| 173 | await git(remote.path, "init --bare"); | |
| 174 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 175 | await git(tmp.path, `push -u origin ${defaultBranch}`); | |
| 176 | await git(tmp.path, `remote set-head origin ${defaultBranch}`); | |
| 177 | ||
| 178 | await createGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 179 | await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 180 | await commitWithDate( | |
| 181 | tmp.path, | |
| 182 | "current.txt", | |
| 183 | "current change\n", | |
| 184 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 185 | "current change", | |
| 186 | ); | |
| 187 | ||
| 188 | await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); | |
| 189 | await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 190 | await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); | |
| 191 | await commitWithDate( | |
| 192 | tmp.path, | |
| 193 | "newer.txt", | |
| 194 | "newer change\n", | |
| 195 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 196 | "newer change", | |
| 197 | ); | |
| 198 | ||
| 199 | await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); | |
| 200 | ||
| 201 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 202 | expect(result.branches[0]!.name).toBe("current-branch"); | |
| 203 | expect(result.branches[1]!.name).toBe(defaultBranch); | |
| 204 | expect(result.branches[2]!.name).toBe("newer-branch"); | |
| 205 | }); | |
| 206 | ||
| 207 | it("lists multiple branches after creating them", async () => { | |
| 208 | await using tmp = await makeTmpDir(); | |
| 209 | await initRepoWithCommit(tmp.path); | |
| 210 | await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); | |
| 211 | await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); | |
| 212 | ||
| 213 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 214 | const names = result.branches.map((b) => b.name); | |
| 215 | expect(names).toContain("feature-a"); | |
| 216 | expect(names).toContain("feature-b"); | |
| 217 | }); | |
| 218 | ||
| 219 | it("isDefault is false when no remote exists", async () => { | |
| 220 | await using tmp = await makeTmpDir(); | |
| 221 | await initRepoWithCommit(tmp.path); | |
| 222 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 223 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); | |
| 224 | }); | |
| 225 | }); | |
| 226 | ||
| 227 | // ── checkoutGitBranch ── | |
| 228 | ||
| 229 | describe("checkoutGitBranch", () => { | |
| 230 | it("checks out an existing branch", async () => { | |
| 231 | await using tmp = await makeTmpDir(); | |
| 232 | await initRepoWithCommit(tmp.path); | |
| 233 | await createGitBranch({ cwd: tmp.path, branch: "feature" }); | |
| 234 | ||
| 235 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); | |
| 236 | ||
| 237 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 238 | const current = result.branches.find((b) => b.current); | |
| 239 | expect(current!.name).toBe("feature"); | |
| 240 | }); | |
| 241 | ||
| 242 | it("refreshes upstream behind count after checkout when remote branch advanced", async () => { | |
| 243 | await using remote = await makeTmpDir(); | |
| 244 | await using source = await makeTmpDir(); | |
| 245 | await using clone = await makeTmpDir(); | |
| 246 | await git(remote.path, "init --bare"); | |
| 247 | ||
| 248 | await initRepoWithCommit(source.path); | |
| 249 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 250 | (branch) => branch.current, | |
| 251 | )!.name; | |
| 252 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 253 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 254 | ||
| 255 | const featureBranch = "feature-behind"; | |
| 256 | await createGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 257 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 258 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 259 | await git(source.path, "add feature.txt"); | |
| 260 | await git(source.path, "commit -m 'feature base'"); | |
| 261 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 262 | await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); | |
| 263 | ||
| 264 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 265 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 266 | await git(clone.path, "config user.name 'Test'"); | |
| 267 | await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); | |
| 268 | await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); | |
| 269 | await git(clone.path, "add feature.txt"); | |
| 270 | await git(clone.path, "commit -m 'remote feature update'"); | |
| 271 | await git(clone.path, `push origin ${featureBranch}`); | |
| 272 | ||
| 273 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | |
| 274 | const core = new GitCoreService(); | |
| 275 | await vi.waitFor(async () => { | |
| 276 | const details = await core.statusDetails(source.path); | |
| 277 | expect(details.branch).toBe(featureBranch); | |
| 278 | expect(details.aheadCount).toBe(0); | |
| 279 | expect(details.behindCount).toBe(1); | |
| 280 | }); | |
| 281 | }); | |
| 282 | ||
| 283 | it("keeps checkout successful when upstream refresh fails", async () => { | |
| 284 | await using remote = await makeTmpDir(); | |
| 285 | await using source = await makeTmpDir(); | |
| 286 | await git(remote.path, "init --bare"); | |
| 287 | ||
| 288 | await initRepoWithCommit(source.path); | |
| 289 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 290 | (branch) => branch.current, | |
| 291 | )!.name; | |
| 292 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 293 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 294 | ||
| 295 | const featureBranch = "feature-refresh-failure"; | |
| 296 | await git(source.path, `branch ${featureBranch}`); | |
| 297 | await git(source.path, `checkout ${featureBranch}`); | |
| 298 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 299 | await git(source.path, "add feature.txt"); | |
| 300 | await git(source.path, "commit -m 'feature base'"); | |
| 301 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 302 | await git(source.path, `checkout ${defaultBranch}`); | |
| 303 | ||
| 304 | class RefreshFailingGitCoreService extends GitCoreService { | |
| 305 | refreshFetchAttempts = 0; | |
| 306 | ||
| 307 | override async git( | |
| 308 | cwd: string, | |
| 309 | args: readonly string[], | |
| 310 | allowNonZeroExit = false, | |
| 311 | ): Promise<void> { | |
| 312 | if (args[0] === "fetch") { | |
| 313 | this.refreshFetchAttempts += 1; | |
| 314 | throw new Error("simulated fetch timeout"); | |
| 315 | } | |
| 316 | await super.git(cwd, args, allowNonZeroExit); | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | const core = new RefreshFailingGitCoreService(); | |
| 321 | await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( | |
| 322 | undefined, | |
| 323 | ); | |
| 324 | await vi.waitFor(() => { | |
| 325 | expect(core.refreshFetchAttempts).toBe(1); | |
| 326 | }); | |
| 327 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 328 | }); | |
| 329 | ||
| 330 | it("refresh fetch is scoped to the checked out branch upstream refspec", async () => { | |
| 331 | await using remote = await makeTmpDir(); | |
| 332 | await using source = await makeTmpDir(); | |
| 333 | await git(remote.path, "init --bare"); | |
| 334 | ||
| 335 | await initRepoWithCommit(source.path); | |
| 336 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 337 | (branch) => branch.current, | |
| 338 | )!.name; | |
| 339 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 340 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 341 | ||
| 342 | const featureBranch = "feature/scoped-fetch"; | |
| 343 | await git(source.path, `checkout -b ${featureBranch}`); | |
| 344 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 345 | await git(source.path, "add feature.txt"); | |
| 346 | await git(source.path, "commit -m 'feature base'"); | |
| 347 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 348 | await git(source.path, `checkout ${defaultBranch}`); | |
| 349 | ||
| 350 | class FetchRecordingGitCoreService extends GitCoreService { | |
| 351 | fetchArgs: readonly string[] | null = null; | |
| 352 | ||
| 353 | override async git( | |
| 354 | cwd: string, | |
| 355 | args: readonly string[], | |
| 356 | allowNonZeroExit = false, | |
| 357 | ): Promise<void> { | |
| 358 | if (args[0] === "fetch") { | |
| 359 | this.fetchArgs = args; | |
| 360 | return; | |
| 361 | } | |
| 362 | await super.git(cwd, args, allowNonZeroExit); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | const core = new FetchRecordingGitCoreService(); | |
| 367 | await core.checkoutBranch({ cwd: source.path, branch: featureBranch }); | |
| 368 | await vi.waitFor(() => { | |
| 369 | expect(core.fetchArgs).not.toBeNull(); | |
| 370 | }); | |
| 371 | ||
| 372 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 373 | expect(core.fetchArgs).toEqual([ | |
| 374 | "fetch", | |
| 375 | "--quiet", | |
| 376 | "--no-tags", | |
| 377 | "origin", | |
| 378 | `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, | |
| 379 | ]); | |
| 380 | }); | |
| 381 | ||
| 382 | it("returns checkout result before background upstream refresh completes", async () => { | |
| 383 | await using remote = await makeTmpDir(); | |
| 384 | await using source = await makeTmpDir(); | |
| 385 | await git(remote.path, "init --bare"); | |
| 386 | ||
| 387 | await initRepoWithCommit(source.path); | |
| 388 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 389 | (branch) => branch.current, | |
| 390 | )!.name; | |
| 391 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 392 | await git(source.path, `push -u origin ${defaultBranch}`); | |
| 393 | ||
| 394 | const featureBranch = "feature/background-refresh"; | |
| 395 | await git(source.path, `checkout -b ${featureBranch}`); | |
| 396 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | |
| 397 | await git(source.path, "add feature.txt"); | |
| 398 | await git(source.path, "commit -m 'feature base'"); | |
| 399 | await git(source.path, `push -u origin ${featureBranch}`); | |
| 400 | await git(source.path, `checkout ${defaultBranch}`); | |
| 401 | ||
| 402 | class SlowRefreshGitCoreService extends GitCoreService { | |
| 403 | fetchStarted = false; | |
| 404 | private readonly waitForReleasePromise: Promise<void>; | |
| 405 | private releaseWait: () => void = () => undefined; | |
| 406 | ||
| 407 | constructor() { | |
| 408 | super(); | |
| 409 | this.waitForReleasePromise = new Promise<void>((resolve) => { | |
| 410 | this.releaseWait = resolve; | |
| 411 | }); | |
| 412 | } | |
| 413 | ||
| 414 | releaseFetch(): void { | |
| 415 | this.releaseWait(); | |
| 416 | } | |
| 417 | ||
| 418 | override async git( | |
| 419 | cwd: string, | |
| 420 | args: readonly string[], | |
| 421 | allowNonZeroExit = false, | |
| 422 | ): Promise<void> { | |
| 423 | if (args[0] === "fetch") { | |
| 424 | this.fetchStarted = true; | |
| 425 | await this.waitForReleasePromise; | |
| 426 | return; | |
| 427 | } | |
| 428 | await super.git(cwd, args, allowNonZeroExit); | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 432 | const core = new SlowRefreshGitCoreService(); | |
| 433 | await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( | |
| 434 | undefined, | |
| 435 | ); | |
| 436 | await vi.waitFor(() => { | |
| 437 | expect(core.fetchStarted).toBe(true); | |
| 438 | }); | |
| 439 | expect(await git(source.path, "branch --show-current")).toBe(featureBranch); | |
| 440 | core.releaseFetch(); | |
| 441 | }); | |
| 442 | ||
| 443 | it("throws when branch does not exist", async () => { | |
| 444 | await using tmp = await makeTmpDir(); | |
| 445 | await initRepoWithCommit(tmp.path); | |
| 446 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); | |
| 447 | }); | |
| 448 | ||
| 449 | it("throws when checkout would overwrite uncommitted changes", async () => { | |
| 450 | await using tmp = await makeTmpDir(); | |
| 451 | await initRepoWithCommit(tmp.path); | |
| 452 | await createGitBranch({ cwd: tmp.path, branch: "other" }); | |
| 453 | ||
| 454 | // Create a conflicting change: modify README on current branch | |
| 455 | await writeFile(path.join(tmp.path, "README.md"), "modified\n"); | |
| 456 | await git(tmp.path, "add README.md"); | |
| 457 | ||
| 458 | // First, checkout other branch cleanly | |
| 459 | await git(tmp.path, "stash"); | |
| 460 | await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); | |
| 461 | await writeFile(path.join(tmp.path, "README.md"), "other content\n"); | |
| 462 | await git(tmp.path, "add ."); | |
| 463 | await git(tmp.path, "commit -m 'other change'"); | |
| 464 | ||
| 465 | // Go back to default branch | |
| 466 | const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 467 | (b) => !b.current, | |
| 468 | )!.name; | |
| 469 | await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); | |
| 470 | ||
| 471 | // Make uncommitted changes to the same file | |
| 472 | await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); | |
| 473 | ||
| 474 | // Checkout should fail due to uncommitted changes | |
| 475 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); | |
| 476 | }); | |
| 477 | }); | |
| 478 | ||
| 479 | // ── createGitBranch ── | |
| 480 | ||
| 481 | describe("createGitBranch", () => { | |
| 482 | it("creates a new branch visible in listGitBranches", async () => { | |
| 483 | await using tmp = await makeTmpDir(); | |
| 484 | await initRepoWithCommit(tmp.path); | |
| 485 | await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); | |
| 486 | ||
| 487 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 488 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); | |
| 489 | }); | |
| 490 | ||
| 491 | it("throws when branch already exists", async () => { | |
| 492 | await using tmp = await makeTmpDir(); | |
| 493 | await initRepoWithCommit(tmp.path); | |
| 494 | await createGitBranch({ cwd: tmp.path, branch: "dupe" }); | |
| 495 | await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); | |
| 496 | }); | |
| 497 | }); | |
| 498 | ||
| 499 | // ── createGitWorktree + removeGitWorktree ── | |
| 500 | ||
| 501 | describe("createGitWorktree", () => { | |
| 502 | it("creates a worktree with a new branch from the base branch", async () => { | |
| 503 | await using tmp = await makeTmpDir(); | |
| 504 | await initRepoWithCommit(tmp.path); | |
| 505 | ||
| 506 | const wtPath = path.join(tmp.path, "worktree-out"); | |
| 507 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 508 | (b) => b.current, | |
| 509 | )!.name; | |
| 510 | ||
| 511 | const result = await createGitWorktree({ | |
| 512 | cwd: tmp.path, | |
| 513 | branch: currentBranch, | |
| 514 | newBranch: "wt-branch", | |
| 515 | path: wtPath, | |
| 516 | }); | |
| 517 | ||
| 518 | expect(result.worktree.path).toBe(wtPath); | |
| 519 | expect(result.worktree.branch).toBe("wt-branch"); | |
| 520 | expect(existsSync(wtPath)).toBe(true); | |
| 521 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); | |
| 522 | ||
| 523 | // Clean up worktree before tmp dir disposal | |
| 524 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 525 | }); | |
| 526 | ||
| 527 | it("worktree has the new branch checked out", async () => { | |
| 528 | await using tmp = await makeTmpDir(); | |
| 529 | await initRepoWithCommit(tmp.path); | |
| 530 | ||
| 531 | const wtPath = path.join(tmp.path, "wt-check-dir"); | |
| 532 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 533 | (b) => b.current, | |
| 534 | )!.name; | |
| 535 | ||
| 536 | await createGitWorktree({ | |
| 537 | cwd: tmp.path, | |
| 538 | branch: currentBranch, | |
| 539 | newBranch: "wt-check", | |
| 540 | path: wtPath, | |
| 541 | }); | |
| 542 | ||
| 543 | // Verify the worktree is on the new branch | |
| 544 | const branchOutput = await git(wtPath, "branch --show-current"); | |
| 545 | expect(branchOutput).toBe("wt-check"); | |
| 546 | ||
| 547 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 548 | }); | |
| 549 | ||
| 550 | it("throws when new branch name already exists", async () => { | |
| 551 | await using tmp = await makeTmpDir(); | |
| 552 | await initRepoWithCommit(tmp.path); | |
| 553 | await createGitBranch({ cwd: tmp.path, branch: "existing" }); | |
| 554 | ||
| 555 | const wtPath = path.join(tmp.path, "wt-conflict"); | |
| 556 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 557 | (b) => b.current, | |
| 558 | )!.name; | |
| 559 | ||
| 560 | await expect( | |
| 561 | createGitWorktree({ | |
| 562 | cwd: tmp.path, | |
| 563 | branch: currentBranch, | |
| 564 | newBranch: "existing", | |
| 565 | path: wtPath, | |
| 566 | }), | |
| 567 | ).rejects.toThrow(); | |
| 568 | }); | |
| 569 | ||
| 570 | it("listGitBranches from worktree cwd reports worktree branch as current", async () => { | |
| 571 | await using tmp = await makeTmpDir(); | |
| 572 | await initRepoWithCommit(tmp.path); | |
| 573 | ||
| 574 | const wtPath = path.join(tmp.path, "wt-list-dir"); | |
| 575 | const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 576 | (b) => b.current, | |
| 577 | )!.name; | |
| 578 | ||
| 579 | await createGitWorktree({ | |
| 580 | cwd: tmp.path, | |
| 581 | branch: mainBranch, | |
| 582 | newBranch: "wt-list", | |
| 583 | path: wtPath, | |
| 584 | }); | |
| 585 | ||
| 586 | // listGitBranches from the worktree should show wt-list as current | |
| 587 | const wtBranches = await listGitBranches({ cwd: wtPath }); | |
| 588 | expect(wtBranches.isRepo).toBe(true); | |
| 589 | const wtCurrent = wtBranches.branches.find((b) => b.current); | |
| 590 | expect(wtCurrent!.name).toBe("wt-list"); | |
| 591 | ||
| 592 | // Main repo should still show the original branch as current | |
| 593 | const mainBranches = await listGitBranches({ cwd: tmp.path }); | |
| 594 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 595 | expect(mainCurrent!.name).toBe(mainBranch); | |
| 596 | ||
| 597 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 598 | }); | |
| 599 | ||
| 600 | it("removeGitWorktree cleans up the worktree", async () => { | |
| 601 | await using tmp = await makeTmpDir(); | |
| 602 | await initRepoWithCommit(tmp.path); | |
| 603 | ||
| 604 | const wtPath = path.join(tmp.path, "wt-remove-dir"); | |
| 605 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 606 | (b) => b.current, | |
| 607 | )!.name; | |
| 608 | ||
| 609 | await createGitWorktree({ | |
| 610 | cwd: tmp.path, | |
| 611 | branch: currentBranch, | |
| 612 | newBranch: "wt-remove", | |
| 613 | path: wtPath, | |
| 614 | }); | |
| 615 | expect(existsSync(wtPath)).toBe(true); | |
| 616 | ||
| 617 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 618 | expect(existsSync(wtPath)).toBe(false); | |
| 619 | }); | |
| 620 | ||
| 621 | it("removeGitWorktree force removes a dirty worktree", async () => { | |
| 622 | await using tmp = await makeTmpDir(); | |
| 623 | await initRepoWithCommit(tmp.path); | |
| 624 | ||
| 625 | const wtPath = path.join(tmp.path, "wt-dirty-dir"); | |
| 626 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 627 | (b) => b.current, | |
| 628 | )!.name; | |
| 629 | ||
| 630 | await createGitWorktree({ | |
| 631 | cwd: tmp.path, | |
| 632 | branch: currentBranch, | |
| 633 | newBranch: "wt-dirty", | |
| 634 | path: wtPath, | |
| 635 | }); | |
| 636 | expect(existsSync(wtPath)).toBe(true); | |
| 637 | ||
| 638 | await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); | |
| 639 | ||
| 640 | await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); | |
| 641 | expect(existsSync(wtPath)).toBe(true); | |
| 642 | ||
| 643 | await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); | |
| 644 | expect(existsSync(wtPath)).toBe(false); | |
| 645 | }); | |
| 646 | }); | |
| 647 | ||
| 648 | // ── Full flow: local branch checkout ── | |
| 649 | ||
| 650 | describe("full flow: local branch checkout", () => { | |
| 651 | it("init → commit → create branch → checkout → verify current", async () => { | |
| 652 | await using tmp = await makeTmpDir(); | |
| 653 | await initRepoWithCommit(tmp.path); | |
| 654 | await createGitBranch({ cwd: tmp.path, branch: "feature-login" }); | |
| 655 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" }); | |
| 656 | ||
| 657 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 658 | const current = result.branches.find((b) => b.current); | |
| 659 | expect(current!.name).toBe("feature-login"); | |
| 660 | }); | |
| 661 | }); | |
| 662 | ||
| 663 | // ── Full flow: worktree creation from base branch ── | |
| 664 | ||
| 665 | describe("full flow: worktree creation", () => { | |
| 666 | it("creates worktree with new branch from current branch", async () => { | |
| 667 | await using tmp = await makeTmpDir(); | |
| 668 | await initRepoWithCommit(tmp.path); | |
| 669 | ||
| 670 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 671 | (b) => b.current, | |
| 672 | )!.name; | |
| 673 | ||
| 674 | const wtPath = path.join(tmp.path, "my-worktree"); | |
| 675 | const result = await createGitWorktree({ | |
| 676 | cwd: tmp.path, | |
| 677 | branch: currentBranch, | |
| 678 | newBranch: "feature-wt", | |
| 679 | path: wtPath, | |
| 680 | }); | |
| 681 | ||
| 682 | // Worktree exists | |
| 683 | expect(existsSync(result.worktree.path)).toBe(true); | |
| 684 | ||
| 685 | // Main repo still on original branch | |
| 686 | const mainBranches = await listGitBranches({ cwd: tmp.path }); | |
| 687 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 688 | expect(mainCurrent!.name).toBe(currentBranch); | |
| 689 | ||
| 690 | // Worktree is on the new branch | |
| 691 | const wtBranch = await git(wtPath, "branch --show-current"); | |
| 692 | expect(wtBranch).toBe("feature-wt"); | |
| 693 | ||
| 694 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); | |
| 695 | }); | |
| 696 | }); | |
| 697 | ||
| 698 | // ── Full flow: thread switching simulation ── | |
| 699 | ||
| 700 | describe("full flow: thread switching (checkout toggling)", () => { | |
| 701 | it("checkout a → checkout b → checkout a → current matches", async () => { | |
| 702 | await using tmp = await makeTmpDir(); | |
| 703 | await initRepoWithCommit(tmp.path); | |
| 704 | await createGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 705 | await createGitBranch({ cwd: tmp.path, branch: "branch-b" }); | |
| 706 | ||
| 707 | // Simulate switching to thread A's branch | |
| 708 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 709 | let branches = await listGitBranches({ cwd: tmp.path }); | |
| 710 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 711 | ||
| 712 | // Simulate switching to thread B's branch | |
| 713 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" }); | |
| 714 | branches = await listGitBranches({ cwd: tmp.path }); | |
| 715 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); | |
| 716 | ||
| 717 | // Switch back to thread A | |
| 718 | await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); | |
| 719 | branches = await listGitBranches({ cwd: tmp.path }); | |
| 720 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 721 | }); | |
| 722 | }); | |
| 723 | ||
| 724 | // ── Full flow: checkout conflict ── | |
| 725 | ||
| 726 | describe("full flow: checkout conflict", () => { | |
| 727 | it("uncommitted changes prevent checkout to a diverged branch", async () => { | |
| 728 | await using tmp = await makeTmpDir(); | |
| 729 | await initRepoWithCommit(tmp.path); | |
| 730 | await createGitBranch({ cwd: tmp.path, branch: "diverged" }); | |
| 731 | ||
| 732 | // Make diverged branch have different file content | |
| 733 | await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" }); | |
| 734 | await writeFile(path.join(tmp.path, "README.md"), "diverged content\n"); | |
| 735 | await git(tmp.path, "add ."); | |
| 736 | await git(tmp.path, "commit -m 'diverge'"); | |
| 737 | ||
| 738 | // Actually, let's just get back to the initial branch explicitly | |
| 739 | const allBranches = await listGitBranches({ cwd: tmp.path }); | |
| 740 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; | |
| 741 | await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); | |
| 742 | ||
| 743 | // Make local uncommitted changes to the same file | |
| 744 | await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n"); | |
| 745 | ||
| 746 | // Attempt checkout should fail | |
| 747 | await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow(); | |
| 748 | ||
| 749 | // Current branch should still be the initial one | |
| 750 | const result = await listGitBranches({ cwd: tmp.path }); | |
| 751 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | |
| 752 | }); | |
| 753 | }); | |
| 754 | ||
| 755 | describe("GitCoreService", () => { | |
| 756 | it("supports branch lifecycle operations through the service API", async () => { | |
| 757 | await using tmp = await makeTmpDir(); | |
| 758 | const core = new GitCoreService(); | |
| 759 | ||
| 760 | await core.initRepo({ cwd: tmp.path }); | |
| 761 | await git(tmp.path, "config user.email 'test@test.com'"); | |
| 762 | await git(tmp.path, "config user.name 'Test'"); | |
| 763 | await writeFile(path.join(tmp.path, "README.md"), "# test\n"); | |
| 764 | await git(tmp.path, "add ."); | |
| 765 | await git(tmp.path, "commit -m 'initial commit'"); | |
| 766 | ||
| 767 | await core.createBranch({ cwd: tmp.path, branch: "feature/service-api" }); | |
| 768 | await core.checkoutBranch({ cwd: tmp.path, branch: "feature/service-api" }); | |
| 769 | const branches = await core.listBranches({ cwd: tmp.path }); | |
| 770 | ||
| 771 | expect(branches.isRepo).toBe(true); | |
| 772 | expect(branches.branches.find((branch) => branch.current)?.name).toBe("feature/service-api"); | |
| 773 | }); | |
| 774 | ||
| 775 | it("reports status details and dirty state", async () => { | |
| 776 | await using tmp = await makeTmpDir(); | |
| 777 | await initRepoWithCommit(tmp.path); | |
| 778 | const core = new GitCoreService(); | |
| 779 | ||
| 780 | const clean = await core.status({ cwd: tmp.path }); | |
| 781 | expect(clean.hasWorkingTreeChanges).toBe(false); | |
| 782 | expect(clean.branch).toBeTruthy(); | |
| 783 | ||
| 784 | await writeFile(path.join(tmp.path, "README.md"), "updated\n"); | |
| 785 | const dirty = await core.statusDetails(tmp.path); | |
| 786 | expect(dirty.hasWorkingTreeChanges).toBe(true); | |
| 787 | }); | |
| 788 | ||
| 789 | it("includes command context when worktree removal fails", async () => { | |
| 790 | await using tmp = await makeTmpDir(); | |
| 791 | await initRepoWithCommit(tmp.path); | |
| 792 | const core = new GitCoreService(); | |
| 793 | const missingWorktreePath = path.join(tmp.path, "missing-worktree"); | |
| 794 | ||
| 795 | const error = await core | |
| 796 | .removeWorktree({ cwd: tmp.path, path: missingWorktreePath }) | |
| 797 | .then(() => null) | |
| 798 | .catch((err: unknown) => err); | |
| 799 | ||
| 800 | expect(error).toBeInstanceOf(Error); | |
| 801 | const message = (error as Error).message; | |
| 802 | expect(message).toContain("git worktree remove"); | |
| 803 | expect(message).toContain(`cwd: ${tmp.path}`); | |
| 804 | expect(message).toContain(missingWorktreePath); | |
| 805 | }); | |
| 806 | ||
| 807 | it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { | |
| 808 | await using remote = await makeTmpDir(); | |
| 809 | await using source = await makeTmpDir(); | |
| 810 | await using clone = await makeTmpDir(); | |
| 811 | await git(remote.path, "init --bare"); | |
| 812 | ||
| 813 | await initRepoWithCommit(source.path); | |
| 814 | const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 815 | (branch) => branch.current, | |
| 816 | )!.name; | |
| 817 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 818 | await git(source.path, `push -u origin ${initialBranch}`); | |
| 819 | ||
| 820 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 821 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 822 | await git(clone.path, "config user.name 'Test'"); | |
| 823 | await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); | |
| 824 | await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); | |
| 825 | await git(clone.path, "add CHANGELOG.md"); | |
| 826 | await git(clone.path, "commit -m 'remote update'"); | |
| 827 | await git(clone.path, `push origin ${initialBranch}`); | |
| 828 | ||
| 829 | const core = new GitCoreService(); | |
| 830 | const details = await core.statusDetails(source.path); | |
| 831 | expect(details.branch).toBe(initialBranch); | |
| 832 | expect(details.aheadCount).toBe(0); | |
| 833 | expect(details.behindCount).toBe(1); | |
| 834 | }); | |
| 835 | ||
| 836 | it("prepares commit context by auto-staging and creates commit", async () => { | |
| 837 | await using tmp = await makeTmpDir(); | |
| 838 | await initRepoWithCommit(tmp.path); | |
| 839 | const core = new GitCoreService(); | |
| 840 | ||
| 841 | await writeFile(path.join(tmp.path, "README.md"), "new content\n"); | |
| 842 | const context = await core.prepareCommitContext(tmp.path); | |
| 843 | expect(context).not.toBeNull(); | |
| 844 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | |
| 845 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | |
| 846 | ||
| 847 | const created = await core.commit(tmp.path, "Add README update", "- include updated content"); | |
| 848 | expect(created.commitSha.length).toBeGreaterThan(0); | |
| 849 | expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update"); | |
| 850 | }); | |
| 851 | ||
| 852 | it("pushes with upstream setup and then skips when up to date", async () => { | |
| 853 | await using tmp = await makeTmpDir(); | |
| 854 | await using remote = await makeTmpDir(); | |
| 855 | await initRepoWithCommit(tmp.path); | |
| 856 | await git(remote.path, "init --bare"); | |
| 857 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 858 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | |
| 859 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | |
| 860 | ||
| 861 | await writeFile(path.join(tmp.path, "feature.txt"), "push me\n"); | |
| 862 | const core = new GitCoreService(); | |
| 863 | const context = await core.prepareCommitContext(tmp.path); | |
| 864 | expect(context).not.toBeNull(); | |
| 865 | await core.commit(tmp.path, "Add feature file", ""); | |
| 866 | ||
| 867 | const pushed = await core.pushCurrentBranch(tmp.path, null); | |
| 868 | expect(pushed.status).toBe("pushed"); | |
| 869 | expect(pushed.setUpstream).toBe(true); | |
| 870 | expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe( | |
| 871 | "origin/feature/core-push", | |
| 872 | ); | |
| 873 | ||
| 874 | const skipped = await core.pushCurrentBranch(tmp.path, null); | |
| 875 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 876 | }); | |
| 877 | ||
| 878 | it("pulls behind branch and then reports up-to-date", async () => { | |
| 879 | await using remote = await makeTmpDir(); | |
| 880 | await using source = await makeTmpDir(); | |
| 881 | await using clone = await makeTmpDir(); | |
| 882 | await git(remote.path, "init --bare"); | |
| 883 | ||
| 884 | await initRepoWithCommit(source.path); | |
| 885 | const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 886 | (branch) => branch.current, | |
| 887 | )!.name; | |
| 888 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 889 | await git(source.path, `push -u origin ${initialBranch}`); | |
| 890 | ||
| 891 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 892 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 893 | await git(clone.path, "config user.name 'Test'"); | |
| 894 | await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); | |
| 895 | await git(clone.path, "add CHANGELOG.md"); | |
| 896 | await git(clone.path, "commit -m 'remote update'"); | |
| 897 | await git(clone.path, `push origin ${initialBranch}`); | |
| 898 | ||
| 899 | const core = new GitCoreService(); | |
| 900 | const pulled = await core.pullCurrentBranch(source.path); | |
| 901 | expect(pulled.status).toBe("pulled"); | |
| 902 | expect((await core.statusDetails(source.path)).behindCount).toBe(0); | |
| 903 | ||
| 904 | const skipped = await core.pullCurrentBranch(source.path); | |
| 905 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 906 | }); | |
| 907 | ||
| 908 | it("top-level pullGitBranch rejects when no upstream exists", async () => { | |
| 909 | await using tmp = await makeTmpDir(); | |
| 910 | await initRepoWithCommit(tmp.path); | |
| 911 | await expect(pullGitBranch({ cwd: tmp.path })).rejects.toThrow("no upstream"); | |
| 912 | }); | |
| 913 | ||
| 914 | it("lists branches when recency lookup fails", async () => { | |
| 915 | await using tmp = await makeTmpDir(); | |
| 916 | await initRepoWithCommit(tmp.path); | |
| 917 | const core = new GitCoreService(); | |
| 918 | const recencySpy = vi | |
| 919 | .spyOn(core as any, "readBranchRecency") | |
| 920 | .mockRejectedValueOnce(new Error("timeout")); | |
| 921 | ||
| 922 | const result = await core.listBranches({ cwd: tmp.path }); | |
| 923 | ||
| 924 | expect(result.isRepo).toBe(true); | |
| 925 | expect(result.branches.length).toBeGreaterThan(0); | |
| 926 | expect(result.branches[0]?.current).toBe(true); | |
| 927 | recencySpy.mockRestore(); | |
| 928 | }); | |
| 929 | }); | |
| 930 | }); |
apps/server/src/git.ts deleted-740| ... | ... | @@ -1,740 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | gitPullInputSchema, | |
| 7 | gitPullResultSchema, | |
| 8 | gitStatusInputSchema, | |
| 9 | gitStatusResultSchema, | |
| 10 | type GitCheckoutInput, | |
| 11 | type GitCreateBranchInput, | |
| 12 | type GitCreateWorktreeInput, | |
| 13 | type GitCreateWorktreeResult, | |
| 14 | type GitInitInput, | |
| 15 | type GitListBranchesInput, | |
| 16 | type GitListBranchesResult, | |
| 17 | type GitPullInput, | |
| 18 | type GitPullResult, | |
| 19 | type GitRemoveWorktreeInput, | |
| 20 | type GitStatusInput, | |
| 21 | type GitStatusResult, | |
| 22 | } from "@t3tools/contracts"; | |
| 23 | import { type ProcessRunResult, runProcess } from "./processRunner"; | |
| 24 | ||
| 25 | export interface TerminalCommandInput { | |
| 26 | command: string; | |
| 27 | cwd: string; | |
| 28 | timeoutMs?: number; | |
| 29 | maxOutputBytes?: number; | |
| 30 | } | |
| 31 | ||
| 32 | export type TerminalCommandResult = ProcessRunResult; | |
| 33 | ||
| 34 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { | |
| 35 | upstreamRef: string | null; | |
| 36 | } | |
| 37 | ||
| 38 | export interface GitPreparedCommitContext { | |
| 39 | stagedSummary: string; | |
| 40 | stagedPatch: string; | |
| 41 | } | |
| 42 | ||
| 43 | export interface GitPushResult { | |
| 44 | status: "pushed" | "skipped_up_to_date"; | |
| 45 | branch: string; | |
| 46 | upstreamBranch?: string | undefined; | |
| 47 | setUpstream?: boolean | undefined; | |
| 48 | } | |
| 49 | ||
| 50 | export interface GitRangeContext { | |
| 51 | commitSummary: string; | |
| 52 | diffSummary: string; | |
| 53 | diffPatch: string; | |
| 54 | } | |
| 55 | ||
| 56 | interface RunGitOptions { | |
| 57 | timeoutMs?: number | undefined; | |
| 58 | allowNonZeroExit?: boolean | undefined; | |
| 59 | } | |
| 60 | ||
| 61 | interface ExecuteGitOptions { | |
| 62 | timeoutMs?: number | undefined; | |
| 63 | allowNonZeroExit?: boolean | undefined; | |
| 64 | fallbackErrorMessage?: string | undefined; | |
| 65 | } | |
| 66 | ||
| 67 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 68 | const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; | |
| 69 | const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; | |
| 70 | ||
| 71 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ | |
| 72 | function runGit( | |
| 73 | args: readonly string[], | |
| 74 | cwd: string, | |
| 75 | timeoutMs = 30_000, | |
| 76 | ): Promise<ProcessRunResult> { | |
| 77 | return runProcess("git", args, { | |
| 78 | cwd, | |
| 79 | timeoutMs, | |
| 80 | allowNonZeroExit: true, | |
| 81 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 82 | outputMode: "truncate", | |
| 83 | }); | |
| 84 | } | |
| 85 | ||
| 86 | function trimStdout(value: string): string { | |
| 87 | return value.trim(); | |
| 88 | } | |
| 89 | ||
| 90 | function parseBranchAb(value: string): { ahead: number; behind: number } { | |
| 91 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | |
| 92 | if (!match) return { ahead: 0, behind: 0 }; | |
| 93 | return { | |
| 94 | ahead: Number(match[1] ?? "0"), | |
| 95 | behind: Number(match[2] ?? "0"), | |
| 96 | }; | |
| 97 | } | |
| 98 | ||
| 99 | function parseNumstatEntries( | |
| 100 | stdout: string, | |
| 101 | ): Array<{ path: string; insertions: number; deletions: number }> { | |
| 102 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | |
| 103 | for (const line of stdout.split(/\r?\n/g)) { | |
| 104 | if (line.trim().length === 0) continue; | |
| 105 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | |
| 106 | const rawPath = | |
| 107 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | |
| 108 | if (rawPath.length === 0) continue; | |
| 109 | const added = Number.parseInt(addedRaw ?? "0", 10); | |
| 110 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | |
| 111 | const renameArrowIndex = rawPath.indexOf(" => "); | |
| 112 | const normalizedPath = | |
| 113 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | |
| 114 | entries.push({ | |
| 115 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | |
| 116 | insertions: Number.isFinite(added) ? added : 0, | |
| 117 | deletions: Number.isFinite(deleted) ? deleted : 0, | |
| 118 | }); | |
| 119 | } | |
| 120 | return entries; | |
| 121 | } | |
| 122 | ||
| 123 | function parsePorcelainPath(line: string): string | null { | |
| 124 | if (line.startsWith("? ") || line.startsWith("! ")) { | |
| 125 | const simple = line.slice(2).trim(); | |
| 126 | return simple.length > 0 ? simple : null; | |
| 127 | } | |
| 128 | ||
| 129 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | |
| 130 | return null; | |
| 131 | } | |
| 132 | ||
| 133 | const tabIndex = line.indexOf("\t"); | |
| 134 | if (tabIndex >= 0) { | |
| 135 | const fromTab = line.slice(tabIndex + 1); | |
| 136 | const [path] = fromTab.split("\t"); | |
| 137 | return path?.trim().length ? path.trim() : null; | |
| 138 | } | |
| 139 | ||
| 140 | const parts = line.trim().split(/\s+/g); | |
| 141 | const path = parts.at(-1) ?? ""; | |
| 142 | return path.length > 0 ? path : null; | |
| 143 | } | |
| 144 | ||
| 145 | function commandLabel(args: readonly string[]): string { | |
| 146 | return `git ${args.join(" ")}`; | |
| 147 | } | |
| 148 | ||
| 149 | function normalizeGitSpawnError(cwd: string, error: unknown): Error { | |
| 150 | if (!(error instanceof Error)) { | |
| 151 | return new Error("Failed to run git command."); | |
| 152 | } | |
| 153 | if (!fs.existsSync(cwd)) { | |
| 154 | return new Error(`Working directory does not exist: ${cwd}`); | |
| 155 | } | |
| 156 | if (error.message.includes("Command not found: git")) { | |
| 157 | return new Error("Git is required but not available on PATH."); | |
| 158 | } | |
| 159 | return new Error(`Failed to run git command: ${error.message}`); | |
| 160 | } | |
| 161 | ||
| 162 | function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error { | |
| 163 | const stderr = result.stderr.trim(); | |
| 164 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 165 | return new Error("Current folder is not a git repository."); | |
| 166 | } | |
| 167 | if (result.timedOut) { | |
| 168 | return new Error(`${commandLabel(args)} timed out.`); | |
| 169 | } | |
| 170 | const detail = | |
| 171 | stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; | |
| 172 | return new Error(`${commandLabel(args)} failed: ${detail}`); | |
| 173 | } | |
| 174 | ||
| 175 | async function runGitOrThrow( | |
| 176 | cwd: string, | |
| 177 | args: readonly string[], | |
| 178 | options: RunGitOptions = {}, | |
| 179 | ): Promise<TerminalCommandResult> { | |
| 180 | let result: TerminalCommandResult; | |
| 181 | try { | |
| 182 | result = await runGit(args, cwd, options.timeoutMs); | |
| 183 | } catch (error) { | |
| 184 | throw normalizeGitSpawnError(cwd, error); | |
| 185 | } | |
| 186 | ||
| 187 | if (result.timedOut) { | |
| 188 | throw normalizeGitExecutionError(args, result); | |
| 189 | } | |
| 190 | if (!options.allowNonZeroExit && result.code !== 0) { | |
| 191 | throw normalizeGitExecutionError(args, result); | |
| 192 | } | |
| 193 | return result; | |
| 194 | } | |
| 195 | ||
| 196 | async function executeGit( | |
| 197 | cwd: string, | |
| 198 | args: readonly string[], | |
| 199 | options: ExecuteGitOptions = {}, | |
| 200 | ): Promise<TerminalCommandResult> { | |
| 201 | const result = await runGitOrThrow(cwd, args, { | |
| 202 | timeoutMs: options.timeoutMs, | |
| 203 | allowNonZeroExit: true, | |
| 204 | }); | |
| 205 | ||
| 206 | if (options.allowNonZeroExit || result.code === 0) { | |
| 207 | return result; | |
| 208 | } | |
| 209 | ||
| 210 | const stderr = result.stderr.trim(); | |
| 211 | if (stderr.length > 0) { | |
| 212 | throw new Error(stderr); | |
| 213 | } | |
| 214 | if (options.fallbackErrorMessage) { | |
| 215 | throw new Error(options.fallbackErrorMessage); | |
| 216 | } | |
| 217 | throw normalizeGitExecutionError(args, result); | |
| 218 | } | |
| 219 | ||
| 220 | export class GitCoreService { | |
| 221 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); | |
| 222 | ||
| 223 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | |
| 224 | const input = gitStatusInputSchema.parse(raw); | |
| 225 | const details = await this.statusDetails(input.cwd); | |
| 226 | return gitStatusResultSchema.parse({ | |
| 227 | branch: details.branch, | |
| 228 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 229 | workingTree: details.workingTree, | |
| 230 | hasUpstream: details.hasUpstream, | |
| 231 | aheadCount: details.aheadCount, | |
| 232 | behindCount: details.behindCount, | |
| 233 | pr: null, | |
| 234 | }); | |
| 235 | } | |
| 236 | ||
| 237 | async statusDetails(cwd: string): Promise<GitStatusDetails> { | |
| 238 | await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); | |
| 239 | ||
| 240 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ | |
| 241 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), | |
| 242 | this.gitStdout(cwd, ["diff", "--numstat"]), | |
| 243 | this.gitStdout(cwd, ["diff", "--cached", "--numstat"]), | |
| 244 | ]); | |
| 245 | ||
| 246 | let branch: string | null = null; | |
| 247 | let upstreamRef: string | null = null; | |
| 248 | let aheadCount = 0; | |
| 249 | let behindCount = 0; | |
| 250 | let hasWorkingTreeChanges = false; | |
| 251 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 252 | ||
| 253 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 254 | if (line.startsWith("# branch.head ")) { | |
| 255 | const value = line.slice("# branch.head ".length).trim(); | |
| 256 | branch = value.startsWith("(") ? null : value; | |
| 257 | continue; | |
| 258 | } | |
| 259 | if (line.startsWith("# branch.upstream ")) { | |
| 260 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 261 | upstreamRef = value.length > 0 ? value : null; | |
| 262 | continue; | |
| 263 | } | |
| 264 | if (line.startsWith("# branch.ab ")) { | |
| 265 | const value = line.slice("# branch.ab ".length).trim(); | |
| 266 | const parsed = parseBranchAb(value); | |
| 267 | aheadCount = parsed.ahead; | |
| 268 | behindCount = parsed.behind; | |
| 269 | continue; | |
| 270 | } | |
| 271 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 272 | hasWorkingTreeChanges = true; | |
| 273 | const pathValue = parsePorcelainPath(line); | |
| 274 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 275 | } | |
| 276 | } | |
| 277 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 278 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 279 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 280 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 281 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 282 | existing.insertions += entry.insertions; | |
| 283 | existing.deletions += entry.deletions; | |
| 284 | fileStatMap.set(entry.path, existing); | |
| 285 | } | |
| 286 | ||
| 287 | let insertions = 0; | |
| 288 | let deletions = 0; | |
| 289 | const files = Array.from(fileStatMap.entries()) | |
| 290 | .map(([path, stat]) => { | |
| 291 | insertions += stat.insertions; | |
| 292 | deletions += stat.deletions; | |
| 293 | return { path, insertions: stat.insertions, deletions: stat.deletions }; | |
| 294 | }) | |
| 295 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 296 | ||
| 297 | for (const filePath of changedFilesWithoutNumstat) { | |
| 298 | if (fileStatMap.has(filePath)) continue; | |
| 299 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 300 | } | |
| 301 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 302 | ||
| 303 | return { | |
| 304 | branch, | |
| 305 | upstreamRef, | |
| 306 | hasWorkingTreeChanges, | |
| 307 | workingTree: { | |
| 308 | files, | |
| 309 | insertions, | |
| 310 | deletions, | |
| 311 | }, | |
| 312 | hasUpstream: upstreamRef !== null, | |
| 313 | aheadCount, | |
| 314 | behindCount, | |
| 315 | }; | |
| 316 | } | |
| 317 | ||
| 318 | async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> { | |
| 319 | await this.git(cwd, ["add", "-A"]); | |
| 320 | ||
| 321 | const stagedSummary = await this.gitStdout(cwd, ["diff", "--cached", "--name-status"]); | |
| 322 | if (trimStdout(stagedSummary).length === 0) { | |
| 323 | return null; | |
| 324 | } | |
| 325 | ||
| 326 | const stagedPatch = await this.gitStdout(cwd, ["diff", "--cached", "--patch", "--minimal"]); | |
| 327 | ||
| 328 | return { | |
| 329 | stagedSummary, | |
| 330 | stagedPatch, | |
| 331 | }; | |
| 332 | } | |
| 333 | ||
| 334 | async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { | |
| 335 | const args = ["commit", "-m", subject]; | |
| 336 | const trimmedBody = body.trim(); | |
| 337 | if (trimmedBody.length > 0) { | |
| 338 | args.push("-m", trimmedBody); | |
| 339 | } | |
| 340 | await this.git(cwd, args); | |
| 341 | const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); | |
| 342 | return { commitSha }; | |
| 343 | } | |
| 344 | ||
| 345 | async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> { | |
| 346 | const details = await this.statusDetails(cwd); | |
| 347 | const branch = details.branch ?? fallbackBranch; | |
| 348 | if (!branch) { | |
| 349 | throw new Error("Cannot push from detached HEAD."); | |
| 350 | } | |
| 351 | ||
| 352 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { | |
| 353 | return { | |
| 354 | status: "skipped_up_to_date", | |
| 355 | branch, | |
| 356 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 357 | }; | |
| 358 | } | |
| 359 | ||
| 360 | if (!details.hasUpstream) { | |
| 361 | await this.git(cwd, ["push", "-u", "origin", branch]); | |
| 362 | return { | |
| 363 | status: "pushed", | |
| 364 | branch, | |
| 365 | upstreamBranch: `origin/${branch}`, | |
| 366 | setUpstream: true, | |
| 367 | }; | |
| 368 | } | |
| 369 | ||
| 370 | await this.git(cwd, ["push"]); | |
| 371 | return { | |
| 372 | status: "pushed", | |
| 373 | branch, | |
| 374 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 375 | setUpstream: false, | |
| 376 | }; | |
| 377 | } | |
| 378 | ||
| 379 | async pullCurrentBranch(cwd: string): Promise<GitPullResult> { | |
| 380 | const details = await this.statusDetails(cwd); | |
| 381 | const branch = details.branch; | |
| 382 | if (!branch) { | |
| 383 | throw new Error("Cannot pull from detached HEAD."); | |
| 384 | } | |
| 385 | if (!details.hasUpstream) { | |
| 386 | throw new Error("Current branch has no upstream configured. Push with upstream first."); | |
| 387 | } | |
| 388 | const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | |
| 389 | await executeGit(cwd, ["pull", "--ff-only"], { | |
| 390 | timeoutMs: 30_000, | |
| 391 | fallbackErrorMessage: "git pull failed", | |
| 392 | }); | |
| 393 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | |
| 394 | const refreshed = await this.statusDetails(cwd); | |
| 395 | return gitPullResultSchema.parse({ | |
| 396 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 397 | branch, | |
| 398 | ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), | |
| 399 | }); | |
| 400 | } | |
| 401 | ||
| 402 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { | |
| 403 | const range = `${baseBranch}..HEAD`; | |
| 404 | const [commitSummary, diffSummary, diffPatch] = await Promise.all([ | |
| 405 | this.gitStdout(cwd, ["log", "--oneline", range]), | |
| 406 | this.gitStdout(cwd, ["diff", "--stat", range]), | |
| 407 | this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), | |
| 408 | ]); | |
| 409 | ||
| 410 | return { | |
| 411 | commitSummary, | |
| 412 | diffSummary, | |
| 413 | diffPatch, | |
| 414 | }; | |
| 415 | } | |
| 416 | ||
| 417 | async readConfigValue(cwd: string, key: string): Promise<string | null> { | |
| 418 | const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); | |
| 419 | const value = trimStdout(stdout); | |
| 420 | return value.length > 0 ? value : null; | |
| 421 | } | |
| 422 | ||
| 423 | private async readBranchRecency(cwd: string): Promise<Map<string, number>> { | |
| 424 | const branchRecency = await executeGit( | |
| 425 | cwd, | |
| 426 | ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], | |
| 427 | { | |
| 428 | timeoutMs: 15_000, | |
| 429 | allowNonZeroExit: true, | |
| 430 | }, | |
| 431 | ); | |
| 432 | ||
| 433 | const branchLastCommit = new Map<string, number>(); | |
| 434 | if (branchRecency.code !== 0) { | |
| 435 | return branchLastCommit; | |
| 436 | } | |
| 437 | ||
| 438 | for (const line of branchRecency.stdout.split("\n")) { | |
| 439 | if (line.length === 0) { | |
| 440 | continue; | |
| 441 | } | |
| 442 | const [name, lastCommitRaw] = line.split("\t"); | |
| 443 | if (!name) { | |
| 444 | continue; | |
| 445 | } | |
| 446 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 447 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 448 | } | |
| 449 | ||
| 450 | return branchLastCommit; | |
| 451 | } | |
| 452 | ||
| 453 | async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 454 | const branchRecencyPromise = this.readBranchRecency(input.cwd).catch( | |
| 455 | () => new Map<string, number>(), | |
| 456 | ); | |
| 457 | const result = await executeGit(input.cwd, ["branch", "--no-color"], { | |
| 458 | timeoutMs: 10_000, | |
| 459 | allowNonZeroExit: true, | |
| 460 | }); | |
| 461 | ||
| 462 | if (result.code !== 0) { | |
| 463 | const stderr = result.stderr.trim(); | |
| 464 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 465 | return { branches: [], isRepo: false }; | |
| 466 | } | |
| 467 | throw new Error(stderr || "git branch failed"); | |
| 468 | } | |
| 469 | ||
| 470 | const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([ | |
| 471 | executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { | |
| 472 | timeoutMs: 5_000, | |
| 473 | allowNonZeroExit: true, | |
| 474 | }), | |
| 475 | executeGit(input.cwd, ["worktree", "list", "--porcelain"], { | |
| 476 | timeoutMs: 5_000, | |
| 477 | allowNonZeroExit: true, | |
| 478 | }), | |
| 479 | branchRecencyPromise, | |
| 480 | ]); | |
| 481 | const defaultBranch = | |
| 482 | defaultRef.code === 0 | |
| 483 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 484 | : null; | |
| 485 | ||
| 486 | const worktreeMap = new Map<string, string>(); | |
| 487 | if (worktreeList.code === 0) { | |
| 488 | let currentPath: string | null = null; | |
| 489 | for (const line of worktreeList.stdout.split("\n")) { | |
| 490 | if (line.startsWith("worktree ")) { | |
| 491 | const candidatePath = line.slice("worktree ".length); | |
| 492 | currentPath = fs.existsSync(candidatePath) ? candidatePath : null; | |
| 493 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 494 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 495 | } else if (line === "") { | |
| 496 | currentPath = null; | |
| 497 | } | |
| 498 | } | |
| 499 | } | |
| 500 | ||
| 501 | const branches = result.stdout | |
| 502 | .split("\n") | |
| 503 | .map((line) => line.trim()) | |
| 504 | .filter((line) => line.length > 0) | |
| 505 | .map((line) => { | |
| 506 | const name = line.replace(/^[*+]\s+/, ""); | |
| 507 | return { | |
| 508 | name, | |
| 509 | current: line.startsWith("* "), | |
| 510 | isDefault: name === defaultBranch, | |
| 511 | worktreePath: worktreeMap.get(name) ?? null, | |
| 512 | }; | |
| 513 | }) | |
| 514 | .toSorted((a, b) => { | |
| 515 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 516 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 517 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 518 | ||
| 519 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 520 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 521 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 522 | return a.name.localeCompare(b.name); | |
| 523 | }); | |
| 524 | ||
| 525 | return { branches, isRepo: true }; | |
| 526 | } | |
| 527 | ||
| 528 | async createWorktree(input: GitCreateWorktreeInput): Promise<GitCreateWorktreeResult> { | |
| 529 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 530 | const repoName = path.basename(input.cwd); | |
| 531 | const worktreePath = | |
| 532 | input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); | |
| 533 | ||
| 534 | await executeGit( | |
| 535 | input.cwd, | |
| 536 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 537 | { fallbackErrorMessage: "git worktree add failed" }, | |
| 538 | ); | |
| 539 | ||
| 540 | return { | |
| 541 | worktree: { | |
| 542 | path: worktreePath, | |
| 543 | branch: input.newBranch, | |
| 544 | }, | |
| 545 | }; | |
| 546 | } | |
| 547 | ||
| 548 | async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> { | |
| 549 | const args = ["worktree", "remove"]; | |
| 550 | if (input.force) { | |
| 551 | args.push("--force"); | |
| 552 | } | |
| 553 | args.push(input.path); | |
| 554 | try { | |
| 555 | await executeGit(input.cwd, args, { | |
| 556 | timeoutMs: 15_000, | |
| 557 | fallbackErrorMessage: "git worktree remove failed", | |
| 558 | }); | |
| 559 | } catch (error) { | |
| 560 | const detail = error instanceof Error ? error.message : String(error); | |
| 561 | throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, { | |
| 562 | cause: error, | |
| 563 | }); | |
| 564 | } | |
| 565 | } | |
| 566 | ||
| 567 | async createBranch(input: GitCreateBranchInput): Promise<void> { | |
| 568 | await executeGit(input.cwd, ["branch", input.branch], { | |
| 569 | timeoutMs: 10_000, | |
| 570 | fallbackErrorMessage: "git branch create failed", | |
| 571 | }); | |
| 572 | } | |
| 573 | ||
| 574 | private async resolveCurrentUpstream( | |
| 575 | cwd: string, | |
| 576 | ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { | |
| 577 | const upstreamRef = trimStdout( | |
| 578 | await this.gitStdout( | |
| 579 | cwd, | |
| 580 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 581 | true, | |
| 582 | ), | |
| 583 | ); | |
| 584 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 585 | return null; | |
| 586 | } | |
| 587 | ||
| 588 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 589 | if (separatorIndex <= 0) { | |
| 590 | return null; | |
| 591 | } | |
| 592 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 593 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 594 | if (remoteName.length === 0) { | |
| 595 | return null; | |
| 596 | } | |
| 597 | if (upstreamBranch.length === 0) { | |
| 598 | return null; | |
| 599 | } | |
| 600 | ||
| 601 | return { | |
| 602 | upstreamRef, | |
| 603 | remoteName, | |
| 604 | upstreamBranch, | |
| 605 | }; | |
| 606 | } | |
| 607 | ||
| 608 | private async fetchUpstreamRef( | |
| 609 | cwd: string, | |
| 610 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 611 | ): Promise<void> { | |
| 612 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 613 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); | |
| 614 | } | |
| 615 | ||
| 616 | private async fetchUpstreamRefForStatus( | |
| 617 | cwd: string, | |
| 618 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 619 | ): Promise<void> { | |
| 620 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 621 | await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { | |
| 622 | allowNonZeroExit: true, | |
| 623 | timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, | |
| 624 | }); | |
| 625 | } | |
| 626 | ||
| 627 | private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> { | |
| 628 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 629 | if (!upstream) { | |
| 630 | return; | |
| 631 | } | |
| 632 | ||
| 633 | const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; | |
| 634 | const now = Date.now(); | |
| 635 | const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; | |
| 636 | if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { | |
| 637 | return; | |
| 638 | } | |
| 639 | ||
| 640 | // Record attempt time first to avoid repeated fetch spikes when status is polled often. | |
| 641 | this.lastUpstreamRefreshAt.set(cacheKey, now); | |
| 642 | await this.fetchUpstreamRefForStatus(cwd, upstream); | |
| 643 | } | |
| 644 | ||
| 645 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | |
| 646 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 647 | if (!upstream) { | |
| 648 | return; | |
| 649 | } | |
| 650 | await this.fetchUpstreamRef(cwd, upstream); | |
| 651 | } | |
| 652 | ||
| 653 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { | |
| 654 | await executeGit(input.cwd, ["checkout", input.branch], { | |
| 655 | timeoutMs: 10_000, | |
| 656 | fallbackErrorMessage: "git checkout failed", | |
| 657 | }); | |
| 658 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 659 | void this.refreshCheckedOutBranchUpstream(input.cwd).catch(() => undefined); | |
| 660 | } | |
| 661 | ||
| 662 | async initRepo(input: GitInitInput): Promise<void> { | |
| 663 | await executeGit(input.cwd, ["init"], { | |
| 664 | timeoutMs: 10_000, | |
| 665 | fallbackErrorMessage: "git init failed", | |
| 666 | }); | |
| 667 | } | |
| 668 | ||
| 669 | async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> { | |
| 670 | await executeGit(cwd, args, { allowNonZeroExit }); | |
| 671 | } | |
| 672 | ||
| 673 | async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> { | |
| 674 | const result = await executeGit(cwd, args, { allowNonZeroExit }); | |
| 675 | return result.stdout; | |
| 676 | } | |
| 677 | } | |
| 678 | ||
| 679 | const defaultGitCoreService = new GitCoreService(); | |
| 680 | ||
| 681 | export async function runTerminalCommand( | |
| 682 | input: TerminalCommandInput, | |
| 683 | ): Promise<TerminalCommandResult> { | |
| 684 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 685 | const shellPath = | |
| 686 | process.platform === "win32" | |
| 687 | ? (process.env.ComSpec ?? "cmd.exe") | |
| 688 | : (process.env.SHELL ?? "/bin/sh"); | |
| 689 | ||
| 690 | const args = | |
| 691 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | |
| 692 | ||
| 693 | const result = await runProcess(shellPath, args, { | |
| 694 | cwd: input.cwd, | |
| 695 | timeoutMs: input.timeoutMs ?? 30_000, | |
| 696 | allowNonZeroExit: true, | |
| 697 | maxBufferBytes: maxOutputBytes, | |
| 698 | outputMode: "truncate", | |
| 699 | }); | |
| 700 | ||
| 701 | if (!result.stdoutTruncated && !result.stderrTruncated) { | |
| 702 | return result; | |
| 703 | } | |
| 704 | ||
| 705 | return { | |
| 706 | ...result, | |
| 707 | stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`, | |
| 708 | }; | |
| 709 | } | |
| 710 | ||
| 711 | export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> { | |
| 712 | return defaultGitCoreService.listBranches(input); | |
| 713 | } | |
| 714 | ||
| 715 | export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> { | |
| 716 | const input = gitPullInputSchema.parse(raw); | |
| 717 | return defaultGitCoreService.pullCurrentBranch(input.cwd); | |
| 718 | } | |
| 719 | ||
| 720 | export async function createGitWorktree( | |
| 721 | input: GitCreateWorktreeInput, | |
| 722 | ): Promise<GitCreateWorktreeResult> { | |
| 723 | return defaultGitCoreService.createWorktree(input); | |
| 724 | } | |
| 725 | ||
| 726 | export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> { | |
| 727 | await defaultGitCoreService.removeWorktree(input); | |
| 728 | } | |
| 729 | ||
| 730 | export async function createGitBranch(input: GitCreateBranchInput): Promise<void> { | |
| 731 | await defaultGitCoreService.createBranch(input); | |
| 732 | } | |
| 733 | ||
| 734 | export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> { | |
| 735 | await defaultGitCoreService.checkoutBranch(input); | |
| 736 | } | |
| 737 | ||
| 738 | export async function initGitRepo(input: GitInitInput): Promise<void> { | |
| 739 | await defaultGitCoreService.initRepo(input); | |
| 740 | } |
apps/server/src/git/Errors.ts created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | /** | |
| 4 | * GitCommandError - Git command execution failed. | |
| 5 | */ | |
| 6 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { | |
| 7 | operation: Schema.String, | |
| 8 | command: Schema.String, | |
| 9 | cwd: Schema.String, | |
| 10 | detail: Schema.String, | |
| 11 | cause: Schema.optional(Schema.Defect), | |
| 12 | }) { | |
| 13 | override get message(): string { | |
| 14 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | /** | |
| 19 | * GitHubCliError - GitHub CLI execution or authentication failed. | |
| 20 | */ | |
| 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | |
| 22 | operation: Schema.String, | |
| 23 | detail: Schema.String, | |
| 24 | cause: Schema.optional(Schema.Defect), | |
| 25 | }) { | |
| 26 | override get message(): string { | |
| 27 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /** | |
| 32 | * TextGenerationError - Commit or PR text generation failed. | |
| 33 | */ | |
| 34 | export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationError>()( | |
| 35 | "TextGenerationError", | |
| 36 | { | |
| 37 | operation: Schema.String, | |
| 38 | detail: Schema.String, | |
| 39 | cause: Schema.optional(Schema.Defect), | |
| 40 | }, | |
| 41 | ) { | |
| 42 | override get message(): string { | |
| 43 | return `Text generation failed in ${this.operation}: ${this.detail}`; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * GitManagerError - Stacked Git workflow orchestration failed. | |
| 49 | */ | |
| 50 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | |
| 51 | operation: Schema.String, | |
| 52 | detail: Schema.String, | |
| 53 | cause: Schema.optional(Schema.Defect), | |
| 54 | }) { | |
| 55 | override get message(): string { | |
| 56 | return `Git manager failed in ${this.operation}: ${this.detail}`; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. | |
| 62 | */ | |
| 63 | export type GitManagerServiceError = | |
| 64 | | GitManagerError | |
| 65 | | GitCommandError | |
| 66 | | GitHubCliError | |
| 67 | | TextGenerationError; |
apps/server/src/git/Layers/CodexTextGeneration.test.ts created+205| ... | ... | @@ -0,0 +1,205 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | import { expect } from "vitest"; | |
| 5 | ||
| 6 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | |
| 7 | import { TextGenerationError } from "../Errors.ts"; | |
| 8 | import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 9 | ||
| 10 | function makeFakeCodexBinary(dir: string) { | |
| 11 | return Effect.gen(function* () { | |
| 12 | const fs = yield* FileSystem.FileSystem; | |
| 13 | const path = yield* Path.Path; | |
| 14 | const binDir = path.join(dir, "bin"); | |
| 15 | const codexPath = path.join(binDir, "codex"); | |
| 16 | yield* fs.makeDirectory(binDir, { recursive: true }); | |
| 17 | ||
| 18 | yield* fs.writeFileString( | |
| 19 | codexPath, | |
| 20 | [ | |
| 21 | "#!/bin/sh", | |
| 22 | 'output_path=""', | |
| 23 | "while [ $# -gt 0 ]; do", | |
| 24 | ' if [ "$1" = "--output-last-message" ]; then', | |
| 25 | " shift", | |
| 26 | ' output_path="$1"', | |
| 27 | " fi", | |
| 28 | " shift", | |
| 29 | "done", | |
| 30 | "cat >/dev/null", | |
| 31 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', | |
| 32 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', | |
| 33 | "fi", | |
| 34 | 'if [ -n "$output_path" ]; then', | |
| 35 | ' node -e \'const fs=require("node:fs"); const value=process.argv[2] ?? ""; fs.writeFileSync(process.argv[1], Buffer.from(value, "base64"));\' "$output_path" "${T3_FAKE_CODEX_OUTPUT_B64:-e30=}"', | |
| 36 | "fi", | |
| 37 | 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', | |
| 38 | "", | |
| 39 | ].join("\n"), | |
| 40 | ); | |
| 41 | yield* fs.chmod(codexPath, 0o755); | |
| 42 | return binDir; | |
| 43 | }); | |
| 44 | } | |
| 45 | ||
| 46 | function withFakeCodexEnv<A, E, R>( | |
| 47 | input: { | |
| 48 | output: string; | |
| 49 | exitCode?: number; | |
| 50 | stderr?: string; | |
| 51 | }, | |
| 52 | effect: Effect.Effect<A, E, R>, | |
| 53 | ) { | |
| 54 | return Effect.acquireUseRelease( | |
| 55 | Effect.gen(function* () { | |
| 56 | const fs = yield* FileSystem.FileSystem; | |
| 57 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | |
| 58 | const binDir = yield* makeFakeCodexBinary(tempDir); | |
| 59 | const previousPath = process.env.PATH; | |
| 60 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 61 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 62 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; | |
| 63 | ||
| 64 | yield* Effect.sync(() => { | |
| 65 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | |
| 66 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); | |
| 67 | ||
| 68 | if (input.exitCode !== undefined) { | |
| 69 | process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); | |
| 70 | } else { | |
| 71 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 72 | } | |
| 73 | ||
| 74 | if (input.stderr !== undefined) { | |
| 75 | process.env.T3_FAKE_CODEX_STDERR = input.stderr; | |
| 76 | } else { | |
| 77 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 78 | } | |
| 79 | }); | |
| 80 | ||
| 81 | return { | |
| 82 | previousPath, | |
| 83 | previousOutput, | |
| 84 | previousExitCode, | |
| 85 | previousStderr, | |
| 86 | }; | |
| 87 | }), | |
| 88 | () => effect, | |
| 89 | (previous) => | |
| 90 | Effect.sync(() => { | |
| 91 | process.env.PATH = previous.previousPath; | |
| 92 | ||
| 93 | if (previous.previousOutput === undefined) { | |
| 94 | delete process.env.T3_FAKE_CODEX_OUTPUT_B64; | |
| 95 | } else { | |
| 96 | process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; | |
| 97 | } | |
| 98 | ||
| 99 | if (previous.previousExitCode === undefined) { | |
| 100 | delete process.env.T3_FAKE_CODEX_EXIT_CODE; | |
| 101 | } else { | |
| 102 | process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; | |
| 103 | } | |
| 104 | ||
| 105 | if (previous.previousStderr === undefined) { | |
| 106 | delete process.env.T3_FAKE_CODEX_STDERR; | |
| 107 | } else { | |
| 108 | process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; | |
| 109 | } | |
| 110 | }), | |
| 111 | ); | |
| 112 | } | |
| 113 | ||
| 114 | const CodexTextGenerationTestLayer = Layer.provideMerge( | |
| 115 | CodexTextGenerationLive, | |
| 116 | NodeServices.layer, | |
| 117 | ); | |
| 118 | ||
| 119 | it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | |
| 120 | it.effect("generates and sanitizes commit messages", () => | |
| 121 | withFakeCodexEnv( | |
| 122 | { | |
| 123 | output: JSON.stringify({ | |
| 124 | subject: | |
| 125 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", | |
| 126 | body: "\n- added migration\n- updated tests\n", | |
| 127 | }), | |
| 128 | }, | |
| 129 | Effect.gen(function* () { | |
| 130 | const textGeneration = yield* TextGeneration; | |
| 131 | ||
| 132 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 133 | cwd: process.cwd(), | |
| 134 | branch: "feature/codex-effect", | |
| 135 | stagedSummary: "M README.md", | |
| 136 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 137 | }); | |
| 138 | ||
| 139 | expect(generated.subject.length).toBeLessThanOrEqual(72); | |
| 140 | expect(generated.subject.endsWith(".")).toBe(false); | |
| 141 | expect(generated.body).toBe("- added migration\n- updated tests"); | |
| 142 | }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | ||
| 146 | it.effect("generates PR content and trims markdown body", () => | |
| 147 | withFakeCodexEnv( | |
| 148 | { | |
| 149 | output: JSON.stringify({ | |
| 150 | title: " Improve orchestration flow\nwith ignored suffix", | |
| 151 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", | |
| 152 | }), | |
| 153 | }, | |
| 154 | Effect.gen(function* () { | |
| 155 | const textGeneration = yield* TextGeneration; | |
| 156 | ||
| 157 | const generated = yield* textGeneration.generatePrContent({ | |
| 158 | cwd: process.cwd(), | |
| 159 | baseBranch: "main", | |
| 160 | headBranch: "feature/codex-effect", | |
| 161 | commitSummary: "feat: improve orchestration flow", | |
| 162 | diffSummary: "2 files changed", | |
| 163 | diffPatch: "diff --git a/a.ts b/a.ts", | |
| 164 | }); | |
| 165 | ||
| 166 | expect(generated.title).toBe("Improve orchestration flow"); | |
| 167 | expect(generated.body.startsWith("## Summary")).toBe(true); | |
| 168 | expect(generated.body.endsWith("\n\n")).toBe(false); | |
| 169 | }), | |
| 170 | ), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => | |
| 174 | withFakeCodexEnv( | |
| 175 | { | |
| 176 | output: JSON.stringify({ subject: "ignored", body: "" }), | |
| 177 | exitCode: 1, | |
| 178 | stderr: "codex execution failed", | |
| 179 | }, | |
| 180 | Effect.gen(function* () { | |
| 181 | const textGeneration = yield* TextGeneration; | |
| 182 | ||
| 183 | const result = yield* textGeneration | |
| 184 | .generateCommitMessage({ | |
| 185 | cwd: process.cwd(), | |
| 186 | branch: "feature/codex-error", | |
| 187 | stagedSummary: "M README.md", | |
| 188 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 189 | }) | |
| 190 | .pipe( | |
| 191 | Effect.match({ | |
| 192 | onFailure: (error) => ({ _tag: "Left" as const, left: error }), | |
| 193 | onSuccess: (value) => ({ _tag: "Right" as const, right: value }), | |
| 194 | }), | |
| 195 | ); | |
| 196 | ||
| 197 | expect(result._tag).toBe("Left"); | |
| 198 | if (result._tag === "Left") { | |
| 199 | expect(result.left).toBeInstanceOf(TextGenerationError); | |
| 200 | expect(result.left.message).toContain("Codex CLI command failed: codex execution failed"); | |
| 201 | } | |
| 202 | }), | |
| 203 | ), | |
| 204 | ); | |
| 205 | }); |
apps/server/src/git/Layers/CodexTextGeneration.ts created+393| ... | ... | @@ -0,0 +1,393 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; | |
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 5 | ||
| 6 | import { TextGenerationError } from "../Errors.ts"; | |
| 7 | import { | |
| 8 | type CommitMessageGenerationResult, | |
| 9 | type PrContentGenerationResult, | |
| 10 | type TextGenerationShape, | |
| 11 | TextGeneration, | |
| 12 | } from "../Services/TextGeneration.ts"; | |
| 13 | ||
| 14 | const CODEX_MODEL = "gpt-5.3-codex"; | |
| 15 | const CODEX_REASONING_EFFORT = "low"; | |
| 16 | const CODEX_TIMEOUT_MS = 180_000; | |
| 17 | ||
| 18 | const COMMIT_OUTPUT_SCHEMA_JSON = { | |
| 19 | type: "object", | |
| 20 | properties: { | |
| 21 | subject: { type: "string" }, | |
| 22 | body: { type: "string" }, | |
| 23 | }, | |
| 24 | required: ["subject", "body"], | |
| 25 | additionalProperties: false, | |
| 26 | } as const; | |
| 27 | ||
| 28 | const PR_OUTPUT_SCHEMA_JSON = { | |
| 29 | type: "object", | |
| 30 | properties: { | |
| 31 | title: { type: "string" }, | |
| 32 | body: { type: "string" }, | |
| 33 | }, | |
| 34 | required: ["title", "body"], | |
| 35 | additionalProperties: false, | |
| 36 | } as const; | |
| 37 | ||
| 38 | function normalizeCodexError( | |
| 39 | operation: string, | |
| 40 | error: unknown, | |
| 41 | fallback: string, | |
| 42 | ): TextGenerationError { | |
| 43 | if (Schema.is(TextGenerationError)(error)) { | |
| 44 | return error; | |
| 45 | } | |
| 46 | ||
| 47 | if (error instanceof Error) { | |
| 48 | const lower = error.message.toLowerCase(); | |
| 49 | if ( | |
| 50 | error.message.includes("Command not found: codex") || | |
| 51 | lower.includes("spawn codex") || | |
| 52 | lower.includes("enoent") | |
| 53 | ) { | |
| 54 | return new TextGenerationError({ | |
| 55 | operation, | |
| 56 | detail: "Codex CLI (`codex`) is required but not available on PATH.", | |
| 57 | cause: error, | |
| 58 | }); | |
| 59 | } | |
| 60 | return new TextGenerationError({ | |
| 61 | operation, | |
| 62 | detail: `${fallback}: ${error.message}`, | |
| 63 | cause: error, | |
| 64 | }); | |
| 65 | } | |
| 66 | ||
| 67 | return new TextGenerationError({ | |
| 68 | operation, | |
| 69 | detail: fallback, | |
| 70 | cause: error, | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | function parseCommitOutput(raw: unknown): { subject: string; body: string } { | |
| 75 | if (!raw || typeof raw !== "object") { | |
| 76 | throw new Error("Codex returned a non-object commit message payload."); | |
| 77 | } | |
| 78 | const record = raw as Record<string, unknown>; | |
| 79 | const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | |
| 80 | const body = typeof record.body === "string" ? record.body : ""; | |
| 81 | if (subject.length === 0) { | |
| 82 | throw new Error("Codex returned an empty commit subject."); | |
| 83 | } | |
| 84 | return { subject, body }; | |
| 85 | } | |
| 86 | ||
| 87 | function parsePrOutput(raw: unknown): { title: string; body: string } { | |
| 88 | if (!raw || typeof raw !== "object") { | |
| 89 | throw new Error("Codex returned a non-object PR payload."); | |
| 90 | } | |
| 91 | const record = raw as Record<string, unknown>; | |
| 92 | const title = typeof record.title === "string" ? record.title.trim() : ""; | |
| 93 | const body = typeof record.body === "string" ? record.body.trim() : ""; | |
| 94 | if (title.length === 0 || body.length === 0) { | |
| 95 | throw new Error("Codex returned an invalid PR title/body payload."); | |
| 96 | } | |
| 97 | return { title, body }; | |
| 98 | } | |
| 99 | ||
| 100 | function limitSection(value: string, maxChars: number): string { | |
| 101 | if (value.length <= maxChars) return value; | |
| 102 | const truncated = value.slice(0, maxChars); | |
| 103 | return `${truncated}\n\n[truncated]`; | |
| 104 | } | |
| 105 | ||
| 106 | function sanitizeCommitSubject(raw: string): string { | |
| 107 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 108 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | |
| 109 | if (withoutTrailingPeriod.length === 0) { | |
| 110 | return "Update project files"; | |
| 111 | } | |
| 112 | ||
| 113 | if (withoutTrailingPeriod.length <= 72) { | |
| 114 | return withoutTrailingPeriod; | |
| 115 | } | |
| 116 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | |
| 117 | } | |
| 118 | ||
| 119 | function sanitizePrTitle(raw: string): string { | |
| 120 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 121 | if (singleLine.length > 0) { | |
| 122 | return singleLine; | |
| 123 | } | |
| 124 | return "Update project changes"; | |
| 125 | } | |
| 126 | ||
| 127 | const makeCodexTextGeneration = Effect.gen(function* () { | |
| 128 | const fileSystem = yield* FileSystem.FileSystem; | |
| 129 | const path = yield* Path.Path; | |
| 130 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 131 | ||
| 132 | const readStreamAsString = <E>( | |
| 133 | operation: string, | |
| 134 | stream: Stream.Stream<Uint8Array, E>, | |
| 135 | ): Effect.Effect<string, TextGenerationError> => | |
| 136 | Effect.gen(function* () { | |
| 137 | let text = ""; | |
| 138 | yield* Stream.runForEach(stream, (chunk) => | |
| 139 | Effect.sync(() => { | |
| 140 | text += Buffer.from(chunk).toString("utf8"); | |
| 141 | }), | |
| 142 | ).pipe( | |
| 143 | Effect.mapError((cause) => | |
| 144 | normalizeCodexError(operation, cause, "Failed to collect process output"), | |
| 145 | ), | |
| 146 | ); | |
| 147 | return text; | |
| 148 | }); | |
| 149 | ||
| 150 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | |
| 151 | ||
| 152 | const writeTempFile = ( | |
| 153 | operation: string, | |
| 154 | prefix: string, | |
| 155 | content: string, | |
| 156 | ): Effect.Effect<string, TextGenerationError> => { | |
| 157 | const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); | |
| 158 | return fileSystem.writeFileString(filePath, content).pipe( | |
| 159 | Effect.mapError( | |
| 160 | (cause) => | |
| 161 | new TextGenerationError({ | |
| 162 | operation, | |
| 163 | detail: `Failed to write temp file at ${filePath}.`, | |
| 164 | cause, | |
| 165 | }), | |
| 166 | ), | |
| 167 | Effect.as(filePath), | |
| 168 | ); | |
| 169 | }; | |
| 170 | ||
| 171 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | |
| 172 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | |
| 173 | ||
| 174 | const runCodexJson = <T>({ | |
| 175 | operation, | |
| 176 | cwd, | |
| 177 | prompt, | |
| 178 | outputSchemaJson, | |
| 179 | parse, | |
| 180 | }: { | |
| 181 | operation: "generateCommitMessage" | "generatePrContent"; | |
| 182 | cwd: string; | |
| 183 | prompt: string; | |
| 184 | outputSchemaJson: object; | |
| 185 | parse: (raw: unknown) => T; | |
| 186 | }): Effect.Effect<T, TextGenerationError> => | |
| 187 | Effect.gen(function* () { | |
| 188 | const schemaPath = yield* writeTempFile( | |
| 189 | operation, | |
| 190 | "codex-schema", | |
| 191 | JSON.stringify(outputSchemaJson), | |
| 192 | ); | |
| 193 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | |
| 194 | ||
| 195 | const runCodexCommand = Effect.gen(function* () { | |
| 196 | const command = ChildProcess.make( | |
| 197 | "codex", | |
| 198 | [ | |
| 199 | "exec", | |
| 200 | "--ephemeral", | |
| 201 | "-s", | |
| 202 | "read-only", | |
| 203 | "--model", | |
| 204 | CODEX_MODEL, | |
| 205 | "--config", | |
| 206 | `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, | |
| 207 | "--output-schema", | |
| 208 | schemaPath, | |
| 209 | "--output-last-message", | |
| 210 | outputPath, | |
| 211 | "-", | |
| 212 | ], | |
| 213 | { | |
| 214 | cwd, | |
| 215 | stdin: { | |
| 216 | stream: Stream.make(new TextEncoder().encode(prompt)), | |
| 217 | }, | |
| 218 | }, | |
| 219 | ); | |
| 220 | ||
| 221 | const child = yield* commandSpawner | |
| 222 | .spawn(command) | |
| 223 | .pipe( | |
| 224 | Effect.mapError((cause) => | |
| 225 | normalizeCodexError(operation, cause, "Failed to spawn Codex CLI process"), | |
| 226 | ), | |
| 227 | ); | |
| 228 | ||
| 229 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 230 | [ | |
| 231 | readStreamAsString(operation, child.stdout), | |
| 232 | readStreamAsString(operation, child.stderr), | |
| 233 | child.exitCode.pipe( | |
| 234 | Effect.map((value) => Number(value)), | |
| 235 | Effect.mapError((cause) => | |
| 236 | normalizeCodexError(operation, cause, "Failed to read Codex CLI exit code"), | |
| 237 | ), | |
| 238 | ), | |
| 239 | ], | |
| 240 | { concurrency: "unbounded" }, | |
| 241 | ); | |
| 242 | ||
| 243 | if (exitCode !== 0) { | |
| 244 | const stderrDetail = stderr.trim(); | |
| 245 | const stdoutDetail = stdout.trim(); | |
| 246 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | |
| 247 | return yield* new TextGenerationError({ | |
| 248 | operation, | |
| 249 | detail: | |
| 250 | detail.length > 0 | |
| 251 | ? `Codex CLI command failed: ${detail}` | |
| 252 | : `Codex CLI command failed with code ${exitCode}.`, | |
| 253 | }); | |
| 254 | } | |
| 255 | }); | |
| 256 | ||
| 257 | const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { | |
| 258 | concurrency: "unbounded", | |
| 259 | }).pipe(Effect.asVoid); | |
| 260 | ||
| 261 | return yield* Effect.gen(function* () { | |
| 262 | yield* runCodexCommand.pipe( | |
| 263 | Effect.scoped, | |
| 264 | Effect.timeoutOption(CODEX_TIMEOUT_MS), | |
| 265 | Effect.flatMap( | |
| 266 | Option.match({ | |
| 267 | onNone: () => | |
| 268 | Effect.fail( | |
| 269 | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), | |
| 270 | ), | |
| 271 | onSome: () => Effect.void, | |
| 272 | }), | |
| 273 | ), | |
| 274 | ); | |
| 275 | ||
| 276 | const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( | |
| 277 | Effect.mapError( | |
| 278 | (cause) => | |
| 279 | new TextGenerationError({ | |
| 280 | operation, | |
| 281 | detail: "Failed to read Codex output file.", | |
| 282 | cause, | |
| 283 | }), | |
| 284 | ), | |
| 285 | ); | |
| 286 | const trimmed = rawOutput.trim(); | |
| 287 | if (trimmed.length === 0) { | |
| 288 | return yield* new TextGenerationError({ | |
| 289 | operation, | |
| 290 | detail: "Codex returned an empty response.", | |
| 291 | }); | |
| 292 | } | |
| 293 | ||
| 294 | const parsedJson = yield* Effect.try({ | |
| 295 | try: () => JSON.parse(trimmed) as unknown, | |
| 296 | catch: (cause) => | |
| 297 | new TextGenerationError({ | |
| 298 | operation, | |
| 299 | detail: "Codex returned invalid JSON output.", | |
| 300 | cause, | |
| 301 | }), | |
| 302 | }); | |
| 303 | ||
| 304 | return yield* Effect.try({ | |
| 305 | try: () => parse(parsedJson), | |
| 306 | catch: (cause) => | |
| 307 | normalizeCodexError(operation, cause, "Codex returned invalid structured output"), | |
| 308 | }); | |
| 309 | }).pipe(Effect.ensuring(cleanup)); | |
| 310 | }); | |
| 311 | ||
| 312 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { | |
| 313 | const prompt = [ | |
| 314 | "You write concise git commit messages.", | |
| 315 | "Return a JSON object with keys: subject, body.", | |
| 316 | "Rules:", | |
| 317 | "- subject must be imperative, <= 72 chars, and no trailing period", | |
| 318 | "- body can be empty string or short bullet points", | |
| 319 | "- capture the primary user-visible or developer-visible change", | |
| 320 | "", | |
| 321 | `Branch: ${input.branch ?? "(detached)"}`, | |
| 322 | "", | |
| 323 | "Staged files:", | |
| 324 | limitSection(input.stagedSummary, 6_000), | |
| 325 | "", | |
| 326 | "Staged patch:", | |
| 327 | limitSection(input.stagedPatch, 40_000), | |
| 328 | ].join("\n"); | |
| 329 | ||
| 330 | return runCodexJson({ | |
| 331 | operation: "generateCommitMessage", | |
| 332 | cwd: input.cwd, | |
| 333 | prompt, | |
| 334 | outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | |
| 335 | parse: (raw) => parseCommitOutput(raw), | |
| 336 | }).pipe( | |
| 337 | Effect.map( | |
| 338 | (generated) => | |
| 339 | ({ | |
| 340 | subject: sanitizeCommitSubject(generated.subject), | |
| 341 | body: generated.body.trim(), | |
| 342 | }) satisfies CommitMessageGenerationResult, | |
| 343 | ), | |
| 344 | ); | |
| 345 | }; | |
| 346 | ||
| 347 | const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { | |
| 348 | const prompt = [ | |
| 349 | "You write GitHub pull request content.", | |
| 350 | "Return a JSON object with keys: title, body.", | |
| 351 | "Rules:", | |
| 352 | "- title should be concise and specific", | |
| 353 | "- body must be markdown and include headings '## Summary' and '## Testing'", | |
| 354 | "- under Summary, provide short bullet points", | |
| 355 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | |
| 356 | "", | |
| 357 | `Base branch: ${input.baseBranch}`, | |
| 358 | `Head branch: ${input.headBranch}`, | |
| 359 | "", | |
| 360 | "Commits:", | |
| 361 | limitSection(input.commitSummary, 12_000), | |
| 362 | "", | |
| 363 | "Diff stat:", | |
| 364 | limitSection(input.diffSummary, 12_000), | |
| 365 | "", | |
| 366 | "Diff patch:", | |
| 367 | limitSection(input.diffPatch, 40_000), | |
| 368 | ].join("\n"); | |
| 369 | ||
| 370 | return runCodexJson({ | |
| 371 | operation: "generatePrContent", | |
| 372 | cwd: input.cwd, | |
| 373 | prompt, | |
| 374 | outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | |
| 375 | parse: (raw) => parsePrOutput(raw), | |
| 376 | }).pipe( | |
| 377 | Effect.map( | |
| 378 | (generated) => | |
| 379 | ({ | |
| 380 | title: sanitizePrTitle(generated.title), | |
| 381 | body: generated.body.trim(), | |
| 382 | }) satisfies PrContentGenerationResult, | |
| 383 | ), | |
| 384 | ); | |
| 385 | }; | |
| 386 | ||
| 387 | return { | |
| 388 | generateCommitMessage, | |
| 389 | generatePrContent, | |
| 390 | } satisfies TextGenerationShape; | |
| 391 | }); | |
| 392 | ||
| 393 | export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration); |
apps/server/src/git/Layers/GitCore.test.ts created+1157| ... | ... | @@ -0,0 +1,1157 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | |
| 7 | import { describe, expect, vi } from "vitest"; | |
| 8 | ||
| 9 | import { GitServiceLive } from "./GitService.ts"; | |
| 10 | import { GitService, type GitServiceShape } from "../Services/GitService.ts"; | |
| 11 | import { GitCoreLive } from "./GitCore.ts"; | |
| 12 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | |
| 13 | import { GitCommandError } from "../Errors.ts"; | |
| 14 | import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; | |
| 15 | ||
| 16 | // ── Helpers ── | |
| 17 | ||
| 18 | const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); | |
| 19 | const GitCoreTestLayer = GitCoreLive.pipe( | |
| 20 | Layer.provide(GitServiceTestLayer), | |
| 21 | Layer.provide(NodeServices.layer), | |
| 22 | ); | |
| 23 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); | |
| 24 | ||
| 25 | function makeTmpDir( | |
| 26 | prefix = "git-test-", | |
| 27 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 28 | return Effect.gen(function* () { | |
| 29 | const fileSystem = yield* FileSystem.FileSystem; | |
| 30 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 31 | }); | |
| 32 | } | |
| 33 | ||
| 34 | function writeTextFile( | |
| 35 | filePath: string, | |
| 36 | contents: string, | |
| 37 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 38 | return Effect.gen(function* () { | |
| 39 | const fileSystem = yield* FileSystem.FileSystem; | |
| 40 | yield* fileSystem.writeFileString(filePath, contents); | |
| 41 | }); | |
| 42 | } | |
| 43 | ||
| 44 | /** Run a raw git command for test setup (not under test). */ | |
| 45 | function git( | |
| 46 | cwd: string, | |
| 47 | args: ReadonlyArray<string>, | |
| 48 | env?: NodeJS.ProcessEnv, | |
| 49 | ): Effect.Effect<string, GitCommandError, GitService> { | |
| 50 | return Effect.gen(function* () { | |
| 51 | const gitService = yield* GitService; | |
| 52 | const result = yield* gitService.execute({ | |
| 53 | operation: "GitCore.test.git", | |
| 54 | cwd, | |
| 55 | args, | |
| 56 | ...(env ? { env } : {}), | |
| 57 | timeoutMs: 10_000, | |
| 58 | }); | |
| 59 | return result.stdout.trim(); | |
| 60 | }); | |
| 61 | } | |
| 62 | ||
| 63 | function runShellCommand(input: { | |
| 64 | command: string; | |
| 65 | cwd: string; | |
| 66 | timeoutMs?: number; | |
| 67 | maxOutputBytes?: number; | |
| 68 | }): Effect.Effect<ProcessRunResult, Error> { | |
| 69 | return Effect.promise(() => { | |
| 70 | const shellPath = | |
| 71 | process.platform === "win32" | |
| 72 | ? (process.env.ComSpec ?? "cmd.exe") | |
| 73 | : (process.env.SHELL ?? "/bin/sh"); | |
| 74 | ||
| 75 | const args = | |
| 76 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | |
| 77 | ||
| 78 | return runProcess(shellPath, args, { | |
| 79 | cwd: input.cwd, | |
| 80 | timeoutMs: input.timeoutMs ?? 30_000, | |
| 81 | allowNonZeroExit: true, | |
| 82 | maxBufferBytes: input.maxOutputBytes ?? 1_000_000, | |
| 83 | outputMode: "truncate", | |
| 84 | }); | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | const makeIsolatedGitCore = (gitService: GitServiceShape) => | |
| 89 | Effect.promise(async () => { | |
| 90 | const gitServiceLayer = Layer.succeed(GitService, gitService); | |
| 91 | const coreLayer = GitCoreLive.pipe( | |
| 92 | Layer.provide(gitServiceLayer), | |
| 93 | Layer.provide(NodeServices.layer), | |
| 94 | ); | |
| 95 | const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); | |
| 96 | ||
| 97 | return { | |
| 98 | status: (input) => core.status(input), | |
| 99 | statusDetails: (cwd) => core.statusDetails(cwd), | |
| 100 | prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), | |
| 101 | commit: (cwd, subject, body) => core.commit(cwd, subject, body), | |
| 102 | pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), | |
| 103 | pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), | |
| 104 | readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), | |
| 105 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), | |
| 106 | listBranches: (input) => core.listBranches(input), | |
| 107 | createWorktree: (input) => core.createWorktree(input), | |
| 108 | removeWorktree: (input) => core.removeWorktree(input), | |
| 109 | createBranch: (input) => core.createBranch(input), | |
| 110 | checkoutBranch: (input) => core.checkoutBranch(input), | |
| 111 | initRepo: (input) => core.initRepo(input), | |
| 112 | } satisfies GitCoreShape; | |
| 113 | }); | |
| 114 | ||
| 115 | function listGitBranches(input: Parameters<GitCoreShape["listBranches"]>[0]) { | |
| 116 | return Effect.gen(function* () { | |
| 117 | const core = yield* GitCore; | |
| 118 | return yield* core.listBranches(input); | |
| 119 | }); | |
| 120 | } | |
| 121 | ||
| 122 | function initGitRepo(input: Parameters<GitCoreShape["initRepo"]>[0]) { | |
| 123 | return Effect.gen(function* () { | |
| 124 | const core = yield* GitCore; | |
| 125 | return yield* core.initRepo(input); | |
| 126 | }); | |
| 127 | } | |
| 128 | ||
| 129 | function createGitBranch(input: Parameters<GitCoreShape["createBranch"]>[0]) { | |
| 130 | return Effect.gen(function* () { | |
| 131 | const core = yield* GitCore; | |
| 132 | return yield* core.createBranch(input); | |
| 133 | }); | |
| 134 | } | |
| 135 | ||
| 136 | function checkoutGitBranch(input: Parameters<GitCoreShape["checkoutBranch"]>[0]) { | |
| 137 | return Effect.gen(function* () { | |
| 138 | const core = yield* GitCore; | |
| 139 | return yield* core.checkoutBranch(input); | |
| 140 | }); | |
| 141 | } | |
| 142 | ||
| 143 | function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) { | |
| 144 | return Effect.gen(function* () { | |
| 145 | const core = yield* GitCore; | |
| 146 | return yield* core.createWorktree(input); | |
| 147 | }); | |
| 148 | } | |
| 149 | ||
| 150 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { | |
| 151 | return Effect.gen(function* () { | |
| 152 | const core = yield* GitCore; | |
| 153 | return yield* core.removeWorktree(input); | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | function pullGitBranch({ cwd }: { cwd: string }) { | |
| 158 | return Effect.gen(function* () { | |
| 159 | const core = yield* GitCore; | |
| 160 | return yield* core.pullCurrentBranch(cwd); | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | /** Create a repo with an initial commit so branches work. */ | |
| 165 | function initRepoWithCommit( | |
| 166 | cwd: string, | |
| 167 | ): Effect.Effect< | |
| 168 | void, | |
| 169 | GitCommandError | PlatformError.PlatformError, | |
| 170 | GitCore | GitService | FileSystem.FileSystem | |
| 171 | > { | |
| 172 | return Effect.gen(function* () { | |
| 173 | yield* initGitRepo({ cwd }); | |
| 174 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | |
| 175 | yield* git(cwd, ["config", "user.name", "Test"]); | |
| 176 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | |
| 177 | yield* git(cwd, ["add", "."]); | |
| 178 | yield* git(cwd, ["commit", "-m", "initial commit"]); | |
| 179 | }); | |
| 180 | } | |
| 181 | ||
| 182 | function commitWithDate( | |
| 183 | cwd: string, | |
| 184 | fileName: string, | |
| 185 | fileContents: string, | |
| 186 | dateIsoString: string, | |
| 187 | message: string, | |
| 188 | ): Effect.Effect< | |
| 189 | void, | |
| 190 | GitCommandError | PlatformError.PlatformError, | |
| 191 | GitService | FileSystem.FileSystem | |
| 192 | > { | |
| 193 | return Effect.gen(function* () { | |
| 194 | yield* writeTextFile(path.join(cwd, fileName), fileContents); | |
| 195 | yield* git(cwd, ["add", fileName]); | |
| 196 | yield* git(cwd, ["commit", "-m", message], { | |
| 197 | ...process.env, | |
| 198 | GIT_AUTHOR_DATE: dateIsoString, | |
| 199 | GIT_COMMITTER_DATE: dateIsoString, | |
| 200 | }); | |
| 201 | }); | |
| 202 | } | |
| 203 | ||
| 204 | // ── Tests ── | |
| 205 | ||
| 206 | it.layer(TestLayer)("git integration", (it) => { | |
| 207 | describe("shell process execution", () => { | |
| 208 | it.effect("caps captured output when maxOutputBytes is exceeded", () => | |
| 209 | Effect.gen(function* () { | |
| 210 | const result = yield* runShellCommand({ | |
| 211 | command: `node -e "process.stdout.write('x'.repeat(2000))"`, | |
| 212 | cwd: process.cwd(), | |
| 213 | timeoutMs: 10_000, | |
| 214 | maxOutputBytes: 128, | |
| 215 | }); | |
| 216 | ||
| 217 | expect(result.code).toBe(0); | |
| 218 | expect(result.stdout.length).toBeLessThanOrEqual(128); | |
| 219 | expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); | |
| 220 | }), | |
| 221 | ); | |
| 222 | }); | |
| 223 | ||
| 224 | // ── initGitRepo ── | |
| 225 | ||
| 226 | describe("initGitRepo", () => { | |
| 227 | it.effect("creates a valid git repo", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | const tmp = yield* makeTmpDir(); | |
| 230 | yield* initGitRepo({ cwd: tmp }); | |
| 231 | expect(existsSync(path.join(tmp, ".git"))).toBe(true); | |
| 232 | }), | |
| 233 | ); | |
| 234 | ||
| 235 | it.effect("listGitBranches reports isRepo: true after init + commit", () => | |
| 236 | Effect.gen(function* () { | |
| 237 | const tmp = yield* makeTmpDir(); | |
| 238 | yield* initRepoWithCommit(tmp); | |
| 239 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 240 | expect(result.isRepo).toBe(true); | |
| 241 | expect(result.branches.length).toBeGreaterThanOrEqual(1); | |
| 242 | }), | |
| 243 | ); | |
| 244 | }); | |
| 245 | ||
| 246 | // ── listGitBranches ── | |
| 247 | ||
| 248 | describe("listGitBranches", () => { | |
| 249 | it.effect("returns isRepo: false for non-git directory", () => | |
| 250 | Effect.gen(function* () { | |
| 251 | const tmp = yield* makeTmpDir(); | |
| 252 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 253 | expect(result.isRepo).toBe(false); | |
| 254 | expect(result.branches).toEqual([]); | |
| 255 | }), | |
| 256 | ); | |
| 257 | ||
| 258 | it.effect("returns the current branch with current: true", () => | |
| 259 | Effect.gen(function* () { | |
| 260 | const tmp = yield* makeTmpDir(); | |
| 261 | yield* initRepoWithCommit(tmp); | |
| 262 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 263 | const current = result.branches.find((b) => b.current); | |
| 264 | expect(current).toBeDefined(); | |
| 265 | expect(current!.current).toBe(true); | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | it.effect("keeps current branch first and sorts the remaining branches by recency", () => | |
| 270 | Effect.gen(function* () { | |
| 271 | const tmp = yield* makeTmpDir(); | |
| 272 | yield* initRepoWithCommit(tmp); | |
| 273 | const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 274 | (branch) => branch.current, | |
| 275 | )!.name; | |
| 276 | ||
| 277 | yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 278 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 279 | yield* commitWithDate( | |
| 280 | tmp, | |
| 281 | "older.txt", | |
| 282 | "older branch change\n", | |
| 283 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 284 | "older branch change", | |
| 285 | ); | |
| 286 | ||
| 287 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 288 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 289 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 290 | yield* commitWithDate( | |
| 291 | tmp, | |
| 292 | "newer.txt", | |
| 293 | "newer branch change\n", | |
| 294 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 295 | "newer branch change", | |
| 296 | ); | |
| 297 | ||
| 298 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. | |
| 299 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 300 | ||
| 301 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 302 | expect(result.branches[0]!.name).toBe("older-branch"); | |
| 303 | expect(result.branches[1]!.name).toBe("newer-branch"); | |
| 304 | }), | |
| 305 | ); | |
| 306 | ||
| 307 | it.effect("keeps default branch right after current branch", () => | |
| 308 | Effect.gen(function* () { | |
| 309 | const tmp = yield* makeTmpDir(); | |
| 310 | const remote = yield* makeTmpDir(); | |
| 311 | yield* initRepoWithCommit(tmp); | |
| 312 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 313 | (branch) => branch.current, | |
| 314 | )!.name; | |
| 315 | ||
| 316 | yield* git(remote, ["init", "--bare"]); | |
| 317 | yield* git(tmp, ["remote", "add", "origin", remote]); | |
| 318 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); | |
| 319 | yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); | |
| 320 | ||
| 321 | yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 322 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 323 | yield* commitWithDate( | |
| 324 | tmp, | |
| 325 | "current.txt", | |
| 326 | "current change\n", | |
| 327 | "Thu, 1 Jan 2037 00:00:00 +0000", | |
| 328 | "current change", | |
| 329 | ); | |
| 330 | ||
| 331 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 332 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 333 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 334 | yield* commitWithDate( | |
| 335 | tmp, | |
| 336 | "newer.txt", | |
| 337 | "newer change\n", | |
| 338 | "Fri, 1 Jan 2038 00:00:00 +0000", | |
| 339 | "newer change", | |
| 340 | ); | |
| 341 | ||
| 342 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 343 | ||
| 344 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 345 | expect(result.branches[0]!.name).toBe("current-branch"); | |
| 346 | expect(result.branches[1]!.name).toBe(defaultBranch); | |
| 347 | expect(result.branches[2]!.name).toBe("newer-branch"); | |
| 348 | }), | |
| 349 | ); | |
| 350 | ||
| 351 | it.effect("lists multiple branches after creating them", () => | |
| 352 | Effect.gen(function* () { | |
| 353 | const tmp = yield* makeTmpDir(); | |
| 354 | yield* initRepoWithCommit(tmp); | |
| 355 | yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); | |
| 356 | yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); | |
| 357 | ||
| 358 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 359 | const names = result.branches.map((b) => b.name); | |
| 360 | expect(names).toContain("feature-a"); | |
| 361 | expect(names).toContain("feature-b"); | |
| 362 | }), | |
| 363 | ); | |
| 364 | ||
| 365 | it.effect("isDefault is false when no remote exists", () => | |
| 366 | Effect.gen(function* () { | |
| 367 | const tmp = yield* makeTmpDir(); | |
| 368 | yield* initRepoWithCommit(tmp); | |
| 369 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 370 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); | |
| 371 | }), | |
| 372 | ); | |
| 373 | }); | |
| 374 | ||
| 375 | // ── checkoutGitBranch ── | |
| 376 | ||
| 377 | describe("checkoutGitBranch", () => { | |
| 378 | it.effect("checks out an existing branch", () => | |
| 379 | Effect.gen(function* () { | |
| 380 | const tmp = yield* makeTmpDir(); | |
| 381 | yield* initRepoWithCommit(tmp); | |
| 382 | yield* createGitBranch({ cwd: tmp, branch: "feature" }); | |
| 383 | ||
| 384 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); | |
| 385 | ||
| 386 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 387 | const current = result.branches.find((b) => b.current); | |
| 388 | expect(current!.name).toBe("feature"); | |
| 389 | }), | |
| 390 | ); | |
| 391 | ||
| 392 | it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => | |
| 393 | Effect.gen(function* () { | |
| 394 | const remote = yield* makeTmpDir(); | |
| 395 | const source = yield* makeTmpDir(); | |
| 396 | const clone = yield* makeTmpDir(); | |
| 397 | yield* git(remote, ["init", "--bare"]); | |
| 398 | ||
| 399 | yield* initRepoWithCommit(source); | |
| 400 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 401 | (branch) => branch.current, | |
| 402 | )!.name; | |
| 403 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 404 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 405 | ||
| 406 | const featureBranch = "feature-behind"; | |
| 407 | yield* createGitBranch({ cwd: source, branch: featureBranch }); | |
| 408 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 409 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 410 | yield* git(source, ["add", "feature.txt"]); | |
| 411 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 412 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 413 | yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); | |
| 414 | ||
| 415 | yield* git(clone, ["clone", remote, "."]); | |
| 416 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 417 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 418 | yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); | |
| 419 | yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); | |
| 420 | yield* git(clone, ["add", "feature.txt"]); | |
| 421 | yield* git(clone, ["commit", "-m", "remote feature update"]); | |
| 422 | yield* git(clone, ["push", "origin", featureBranch]); | |
| 423 | ||
| 424 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 425 | const core = yield* GitCore; | |
| 426 | yield* Effect.promise(() => | |
| 427 | vi.waitFor(async () => { | |
| 428 | const details = await Effect.runPromise(core.statusDetails(source)); | |
| 429 | expect(details.branch).toBe(featureBranch); | |
| 430 | expect(details.aheadCount).toBe(0); | |
| 431 | expect(details.behindCount).toBe(1); | |
| 432 | }), | |
| 433 | ); | |
| 434 | }), | |
| 435 | ); | |
| 436 | ||
| 437 | it.effect("keeps checkout successful when upstream refresh fails", () => | |
| 438 | Effect.gen(function* () { | |
| 439 | const remote = yield* makeTmpDir(); | |
| 440 | const source = yield* makeTmpDir(); | |
| 441 | yield* git(remote, ["init", "--bare"]); | |
| 442 | ||
| 443 | yield* initRepoWithCommit(source); | |
| 444 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 445 | (branch) => branch.current, | |
| 446 | )!.name; | |
| 447 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 448 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 449 | ||
| 450 | const featureBranch = "feature-refresh-failure"; | |
| 451 | yield* git(source, ["branch", featureBranch]); | |
| 452 | yield* git(source, ["checkout", featureBranch]); | |
| 453 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 454 | yield* git(source, ["add", "feature.txt"]); | |
| 455 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 456 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 457 | yield* git(source, ["checkout", defaultBranch]); | |
| 458 | ||
| 459 | const realGitService = yield* GitService; | |
| 460 | let refreshFetchAttempts = 0; | |
| 461 | const core = yield* makeIsolatedGitCore({ | |
| 462 | execute: (input) => { | |
| 463 | if (input.args[0] === "fetch") { | |
| 464 | refreshFetchAttempts += 1; | |
| 465 | return Effect.fail( | |
| 466 | new GitCommandError({ | |
| 467 | operation: "git.test.refreshFailure", | |
| 468 | command: `git ${input.args.join(" ")}`, | |
| 469 | cwd: input.cwd, | |
| 470 | detail: "simulated fetch timeout", | |
| 471 | }), | |
| 472 | ); | |
| 473 | } | |
| 474 | return realGitService.execute(input); | |
| 475 | }, | |
| 476 | }); | |
| 477 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 478 | yield* Effect.promise(() => | |
| 479 | vi.waitFor(() => { | |
| 480 | expect(refreshFetchAttempts).toBe(1); | |
| 481 | }), | |
| 482 | ); | |
| 483 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 484 | }), | |
| 485 | ); | |
| 486 | ||
| 487 | it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => | |
| 488 | Effect.gen(function* () { | |
| 489 | const remote = yield* makeTmpDir(); | |
| 490 | const source = yield* makeTmpDir(); | |
| 491 | yield* git(remote, ["init", "--bare"]); | |
| 492 | ||
| 493 | yield* initRepoWithCommit(source); | |
| 494 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 495 | (branch) => branch.current, | |
| 496 | )!.name; | |
| 497 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 498 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 499 | ||
| 500 | const featureBranch = "feature/scoped-fetch"; | |
| 501 | yield* git(source, ["checkout", "-b", featureBranch]); | |
| 502 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 503 | yield* git(source, ["add", "feature.txt"]); | |
| 504 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 505 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 506 | yield* git(source, ["checkout", defaultBranch]); | |
| 507 | ||
| 508 | const realGitService = yield* GitService; | |
| 509 | let fetchArgs: readonly string[] | null = null; | |
| 510 | const core = yield* makeIsolatedGitCore({ | |
| 511 | execute: (input) => { | |
| 512 | if (input.args[0] === "fetch") { | |
| 513 | fetchArgs = [...input.args]; | |
| 514 | return Effect.succeed({ code: 0, stdout: "", stderr: "" }); | |
| 515 | } | |
| 516 | return realGitService.execute(input); | |
| 517 | }, | |
| 518 | }); | |
| 519 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 520 | yield* Effect.promise(() => | |
| 521 | vi.waitFor(() => { | |
| 522 | expect(fetchArgs).not.toBeNull(); | |
| 523 | }), | |
| 524 | ); | |
| 525 | ||
| 526 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 527 | expect(fetchArgs).toEqual([ | |
| 528 | "fetch", | |
| 529 | "--quiet", | |
| 530 | "--no-tags", | |
| 531 | "origin", | |
| 532 | `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, | |
| 533 | ]); | |
| 534 | }), | |
| 535 | ); | |
| 536 | ||
| 537 | it.effect("returns checkout result before background upstream refresh completes", () => | |
| 538 | Effect.gen(function* () { | |
| 539 | const remote = yield* makeTmpDir(); | |
| 540 | const source = yield* makeTmpDir(); | |
| 541 | yield* git(remote, ["init", "--bare"]); | |
| 542 | ||
| 543 | yield* initRepoWithCommit(source); | |
| 544 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 545 | (branch) => branch.current, | |
| 546 | )!.name; | |
| 547 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 548 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | |
| 549 | ||
| 550 | const featureBranch = "feature/background-refresh"; | |
| 551 | yield* git(source, ["checkout", "-b", featureBranch]); | |
| 552 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | |
| 553 | yield* git(source, ["add", "feature.txt"]); | |
| 554 | yield* git(source, ["commit", "-m", "feature base"]); | |
| 555 | yield* git(source, ["push", "-u", "origin", featureBranch]); | |
| 556 | yield* git(source, ["checkout", defaultBranch]); | |
| 557 | ||
| 558 | const realGitService = yield* GitService; | |
| 559 | let fetchStarted = false; | |
| 560 | let releaseFetch: () => void = () => undefined; | |
| 561 | const waitForReleasePromise = new Promise<void>((resolve) => { | |
| 562 | releaseFetch = resolve; | |
| 563 | }); | |
| 564 | const core = yield* makeIsolatedGitCore({ | |
| 565 | execute: (input) => { | |
| 566 | if (input.args[0] === "fetch") { | |
| 567 | fetchStarted = true; | |
| 568 | return Effect.promise(() => | |
| 569 | waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), | |
| 570 | ); | |
| 571 | } | |
| 572 | return realGitService.execute(input); | |
| 573 | }, | |
| 574 | }); | |
| 575 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 576 | yield* Effect.promise(() => | |
| 577 | vi.waitFor(() => { | |
| 578 | expect(fetchStarted).toBe(true); | |
| 579 | }), | |
| 580 | ); | |
| 581 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | |
| 582 | releaseFetch(); | |
| 583 | }), | |
| 584 | ); | |
| 585 | ||
| 586 | it.effect("throws when branch does not exist", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | const tmp = yield* makeTmpDir(); | |
| 589 | yield* initRepoWithCommit(tmp); | |
| 590 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); | |
| 591 | expect(result._tag).toBe("Failure"); | |
| 592 | }), | |
| 593 | ); | |
| 594 | ||
| 595 | it.effect("throws when checkout would overwrite uncommitted changes", () => | |
| 596 | Effect.gen(function* () { | |
| 597 | const tmp = yield* makeTmpDir(); | |
| 598 | yield* initRepoWithCommit(tmp); | |
| 599 | yield* createGitBranch({ cwd: tmp, branch: "other" }); | |
| 600 | ||
| 601 | // Create a conflicting change: modify README on current branch | |
| 602 | yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); | |
| 603 | yield* git(tmp, ["add", "README.md"]); | |
| 604 | ||
| 605 | // First, checkout other branch cleanly | |
| 606 | yield* git(tmp, ["stash"]); | |
| 607 | yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); | |
| 608 | yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); | |
| 609 | yield* git(tmp, ["add", "."]); | |
| 610 | yield* git(tmp, ["commit", "-m", "other change"]); | |
| 611 | ||
| 612 | // Go back to default branch | |
| 613 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 614 | (b) => !b.current, | |
| 615 | )!.name; | |
| 616 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 617 | ||
| 618 | // Make uncommitted changes to the same file | |
| 619 | yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); | |
| 620 | ||
| 621 | // Checkout should fail due to uncommitted changes | |
| 622 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); | |
| 623 | expect(result._tag).toBe("Failure"); | |
| 624 | }), | |
| 625 | ); | |
| 626 | }); | |
| 627 | ||
| 628 | // ── createGitBranch ── | |
| 629 | ||
| 630 | describe("createGitBranch", () => { | |
| 631 | it.effect("creates a new branch visible in listGitBranches", () => | |
| 632 | Effect.gen(function* () { | |
| 633 | const tmp = yield* makeTmpDir(); | |
| 634 | yield* initRepoWithCommit(tmp); | |
| 635 | yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); | |
| 636 | ||
| 637 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 638 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); | |
| 639 | }), | |
| 640 | ); | |
| 641 | ||
| 642 | it.effect("throws when branch already exists", () => | |
| 643 | Effect.gen(function* () { | |
| 644 | const tmp = yield* makeTmpDir(); | |
| 645 | yield* initRepoWithCommit(tmp); | |
| 646 | yield* createGitBranch({ cwd: tmp, branch: "dupe" }); | |
| 647 | const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); | |
| 648 | expect(result._tag).toBe("Failure"); | |
| 649 | }), | |
| 650 | ); | |
| 651 | }); | |
| 652 | ||
| 653 | // ── createGitWorktree + removeGitWorktree ── | |
| 654 | ||
| 655 | describe("createGitWorktree", () => { | |
| 656 | it.effect("creates a worktree with a new branch from the base branch", () => | |
| 657 | Effect.gen(function* () { | |
| 658 | const tmp = yield* makeTmpDir(); | |
| 659 | yield* initRepoWithCommit(tmp); | |
| 660 | ||
| 661 | const wtPath = path.join(tmp, "worktree-out"); | |
| 662 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 663 | (b) => b.current, | |
| 664 | )!.name; | |
| 665 | ||
| 666 | const result = yield* createGitWorktree({ | |
| 667 | cwd: tmp, | |
| 668 | branch: currentBranch, | |
| 669 | newBranch: "wt-branch", | |
| 670 | path: wtPath, | |
| 671 | }); | |
| 672 | ||
| 673 | expect(result.worktree.path).toBe(wtPath); | |
| 674 | expect(result.worktree.branch).toBe("wt-branch"); | |
| 675 | expect(existsSync(wtPath)).toBe(true); | |
| 676 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); | |
| 677 | ||
| 678 | // Clean up worktree before tmp dir disposal | |
| 679 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 680 | }), | |
| 681 | ); | |
| 682 | ||
| 683 | it.effect("worktree has the new branch checked out", () => | |
| 684 | Effect.gen(function* () { | |
| 685 | const tmp = yield* makeTmpDir(); | |
| 686 | yield* initRepoWithCommit(tmp); | |
| 687 | ||
| 688 | const wtPath = path.join(tmp, "wt-check-dir"); | |
| 689 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 690 | (b) => b.current, | |
| 691 | )!.name; | |
| 692 | ||
| 693 | yield* createGitWorktree({ | |
| 694 | cwd: tmp, | |
| 695 | branch: currentBranch, | |
| 696 | newBranch: "wt-check", | |
| 697 | path: wtPath, | |
| 698 | }); | |
| 699 | ||
| 700 | // Verify the worktree is on the new branch | |
| 701 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); | |
| 702 | expect(branchOutput).toBe("wt-check"); | |
| 703 | ||
| 704 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 705 | }), | |
| 706 | ); | |
| 707 | ||
| 708 | it.effect("throws when new branch name already exists", () => | |
| 709 | Effect.gen(function* () { | |
| 710 | const tmp = yield* makeTmpDir(); | |
| 711 | yield* initRepoWithCommit(tmp); | |
| 712 | yield* createGitBranch({ cwd: tmp, branch: "existing" }); | |
| 713 | ||
| 714 | const wtPath = path.join(tmp, "wt-conflict"); | |
| 715 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 716 | (b) => b.current, | |
| 717 | )!.name; | |
| 718 | ||
| 719 | const result = yield* Effect.result( | |
| 720 | createGitWorktree({ | |
| 721 | cwd: tmp, | |
| 722 | branch: currentBranch, | |
| 723 | newBranch: "existing", | |
| 724 | path: wtPath, | |
| 725 | }), | |
| 726 | ); | |
| 727 | expect(result._tag).toBe("Failure"); | |
| 728 | }), | |
| 729 | ); | |
| 730 | ||
| 731 | it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => | |
| 732 | Effect.gen(function* () { | |
| 733 | const tmp = yield* makeTmpDir(); | |
| 734 | yield* initRepoWithCommit(tmp); | |
| 735 | ||
| 736 | const wtPath = path.join(tmp, "wt-list-dir"); | |
| 737 | const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 738 | (b) => b.current, | |
| 739 | )!.name; | |
| 740 | ||
| 741 | yield* createGitWorktree({ | |
| 742 | cwd: tmp, | |
| 743 | branch: mainBranch, | |
| 744 | newBranch: "wt-list", | |
| 745 | path: wtPath, | |
| 746 | }); | |
| 747 | ||
| 748 | // listGitBranches from the worktree should show wt-list as current | |
| 749 | const wtBranches = yield* listGitBranches({ cwd: wtPath }); | |
| 750 | expect(wtBranches.isRepo).toBe(true); | |
| 751 | const wtCurrent = wtBranches.branches.find((b) => b.current); | |
| 752 | expect(wtCurrent!.name).toBe("wt-list"); | |
| 753 | ||
| 754 | // Main repo should still show the original branch as current | |
| 755 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 756 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 757 | expect(mainCurrent!.name).toBe(mainBranch); | |
| 758 | ||
| 759 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 760 | }), | |
| 761 | ); | |
| 762 | ||
| 763 | it.effect("removeGitWorktree cleans up the worktree", () => | |
| 764 | Effect.gen(function* () { | |
| 765 | const tmp = yield* makeTmpDir(); | |
| 766 | yield* initRepoWithCommit(tmp); | |
| 767 | ||
| 768 | const wtPath = path.join(tmp, "wt-remove-dir"); | |
| 769 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 770 | (b) => b.current, | |
| 771 | )!.name; | |
| 772 | ||
| 773 | yield* createGitWorktree({ | |
| 774 | cwd: tmp, | |
| 775 | branch: currentBranch, | |
| 776 | newBranch: "wt-remove", | |
| 777 | path: wtPath, | |
| 778 | }); | |
| 779 | expect(existsSync(wtPath)).toBe(true); | |
| 780 | ||
| 781 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 782 | expect(existsSync(wtPath)).toBe(false); | |
| 783 | }), | |
| 784 | ); | |
| 785 | ||
| 786 | it.effect("removeGitWorktree force removes a dirty worktree", () => | |
| 787 | Effect.gen(function* () { | |
| 788 | const tmp = yield* makeTmpDir(); | |
| 789 | yield* initRepoWithCommit(tmp); | |
| 790 | ||
| 791 | const wtPath = path.join(tmp, "wt-dirty-dir"); | |
| 792 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 793 | (b) => b.current, | |
| 794 | )!.name; | |
| 795 | ||
| 796 | yield* createGitWorktree({ | |
| 797 | cwd: tmp, | |
| 798 | branch: currentBranch, | |
| 799 | newBranch: "wt-dirty", | |
| 800 | path: wtPath, | |
| 801 | }); | |
| 802 | expect(existsSync(wtPath)).toBe(true); | |
| 803 | ||
| 804 | yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); | |
| 805 | ||
| 806 | const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); | |
| 807 | expect(failedRemove._tag).toBe("Failure"); | |
| 808 | expect(existsSync(wtPath)).toBe(true); | |
| 809 | ||
| 810 | yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); | |
| 811 | expect(existsSync(wtPath)).toBe(false); | |
| 812 | }), | |
| 813 | ); | |
| 814 | }); | |
| 815 | ||
| 816 | // ── Full flow: local branch checkout ── | |
| 817 | ||
| 818 | describe("full flow: local branch checkout", () => { | |
| 819 | it.effect("init → commit → create branch → checkout → verify current", () => | |
| 820 | Effect.gen(function* () { | |
| 821 | const tmp = yield* makeTmpDir(); | |
| 822 | yield* initRepoWithCommit(tmp); | |
| 823 | yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 824 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 825 | ||
| 826 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 827 | const current = result.branches.find((b) => b.current); | |
| 828 | expect(current!.name).toBe("feature-login"); | |
| 829 | }), | |
| 830 | ); | |
| 831 | }); | |
| 832 | ||
| 833 | // ── Full flow: worktree creation from base branch ── | |
| 834 | ||
| 835 | describe("full flow: worktree creation", () => { | |
| 836 | it.effect("creates worktree with new branch from current branch", () => | |
| 837 | Effect.gen(function* () { | |
| 838 | const tmp = yield* makeTmpDir(); | |
| 839 | yield* initRepoWithCommit(tmp); | |
| 840 | ||
| 841 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 842 | (b) => b.current, | |
| 843 | )!.name; | |
| 844 | ||
| 845 | const wtPath = path.join(tmp, "my-worktree"); | |
| 846 | const result = yield* createGitWorktree({ | |
| 847 | cwd: tmp, | |
| 848 | branch: currentBranch, | |
| 849 | newBranch: "feature-wt", | |
| 850 | path: wtPath, | |
| 851 | }); | |
| 852 | ||
| 853 | // Worktree exists | |
| 854 | expect(existsSync(result.worktree.path)).toBe(true); | |
| 855 | ||
| 856 | // Main repo still on original branch | |
| 857 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 858 | const mainCurrent = mainBranches.branches.find((b) => b.current); | |
| 859 | expect(mainCurrent!.name).toBe(currentBranch); | |
| 860 | ||
| 861 | // Worktree is on the new branch | |
| 862 | const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); | |
| 863 | expect(wtBranch).toBe("feature-wt"); | |
| 864 | ||
| 865 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 866 | }), | |
| 867 | ); | |
| 868 | }); | |
| 869 | ||
| 870 | // ── Full flow: thread switching simulation ── | |
| 871 | ||
| 872 | describe("full flow: thread switching (checkout toggling)", () => { | |
| 873 | it.effect("checkout a → checkout b → checkout a → current matches", () => | |
| 874 | Effect.gen(function* () { | |
| 875 | const tmp = yield* makeTmpDir(); | |
| 876 | yield* initRepoWithCommit(tmp); | |
| 877 | yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 878 | yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 879 | ||
| 880 | // Simulate switching to thread A's branch | |
| 881 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 882 | let branches = yield* listGitBranches({ cwd: tmp }); | |
| 883 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 884 | ||
| 885 | // Simulate switching to thread B's branch | |
| 886 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 887 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 888 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); | |
| 889 | ||
| 890 | // Switch back to thread A | |
| 891 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 892 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 893 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | |
| 894 | }), | |
| 895 | ); | |
| 896 | }); | |
| 897 | ||
| 898 | // ── Full flow: checkout conflict ── | |
| 899 | ||
| 900 | describe("full flow: checkout conflict", () => { | |
| 901 | it.effect("uncommitted changes prevent checkout to a diverged branch", () => | |
| 902 | Effect.gen(function* () { | |
| 903 | const tmp = yield* makeTmpDir(); | |
| 904 | yield* initRepoWithCommit(tmp); | |
| 905 | yield* createGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 906 | ||
| 907 | // Make diverged branch have different file content | |
| 908 | yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 909 | yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); | |
| 910 | yield* git(tmp, ["add", "."]); | |
| 911 | yield* git(tmp, ["commit", "-m", "diverge"]); | |
| 912 | ||
| 913 | // Actually, let's just get back to the initial branch explicitly | |
| 914 | const allBranches = yield* listGitBranches({ cwd: tmp }); | |
| 915 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; | |
| 916 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 917 | ||
| 918 | // Make local uncommitted changes to the same file | |
| 919 | yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); | |
| 920 | ||
| 921 | // Attempt checkout should fail | |
| 922 | const failedCheckout = yield* Effect.result( | |
| 923 | checkoutGitBranch({ cwd: tmp, branch: "diverged" }), | |
| 924 | ); | |
| 925 | expect(failedCheckout._tag).toBe("Failure"); | |
| 926 | ||
| 927 | // Current branch should still be the initial one | |
| 928 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 929 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | |
| 930 | }), | |
| 931 | ); | |
| 932 | }); | |
| 933 | ||
| 934 | describe("GitCore", () => { | |
| 935 | it.effect("supports branch lifecycle operations through the service API", () => | |
| 936 | Effect.gen(function* () { | |
| 937 | const tmp = yield* makeTmpDir(); | |
| 938 | const core = yield* GitCore; | |
| 939 | ||
| 940 | yield* core.initRepo({ cwd: tmp }); | |
| 941 | yield* git(tmp, ["config", "user.email", "test@test.com"]); | |
| 942 | yield* git(tmp, ["config", "user.name", "Test"]); | |
| 943 | yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); | |
| 944 | yield* git(tmp, ["add", "."]); | |
| 945 | yield* git(tmp, ["commit", "-m", "initial commit"]); | |
| 946 | ||
| 947 | yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); | |
| 948 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); | |
| 949 | const branches = yield* core.listBranches({ cwd: tmp }); | |
| 950 | ||
| 951 | expect(branches.isRepo).toBe(true); | |
| 952 | expect( | |
| 953 | branches.branches.find((branch: { current: boolean; name: string }) => branch.current) | |
| 954 | ?.name, | |
| 955 | ).toBe("feature/service-api"); | |
| 956 | }), | |
| 957 | ); | |
| 958 | ||
| 959 | it.effect("reports status details and dirty state", () => | |
| 960 | Effect.gen(function* () { | |
| 961 | const tmp = yield* makeTmpDir(); | |
| 962 | yield* initRepoWithCommit(tmp); | |
| 963 | const core = yield* GitCore; | |
| 964 | ||
| 965 | const clean = yield* core.status({ cwd: tmp }); | |
| 966 | expect(clean.hasWorkingTreeChanges).toBe(false); | |
| 967 | expect(clean.branch).toBeTruthy(); | |
| 968 | ||
| 969 | yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); | |
| 970 | const dirty = yield* core.statusDetails(tmp); | |
| 971 | expect(dirty.hasWorkingTreeChanges).toBe(true); | |
| 972 | }), | |
| 973 | ); | |
| 974 | ||
| 975 | it.effect("includes command context when worktree removal fails", () => | |
| 976 | Effect.gen(function* () { | |
| 977 | const tmp = yield* makeTmpDir(); | |
| 978 | yield* initRepoWithCommit(tmp); | |
| 979 | const core = yield* GitCore; | |
| 980 | const missingWorktreePath = path.join(tmp, "missing-worktree"); | |
| 981 | ||
| 982 | const removeResult = yield* Effect.result( | |
| 983 | core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), | |
| 984 | ); | |
| 985 | expect(removeResult._tag).toBe("Failure"); | |
| 986 | if (removeResult._tag !== "Failure") { | |
| 987 | return; | |
| 988 | } | |
| 989 | const message = removeResult.failure.message; | |
| 990 | expect(message).toContain("git worktree remove"); | |
| 991 | expect(message).toContain(`cwd: ${tmp}`); | |
| 992 | expect(message).toContain(missingWorktreePath); | |
| 993 | }), | |
| 994 | ); | |
| 995 | ||
| 996 | it.effect( | |
| 997 | "refreshes upstream before statusDetails so behind count reflects remote updates", | |
| 998 | () => | |
| 999 | Effect.gen(function* () { | |
| 1000 | const remote = yield* makeTmpDir(); | |
| 1001 | const source = yield* makeTmpDir(); | |
| 1002 | const clone = yield* makeTmpDir(); | |
| 1003 | yield* git(remote, ["init", "--bare"]); | |
| 1004 | ||
| 1005 | yield* initRepoWithCommit(source); | |
| 1006 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1007 | (branch) => branch.current, | |
| 1008 | )!.name; | |
| 1009 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 1010 | yield* git(source, ["push", "-u", "origin", initialBranch]); | |
| 1011 | ||
| 1012 | yield* git(clone, ["clone", remote, "."]); | |
| 1013 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 1014 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 1015 | yield* git(clone, [ | |
| 1016 | "checkout", | |
| 1017 | "-B", | |
| 1018 | initialBranch, | |
| 1019 | "--track", | |
| 1020 | `origin/${initialBranch}`, | |
| 1021 | ]); | |
| 1022 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | |
| 1023 | yield* git(clone, ["add", "CHANGELOG.md"]); | |
| 1024 | yield* git(clone, ["commit", "-m", "remote update"]); | |
| 1025 | yield* git(clone, ["push", "origin", initialBranch]); | |
| 1026 | ||
| 1027 | const core = yield* GitCore; | |
| 1028 | const details = yield* core.statusDetails(source); | |
| 1029 | expect(details.branch).toBe(initialBranch); | |
| 1030 | expect(details.aheadCount).toBe(0); | |
| 1031 | expect(details.behindCount).toBe(1); | |
| 1032 | }), | |
| 1033 | ); | |
| 1034 | ||
| 1035 | it.effect("prepares commit context by auto-staging and creates commit", () => | |
| 1036 | Effect.gen(function* () { | |
| 1037 | const tmp = yield* makeTmpDir(); | |
| 1038 | yield* initRepoWithCommit(tmp); | |
| 1039 | const core = yield* GitCore; | |
| 1040 | ||
| 1041 | yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); | |
| 1042 | const context = yield* core.prepareCommitContext(tmp); | |
| 1043 | expect(context).not.toBeNull(); | |
| 1044 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | |
| 1045 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | |
| 1046 | ||
| 1047 | const created = yield* core.commit(tmp, "Add README update", "- include updated content"); | |
| 1048 | expect(created.commitSha.length).toBeGreaterThan(0); | |
| 1049 | expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); | |
| 1050 | }), | |
| 1051 | ); | |
| 1052 | ||
| 1053 | it.effect("pushes with upstream setup and then skips when up to date", () => | |
| 1054 | Effect.gen(function* () { | |
| 1055 | const tmp = yield* makeTmpDir(); | |
| 1056 | const remote = yield* makeTmpDir(); | |
| 1057 | yield* initRepoWithCommit(tmp); | |
| 1058 | yield* git(remote, ["init", "--bare"]); | |
| 1059 | yield* git(tmp, ["remote", "add", "origin", remote]); | |
| 1060 | yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1061 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1062 | ||
| 1063 | yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); | |
| 1064 | const core = yield* GitCore; | |
| 1065 | const context = yield* core.prepareCommitContext(tmp); | |
| 1066 | expect(context).not.toBeNull(); | |
| 1067 | yield* core.commit(tmp, "Add feature file", ""); | |
| 1068 | ||
| 1069 | const pushed = yield* core.pushCurrentBranch(tmp, null); | |
| 1070 | expect(pushed.status).toBe("pushed"); | |
| 1071 | expect(pushed.setUpstream).toBe(true); | |
| 1072 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | |
| 1073 | "origin/feature/core-push", | |
| 1074 | ); | |
| 1075 | ||
| 1076 | const skipped = yield* core.pushCurrentBranch(tmp, null); | |
| 1077 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 1078 | }), | |
| 1079 | ); | |
| 1080 | ||
| 1081 | it.effect("pulls behind branch and then reports up-to-date", () => | |
| 1082 | Effect.gen(function* () { | |
| 1083 | const remote = yield* makeTmpDir(); | |
| 1084 | const source = yield* makeTmpDir(); | |
| 1085 | const clone = yield* makeTmpDir(); | |
| 1086 | yield* git(remote, ["init", "--bare"]); | |
| 1087 | ||
| 1088 | yield* initRepoWithCommit(source); | |
| 1089 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1090 | (branch) => branch.current, | |
| 1091 | )!.name; | |
| 1092 | yield* git(source, ["remote", "add", "origin", remote]); | |
| 1093 | yield* git(source, ["push", "-u", "origin", initialBranch]); | |
| 1094 | ||
| 1095 | yield* git(clone, ["clone", remote, "."]); | |
| 1096 | yield* git(clone, ["config", "user.email", "test@test.com"]); | |
| 1097 | yield* git(clone, ["config", "user.name", "Test"]); | |
| 1098 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | |
| 1099 | yield* git(clone, ["add", "CHANGELOG.md"]); | |
| 1100 | yield* git(clone, ["commit", "-m", "remote update"]); | |
| 1101 | yield* git(clone, ["push", "origin", initialBranch]); | |
| 1102 | ||
| 1103 | const core = yield* GitCore; | |
| 1104 | const pulled = yield* core.pullCurrentBranch(source); | |
| 1105 | expect(pulled.status).toBe("pulled"); | |
| 1106 | expect((yield* core.statusDetails(source)).behindCount).toBe(0); | |
| 1107 | ||
| 1108 | const skipped = yield* core.pullCurrentBranch(source); | |
| 1109 | expect(skipped.status).toBe("skipped_up_to_date"); | |
| 1110 | }), | |
| 1111 | ); | |
| 1112 | ||
| 1113 | it.effect("top-level pullGitBranch rejects when no upstream exists", () => | |
| 1114 | Effect.gen(function* () { | |
| 1115 | const tmp = yield* makeTmpDir(); | |
| 1116 | yield* initRepoWithCommit(tmp); | |
| 1117 | const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); | |
| 1118 | expect(result._tag).toBe("Failure"); | |
| 1119 | if (result._tag === "Failure") { | |
| 1120 | expect(result.failure.message.toLowerCase()).toContain("no upstream"); | |
| 1121 | } | |
| 1122 | }), | |
| 1123 | ); | |
| 1124 | ||
| 1125 | it.effect("lists branches when recency lookup fails", () => | |
| 1126 | Effect.gen(function* () { | |
| 1127 | const tmp = yield* makeTmpDir(); | |
| 1128 | yield* initRepoWithCommit(tmp); | |
| 1129 | const realGitService = yield* GitService; | |
| 1130 | let didFailRecency = false; | |
| 1131 | const core = yield* makeIsolatedGitCore({ | |
| 1132 | execute: (input) => { | |
| 1133 | if (!didFailRecency && input.args[0] === "for-each-ref") { | |
| 1134 | didFailRecency = true; | |
| 1135 | return Effect.fail( | |
| 1136 | new GitCommandError({ | |
| 1137 | operation: "git.test.listBranchesRecency", | |
| 1138 | command: `git ${input.args.join(" ")}`, | |
| 1139 | cwd: input.cwd, | |
| 1140 | detail: "timeout", | |
| 1141 | }), | |
| 1142 | ); | |
| 1143 | } | |
| 1144 | return realGitService.execute(input); | |
| 1145 | }, | |
| 1146 | }); | |
| 1147 | ||
| 1148 | const result = yield* core.listBranches({ cwd: tmp }); | |
| 1149 | ||
| 1150 | expect(result.isRepo).toBe(true); | |
| 1151 | expect(result.branches.length).toBeGreaterThan(0); | |
| 1152 | expect(result.branches[0]?.current).toBe(true); | |
| 1153 | expect(didFailRecency).toBe(true); | |
| 1154 | }), | |
| 1155 | ); | |
| 1156 | }); | |
| 1157 | }); |
apps/server/src/git/Layers/GitCore.ts created+759| ... | ... | @@ -0,0 +1,759 @@ |
| 1 | import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; | |
| 2 | ||
| 3 | import { GitCommandError } from "../Errors.ts"; | |
| 4 | import { GitService } from "../Services/GitService.ts"; | |
| 5 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | |
| 6 | ||
| 7 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | |
| 8 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | |
| 9 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | |
| 10 | ||
| 11 | class StatusUpstreamRefreshCacheKey extends Data.Class<{ | |
| 12 | cwd: string; | |
| 13 | upstreamRef: string; | |
| 14 | remoteName: string; | |
| 15 | upstreamBranch: string; | |
| 16 | }> {} | |
| 17 | ||
| 18 | interface ExecuteGitOptions { | |
| 19 | timeoutMs?: number | undefined; | |
| 20 | allowNonZeroExit?: boolean | undefined; | |
| 21 | fallbackErrorMessage?: string | undefined; | |
| 22 | } | |
| 23 | ||
| 24 | function parseBranchAb(value: string): { ahead: number; behind: number } { | |
| 25 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | |
| 26 | if (!match) return { ahead: 0, behind: 0 }; | |
| 27 | return { | |
| 28 | ahead: Number(match[1] ?? "0"), | |
| 29 | behind: Number(match[2] ?? "0"), | |
| 30 | }; | |
| 31 | } | |
| 32 | ||
| 33 | function parseNumstatEntries( | |
| 34 | stdout: string, | |
| 35 | ): Array<{ path: string; insertions: number; deletions: number }> { | |
| 36 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | |
| 37 | for (const line of stdout.split(/\r?\n/g)) { | |
| 38 | if (line.trim().length === 0) continue; | |
| 39 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | |
| 40 | const rawPath = | |
| 41 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | |
| 42 | if (rawPath.length === 0) continue; | |
| 43 | const added = Number.parseInt(addedRaw ?? "0", 10); | |
| 44 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | |
| 45 | const renameArrowIndex = rawPath.indexOf(" => "); | |
| 46 | const normalizedPath = | |
| 47 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | |
| 48 | entries.push({ | |
| 49 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | |
| 50 | insertions: Number.isFinite(added) ? added : 0, | |
| 51 | deletions: Number.isFinite(deleted) ? deleted : 0, | |
| 52 | }); | |
| 53 | } | |
| 54 | return entries; | |
| 55 | } | |
| 56 | ||
| 57 | function parsePorcelainPath(line: string): string | null { | |
| 58 | if (line.startsWith("? ") || line.startsWith("! ")) { | |
| 59 | const simple = line.slice(2).trim(); | |
| 60 | return simple.length > 0 ? simple : null; | |
| 61 | } | |
| 62 | ||
| 63 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | |
| 64 | return null; | |
| 65 | } | |
| 66 | ||
| 67 | const tabIndex = line.indexOf("\t"); | |
| 68 | if (tabIndex >= 0) { | |
| 69 | const fromTab = line.slice(tabIndex + 1); | |
| 70 | const [filePath] = fromTab.split("\t"); | |
| 71 | return filePath?.trim().length ? filePath.trim() : null; | |
| 72 | } | |
| 73 | ||
| 74 | const parts = line.trim().split(/\s+/g); | |
| 75 | const filePath = parts.at(-1) ?? ""; | |
| 76 | return filePath.length > 0 ? filePath : null; | |
| 77 | } | |
| 78 | ||
| 79 | function commandLabel(args: readonly string[]): string { | |
| 80 | return `git ${args.join(" ")}`; | |
| 81 | } | |
| 82 | ||
| 83 | function createGitCommandError( | |
| 84 | operation: string, | |
| 85 | cwd: string, | |
| 86 | args: readonly string[], | |
| 87 | detail: string, | |
| 88 | cause?: unknown, | |
| 89 | ): GitCommandError { | |
| 90 | return new GitCommandError({ | |
| 91 | operation, | |
| 92 | command: commandLabel(args), | |
| 93 | cwd, | |
| 94 | detail, | |
| 95 | ...(cause !== undefined ? { cause } : {}), | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | const makeGitCore = Effect.gen(function* () { | |
| 100 | const git = yield* GitService; | |
| 101 | const fileSystem = yield* FileSystem.FileSystem; | |
| 102 | const path = yield* Path.Path; | |
| 103 | ||
| 104 | const executeGit = ( | |
| 105 | operation: string, | |
| 106 | cwd: string, | |
| 107 | args: readonly string[], | |
| 108 | options: ExecuteGitOptions = {}, | |
| 109 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | |
| 110 | git | |
| 111 | .execute({ | |
| 112 | operation, | |
| 113 | cwd, | |
| 114 | args, | |
| 115 | allowNonZeroExit: true, | |
| 116 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | |
| 117 | }) | |
| 118 | .pipe( | |
| 119 | Effect.flatMap((result) => { | |
| 120 | if (options.allowNonZeroExit || result.code === 0) { | |
| 121 | return Effect.succeed(result); | |
| 122 | } | |
| 123 | const stderr = result.stderr.trim(); | |
| 124 | if (stderr.length > 0) { | |
| 125 | return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); | |
| 126 | } | |
| 127 | if (options.fallbackErrorMessage) { | |
| 128 | return Effect.fail( | |
| 129 | createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), | |
| 130 | ); | |
| 131 | } | |
| 132 | return Effect.fail( | |
| 133 | createGitCommandError( | |
| 134 | operation, | |
| 135 | cwd, | |
| 136 | args, | |
| 137 | `${commandLabel(args)} failed: code=${result.code ?? "null"}`, | |
| 138 | ), | |
| 139 | ); | |
| 140 | }), | |
| 141 | ); | |
| 142 | ||
| 143 | const runGit = ( | |
| 144 | operation: string, | |
| 145 | cwd: string, | |
| 146 | args: readonly string[], | |
| 147 | allowNonZeroExit = false, | |
| 148 | ): Effect.Effect<void, GitCommandError> => | |
| 149 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | |
| 150 | ||
| 151 | const runGitStdout = ( | |
| 152 | operation: string, | |
| 153 | cwd: string, | |
| 154 | args: readonly string[], | |
| 155 | allowNonZeroExit = false, | |
| 156 | ): Effect.Effect<string, GitCommandError> => | |
| 157 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | |
| 158 | Effect.map((result) => result.stdout), | |
| 159 | ); | |
| 160 | ||
| 161 | const resolveCurrentUpstream = ( | |
| 162 | cwd: string, | |
| 163 | ): Effect.Effect< | |
| 164 | { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, | |
| 165 | GitCommandError | |
| 166 | > => | |
| 167 | Effect.gen(function* () { | |
| 168 | const upstreamRef = yield* runGitStdout( | |
| 169 | "GitCore.resolveCurrentUpstream", | |
| 170 | cwd, | |
| 171 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 172 | true, | |
| 173 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 174 | ||
| 175 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 176 | return null; | |
| 177 | } | |
| 178 | ||
| 179 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 180 | if (separatorIndex <= 0) { | |
| 181 | return null; | |
| 182 | } | |
| 183 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 184 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 185 | if (remoteName.length === 0 || upstreamBranch.length === 0) { | |
| 186 | return null; | |
| 187 | } | |
| 188 | ||
| 189 | return { | |
| 190 | upstreamRef, | |
| 191 | remoteName, | |
| 192 | upstreamBranch, | |
| 193 | }; | |
| 194 | }); | |
| 195 | ||
| 196 | const fetchUpstreamRef = ( | |
| 197 | cwd: string, | |
| 198 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 199 | ): Effect.Effect<void, GitCommandError> => { | |
| 200 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 201 | return runGit( | |
| 202 | "GitCore.fetchUpstreamRef", | |
| 203 | cwd, | |
| 204 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 205 | true, | |
| 206 | ); | |
| 207 | }; | |
| 208 | ||
| 209 | const fetchUpstreamRefForStatus = ( | |
| 210 | cwd: string, | |
| 211 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 212 | ): Effect.Effect<void, GitCommandError> => { | |
| 213 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 214 | return executeGit( | |
| 215 | "GitCore.fetchUpstreamRefForStatus", | |
| 216 | cwd, | |
| 217 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 218 | { | |
| 219 | allowNonZeroExit: true, | |
| 220 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | |
| 221 | }, | |
| 222 | ).pipe(Effect.asVoid); | |
| 223 | }; | |
| 224 | ||
| 225 | const statusUpstreamRefreshCache = yield* Cache.makeWith({ | |
| 226 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, | |
| 227 | lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => | |
| 228 | Effect.gen(function* () { | |
| 229 | yield* fetchUpstreamRefForStatus(cacheKey.cwd, { | |
| 230 | upstreamRef: cacheKey.upstreamRef, | |
| 231 | remoteName: cacheKey.remoteName, | |
| 232 | upstreamBranch: cacheKey.upstreamBranch, | |
| 233 | }); | |
| 234 | return true as const; | |
| 235 | }), | |
| 236 | // Keep successful refreshes warm; drop failures immediately so next request can retry. | |
| 237 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), | |
| 238 | }); | |
| 239 | ||
| 240 | const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 241 | Effect.gen(function* () { | |
| 242 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 243 | if (!upstream) return; | |
| 244 | yield* Cache.get( | |
| 245 | statusUpstreamRefreshCache, | |
| 246 | new StatusUpstreamRefreshCacheKey({ | |
| 247 | cwd, | |
| 248 | upstreamRef: upstream.upstreamRef, | |
| 249 | remoteName: upstream.remoteName, | |
| 250 | upstreamBranch: upstream.upstreamBranch, | |
| 251 | }), | |
| 252 | ); | |
| 253 | }); | |
| 254 | ||
| 255 | const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 256 | Effect.gen(function* () { | |
| 257 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 258 | if (!upstream) return; | |
| 259 | yield* fetchUpstreamRef(cwd, upstream); | |
| 260 | }); | |
| 261 | ||
| 262 | const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => | |
| 263 | Effect.gen(function* () { | |
| 264 | const branchRecency = yield* executeGit( | |
| 265 | "GitCore.readBranchRecency", | |
| 266 | cwd, | |
| 267 | ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], | |
| 268 | { | |
| 269 | timeoutMs: 15_000, | |
| 270 | allowNonZeroExit: true, | |
| 271 | }, | |
| 272 | ); | |
| 273 | ||
| 274 | const branchLastCommit = new Map<string, number>(); | |
| 275 | if (branchRecency.code !== 0) { | |
| 276 | return branchLastCommit; | |
| 277 | } | |
| 278 | ||
| 279 | for (const line of branchRecency.stdout.split("\n")) { | |
| 280 | if (line.length === 0) { | |
| 281 | continue; | |
| 282 | } | |
| 283 | const [name, lastCommitRaw] = line.split("\t"); | |
| 284 | if (!name) { | |
| 285 | continue; | |
| 286 | } | |
| 287 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 288 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 289 | } | |
| 290 | ||
| 291 | return branchLastCommit; | |
| 292 | }); | |
| 293 | ||
| 294 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => | |
| 295 | Effect.gen(function* () { | |
| 296 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); | |
| 297 | ||
| 298 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | |
| 299 | [ | |
| 300 | runGitStdout("GitCore.statusDetails.status", cwd, [ | |
| 301 | "status", | |
| 302 | "--porcelain=2", | |
| 303 | "--branch", | |
| 304 | ]), | |
| 305 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 306 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | |
| 307 | "diff", | |
| 308 | "--cached", | |
| 309 | "--numstat", | |
| 310 | ]), | |
| 311 | ], | |
| 312 | { concurrency: "unbounded" }, | |
| 313 | ); | |
| 314 | ||
| 315 | let branch: string | null = null; | |
| 316 | let upstreamRef: string | null = null; | |
| 317 | let aheadCount = 0; | |
| 318 | let behindCount = 0; | |
| 319 | let hasWorkingTreeChanges = false; | |
| 320 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 321 | ||
| 322 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 323 | if (line.startsWith("# branch.head ")) { | |
| 324 | const value = line.slice("# branch.head ".length).trim(); | |
| 325 | branch = value.startsWith("(") ? null : value; | |
| 326 | continue; | |
| 327 | } | |
| 328 | if (line.startsWith("# branch.upstream ")) { | |
| 329 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 330 | upstreamRef = value.length > 0 ? value : null; | |
| 331 | continue; | |
| 332 | } | |
| 333 | if (line.startsWith("# branch.ab ")) { | |
| 334 | const value = line.slice("# branch.ab ".length).trim(); | |
| 335 | const parsed = parseBranchAb(value); | |
| 336 | aheadCount = parsed.ahead; | |
| 337 | behindCount = parsed.behind; | |
| 338 | continue; | |
| 339 | } | |
| 340 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 341 | hasWorkingTreeChanges = true; | |
| 342 | const pathValue = parsePorcelainPath(line); | |
| 343 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 344 | } | |
| 345 | } | |
| 346 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 347 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 348 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 349 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 350 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 351 | existing.insertions += entry.insertions; | |
| 352 | existing.deletions += entry.deletions; | |
| 353 | fileStatMap.set(entry.path, existing); | |
| 354 | } | |
| 355 | ||
| 356 | let insertions = 0; | |
| 357 | let deletions = 0; | |
| 358 | const files = Array.from(fileStatMap.entries()) | |
| 359 | .map(([filePath, stat]) => { | |
| 360 | insertions += stat.insertions; | |
| 361 | deletions += stat.deletions; | |
| 362 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | |
| 363 | }) | |
| 364 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 365 | ||
| 366 | for (const filePath of changedFilesWithoutNumstat) { | |
| 367 | if (fileStatMap.has(filePath)) continue; | |
| 368 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 369 | } | |
| 370 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 371 | ||
| 372 | return { | |
| 373 | branch, | |
| 374 | upstreamRef, | |
| 375 | hasWorkingTreeChanges, | |
| 376 | workingTree: { | |
| 377 | files, | |
| 378 | insertions, | |
| 379 | deletions, | |
| 380 | }, | |
| 381 | hasUpstream: upstreamRef !== null, | |
| 382 | aheadCount, | |
| 383 | behindCount, | |
| 384 | }; | |
| 385 | }); | |
| 386 | ||
| 387 | const status: GitCoreShape["status"] = (input) => | |
| 388 | statusDetails(input.cwd).pipe( | |
| 389 | Effect.map((details) => ({ | |
| 390 | branch: details.branch, | |
| 391 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 392 | workingTree: details.workingTree, | |
| 393 | hasUpstream: details.hasUpstream, | |
| 394 | aheadCount: details.aheadCount, | |
| 395 | behindCount: details.behindCount, | |
| 396 | pr: null, | |
| 397 | })), | |
| 398 | ); | |
| 399 | ||
| 400 | const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => | |
| 401 | Effect.gen(function* () { | |
| 402 | yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); | |
| 403 | ||
| 404 | const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ | |
| 405 | "diff", | |
| 406 | "--cached", | |
| 407 | "--name-status", | |
| 408 | ]).pipe(Effect.map((stdout) => stdout.trim())); | |
| 409 | if (stagedSummary.length === 0) { | |
| 410 | return null; | |
| 411 | } | |
| 412 | ||
| 413 | const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ | |
| 414 | "diff", | |
| 415 | "--cached", | |
| 416 | "--patch", | |
| 417 | "--minimal", | |
| 418 | ]); | |
| 419 | ||
| 420 | return { | |
| 421 | stagedSummary, | |
| 422 | stagedPatch, | |
| 423 | }; | |
| 424 | }); | |
| 425 | ||
| 426 | const commit: GitCoreShape["commit"] = (cwd, subject, body) => | |
| 427 | Effect.gen(function* () { | |
| 428 | const args = ["commit", "-m", subject]; | |
| 429 | const trimmedBody = body.trim(); | |
| 430 | if (trimmedBody.length > 0) { | |
| 431 | args.push("-m", trimmedBody); | |
| 432 | } | |
| 433 | yield* runGit("GitCore.commit.commit", cwd, args); | |
| 434 | const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ | |
| 435 | "rev-parse", | |
| 436 | "HEAD", | |
| 437 | ]).pipe(Effect.map((stdout) => stdout.trim())); | |
| 438 | ||
| 439 | return { commitSha }; | |
| 440 | }); | |
| 441 | ||
| 442 | const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => | |
| 443 | Effect.gen(function* () { | |
| 444 | const details = yield* statusDetails(cwd); | |
| 445 | const branch = details.branch ?? fallbackBranch; | |
| 446 | if (!branch) { | |
| 447 | return yield* createGitCommandError( | |
| 448 | "GitCore.pushCurrentBranch", | |
| 449 | cwd, | |
| 450 | ["push"], | |
| 451 | "Cannot push from detached HEAD.", | |
| 452 | ); | |
| 453 | } | |
| 454 | ||
| 455 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { | |
| 456 | return { | |
| 457 | status: "skipped_up_to_date" as const, | |
| 458 | branch, | |
| 459 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 460 | }; | |
| 461 | } | |
| 462 | ||
| 463 | if (!details.hasUpstream) { | |
| 464 | yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ | |
| 465 | "push", | |
| 466 | "-u", | |
| 467 | "origin", | |
| 468 | branch, | |
| 469 | ]); | |
| 470 | return { | |
| 471 | status: "pushed" as const, | |
| 472 | branch, | |
| 473 | upstreamBranch: `origin/${branch}`, | |
| 474 | setUpstream: true, | |
| 475 | }; | |
| 476 | } | |
| 477 | ||
| 478 | yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); | |
| 479 | return { | |
| 480 | status: "pushed" as const, | |
| 481 | branch, | |
| 482 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 483 | setUpstream: false, | |
| 484 | }; | |
| 485 | }); | |
| 486 | ||
| 487 | const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => | |
| 488 | Effect.gen(function* () { | |
| 489 | const details = yield* statusDetails(cwd); | |
| 490 | const branch = details.branch; | |
| 491 | if (!branch) { | |
| 492 | return yield* createGitCommandError( | |
| 493 | "GitCore.pullCurrentBranch", | |
| 494 | cwd, | |
| 495 | ["pull", "--ff-only"], | |
| 496 | "Cannot pull from detached HEAD.", | |
| 497 | ); | |
| 498 | } | |
| 499 | if (!details.hasUpstream) { | |
| 500 | return yield* createGitCommandError( | |
| 501 | "GitCore.pullCurrentBranch", | |
| 502 | cwd, | |
| 503 | ["pull", "--ff-only"], | |
| 504 | "Current branch has no upstream configured. Push with upstream first.", | |
| 505 | ); | |
| 506 | } | |
| 507 | const beforeSha = yield* runGitStdout( | |
| 508 | "GitCore.pullCurrentBranch.beforeSha", | |
| 509 | cwd, | |
| 510 | ["rev-parse", "HEAD"], | |
| 511 | true, | |
| 512 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 513 | yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | |
| 514 | timeoutMs: 30_000, | |
| 515 | fallbackErrorMessage: "git pull failed", | |
| 516 | }); | |
| 517 | const afterSha = yield* runGitStdout( | |
| 518 | "GitCore.pullCurrentBranch.afterSha", | |
| 519 | cwd, | |
| 520 | ["rev-parse", "HEAD"], | |
| 521 | true, | |
| 522 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 523 | ||
| 524 | const refreshed = yield* statusDetails(cwd); | |
| 525 | return { | |
| 526 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 527 | branch, | |
| 528 | upstreamBranch: refreshed.upstreamRef, | |
| 529 | }; | |
| 530 | }); | |
| 531 | ||
| 532 | const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => | |
| 533 | Effect.gen(function* () { | |
| 534 | const range = `${baseBranch}..HEAD`; | |
| 535 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | |
| 536 | [ | |
| 537 | runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), | |
| 538 | runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), | |
| 539 | runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ | |
| 540 | "diff", | |
| 541 | "--patch", | |
| 542 | "--minimal", | |
| 543 | range, | |
| 544 | ]), | |
| 545 | ], | |
| 546 | { concurrency: "unbounded" }, | |
| 547 | ); | |
| 548 | ||
| 549 | return { | |
| 550 | commitSummary, | |
| 551 | diffSummary, | |
| 552 | diffPatch, | |
| 553 | }; | |
| 554 | }); | |
| 555 | ||
| 556 | const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => | |
| 557 | runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( | |
| 558 | Effect.map((stdout) => stdout.trim()), | |
| 559 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | |
| 560 | ); | |
| 561 | ||
| 562 | const listBranches: GitCoreShape["listBranches"] = (input) => | |
| 563 | Effect.gen(function* () { | |
| 564 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | |
| 565 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | |
| 566 | ); | |
| 567 | const result = yield* executeGit( | |
| 568 | "GitCore.listBranches.branchNoColor", | |
| 569 | input.cwd, | |
| 570 | ["branch", "--no-color"], | |
| 571 | { | |
| 572 | timeoutMs: 10_000, | |
| 573 | allowNonZeroExit: true, | |
| 574 | }, | |
| 575 | ); | |
| 576 | ||
| 577 | if (result.code !== 0) { | |
| 578 | const stderr = result.stderr.trim(); | |
| 579 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 580 | return { branches: [], isRepo: false }; | |
| 581 | } | |
| 582 | return yield* createGitCommandError( | |
| 583 | "GitCore.listBranches", | |
| 584 | input.cwd, | |
| 585 | ["branch", "--no-color"], | |
| 586 | stderr || "git branch failed", | |
| 587 | ); | |
| 588 | } | |
| 589 | ||
| 590 | const [defaultRef, worktreeList, branchLastCommit] = yield* Effect.all( | |
| 591 | [ | |
| 592 | executeGit( | |
| 593 | "GitCore.listBranches.defaultRef", | |
| 594 | input.cwd, | |
| 595 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 596 | { | |
| 597 | timeoutMs: 5_000, | |
| 598 | allowNonZeroExit: true, | |
| 599 | }, | |
| 600 | ), | |
| 601 | executeGit( | |
| 602 | "GitCore.listBranches.worktreeList", | |
| 603 | input.cwd, | |
| 604 | ["worktree", "list", "--porcelain"], | |
| 605 | { | |
| 606 | timeoutMs: 5_000, | |
| 607 | allowNonZeroExit: true, | |
| 608 | }, | |
| 609 | ), | |
| 610 | branchRecencyPromise, | |
| 611 | ], | |
| 612 | { concurrency: "unbounded" }, | |
| 613 | ); | |
| 614 | ||
| 615 | const defaultBranch = | |
| 616 | defaultRef.code === 0 | |
| 617 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 618 | : null; | |
| 619 | ||
| 620 | const worktreeMap = new Map<string, string>(); | |
| 621 | if (worktreeList.code === 0) { | |
| 622 | let currentPath: string | null = null; | |
| 623 | for (const line of worktreeList.stdout.split("\n")) { | |
| 624 | if (line.startsWith("worktree ")) { | |
| 625 | const candidatePath = line.slice("worktree ".length); | |
| 626 | const exists = yield* fileSystem.stat(candidatePath).pipe( | |
| 627 | Effect.map(() => true), | |
| 628 | Effect.catch(() => Effect.succeed(false)), | |
| 629 | ); | |
| 630 | currentPath = exists ? candidatePath : null; | |
| 631 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 632 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 633 | } else if (line === "") { | |
| 634 | currentPath = null; | |
| 635 | } | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | const branches = result.stdout | |
| 640 | .split("\n") | |
| 641 | .map((line) => line.trim()) | |
| 642 | .filter((line) => line.length > 0) | |
| 643 | .map((line) => { | |
| 644 | const name = line.replace(/^[*+]\s+/, ""); | |
| 645 | return { | |
| 646 | name, | |
| 647 | current: line.startsWith("* "), | |
| 648 | isDefault: name === defaultBranch, | |
| 649 | worktreePath: worktreeMap.get(name) ?? null, | |
| 650 | }; | |
| 651 | }) | |
| 652 | .toSorted((a, b) => { | |
| 653 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 654 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 655 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 656 | ||
| 657 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 658 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 659 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 660 | return a.name.localeCompare(b.name); | |
| 661 | }); | |
| 662 | ||
| 663 | return { branches, isRepo: true }; | |
| 664 | }); | |
| 665 | ||
| 666 | const createWorktree: GitCoreShape["createWorktree"] = (input) => | |
| 667 | Effect.gen(function* () { | |
| 668 | const sanitizedBranch = input.newBranch.replace(/\//g, "-"); | |
| 669 | const repoName = path.basename(input.cwd); | |
| 670 | const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; | |
| 671 | const worktreePath = | |
| 672 | input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); | |
| 673 | ||
| 674 | yield* executeGit( | |
| 675 | "GitCore.createWorktree", | |
| 676 | input.cwd, | |
| 677 | ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], | |
| 678 | { | |
| 679 | fallbackErrorMessage: "git worktree add failed", | |
| 680 | }, | |
| 681 | ); | |
| 682 | ||
| 683 | return { | |
| 684 | worktree: { | |
| 685 | path: worktreePath, | |
| 686 | branch: input.newBranch, | |
| 687 | }, | |
| 688 | }; | |
| 689 | }); | |
| 690 | ||
| 691 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => | |
| 692 | Effect.gen(function* () { | |
| 693 | const args = ["worktree", "remove"]; | |
| 694 | if (input.force) { | |
| 695 | args.push("--force"); | |
| 696 | } | |
| 697 | args.push(input.path); | |
| 698 | yield* executeGit("GitCore.removeWorktree", input.cwd, args, { | |
| 699 | timeoutMs: 15_000, | |
| 700 | fallbackErrorMessage: "git worktree remove failed", | |
| 701 | }).pipe( | |
| 702 | Effect.mapError((error) => | |
| 703 | createGitCommandError( | |
| 704 | "GitCore.removeWorktree", | |
| 705 | input.cwd, | |
| 706 | args, | |
| 707 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, | |
| 708 | error, | |
| 709 | ), | |
| 710 | ), | |
| 711 | ); | |
| 712 | }); | |
| 713 | ||
| 714 | const createBranch: GitCoreShape["createBranch"] = (input) => | |
| 715 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 716 | timeoutMs: 10_000, | |
| 717 | fallbackErrorMessage: "git branch create failed", | |
| 718 | }).pipe(Effect.asVoid); | |
| 719 | ||
| 720 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | |
| 721 | Effect.gen(function* () { | |
| 722 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, ["checkout", input.branch], { | |
| 723 | timeoutMs: 10_000, | |
| 724 | fallbackErrorMessage: "git checkout failed", | |
| 725 | }); | |
| 726 | ||
| 727 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 728 | yield* Effect.sync(() => { | |
| 729 | void Effect.runPromise( | |
| 730 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), | |
| 731 | ); | |
| 732 | }); | |
| 733 | }); | |
| 734 | ||
| 735 | const initRepo: GitCoreShape["initRepo"] = (input) => | |
| 736 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | |
| 737 | timeoutMs: 10_000, | |
| 738 | fallbackErrorMessage: "git init failed", | |
| 739 | }).pipe(Effect.asVoid); | |
| 740 | ||
| 741 | return { | |
| 742 | status, | |
| 743 | statusDetails, | |
| 744 | prepareCommitContext, | |
| 745 | commit, | |
| 746 | pushCurrentBranch, | |
| 747 | pullCurrentBranch, | |
| 748 | readRangeContext, | |
| 749 | readConfigValue, | |
| 750 | listBranches, | |
| 751 | createWorktree, | |
| 752 | removeWorktree, | |
| 753 | createBranch, | |
| 754 | checkoutBranch, | |
| 755 | initRepo, | |
| 756 | } satisfies GitCoreShape; | |
| 757 | }); | |
| 758 | ||
| 759 | export const GitCoreLive = Layer.effect(GitCore, makeGitCore); |
apps/server/src/git/Layers/GitHubCli.ts created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | ||
| 3 | import { runProcess } from "../../processRunner"; | |
| 4 | import { GitHubCliError } from "../Errors.ts"; | |
| 5 | import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts"; | |
| 6 | ||
| 7 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 8 | ||
| 9 | function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { | |
| 10 | if (error instanceof Error) { | |
| 11 | if (error.message.includes("Command not found: gh")) { | |
| 12 | return new GitHubCliError({ | |
| 13 | operation, | |
| 14 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | |
| 15 | cause: error, | |
| 16 | }); | |
| 17 | } | |
| 18 | ||
| 19 | const lower = error.message.toLowerCase(); | |
| 20 | if ( | |
| 21 | lower.includes("authentication failed") || | |
| 22 | lower.includes("not logged in") || | |
| 23 | lower.includes("gh auth login") || | |
| 24 | lower.includes("no oauth token") | |
| 25 | ) { | |
| 26 | return new GitHubCliError({ | |
| 27 | operation, | |
| 28 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | |
| 29 | cause: error, | |
| 30 | }); | |
| 31 | } | |
| 32 | ||
| 33 | return new GitHubCliError({ | |
| 34 | operation, | |
| 35 | detail: `GitHub CLI command failed: ${error.message}`, | |
| 36 | cause: error, | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | return new GitHubCliError({ | |
| 41 | operation, | |
| 42 | detail: "GitHub CLI command failed.", | |
| 43 | cause: error, | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | function parseOpenPullRequests(raw: string): ReadonlyArray<{ | |
| 48 | number: number; | |
| 49 | title: string; | |
| 50 | url: string; | |
| 51 | baseRefName: string; | |
| 52 | headRefName: string; | |
| 53 | }> { | |
| 54 | const trimmed = raw.trim(); | |
| 55 | if (trimmed.length === 0) return []; | |
| 56 | ||
| 57 | const parsed: unknown = JSON.parse(trimmed); | |
| 58 | if (!Array.isArray(parsed)) { | |
| 59 | throw new Error("GitHub CLI returned non-array JSON."); | |
| 60 | } | |
| 61 | ||
| 62 | const result: Array<{ | |
| 63 | number: number; | |
| 64 | title: string; | |
| 65 | url: string; | |
| 66 | baseRefName: string; | |
| 67 | headRefName: string; | |
| 68 | }> = []; | |
| 69 | for (const entry of parsed) { | |
| 70 | if (!entry || typeof entry !== "object") { | |
| 71 | continue; | |
| 72 | } | |
| 73 | const record = entry as Record<string, unknown>; | |
| 74 | const number = record.number; | |
| 75 | const title = record.title; | |
| 76 | const url = record.url; | |
| 77 | const baseRefName = record.baseRefName; | |
| 78 | const headRefName = record.headRefName; | |
| 79 | if ( | |
| 80 | typeof number !== "number" || | |
| 81 | !Number.isInteger(number) || | |
| 82 | number <= 0 || | |
| 83 | typeof title !== "string" || | |
| 84 | typeof url !== "string" || | |
| 85 | typeof baseRefName !== "string" || | |
| 86 | typeof headRefName !== "string" | |
| 87 | ) { | |
| 88 | continue; | |
| 89 | } | |
| 90 | result.push({ | |
| 91 | number, | |
| 92 | title, | |
| 93 | url, | |
| 94 | baseRefName, | |
| 95 | headRefName, | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | return result; | |
| 100 | } | |
| 101 | ||
| 102 | const makeGitHubCli = Effect.sync(() => { | |
| 103 | const execute: GitHubCliShape["execute"] = (input) => | |
| 104 | Effect.tryPromise({ | |
| 105 | try: () => | |
| 106 | runProcess("gh", input.args, { | |
| 107 | cwd: input.cwd, | |
| 108 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | |
| 109 | }), | |
| 110 | catch: (error) => normalizeGitHubCliError("execute", error), | |
| 111 | }); | |
| 112 | ||
| 113 | const service = { | |
| 114 | execute, | |
| 115 | listOpenPullRequests: (input) => | |
| 116 | execute({ | |
| 117 | cwd: input.cwd, | |
| 118 | args: [ | |
| 119 | "pr", | |
| 120 | "list", | |
| 121 | "--head", | |
| 122 | input.headBranch, | |
| 123 | "--state", | |
| 124 | "open", | |
| 125 | "--limit", | |
| 126 | String(input.limit ?? 1), | |
| 127 | "--json", | |
| 128 | "number,title,url,baseRefName,headRefName", | |
| 129 | ], | |
| 130 | }).pipe( | |
| 131 | Effect.map((result) => result.stdout), | |
| 132 | Effect.flatMap((raw) => | |
| 133 | Effect.try({ | |
| 134 | try: () => parseOpenPullRequests(raw), | |
| 135 | catch: (error: unknown) => | |
| 136 | new GitHubCliError({ | |
| 137 | operation: "listOpenPullRequests", | |
| 138 | detail: | |
| 139 | error instanceof Error | |
| 140 | ? `GitHub CLI returned invalid PR list JSON: ${error.message}` | |
| 141 | : "GitHub CLI returned invalid PR list JSON.", | |
| 142 | ...(error !== undefined ? { cause: error } : {}), | |
| 143 | }), | |
| 144 | }), | |
| 145 | ), | |
| 146 | ), | |
| 147 | createPullRequest: (input) => | |
| 148 | execute({ | |
| 149 | cwd: input.cwd, | |
| 150 | args: [ | |
| 151 | "pr", | |
| 152 | "create", | |
| 153 | "--base", | |
| 154 | input.baseBranch, | |
| 155 | "--head", | |
| 156 | input.headBranch, | |
| 157 | "--title", | |
| 158 | input.title, | |
| 159 | "--body-file", | |
| 160 | input.bodyFile, | |
| 161 | ], | |
| 162 | }).pipe(Effect.asVoid), | |
| 163 | getDefaultBranch: (input) => | |
| 164 | execute({ | |
| 165 | cwd: input.cwd, | |
| 166 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | |
| 167 | }).pipe( | |
| 168 | Effect.map((value) => { | |
| 169 | const trimmed = value.stdout.trim(); | |
| 170 | return trimmed.length > 0 ? trimmed : null; | |
| 171 | }), | |
| 172 | ), | |
| 173 | } satisfies GitHubCliShape; | |
| 174 | ||
| 175 | return service; | |
| 176 | }); | |
| 177 | ||
| 178 | export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); |
apps/server/src/git/Layers/GitManager.test.ts created+680| ... | ... | @@ -0,0 +1,680 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { it } from "@effect/vitest"; | |
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | |
| 7 | import { expect } from "vitest"; | |
| 8 | ||
| 9 | import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; | |
| 10 | import { type GitManagerShape } from "../Services/GitManager.ts"; | |
| 11 | import { | |
| 12 | type GitHubCliShape, | |
| 13 | type GitHubPullRequestSummary, | |
| 14 | GitHubCli, | |
| 15 | } from "../Services/GitHubCli.ts"; | |
| 16 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | |
| 17 | import { GitServiceLive } from "./GitService.ts"; | |
| 18 | import { GitService } from "../Services/GitService.ts"; | |
| 19 | import { GitCoreLive } from "./GitCore.ts"; | |
| 20 | import { makeGitManager } from "./GitManager.ts"; | |
| 21 | ||
| 22 | interface FakeGhScenario { | |
| 23 | prListSequence?: string[]; | |
| 24 | createdPrUrl?: string; | |
| 25 | defaultBranch?: string; | |
| 26 | failWith?: GitHubCliError; | |
| 27 | } | |
| 28 | ||
| 29 | interface FakeGitTextGeneration { | |
| 30 | generateCommitMessage: (input: { | |
| 31 | cwd: string; | |
| 32 | branch: string | null; | |
| 33 | stagedSummary: string; | |
| 34 | stagedPatch: string; | |
| 35 | }) => Effect.Effect<{ subject: string; body: string }, TextGenerationError>; | |
| 36 | generatePrContent: (input: { | |
| 37 | cwd: string; | |
| 38 | baseBranch: string; | |
| 39 | headBranch: string; | |
| 40 | commitSummary: string; | |
| 41 | diffSummary: string; | |
| 42 | diffPatch: string; | |
| 43 | }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; | |
| 44 | } | |
| 45 | ||
| 46 | function makeTempDir( | |
| 47 | prefix: string, | |
| 48 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | |
| 49 | return Effect.gen(function* () { | |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | function runGit( | |
| 56 | cwd: string, | |
| 57 | args: readonly string[], | |
| 58 | allowNonZeroExit = false, | |
| 59 | ): Effect.Effect< | |
| 60 | { readonly code: number; readonly stdout: string; readonly stderr: string }, | |
| 61 | GitCommandError, | |
| 62 | GitService | |
| 63 | > { | |
| 64 | return Effect.gen(function* () { | |
| 65 | const gitService = yield* GitService; | |
| 66 | return yield* gitService.execute({ | |
| 67 | operation: "GitManager.test.runGit", | |
| 68 | cwd, | |
| 69 | args, | |
| 70 | allowNonZeroExit, | |
| 71 | }); | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | function initRepo( | |
| 76 | cwd: string, | |
| 77 | ): Effect.Effect<void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService> { | |
| 78 | return Effect.gen(function* () { | |
| 79 | const fs = yield* FileSystem.FileSystem; | |
| 80 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | |
| 81 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 82 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | |
| 83 | yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); | |
| 84 | yield* runGit(cwd, ["add", "README.md"]); | |
| 85 | yield* runGit(cwd, ["commit", "-m", "Initial commit"]); | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | function createBareRemote(): Effect.Effect< | |
| 90 | string, | |
| 91 | PlatformError.PlatformError | GitCommandError, | |
| 92 | FileSystem.FileSystem | Scope.Scope | GitService | |
| 93 | > { | |
| 94 | return Effect.gen(function* () { | |
| 95 | const remoteDir = yield* makeTempDir("t3code-git-remote-"); | |
| 96 | yield* runGit(remoteDir, ["init", "--bare"]); | |
| 97 | return remoteDir; | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape { | |
| 102 | const implementation: FakeGitTextGeneration = { | |
| 103 | generateCommitMessage: () => | |
| 104 | Effect.succeed({ | |
| 105 | subject: "Implement stacked git actions", | |
| 106 | body: "", | |
| 107 | }), | |
| 108 | generatePrContent: () => | |
| 109 | Effect.succeed({ | |
| 110 | title: "Add stacked git actions", | |
| 111 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | |
| 112 | }), | |
| 113 | ...overrides, | |
| 114 | }; | |
| 115 | ||
| 116 | return { | |
| 117 | generateCommitMessage: (input) => | |
| 118 | implementation.generateCommitMessage(input).pipe( | |
| 119 | Effect.mapError( | |
| 120 | (cause) => | |
| 121 | new TextGenerationError({ | |
| 122 | operation: "generateCommitMessage", | |
| 123 | detail: "fake text generation failed", | |
| 124 | ...(cause !== undefined ? { cause } : {}), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ), | |
| 128 | generatePrContent: (input) => | |
| 129 | implementation.generatePrContent(input).pipe( | |
| 130 | Effect.mapError( | |
| 131 | (cause) => | |
| 132 | new TextGenerationError({ | |
| 133 | operation: "generatePrContent", | |
| 134 | detail: "fake text generation failed", | |
| 135 | ...(cause !== undefined ? { cause } : {}), | |
| 136 | }), | |
| 137 | ), | |
| 138 | ), | |
| 139 | }; | |
| 140 | } | |
| 141 | ||
| 142 | function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 143 | service: GitHubCliShape; | |
| 144 | ghCalls: string[]; | |
| 145 | } { | |
| 146 | const prListQueue = [...(scenario.prListSequence ?? [])]; | |
| 147 | const ghCalls: string[] = []; | |
| 148 | ||
| 149 | const execute: GitHubCliShape["execute"] = (input) => { | |
| 150 | const args = [...input.args]; | |
| 151 | ghCalls.push(args.join(" ")); | |
| 152 | ||
| 153 | if (scenario.failWith) { | |
| 154 | return Effect.fail(scenario.failWith); | |
| 155 | } | |
| 156 | ||
| 157 | if (args[0] === "pr" && args[1] === "list") { | |
| 158 | const stdout = (prListQueue.shift() ?? "[]") + "\n"; | |
| 159 | return Effect.succeed({ | |
| 160 | stdout, | |
| 161 | stderr: "", | |
| 162 | code: 0, | |
| 163 | signal: null, | |
| 164 | timedOut: false, | |
| 165 | }); | |
| 166 | } | |
| 167 | ||
| 168 | if (args[0] === "pr" && args[1] === "create") { | |
| 169 | return Effect.succeed({ | |
| 170 | stdout: | |
| 171 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | |
| 172 | stderr: "", | |
| 173 | code: 0, | |
| 174 | signal: null, | |
| 175 | timedOut: false, | |
| 176 | }); | |
| 177 | } | |
| 178 | ||
| 179 | if (args[0] === "pr" && args[1] === "view") { | |
| 180 | return Effect.succeed({ | |
| 181 | stdout: "", | |
| 182 | stderr: "", | |
| 183 | code: 0, | |
| 184 | signal: null, | |
| 185 | timedOut: false, | |
| 186 | }); | |
| 187 | } | |
| 188 | ||
| 189 | if (args[0] === "repo" && args[1] === "view") { | |
| 190 | return Effect.succeed({ | |
| 191 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | |
| 192 | stderr: "", | |
| 193 | code: 0, | |
| 194 | signal: null, | |
| 195 | timedOut: false, | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 199 | return Effect.fail( | |
| 200 | new GitHubCliError({ | |
| 201 | operation: "execute", | |
| 202 | detail: `Unexpected gh command: ${args.join(" ")}`, | |
| 203 | }), | |
| 204 | ); | |
| 205 | }; | |
| 206 | ||
| 207 | return { | |
| 208 | service: { | |
| 209 | execute, | |
| 210 | listOpenPullRequests: (input) => | |
| 211 | execute({ | |
| 212 | cwd: input.cwd, | |
| 213 | args: [ | |
| 214 | "pr", | |
| 215 | "list", | |
| 216 | "--head", | |
| 217 | input.headBranch, | |
| 218 | "--state", | |
| 219 | "open", | |
| 220 | "--limit", | |
| 221 | String(input.limit ?? 1), | |
| 222 | "--json", | |
| 223 | "number,title,url,baseRefName,headRefName", | |
| 224 | ], | |
| 225 | }).pipe( | |
| 226 | Effect.map( | |
| 227 | (result) => JSON.parse(result.stdout) as ReadonlyArray<GitHubPullRequestSummary>, | |
| 228 | ), | |
| 229 | ), | |
| 230 | createPullRequest: (input) => | |
| 231 | execute({ | |
| 232 | cwd: input.cwd, | |
| 233 | args: [ | |
| 234 | "pr", | |
| 235 | "create", | |
| 236 | "--base", | |
| 237 | input.baseBranch, | |
| 238 | "--head", | |
| 239 | input.headBranch, | |
| 240 | "--title", | |
| 241 | input.title, | |
| 242 | "--body-file", | |
| 243 | input.bodyFile, | |
| 244 | ], | |
| 245 | }).pipe(Effect.asVoid), | |
| 246 | getDefaultBranch: (input) => | |
| 247 | execute({ | |
| 248 | cwd: input.cwd, | |
| 249 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | |
| 250 | }).pipe( | |
| 251 | Effect.map((result) => { | |
| 252 | const value = result.stdout.trim(); | |
| 253 | return value.length > 0 ? value : null; | |
| 254 | }), | |
| 255 | ), | |
| 256 | }, | |
| 257 | ghCalls, | |
| 258 | }; | |
| 259 | } | |
| 260 | ||
| 261 | function runStackedAction( | |
| 262 | manager: GitManagerShape, | |
| 263 | input: { | |
| 264 | cwd: string; | |
| 265 | action: "commit" | "commit_push" | "commit_push_pr"; | |
| 266 | commitMessage?: string; | |
| 267 | }, | |
| 268 | ) { | |
| 269 | return manager.runStackedAction(input); | |
| 270 | } | |
| 271 | ||
| 272 | function makeManager(input?: { | |
| 273 | ghScenario?: FakeGhScenario; | |
| 274 | textGeneration?: Partial<FakeGitTextGeneration>; | |
| 275 | }) { | |
| 276 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); | |
| 277 | const textGeneration = createTextGeneration(input?.textGeneration); | |
| 278 | ||
| 279 | const gitCoreLayer = GitCoreLive.pipe( | |
| 280 | Layer.provideMerge(GitServiceLive), | |
| 281 | Layer.provideMerge(NodeServices.layer), | |
| 282 | ); | |
| 283 | ||
| 284 | const managerLayer = Layer.mergeAll( | |
| 285 | Layer.succeed(GitHubCli, gitHubCli), | |
| 286 | Layer.succeed(TextGeneration, textGeneration), | |
| 287 | gitCoreLayer, | |
| 288 | NodeServices.layer, | |
| 289 | ); | |
| 290 | ||
| 291 | return makeGitManager.pipe( | |
| 292 | Effect.provide(managerLayer), | |
| 293 | Effect.map((manager) => ({ manager, ghCalls })), | |
| 294 | ); | |
| 295 | } | |
| 296 | ||
| 297 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); | |
| 298 | ||
| 299 | it.layer(GitManagerTestLayer)("GitManager", (it) => { | |
| 300 | it.effect("status includes PR metadata when branch already has an open PR", () => | |
| 301 | Effect.gen(function* () { | |
| 302 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 303 | yield* initRepo(repoDir); | |
| 304 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | |
| 305 | const remoteDir = yield* createBareRemote(); | |
| 306 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 307 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | |
| 308 | ||
| 309 | const { manager } = yield* makeManager({ | |
| 310 | ghScenario: { | |
| 311 | prListSequence: [ | |
| 312 | JSON.stringify([ | |
| 313 | { | |
| 314 | number: 13, | |
| 315 | title: "Existing PR", | |
| 316 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 317 | baseRefName: "main", | |
| 318 | headRefName: "feature/status-open-pr", | |
| 319 | }, | |
| 320 | ]), | |
| 321 | ], | |
| 322 | }, | |
| 323 | }); | |
| 324 | ||
| 325 | const status = yield* manager.status({ cwd: repoDir }); | |
| 326 | expect(status.branch).toBe("feature/status-open-pr"); | |
| 327 | expect(status.pr).toEqual({ | |
| 328 | number: 13, | |
| 329 | title: "Existing PR", | |
| 330 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 331 | baseBranch: "main", | |
| 332 | headBranch: "feature/status-open-pr", | |
| 333 | state: "open", | |
| 334 | }); | |
| 335 | }), | |
| 336 | ); | |
| 337 | ||
| 338 | it.effect("status returns merged PR state when latest PR was merged", () => | |
| 339 | Effect.gen(function* () { | |
| 340 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 341 | yield* initRepo(repoDir); | |
| 342 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); | |
| 343 | ||
| 344 | const { manager } = yield* makeManager({ | |
| 345 | ghScenario: { | |
| 346 | prListSequence: [ | |
| 347 | JSON.stringify([ | |
| 348 | { | |
| 349 | number: 22, | |
| 350 | title: "Merged PR", | |
| 351 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | |
| 352 | baseRefName: "main", | |
| 353 | headRefName: "feature/status-merged-pr", | |
| 354 | state: "MERGED", | |
| 355 | mergedAt: "2026-01-30T10:00:00Z", | |
| 356 | updatedAt: "2026-01-30T10:00:00Z", | |
| 357 | }, | |
| 358 | ]), | |
| 359 | ], | |
| 360 | }, | |
| 361 | }); | |
| 362 | ||
| 363 | const status = yield* manager.status({ cwd: repoDir }); | |
| 364 | expect(status.branch).toBe("feature/status-merged-pr"); | |
| 365 | expect(status.pr).toEqual({ | |
| 366 | number: 22, | |
| 367 | title: "Merged PR", | |
| 368 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | |
| 369 | baseBranch: "main", | |
| 370 | headBranch: "feature/status-merged-pr", | |
| 371 | state: "merged", | |
| 372 | }); | |
| 373 | }), | |
| 374 | ); | |
| 375 | ||
| 376 | it.effect("status is resilient to gh lookup failures and returns pr null", () => | |
| 377 | Effect.gen(function* () { | |
| 378 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 379 | yield* initRepo(repoDir); | |
| 380 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | |
| 381 | const remoteDir = yield* createBareRemote(); | |
| 382 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 383 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | |
| 384 | ||
| 385 | const { manager } = yield* makeManager({ | |
| 386 | ghScenario: { | |
| 387 | failWith: new GitHubCliError({ | |
| 388 | operation: "execute", | |
| 389 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | |
| 390 | }), | |
| 391 | }, | |
| 392 | }); | |
| 393 | ||
| 394 | const status = yield* manager.status({ cwd: repoDir }); | |
| 395 | expect(status.branch).toBe("feature/status-no-gh"); | |
| 396 | expect(status.pr).toBeNull(); | |
| 397 | }), | |
| 398 | ); | |
| 399 | ||
| 400 | it.effect("creates a commit when working tree is dirty", () => | |
| 401 | Effect.gen(function* () { | |
| 402 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 403 | yield* initRepo(repoDir); | |
| 404 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | |
| 405 | ||
| 406 | const { manager } = yield* makeManager(); | |
| 407 | const result = yield* runStackedAction(manager, { | |
| 408 | cwd: repoDir, | |
| 409 | action: "commit", | |
| 410 | }); | |
| 411 | ||
| 412 | expect(result.commit.status).toBe("created"); | |
| 413 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 414 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 415 | expect( | |
| 416 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | |
| 417 | Effect.map((result) => result.stdout.trim()), | |
| 418 | ), | |
| 419 | ).toBe("Implement stacked git actions"); | |
| 420 | }), | |
| 421 | ); | |
| 422 | ||
| 423 | it.effect("uses custom commit message when provided", () => | |
| 424 | Effect.gen(function* () { | |
| 425 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 426 | yield* initRepo(repoDir); | |
| 427 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | |
| 428 | let generatedCount = 0; | |
| 429 | ||
| 430 | const { manager } = yield* makeManager({ | |
| 431 | textGeneration: { | |
| 432 | generateCommitMessage: () => | |
| 433 | Effect.sync(() => { | |
| 434 | generatedCount += 1; | |
| 435 | return { | |
| 436 | subject: "this should not be used", | |
| 437 | body: "", | |
| 438 | }; | |
| 439 | }), | |
| 440 | }, | |
| 441 | }); | |
| 442 | const result = yield* runStackedAction(manager, { | |
| 443 | cwd: repoDir, | |
| 444 | action: "commit", | |
| 445 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 446 | }); | |
| 447 | ||
| 448 | expect(result.commit.status).toBe("created"); | |
| 449 | expect(result.commit.subject).toBe("feat: custom summary line"); | |
| 450 | expect(generatedCount).toBe(0); | |
| 451 | expect( | |
| 452 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | |
| 453 | Effect.map((result) => result.stdout.trim()), | |
| 454 | ), | |
| 455 | ).toBe("feat: custom summary line"); | |
| 456 | expect( | |
| 457 | yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( | |
| 458 | Effect.map((result) => result.stdout.trim()), | |
| 459 | ), | |
| 460 | ).toContain("- details from user"); | |
| 461 | }), | |
| 462 | ); | |
| 463 | ||
| 464 | it.effect("skips commit when there are no uncommitted changes", () => | |
| 465 | Effect.gen(function* () { | |
| 466 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 467 | yield* initRepo(repoDir); | |
| 468 | ||
| 469 | const { manager } = yield* makeManager(); | |
| 470 | const result = yield* runStackedAction(manager, { | |
| 471 | cwd: repoDir, | |
| 472 | action: "commit", | |
| 473 | }); | |
| 474 | ||
| 475 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 476 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 477 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 478 | }), | |
| 479 | ); | |
| 480 | ||
| 481 | it.effect("commits and pushes with upstream auto-setup when needed", () => | |
| 482 | Effect.gen(function* () { | |
| 483 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 484 | yield* initRepo(repoDir); | |
| 485 | yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | |
| 486 | const remoteDir = yield* createBareRemote(); | |
| 487 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 488 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | |
| 489 | ||
| 490 | const { manager } = yield* makeManager(); | |
| 491 | const result = yield* runStackedAction(manager, { | |
| 492 | cwd: repoDir, | |
| 493 | action: "commit_push", | |
| 494 | }); | |
| 495 | ||
| 496 | expect(result.commit.status).toBe("created"); | |
| 497 | expect(result.push.status).toBe("pushed"); | |
| 498 | expect(result.push.setUpstream).toBe(true); | |
| 499 | expect( | |
| 500 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | |
| 501 | Effect.map((result) => result.stdout.trim()), | |
| 502 | ), | |
| 503 | ).toBe("origin/feature/stacked-flow"); | |
| 504 | }), | |
| 505 | ); | |
| 506 | ||
| 507 | it.effect("skips push when branch is already up to date", () => | |
| 508 | Effect.gen(function* () { | |
| 509 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 510 | yield* initRepo(repoDir); | |
| 511 | yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | |
| 512 | const remoteDir = yield* createBareRemote(); | |
| 513 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 514 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | |
| 515 | ||
| 516 | const { manager } = yield* makeManager(); | |
| 517 | const result = yield* runStackedAction(manager, { | |
| 518 | cwd: repoDir, | |
| 519 | action: "commit_push", | |
| 520 | }); | |
| 521 | ||
| 522 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 523 | expect(result.push.status).toBe("skipped_up_to_date"); | |
| 524 | }), | |
| 525 | ); | |
| 526 | ||
| 527 | it.effect("returns existing PR metadata for commit/push/pr action", () => | |
| 528 | Effect.gen(function* () { | |
| 529 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 530 | yield* initRepo(repoDir); | |
| 531 | yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | |
| 532 | const remoteDir = yield* createBareRemote(); | |
| 533 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 534 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | |
| 535 | ||
| 536 | const { manager, ghCalls } = yield* makeManager({ | |
| 537 | ghScenario: { | |
| 538 | prListSequence: [ | |
| 539 | JSON.stringify([ | |
| 540 | { | |
| 541 | number: 42, | |
| 542 | title: "Existing PR", | |
| 543 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 544 | baseRefName: "main", | |
| 545 | headRefName: "feature/existing-pr", | |
| 546 | }, | |
| 547 | ]), | |
| 548 | ], | |
| 549 | }, | |
| 550 | }); | |
| 551 | const result = yield* runStackedAction(manager, { | |
| 552 | cwd: repoDir, | |
| 553 | action: "commit_push_pr", | |
| 554 | }); | |
| 555 | ||
| 556 | expect(result.pr.status).toBe("opened_existing"); | |
| 557 | expect(result.pr.number).toBe(42); | |
| 558 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 559 | }), | |
| 560 | ); | |
| 561 | ||
| 562 | it.effect("creates PR when one does not already exist", () => | |
| 563 | Effect.gen(function* () { | |
| 564 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 565 | yield* initRepo(repoDir); | |
| 566 | yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | |
| 567 | const remoteDir = yield* createBareRemote(); | |
| 568 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 569 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | |
| 570 | yield* runGit(repoDir, ["add", "changes.txt"]); | |
| 571 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | |
| 572 | yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | |
| 573 | yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | |
| 574 | ||
| 575 | const { manager, ghCalls } = yield* makeManager({ | |
| 576 | ghScenario: { | |
| 577 | prListSequence: [ | |
| 578 | "[]", | |
| 579 | JSON.stringify([ | |
| 580 | { | |
| 581 | number: 88, | |
| 582 | title: "Add stacked git actions", | |
| 583 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | |
| 584 | baseRefName: "main", | |
| 585 | headRefName: "feature-create-pr", | |
| 586 | }, | |
| 587 | ]), | |
| 588 | ], | |
| 589 | }, | |
| 590 | }); | |
| 591 | const result = yield* runStackedAction(manager, { | |
| 592 | cwd: repoDir, | |
| 593 | action: "commit_push_pr", | |
| 594 | }); | |
| 595 | ||
| 596 | expect(result.pr.status).toBe("created"); | |
| 597 | expect(result.pr.number).toBe(88); | |
| 598 | expect( | |
| 599 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | |
| 600 | ).toBe(true); | |
| 601 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 602 | }), | |
| 603 | ); | |
| 604 | ||
| 605 | it.effect("rejects push/pr actions from detached HEAD", () => | |
| 606 | Effect.gen(function* () { | |
| 607 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 608 | yield* initRepo(repoDir); | |
| 609 | yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); | |
| 610 | ||
| 611 | const { manager } = yield* makeManager(); | |
| 612 | const errorMessage = yield* runStackedAction(manager, { | |
| 613 | cwd: repoDir, | |
| 614 | action: "commit_push", | |
| 615 | }).pipe( | |
| 616 | Effect.flip, | |
| 617 | Effect.map((error) => error.message), | |
| 618 | ); | |
| 619 | expect(errorMessage).toContain("detached HEAD"); | |
| 620 | }), | |
| 621 | ); | |
| 622 | ||
| 623 | it.effect("surfaces missing gh binary errors", () => | |
| 624 | Effect.gen(function* () { | |
| 625 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 626 | yield* initRepo(repoDir); | |
| 627 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | |
| 628 | const remoteDir = yield* createBareRemote(); | |
| 629 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 630 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | |
| 631 | ||
| 632 | const { manager } = yield* makeManager({ | |
| 633 | ghScenario: { | |
| 634 | failWith: new GitHubCliError({ | |
| 635 | operation: "execute", | |
| 636 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | |
| 637 | }), | |
| 638 | }, | |
| 639 | }); | |
| 640 | ||
| 641 | const errorMessage = yield* runStackedAction(manager, { | |
| 642 | cwd: repoDir, | |
| 643 | action: "commit_push_pr", | |
| 644 | }).pipe( | |
| 645 | Effect.flip, | |
| 646 | Effect.map((error) => error.message), | |
| 647 | ); | |
| 648 | expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); | |
| 649 | }), | |
| 650 | ); | |
| 651 | ||
| 652 | it.effect("surfaces gh auth errors with guidance", () => | |
| 653 | Effect.gen(function* () { | |
| 654 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 655 | yield* initRepo(repoDir); | |
| 656 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | |
| 657 | const remoteDir = yield* createBareRemote(); | |
| 658 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 659 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | |
| 660 | ||
| 661 | const { manager } = yield* makeManager({ | |
| 662 | ghScenario: { | |
| 663 | failWith: new GitHubCliError({ | |
| 664 | operation: "execute", | |
| 665 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | |
| 666 | }), | |
| 667 | }, | |
| 668 | }); | |
| 669 | ||
| 670 | const errorMessage = yield* runStackedAction(manager, { | |
| 671 | cwd: repoDir, | |
| 672 | action: "commit_push_pr", | |
| 673 | }).pipe( | |
| 674 | Effect.flip, | |
| 675 | Effect.map((error) => error.message), | |
| 676 | ); | |
| 677 | expect(errorMessage).toContain("gh auth login"); | |
| 678 | }), | |
| 679 | ); | |
| 680 | }); |
apps/server/src/git/Layers/GitManager.ts created+420| ... | ... | @@ -0,0 +1,420 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | ||
| 5 | import { GitManagerError } from "../Errors.ts"; | |
| 6 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; | |
| 7 | import { GitCore } from "../Services/GitCore.ts"; | |
| 8 | import { GitHubCli } from "../Services/GitHubCli.ts"; | |
| 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 10 | ||
| 11 | interface OpenPrInfo { | |
| 12 | number: number; | |
| 13 | title: string; | |
| 14 | url: string; | |
| 15 | baseRefName: string; | |
| 16 | headRefName: string; | |
| 17 | } | |
| 18 | ||
| 19 | interface PullRequestInfo extends OpenPrInfo { | |
| 20 | state: "open" | "closed" | "merged"; | |
| 21 | updatedAt: string | null; | |
| 22 | } | |
| 23 | ||
| 24 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { | |
| 25 | if (!Array.isArray(raw)) return []; | |
| 26 | ||
| 27 | const parsed: PullRequestInfo[] = []; | |
| 28 | for (const entry of raw) { | |
| 29 | if (!entry || typeof entry !== "object") continue; | |
| 30 | const record = entry as Record<string, unknown>; | |
| 31 | const number = record.number; | |
| 32 | const title = record.title; | |
| 33 | const url = record.url; | |
| 34 | const baseRefName = record.baseRefName; | |
| 35 | const headRefName = record.headRefName; | |
| 36 | const state = record.state; | |
| 37 | const mergedAt = record.mergedAt; | |
| 38 | const updatedAt = record.updatedAt; | |
| 39 | if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { | |
| 40 | continue; | |
| 41 | } | |
| 42 | if ( | |
| 43 | typeof title !== "string" || | |
| 44 | typeof url !== "string" || | |
| 45 | typeof baseRefName !== "string" || | |
| 46 | typeof headRefName !== "string" | |
| 47 | ) { | |
| 48 | continue; | |
| 49 | } | |
| 50 | ||
| 51 | let normalizedState: "open" | "closed" | "merged"; | |
| 52 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | |
| 53 | normalizedState = "merged"; | |
| 54 | } else if (state === "OPEN" || state === undefined || state === null) { | |
| 55 | normalizedState = "open"; | |
| 56 | } else if (state === "CLOSED") { | |
| 57 | normalizedState = "closed"; | |
| 58 | } else { | |
| 59 | continue; | |
| 60 | } | |
| 61 | ||
| 62 | parsed.push({ | |
| 63 | number, | |
| 64 | title, | |
| 65 | url, | |
| 66 | baseRefName, | |
| 67 | headRefName, | |
| 68 | state: normalizedState, | |
| 69 | updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, | |
| 70 | }); | |
| 71 | } | |
| 72 | return parsed; | |
| 73 | } | |
| 74 | ||
| 75 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | |
| 76 | return new GitManagerError({ | |
| 77 | operation, | |
| 78 | detail, | |
| 79 | ...(cause !== undefined ? { cause } : {}), | |
| 80 | }); | |
| 81 | } | |
| 82 | ||
| 83 | function limitContext(value: string, maxChars: number): string { | |
| 84 | if (value.length <= maxChars) return value; | |
| 85 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 86 | } | |
| 87 | ||
| 88 | function sanitizeCommitMessage(generated: { subject: string; body: string }): { | |
| 89 | subject: string; | |
| 90 | body: string; | |
| 91 | } { | |
| 92 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 93 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | |
| 94 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | |
| 95 | return { | |
| 96 | subject: safeSubject, | |
| 97 | body: generated.body.trim(), | |
| 98 | }; | |
| 99 | } | |
| 100 | ||
| 101 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { | |
| 102 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | |
| 103 | if (normalized.length === 0) { | |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | const [firstLine, ...rest] = normalized.split("\n"); | |
| 108 | const subject = firstLine?.trim() ?? ""; | |
| 109 | if (subject.length === 0) { | |
| 110 | return null; | |
| 111 | } | |
| 112 | ||
| 113 | return { | |
| 114 | subject, | |
| 115 | body: rest.join("\n").trim(), | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | function extractBranchFromRef(ref: string): string { | |
| 120 | const normalized = ref.trim(); | |
| 121 | ||
| 122 | if (normalized.startsWith("refs/remotes/")) { | |
| 123 | const withoutPrefix = normalized.slice("refs/remotes/".length); | |
| 124 | const firstSlash = withoutPrefix.indexOf("/"); | |
| 125 | if (firstSlash === -1) { | |
| 126 | return withoutPrefix.trim(); | |
| 127 | } | |
| 128 | return withoutPrefix.slice(firstSlash + 1).trim(); | |
| 129 | } | |
| 130 | ||
| 131 | const firstSlash = normalized.indexOf("/"); | |
| 132 | if (firstSlash === -1) { | |
| 133 | return normalized; | |
| 134 | } | |
| 135 | return normalized.slice(firstSlash + 1).trim(); | |
| 136 | } | |
| 137 | ||
| 138 | function toStatusPr(pr: PullRequestInfo): { | |
| 139 | number: number; | |
| 140 | title: string; | |
| 141 | url: string; | |
| 142 | baseBranch: string; | |
| 143 | headBranch: string; | |
| 144 | state: "open" | "closed" | "merged"; | |
| 145 | } { | |
| 146 | return { | |
| 147 | number: pr.number, | |
| 148 | title: pr.title, | |
| 149 | url: pr.url, | |
| 150 | baseBranch: pr.baseRefName, | |
| 151 | headBranch: pr.headRefName, | |
| 152 | state: pr.state, | |
| 153 | }; | |
| 154 | } | |
| 155 | ||
| 156 | export const makeGitManager = Effect.gen(function* () { | |
| 157 | const gitCore = yield* GitCore; | |
| 158 | const gitHubCli = yield* GitHubCli; | |
| 159 | const textGeneration = yield* TextGeneration; | |
| 160 | const fileSystem = yield* FileSystem.FileSystem; | |
| 161 | const path = yield* Path.Path; | |
| 162 | ||
| 163 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | |
| 164 | ||
| 165 | const findOpenPr = (cwd: string, branch: string) => | |
| 166 | gitHubCli | |
| 167 | .listOpenPullRequests({ | |
| 168 | cwd, | |
| 169 | headBranch: branch, | |
| 170 | limit: 1, | |
| 171 | }) | |
| 172 | .pipe( | |
| 173 | Effect.map((prs) => { | |
| 174 | const [first] = prs; | |
| 175 | if (!first) { | |
| 176 | return null; | |
| 177 | } | |
| 178 | return { | |
| 179 | number: first.number, | |
| 180 | title: first.title, | |
| 181 | url: first.url, | |
| 182 | baseRefName: first.baseRefName, | |
| 183 | headRefName: first.headRefName, | |
| 184 | state: "open", | |
| 185 | updatedAt: null, | |
| 186 | } satisfies PullRequestInfo; | |
| 187 | }), | |
| 188 | ); | |
| 189 | ||
| 190 | const findLatestPr = (cwd: string, branch: string) => | |
| 191 | Effect.gen(function* () { | |
| 192 | const stdout = yield* gitHubCli | |
| 193 | .execute({ | |
| 194 | cwd, | |
| 195 | args: [ | |
| 196 | "pr", | |
| 197 | "list", | |
| 198 | "--head", | |
| 199 | branch, | |
| 200 | "--state", | |
| 201 | "all", | |
| 202 | "--limit", | |
| 203 | "20", | |
| 204 | "--json", | |
| 205 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", | |
| 206 | ], | |
| 207 | }) | |
| 208 | .pipe(Effect.map((result) => result.stdout)); | |
| 209 | ||
| 210 | const raw = stdout.trim(); | |
| 211 | if (raw.length === 0) { | |
| 212 | return null; | |
| 213 | } | |
| 214 | ||
| 215 | const parsedJson = yield* Effect.try({ | |
| 216 | try: () => JSON.parse(raw) as unknown, | |
| 217 | catch: (cause) => | |
| 218 | gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), | |
| 219 | }); | |
| 220 | ||
| 221 | const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => { | |
| 222 | const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; | |
| 223 | const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; | |
| 224 | return right - left; | |
| 225 | }); | |
| 226 | return parsed[0] ?? null; | |
| 227 | }); | |
| 228 | ||
| 229 | const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => | |
| 230 | Effect.gen(function* () { | |
| 231 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | |
| 232 | if (configured) return configured; | |
| 233 | ||
| 234 | if (upstreamRef) { | |
| 235 | const upstreamBranch = extractBranchFromRef(upstreamRef); | |
| 236 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | |
| 237 | return upstreamBranch; | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | const defaultFromGh = yield* gitHubCli | |
| 242 | .getDefaultBranch({ cwd }) | |
| 243 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 244 | if (defaultFromGh) { | |
| 245 | return defaultFromGh; | |
| 246 | } | |
| 247 | ||
| 248 | return "main"; | |
| 249 | }); | |
| 250 | ||
| 251 | const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => | |
| 252 | Effect.gen(function* () { | |
| 253 | const context = yield* gitCore.prepareCommitContext(cwd); | |
| 254 | if (!context) { | |
| 255 | return { status: "skipped_no_changes" as const }; | |
| 256 | } | |
| 257 | ||
| 258 | let generated = parseCustomCommitMessage(commitMessage ?? ""); | |
| 259 | if (!generated) { | |
| 260 | generated = yield* textGeneration | |
| 261 | .generateCommitMessage({ | |
| 262 | cwd, | |
| 263 | branch, | |
| 264 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 265 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 266 | }) | |
| 267 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | |
| 268 | } | |
| 269 | ||
| 270 | const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body); | |
| 271 | return { | |
| 272 | status: "created" as const, | |
| 273 | commitSha, | |
| 274 | subject: generated.subject, | |
| 275 | }; | |
| 276 | }); | |
| 277 | ||
| 278 | const runPrStep = (cwd: string, fallbackBranch: string | null) => | |
| 279 | Effect.gen(function* () { | |
| 280 | const details = yield* gitCore.statusDetails(cwd); | |
| 281 | const branch = details.branch ?? fallbackBranch; | |
| 282 | if (!branch) { | |
| 283 | return yield* gitManagerError( | |
| 284 | "runPrStep", | |
| 285 | "Cannot create a pull request from detached HEAD.", | |
| 286 | ); | |
| 287 | } | |
| 288 | if (!details.hasUpstream) { | |
| 289 | return yield* gitManagerError( | |
| 290 | "runPrStep", | |
| 291 | "Current branch has not been pushed. Push before creating a PR.", | |
| 292 | ); | |
| 293 | } | |
| 294 | ||
| 295 | const existing = yield* findOpenPr(cwd, branch); | |
| 296 | if (existing) { | |
| 297 | return { | |
| 298 | status: "opened_existing" as const, | |
| 299 | url: existing.url, | |
| 300 | number: existing.number, | |
| 301 | baseBranch: existing.baseRefName, | |
| 302 | headBranch: existing.headRefName, | |
| 303 | title: existing.title, | |
| 304 | }; | |
| 305 | } | |
| 306 | ||
| 307 | const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); | |
| 308 | const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); | |
| 309 | ||
| 310 | const generated = yield* textGeneration.generatePrContent({ | |
| 311 | cwd, | |
| 312 | baseBranch, | |
| 313 | headBranch: branch, | |
| 314 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | |
| 315 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | |
| 316 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | |
| 317 | }); | |
| 318 | ||
| 319 | const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | |
| 320 | yield* fileSystem | |
| 321 | .writeFileString(bodyFile, generated.body) | |
| 322 | .pipe( | |
| 323 | Effect.mapError((cause) => | |
| 324 | gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), | |
| 325 | ), | |
| 326 | ); | |
| 327 | yield* gitHubCli | |
| 328 | .createPullRequest({ | |
| 329 | cwd, | |
| 330 | baseBranch, | |
| 331 | headBranch: branch, | |
| 332 | title: generated.title, | |
| 333 | bodyFile, | |
| 334 | }) | |
| 335 | .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); | |
| 336 | ||
| 337 | const created = yield* findOpenPr(cwd, branch); | |
| 338 | if (!created) { | |
| 339 | return { | |
| 340 | status: "created" as const, | |
| 341 | baseBranch, | |
| 342 | headBranch: branch, | |
| 343 | title: generated.title, | |
| 344 | }; | |
| 345 | } | |
| 346 | ||
| 347 | return { | |
| 348 | status: "created" as const, | |
| 349 | url: created.url, | |
| 350 | number: created.number, | |
| 351 | baseBranch: created.baseRefName, | |
| 352 | headBranch: created.headRefName, | |
| 353 | title: created.title, | |
| 354 | }; | |
| 355 | }); | |
| 356 | ||
| 357 | const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { | |
| 358 | const details = yield* gitCore.statusDetails(input.cwd); | |
| 359 | ||
| 360 | const pr = | |
| 361 | details.branch !== null | |
| 362 | ? yield* findLatestPr(input.cwd, details.branch).pipe( | |
| 363 | Effect.map((latest) => (latest ? toStatusPr(latest) : null)), | |
| 364 | Effect.catch(() => Effect.succeed(null)), | |
| 365 | ) | |
| 366 | : null; | |
| 367 | ||
| 368 | return { | |
| 369 | branch: details.branch, | |
| 370 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 371 | workingTree: details.workingTree, | |
| 372 | hasUpstream: details.hasUpstream, | |
| 373 | aheadCount: details.aheadCount, | |
| 374 | behindCount: details.behindCount, | |
| 375 | pr, | |
| 376 | }; | |
| 377 | }); | |
| 378 | ||
| 379 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( | |
| 380 | function* (input) { | |
| 381 | const wantsPush = input.action !== "commit"; | |
| 382 | const wantsPr = input.action === "commit_push_pr"; | |
| 383 | ||
| 384 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | |
| 385 | if (wantsPush && !initialStatus.branch) { | |
| 386 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | |
| 387 | } | |
| 388 | if (wantsPr && !initialStatus.branch) { | |
| 389 | return yield* gitManagerError( | |
| 390 | "runStackedAction", | |
| 391 | "Cannot create a pull request from detached HEAD.", | |
| 392 | ); | |
| 393 | } | |
| 394 | ||
| 395 | const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); | |
| 396 | ||
| 397 | const push = wantsPush | |
| 398 | ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 399 | : { status: "skipped_not_requested" as const }; | |
| 400 | ||
| 401 | const pr = wantsPr | |
| 402 | ? yield* runPrStep(input.cwd, initialStatus.branch) | |
| 403 | : { status: "skipped_not_requested" as const }; | |
| 404 | ||
| 405 | return { | |
| 406 | action: input.action, | |
| 407 | commit, | |
| 408 | push, | |
| 409 | pr, | |
| 410 | }; | |
| 411 | }, | |
| 412 | ); | |
| 413 | ||
| 414 | return { | |
| 415 | status, | |
| 416 | runStackedAction, | |
| 417 | } satisfies GitManagerShape; | |
| 418 | }); | |
| 419 | ||
| 420 | export const GitManagerLive = Layer.effect(GitManager, makeGitManager); |
apps/server/src/git/Layers/GitService.test.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, Layer, Schema } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "../Errors.ts"; | |
| 6 | import { GitServiceLive } from "./GitService.ts"; | |
| 7 | import { GitService } from "../Services/GitService.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | |
| 10 | ||
| 11 | layer("GitServiceLive", (it) => { | |
| 12 | it.effect("runGit executes successful git commands", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const gitService = yield* GitService; | |
| 15 | const result = yield* gitService.execute({ | |
| 16 | operation: "GitProcess.test.version", | |
| 17 | cwd: process.cwd(), | |
| 18 | args: ["--version"], | |
| 19 | }); | |
| 20 | ||
| 21 | assert.equal(result.code, 0); | |
| 22 | assert.ok(result.stdout.toLowerCase().includes("git version")); | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | it.effect("runGit can return non-zero exit codes when allowed", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const gitService = yield* GitService; | |
| 29 | const result = yield* gitService.execute({ | |
| 30 | operation: "GitProcess.test.allowNonZero", | |
| 31 | cwd: process.cwd(), | |
| 32 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 33 | allowNonZeroExit: true, | |
| 34 | }); | |
| 35 | ||
| 36 | assert.notEqual(result.code, 0); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const gitService = yield* GitService; | |
| 43 | const result = yield* Effect.result( | |
| 44 | gitService.execute({ | |
| 45 | operation: "GitProcess.test.failOnNonZero", | |
| 46 | cwd: process.cwd(), | |
| 47 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | assert.equal(result._tag, "Failure"); | |
| 52 | if (result._tag === "Failure") { | |
| 53 | assert.ok(Schema.is(GitCommandError)(result.failure)); | |
| 54 | assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); | |
| 55 | assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); | |
| 59 | }); |
apps/server/src/git/Layers/GitService.ts created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | /** | |
| 2 | * Git process helpers - Effect-native git execution with typed errors. | |
| 3 | * | |
| 4 | * Centralizes child-process git invocation for server modules. This module | |
| 5 | * only executes git commands and reports structured failures. | |
| 6 | * | |
| 7 | * @module GitServiceLive | |
| 8 | */ | |
| 9 | import { Effect, Layer, Option, Schema, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "../Errors.ts"; | |
| 12 | import { | |
| 13 | ExecuteGitInput, | |
| 14 | ExecuteGitResult, | |
| 15 | GitService, | |
| 16 | GitServiceShape, | |
| 17 | } from "../Services/GitService.ts"; | |
| 18 | ||
| 19 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 20 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 21 | ||
| 22 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 23 | return `git ${args.join(" ")}`; | |
| 24 | } | |
| 25 | ||
| 26 | function toGitCommandError( | |
| 27 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 28 | detail: string, | |
| 29 | ) { | |
| 30 | return (cause: unknown) => | |
| 31 | Schema.is(GitCommandError)(cause) | |
| 32 | ? cause | |
| 33 | : new GitCommandError({ | |
| 34 | operation: input.operation, | |
| 35 | command: quoteGitCommand(input.args), | |
| 36 | cwd: input.cwd, | |
| 37 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | |
| 38 | ...(cause !== undefined ? { cause } : {}), | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | const collectOutput = Effect.fn(function* <E>( | |
| 43 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 44 | stream: Stream.Stream<Uint8Array, E>, | |
| 45 | maxOutputBytes: number, | |
| 46 | ): Effect.fn.Return<string, GitCommandError> { | |
| 47 | const decoder = new TextDecoder(); | |
| 48 | let bytes = 0; | |
| 49 | let text = ""; | |
| 50 | ||
| 51 | yield* Stream.runForEach(stream, (chunk) => | |
| 52 | Effect.gen(function* () { | |
| 53 | bytes += chunk.byteLength; | |
| 54 | if (bytes > maxOutputBytes) { | |
| 55 | return yield* new GitCommandError({ | |
| 56 | operation: input.operation, | |
| 57 | command: quoteGitCommand(input.args), | |
| 58 | cwd: input.cwd, | |
| 59 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | |
| 60 | }); | |
| 61 | } | |
| 62 | text += decoder.decode(chunk, { stream: true }); | |
| 63 | }), | |
| 64 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | |
| 65 | ||
| 66 | text += decoder.decode(); | |
| 67 | return text; | |
| 68 | }); | |
| 69 | ||
| 70 | const makeGitService = Effect.gen(function* () { | |
| 71 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 72 | ||
| 73 | const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { | |
| 74 | const commandInput = { | |
| 75 | ...input, | |
| 76 | args: [...input.args], | |
| 77 | } as const; | |
| 78 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 79 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 80 | ||
| 81 | const commandEffect = Effect.gen(function* () { | |
| 82 | const child = yield* commandSpawner | |
| 83 | .spawn( | |
| 84 | ChildProcess.make("git", commandInput.args, { | |
| 85 | cwd: commandInput.cwd, | |
| 86 | ...(input.env ? { env: input.env } : {}), | |
| 87 | }), | |
| 88 | ) | |
| 89 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | |
| 90 | ||
| 91 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 92 | [ | |
| 93 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 94 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 95 | child.exitCode.pipe( | |
| 96 | Effect.map((value) => Number(value)), | |
| 97 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | |
| 98 | ), | |
| 99 | ], | |
| 100 | { concurrency: "unbounded" }, | |
| 101 | ); | |
| 102 | ||
| 103 | if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 104 | const trimmedStderr = stderr.trim(); | |
| 105 | return yield* new GitCommandError({ | |
| 106 | operation: commandInput.operation, | |
| 107 | command: quoteGitCommand(commandInput.args), | |
| 108 | cwd: commandInput.cwd, | |
| 109 | detail: | |
| 110 | trimmedStderr.length > 0 | |
| 111 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 112 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 113 | }); | |
| 114 | } | |
| 115 | ||
| 116 | return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; | |
| 117 | }); | |
| 118 | ||
| 119 | return yield* commandEffect.pipe( | |
| 120 | Effect.scoped, | |
| 121 | Effect.timeoutOption(timeoutMs), | |
| 122 | Effect.flatMap((result) => | |
| 123 | Option.match(result, { | |
| 124 | onNone: () => | |
| 125 | Effect.fail( | |
| 126 | new GitCommandError({ | |
| 127 | operation: commandInput.operation, | |
| 128 | command: quoteGitCommand(commandInput.args), | |
| 129 | cwd: commandInput.cwd, | |
| 130 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 131 | }), | |
| 132 | ), | |
| 133 | onSome: Effect.succeed, | |
| 134 | }), | |
| 135 | ), | |
| 136 | ); | |
| 137 | }); | |
| 138 | ||
| 139 | return { | |
| 140 | execute, | |
| 141 | } satisfies GitServiceShape; | |
| 142 | }); | |
| 143 | ||
| 144 | export const GitServiceLive = Layer.effect(GitService, makeGitService); |
apps/server/src/git/Services/GitCore.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | /** | |
| 2 | * GitCore - Effect service contract for low-level Git operations. | |
| 3 | * | |
| 4 | * Wraps core repository primitives used by higher-level orchestration | |
| 5 | * services and WebSocket routes. | |
| 6 | * | |
| 7 | * @module GitCore | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | import type { | |
| 12 | GitCheckoutInput, | |
| 13 | GitCreateBranchInput, | |
| 14 | GitCreateWorktreeInput, | |
| 15 | GitCreateWorktreeResult, | |
| 16 | GitInitInput, | |
| 17 | GitListBranchesInput, | |
| 18 | GitListBranchesResult, | |
| 19 | GitPullResult, | |
| 20 | GitRemoveWorktreeInput, | |
| 21 | GitStatusInput, | |
| 22 | GitStatusResult, | |
| 23 | } from "@t3tools/contracts"; | |
| 24 | ||
| 25 | import type { GitCommandError } from "../Errors.ts"; | |
| 26 | ||
| 27 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { | |
| 28 | upstreamRef: string | null; | |
| 29 | } | |
| 30 | ||
| 31 | export interface GitPreparedCommitContext { | |
| 32 | stagedSummary: string; | |
| 33 | stagedPatch: string; | |
| 34 | } | |
| 35 | ||
| 36 | export interface GitPushResult { | |
| 37 | status: "pushed" | "skipped_up_to_date"; | |
| 38 | branch: string; | |
| 39 | upstreamBranch?: string | undefined; | |
| 40 | setUpstream?: boolean | undefined; | |
| 41 | } | |
| 42 | ||
| 43 | export interface GitRangeContext { | |
| 44 | commitSummary: string; | |
| 45 | diffSummary: string; | |
| 46 | diffPatch: string; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * GitCoreShape - Service API for low-level Git repository interactions. | |
| 51 | */ | |
| 52 | export interface GitCoreShape { | |
| 53 | /** | |
| 54 | * Read Git status for a repository. | |
| 55 | */ | |
| 56 | readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitCommandError>; | |
| 57 | ||
| 58 | /** | |
| 59 | * Read detailed working tree / branch status for a repository. | |
| 60 | */ | |
| 61 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | |
| 62 | ||
| 63 | /** | |
| 64 | * Build staged change context for commit generation. | |
| 65 | */ | |
| 66 | readonly prepareCommitContext: ( | |
| 67 | cwd: string, | |
| 68 | ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Create a commit with provided subject/body. | |
| 72 | */ | |
| 73 | readonly commit: ( | |
| 74 | cwd: string, | |
| 75 | subject: string, | |
| 76 | body: string, | |
| 77 | ) => Effect.Effect<{ commitSha: string }, GitCommandError>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Push current branch, setting upstream if needed. | |
| 81 | */ | |
| 82 | readonly pushCurrentBranch: ( | |
| 83 | cwd: string, | |
| 84 | fallbackBranch: string | null, | |
| 85 | ) => Effect.Effect<GitPushResult, GitCommandError>; | |
| 86 | ||
| 87 | /** | |
| 88 | * Collect commit/diff context between base branch and current HEAD. | |
| 89 | */ | |
| 90 | readonly readRangeContext: ( | |
| 91 | cwd: string, | |
| 92 | baseBranch: string, | |
| 93 | ) => Effect.Effect<GitRangeContext, GitCommandError>; | |
| 94 | ||
| 95 | /** | |
| 96 | * Read a Git config value from the local repository. | |
| 97 | */ | |
| 98 | readonly readConfigValue: ( | |
| 99 | cwd: string, | |
| 100 | key: string, | |
| 101 | ) => Effect.Effect<string | null, GitCommandError>; | |
| 102 | ||
| 103 | /** | |
| 104 | * List local branches and branch metadata. | |
| 105 | */ | |
| 106 | readonly listBranches: ( | |
| 107 | input: GitListBranchesInput, | |
| 108 | ) => Effect.Effect<GitListBranchesResult, GitCommandError>; | |
| 109 | ||
| 110 | /** | |
| 111 | * Pull current branch from upstream using fast-forward only. | |
| 112 | */ | |
| 113 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>; | |
| 114 | ||
| 115 | /** | |
| 116 | * Create a worktree and branch from a base branch. | |
| 117 | */ | |
| 118 | readonly createWorktree: ( | |
| 119 | input: GitCreateWorktreeInput, | |
| 120 | ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>; | |
| 121 | ||
| 122 | /** | |
| 123 | * Remove an existing worktree. | |
| 124 | */ | |
| 125 | readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | |
| 126 | ||
| 127 | /** | |
| 128 | * Create a local branch. | |
| 129 | */ | |
| 130 | readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>; | |
| 131 | ||
| 132 | /** | |
| 133 | * Checkout an existing branch and refresh its upstream metadata in background. | |
| 134 | */ | |
| 135 | readonly checkoutBranch: (input: GitCheckoutInput) => Effect.Effect<void, GitCommandError>; | |
| 136 | ||
| 137 | /** | |
| 138 | * Initialize a repository in the provided directory. | |
| 139 | */ | |
| 140 | readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>; | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * GitCore - Service tag for low-level Git repository operations. | |
| 145 | */ | |
| 146 | export class GitCore extends ServiceMap.Service<GitCore, GitCoreShape>()( | |
| 147 | "t3/git/Services/GitCore", | |
| 148 | ) {} |
apps/server/src/git/Services/GitHubCli.ts created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | /** | |
| 2 | * GitHubCli - Effect service contract for `gh` process interactions. | |
| 3 | * | |
| 4 | * Provides thin command execution helpers used by Git workflow orchestration. | |
| 5 | * | |
| 6 | * @module GitHubCli | |
| 7 | */ | |
| 8 | import { ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProcessRunResult } from "../../processRunner"; | |
| 12 | import type { GitHubCliError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface GitHubPullRequestSummary { | |
| 15 | readonly number: number; | |
| 16 | readonly title: string; | |
| 17 | readonly url: string; | |
| 18 | readonly baseRefName: string; | |
| 19 | readonly headRefName: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * GitHubCliShape - Service API for executing GitHub CLI commands. | |
| 24 | */ | |
| 25 | export interface GitHubCliShape { | |
| 26 | /** | |
| 27 | * Execute a GitHub CLI command and return full process output. | |
| 28 | */ | |
| 29 | readonly execute: (input: { | |
| 30 | readonly cwd: string; | |
| 31 | readonly args: ReadonlyArray<string>; | |
| 32 | readonly timeoutMs?: number; | |
| 33 | }) => Effect.Effect<ProcessRunResult, GitHubCliError>; | |
| 34 | ||
| 35 | /** | |
| 36 | * List open pull requests for a head branch. | |
| 37 | */ | |
| 38 | readonly listOpenPullRequests: (input: { | |
| 39 | readonly cwd: string; | |
| 40 | readonly headBranch: string; | |
| 41 | readonly limit?: number; | |
| 42 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | |
| 43 | ||
| 44 | /** | |
| 45 | * Create a pull request from branch context and body file. | |
| 46 | */ | |
| 47 | readonly createPullRequest: (input: { | |
| 48 | readonly cwd: string; | |
| 49 | readonly baseBranch: string; | |
| 50 | readonly headBranch: string; | |
| 51 | readonly title: string; | |
| 52 | readonly bodyFile: string; | |
| 53 | }) => Effect.Effect<void, GitHubCliError>; | |
| 54 | ||
| 55 | /** | |
| 56 | * Resolve repository default branch through GitHub metadata. | |
| 57 | */ | |
| 58 | readonly getDefaultBranch: (input: { | |
| 59 | readonly cwd: string; | |
| 60 | }) => Effect.Effect<string | null, GitHubCliError>; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * GitHubCli - Service tag for GitHub CLI process execution. | |
| 65 | */ | |
| 66 | export class GitHubCli extends ServiceMap.Service<GitHubCli, GitHubCliShape>()( | |
| 67 | "t3/git/Services/GitHubCli", | |
| 68 | ) {} |
apps/server/src/git/Services/GitManager.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | /** | |
| 2 | * GitManager - Effect service contract for stacked Git workflows. | |
| 3 | * | |
| 4 | * Orchestrates status inspection and commit/push/PR flows by composing | |
| 5 | * lower-level Git and external tool services. | |
| 6 | * | |
| 7 | * @module GitManager | |
| 8 | */ | |
| 9 | import { | |
| 10 | GitRunStackedActionInput, | |
| 11 | GitRunStackedActionResult, | |
| 12 | GitStatusInput, | |
| 13 | GitStatusResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { ServiceMap } from "effect"; | |
| 16 | import type { Effect } from "effect"; | |
| 17 | import type { GitManagerServiceError } from "../Errors.ts"; | |
| 18 | ||
| 19 | /** | |
| 20 | * GitManagerShape - Service API for high-level Git workflow actions. | |
| 21 | */ | |
| 22 | export interface GitManagerShape { | |
| 23 | /** | |
| 24 | * Read current repository Git status plus open PR metadata when available. | |
| 25 | */ | |
| 26 | readonly status: ( | |
| 27 | input: GitStatusInput, | |
| 28 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | |
| 29 | ||
| 30 | /** | |
| 31 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). | |
| 32 | */ | |
| 33 | readonly runStackedAction: ( | |
| 34 | input: GitRunStackedActionInput, | |
| 35 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * GitManager - Service tag for stacked Git workflow orchestration. | |
| 40 | */ | |
| 41 | export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( | |
| 42 | "t3/git/Services/GitManager", | |
| 43 | ) {} |
apps/server/src/git/Services/GitService.ts created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | /** | |
| 2 | * GitService - Service for Git command execution. | |
| 3 | * | |
| 4 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 5 | * domain errors for Git command execution. | |
| 6 | * | |
| 7 | * @module GitService | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { GitCommandError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface ExecuteGitInput { | |
| 15 | readonly operation: string; | |
| 16 | readonly cwd: string; | |
| 17 | readonly args: ReadonlyArray<string>; | |
| 18 | readonly env?: NodeJS.ProcessEnv; | |
| 19 | readonly allowNonZeroExit?: boolean; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | readonly maxOutputBytes?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ExecuteGitResult { | |
| 25 | readonly code: number; | |
| 26 | readonly stdout: string; | |
| 27 | readonly stderr: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * GitServiceShape - Service API for Git command execution. | |
| 32 | */ | |
| 33 | export interface GitServiceShape { | |
| 34 | /** | |
| 35 | * Execute a Git command. | |
| 36 | */ | |
| 37 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * GitService - Service for Git command execution. | |
| 42 | */ | |
| 43 | export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()( | |
| 44 | "t3/git/Services/GitService", | |
| 45 | ) {} |
apps/server/src/git/Services/TextGeneration.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | /** | |
| 2 | * TextGeneration - Effect service contract for AI-generated Git content. | |
| 3 | * | |
| 4 | * Generates commit messages and pull request titles/bodies from repository | |
| 5 | * context prepared by Git services. | |
| 6 | * | |
| 7 | * @module TextGeneration | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { TextGenerationError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface CommitMessageGenerationInput { | |
| 15 | cwd: string; | |
| 16 | branch: string | null; | |
| 17 | stagedSummary: string; | |
| 18 | stagedPatch: string; | |
| 19 | } | |
| 20 | ||
| 21 | export interface CommitMessageGenerationResult { | |
| 22 | subject: string; | |
| 23 | body: string; | |
| 24 | } | |
| 25 | ||
| 26 | export interface PrContentGenerationInput { | |
| 27 | cwd: string; | |
| 28 | baseBranch: string; | |
| 29 | headBranch: string; | |
| 30 | commitSummary: string; | |
| 31 | diffSummary: string; | |
| 32 | diffPatch: string; | |
| 33 | } | |
| 34 | ||
| 35 | export interface PrContentGenerationResult { | |
| 36 | title: string; | |
| 37 | body: string; | |
| 38 | } | |
| 39 | ||
| 40 | export interface TextGenerationService { | |
| 41 | generateCommitMessage( | |
| 42 | input: CommitMessageGenerationInput, | |
| 43 | ): Promise<CommitMessageGenerationResult>; | |
| 44 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * TextGenerationShape - Service API for commit/PR text generation. | |
| 49 | */ | |
| 50 | export interface TextGenerationShape { | |
| 51 | /** | |
| 52 | * Generate a commit message from staged change context. | |
| 53 | */ | |
| 54 | readonly generateCommitMessage: ( | |
| 55 | input: CommitMessageGenerationInput, | |
| 56 | ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>; | |
| 57 | ||
| 58 | /** | |
| 59 | * Generate pull request title/body from branch and diff context. | |
| 60 | */ | |
| 61 | readonly generatePrContent: ( | |
| 62 | input: PrContentGenerationInput, | |
| 63 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * TextGeneration - Service tag for commit and PR text generation. | |
| 68 | */ | |
| 69 | export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()( | |
| 70 | "t3/git/Services/TextGeneration", | |
| 71 | ) {} |
apps/server/src/gitManager.test.ts deleted-547| ... | ... | @@ -1,547 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { GitManager } from "./gitManager"; | |
| 8 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 9 | ||
| 10 | interface FakeGhScenario { | |
| 11 | prListSequence?: string[]; | |
| 12 | createdPrUrl?: string; | |
| 13 | defaultBranch?: string; | |
| 14 | failWith?: Error; | |
| 15 | } | |
| 16 | ||
| 17 | interface FakeGitTextGenerator { | |
| 18 | generateCommitMessage: (input: { | |
| 19 | cwd: string; | |
| 20 | branch: string | null; | |
| 21 | stagedSummary: string; | |
| 22 | stagedPatch: string; | |
| 23 | }) => Promise<{ subject: string; body: string }>; | |
| 24 | generatePrContent: (input: { | |
| 25 | cwd: string; | |
| 26 | baseBranch: string; | |
| 27 | headBranch: string; | |
| 28 | commitSummary: string; | |
| 29 | diffSummary: string; | |
| 30 | diffPatch: string; | |
| 31 | }) => Promise<{ title: string; body: string }>; | |
| 32 | } | |
| 33 | ||
| 34 | const tempDirs: string[] = []; | |
| 35 | ||
| 36 | function makeTempDir(prefix: string): string { | |
| 37 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 38 | tempDirs.push(dir); | |
| 39 | return dir; | |
| 40 | } | |
| 41 | ||
| 42 | async function runGit( | |
| 43 | cwd: string, | |
| 44 | args: readonly string[], | |
| 45 | allowNonZeroExit = false, | |
| 46 | ): Promise<ProcessRunResult> { | |
| 47 | return runProcess("git", args, { cwd, allowNonZeroExit }); | |
| 48 | } | |
| 49 | ||
| 50 | async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 51 | const result = await runGit(cwd, args); | |
| 52 | return result.stdout.trim(); | |
| 53 | } | |
| 54 | ||
| 55 | async function initRepo(cwd: string): Promise<void> { | |
| 56 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 57 | await runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 58 | await runGit(cwd, ["config", "user.name", "Test User"]); | |
| 59 | fs.writeFileSync(path.join(cwd, "README.md"), "hello\n"); | |
| 60 | await runGit(cwd, ["add", "README.md"]); | |
| 61 | await runGit(cwd, ["commit", "-m", "Initial commit"]); | |
| 62 | } | |
| 63 | ||
| 64 | async function createBareRemote(): Promise<string> { | |
| 65 | const remoteDir = makeTempDir("t3code-git-remote-"); | |
| 66 | await runProcess("git", ["init", "--bare"], { cwd: remoteDir }); | |
| 67 | return remoteDir; | |
| 68 | } | |
| 69 | ||
| 70 | function createTextGenerator(overrides: Partial<FakeGitTextGenerator> = {}): FakeGitTextGenerator { | |
| 71 | return { | |
| 72 | generateCommitMessage: async () => ({ | |
| 73 | subject: "Implement stacked git actions", | |
| 74 | body: "", | |
| 75 | }), | |
| 76 | generatePrContent: async () => ({ | |
| 77 | title: "Add stacked git actions", | |
| 78 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | |
| 79 | }), | |
| 80 | ...overrides, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | function createRunnerWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 85 | runner: ( | |
| 86 | command: string, | |
| 87 | args: readonly string[], | |
| 88 | options?: ProcessRunOptions, | |
| 89 | ) => Promise<ProcessRunResult>; | |
| 90 | ghCalls: string[]; | |
| 91 | } { | |
| 92 | const prListQueue = [...(scenario.prListSequence ?? [])]; | |
| 93 | const ghCalls: string[] = []; | |
| 94 | ||
| 95 | const runner = async ( | |
| 96 | command: string, | |
| 97 | args: readonly string[], | |
| 98 | options?: ProcessRunOptions, | |
| 99 | ): Promise<ProcessRunResult> => { | |
| 100 | if (command !== "gh") { | |
| 101 | return runProcess(command, args, options); | |
| 102 | } | |
| 103 | ||
| 104 | ghCalls.push(args.join(" ")); | |
| 105 | if (scenario.failWith) { | |
| 106 | throw scenario.failWith; | |
| 107 | } | |
| 108 | ||
| 109 | if (args[0] === "pr" && args[1] === "list") { | |
| 110 | const stdout = (prListQueue.shift() ?? "[]") + "\n"; | |
| 111 | return { | |
| 112 | stdout, | |
| 113 | stderr: "", | |
| 114 | code: 0, | |
| 115 | signal: null, | |
| 116 | timedOut: false, | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | if (args[0] === "pr" && args[1] === "create") { | |
| 121 | return { | |
| 122 | stdout: | |
| 123 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | |
| 124 | stderr: "", | |
| 125 | code: 0, | |
| 126 | signal: null, | |
| 127 | timedOut: false, | |
| 128 | }; | |
| 129 | } | |
| 130 | ||
| 131 | if (args[0] === "pr" && args[1] === "view") { | |
| 132 | return { | |
| 133 | stdout: "", | |
| 134 | stderr: "", | |
| 135 | code: 0, | |
| 136 | signal: null, | |
| 137 | timedOut: false, | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | if (args[0] === "repo" && args[1] === "view") { | |
| 142 | return { | |
| 143 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | |
| 144 | stderr: "", | |
| 145 | code: 0, | |
| 146 | signal: null, | |
| 147 | timedOut: false, | |
| 148 | }; | |
| 149 | } | |
| 150 | ||
| 151 | throw new Error(`Unexpected gh command: ${args.join(" ")}`); | |
| 152 | }; | |
| 153 | ||
| 154 | return { runner, ghCalls }; | |
| 155 | } | |
| 156 | ||
| 157 | afterEach(() => { | |
| 158 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 159 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 163 | describe("GitManager", () => { | |
| 164 | it("status includes open PR metadata when branch already has an open PR", async () => { | |
| 165 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 166 | await initRepo(repoDir); | |
| 167 | await runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | |
| 168 | const remoteDir = await createBareRemote(); | |
| 169 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 170 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | |
| 171 | ||
| 172 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 173 | prListSequence: [ | |
| 174 | JSON.stringify([ | |
| 175 | { | |
| 176 | number: 13, | |
| 177 | title: "Existing PR", | |
| 178 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 179 | baseRefName: "main", | |
| 180 | headRefName: "feature/status-open-pr", | |
| 181 | }, | |
| 182 | ]), | |
| 183 | ], | |
| 184 | }); | |
| 185 | ||
| 186 | const manager = new GitManager({ | |
| 187 | runProcess: runner, | |
| 188 | textGenerator: createTextGenerator(), | |
| 189 | }); | |
| 190 | ||
| 191 | const status = await manager.status({ cwd: repoDir }); | |
| 192 | expect(status.branch).toBe("feature/status-open-pr"); | |
| 193 | expect(status.pr).toEqual({ | |
| 194 | number: 13, | |
| 195 | title: "Existing PR", | |
| 196 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | |
| 197 | baseBranch: "main", | |
| 198 | headBranch: "feature/status-open-pr", | |
| 199 | state: "open", | |
| 200 | }); | |
| 201 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 202 | }); | |
| 203 | ||
| 204 | it("status includes merged PR metadata when there is no open PR", async () => { | |
| 205 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 206 | await initRepo(repoDir); | |
| 207 | await runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); | |
| 208 | const remoteDir = await createBareRemote(); | |
| 209 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 210 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-merged-pr"]); | |
| 211 | ||
| 212 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 213 | prListSequence: [ | |
| 214 | JSON.stringify([ | |
| 215 | { | |
| 216 | number: 26, | |
| 217 | title: "Merged PR", | |
| 218 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | |
| 219 | baseRefName: "main", | |
| 220 | headRefName: "feature/status-merged-pr", | |
| 221 | state: "CLOSED", | |
| 222 | mergedAt: "2026-02-14T10:00:00Z", | |
| 223 | updatedAt: "2026-02-14T10:00:00Z", | |
| 224 | }, | |
| 225 | ]), | |
| 226 | ], | |
| 227 | }); | |
| 228 | ||
| 229 | const manager = new GitManager({ | |
| 230 | runProcess: runner, | |
| 231 | textGenerator: createTextGenerator(), | |
| 232 | }); | |
| 233 | ||
| 234 | const status = await manager.status({ cwd: repoDir }); | |
| 235 | expect(status.branch).toBe("feature/status-merged-pr"); | |
| 236 | expect(status.pr).toEqual({ | |
| 237 | number: 26, | |
| 238 | title: "Merged PR", | |
| 239 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | |
| 240 | baseBranch: "main", | |
| 241 | headBranch: "feature/status-merged-pr", | |
| 242 | state: "merged", | |
| 243 | }); | |
| 244 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 245 | }); | |
| 246 | ||
| 247 | it("status checks all PR states for branches without upstream", async () => { | |
| 248 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 249 | await initRepo(repoDir); | |
| 250 | await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); | |
| 251 | ||
| 252 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 253 | prListSequence: ["[]"], | |
| 254 | }); | |
| 255 | ||
| 256 | const manager = new GitManager({ | |
| 257 | runProcess: runner, | |
| 258 | textGenerator: createTextGenerator(), | |
| 259 | }); | |
| 260 | ||
| 261 | const status = await manager.status({ cwd: repoDir }); | |
| 262 | expect(status.branch).toBe("feature/local-only"); | |
| 263 | expect(status.pr).toBeNull(); | |
| 264 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); | |
| 265 | }); | |
| 266 | ||
| 267 | it("status is resilient to gh lookup failures and returns pr null", async () => { | |
| 268 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 269 | await initRepo(repoDir); | |
| 270 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | |
| 271 | const remoteDir = await createBareRemote(); | |
| 272 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 273 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | |
| 274 | ||
| 275 | const { runner } = createRunnerWithFakeGh({ | |
| 276 | failWith: new Error("Command not found: gh"), | |
| 277 | }); | |
| 278 | const manager = new GitManager({ | |
| 279 | runProcess: runner, | |
| 280 | textGenerator: createTextGenerator(), | |
| 281 | }); | |
| 282 | ||
| 283 | const status = await manager.status({ cwd: repoDir }); | |
| 284 | expect(status.branch).toBe("feature/status-no-gh"); | |
| 285 | expect(status.pr).toBeNull(); | |
| 286 | }); | |
| 287 | ||
| 288 | it("creates a commit when working tree is dirty", async () => { | |
| 289 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 290 | await initRepo(repoDir); | |
| 291 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | |
| 292 | ||
| 293 | const manager = new GitManager({ | |
| 294 | textGenerator: createTextGenerator(), | |
| 295 | }); | |
| 296 | const result = await manager.runStackedAction({ | |
| 297 | cwd: repoDir, | |
| 298 | action: "commit", | |
| 299 | }); | |
| 300 | ||
| 301 | expect(result.commit.status).toBe("created"); | |
| 302 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 303 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 304 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( | |
| 305 | "Implement stacked git actions", | |
| 306 | ); | |
| 307 | }); | |
| 308 | ||
| 309 | it("uses custom commit message when provided", async () => { | |
| 310 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 311 | await initRepo(repoDir); | |
| 312 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | |
| 313 | let generatedCount = 0; | |
| 314 | ||
| 315 | const manager = new GitManager({ | |
| 316 | textGenerator: createTextGenerator({ | |
| 317 | generateCommitMessage: async () => { | |
| 318 | generatedCount += 1; | |
| 319 | return { | |
| 320 | subject: "this should not be used", | |
| 321 | body: "", | |
| 322 | }; | |
| 323 | }, | |
| 324 | }), | |
| 325 | }); | |
| 326 | const result = await manager.runStackedAction({ | |
| 327 | cwd: repoDir, | |
| 328 | action: "commit", | |
| 329 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 330 | }); | |
| 331 | ||
| 332 | expect(result.commit.status).toBe("created"); | |
| 333 | expect(result.commit.subject).toBe("feat: custom summary line"); | |
| 334 | expect(generatedCount).toBe(0); | |
| 335 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( | |
| 336 | "feat: custom summary line", | |
| 337 | ); | |
| 338 | expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain( | |
| 339 | "- details from user", | |
| 340 | ); | |
| 341 | }); | |
| 342 | ||
| 343 | it("skips commit when there are no uncommitted changes", async () => { | |
| 344 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 345 | await initRepo(repoDir); | |
| 346 | ||
| 347 | const manager = new GitManager({ | |
| 348 | textGenerator: createTextGenerator(), | |
| 349 | }); | |
| 350 | const result = await manager.runStackedAction({ | |
| 351 | cwd: repoDir, | |
| 352 | action: "commit", | |
| 353 | }); | |
| 354 | ||
| 355 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 356 | expect(result.push.status).toBe("skipped_not_requested"); | |
| 357 | expect(result.pr.status).toBe("skipped_not_requested"); | |
| 358 | }); | |
| 359 | ||
| 360 | it("commits and pushes with upstream auto-setup when needed", async () => { | |
| 361 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 362 | await initRepo(repoDir); | |
| 363 | await runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | |
| 364 | const remoteDir = await createBareRemote(); | |
| 365 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 366 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | |
| 367 | ||
| 368 | const manager = new GitManager({ | |
| 369 | textGenerator: createTextGenerator(), | |
| 370 | }); | |
| 371 | const result = await manager.runStackedAction({ | |
| 372 | cwd: repoDir, | |
| 373 | action: "commit_push", | |
| 374 | }); | |
| 375 | ||
| 376 | expect(result.commit.status).toBe("created"); | |
| 377 | expect(result.push.status).toBe("pushed"); | |
| 378 | expect(result.push.setUpstream).toBe(true); | |
| 379 | expect(await runGitStdout(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | |
| 380 | "origin/feature/stacked-flow", | |
| 381 | ); | |
| 382 | }); | |
| 383 | ||
| 384 | it("skips push when branch is already up to date", async () => { | |
| 385 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 386 | await initRepo(repoDir); | |
| 387 | await runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | |
| 388 | const remoteDir = await createBareRemote(); | |
| 389 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 390 | await runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | |
| 391 | ||
| 392 | const manager = new GitManager({ | |
| 393 | textGenerator: createTextGenerator(), | |
| 394 | }); | |
| 395 | const result = await manager.runStackedAction({ | |
| 396 | cwd: repoDir, | |
| 397 | action: "commit_push", | |
| 398 | }); | |
| 399 | ||
| 400 | expect(result.commit.status).toBe("skipped_no_changes"); | |
| 401 | expect(result.push.status).toBe("skipped_up_to_date"); | |
| 402 | }); | |
| 403 | ||
| 404 | it("returns existing PR metadata for commit/push/pr action", async () => { | |
| 405 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 406 | await initRepo(repoDir); | |
| 407 | await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | |
| 408 | const remoteDir = await createBareRemote(); | |
| 409 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 410 | await runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | |
| 411 | ||
| 412 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 413 | prListSequence: [ | |
| 414 | JSON.stringify([ | |
| 415 | { | |
| 416 | number: 42, | |
| 417 | title: "Existing PR", | |
| 418 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 419 | baseRefName: "main", | |
| 420 | headRefName: "feature/existing-pr", | |
| 421 | }, | |
| 422 | ]), | |
| 423 | ], | |
| 424 | }); | |
| 425 | ||
| 426 | const manager = new GitManager({ | |
| 427 | runProcess: runner, | |
| 428 | textGenerator: createTextGenerator(), | |
| 429 | }); | |
| 430 | const result = await manager.runStackedAction({ | |
| 431 | cwd: repoDir, | |
| 432 | action: "commit_push_pr", | |
| 433 | }); | |
| 434 | ||
| 435 | expect(result.pr.status).toBe("opened_existing"); | |
| 436 | expect(result.pr.number).toBe(42); | |
| 437 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 438 | }); | |
| 439 | ||
| 440 | it("creates PR when one does not already exist", async () => { | |
| 441 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 442 | await initRepo(repoDir); | |
| 443 | await runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | |
| 444 | const remoteDir = await createBareRemote(); | |
| 445 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 446 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | |
| 447 | await runGit(repoDir, ["add", "changes.txt"]); | |
| 448 | await runGit(repoDir, ["commit", "-m", "Feature commit"]); | |
| 449 | await runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | |
| 450 | await runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | |
| 451 | ||
| 452 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 453 | prListSequence: [ | |
| 454 | "[]", | |
| 455 | JSON.stringify([ | |
| 456 | { | |
| 457 | number: 88, | |
| 458 | title: "Add stacked git actions", | |
| 459 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | |
| 460 | baseRefName: "main", | |
| 461 | headRefName: "feature-create-pr", | |
| 462 | }, | |
| 463 | ]), | |
| 464 | ], | |
| 465 | }); | |
| 466 | ||
| 467 | const manager = new GitManager({ | |
| 468 | runProcess: runner, | |
| 469 | textGenerator: createTextGenerator(), | |
| 470 | }); | |
| 471 | const result = await manager.runStackedAction({ | |
| 472 | cwd: repoDir, | |
| 473 | action: "commit_push_pr", | |
| 474 | }); | |
| 475 | ||
| 476 | expect(result.pr.status).toBe("created"); | |
| 477 | expect(result.pr.number).toBe(88); | |
| 478 | expect( | |
| 479 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | |
| 480 | ).toBe(true); | |
| 481 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | |
| 482 | }); | |
| 483 | ||
| 484 | it("rejects push/pr actions from detached HEAD", async () => { | |
| 485 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 486 | await initRepo(repoDir); | |
| 487 | await runGit(repoDir, ["checkout", "--detach", "HEAD"]); | |
| 488 | ||
| 489 | const manager = new GitManager({ | |
| 490 | textGenerator: createTextGenerator(), | |
| 491 | }); | |
| 492 | await expect( | |
| 493 | manager.runStackedAction({ | |
| 494 | cwd: repoDir, | |
| 495 | action: "commit_push", | |
| 496 | }), | |
| 497 | ).rejects.toThrow("detached HEAD"); | |
| 498 | }); | |
| 499 | ||
| 500 | it("surfaces missing gh binary errors", async () => { | |
| 501 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 502 | await initRepo(repoDir); | |
| 503 | await runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | |
| 504 | const remoteDir = await createBareRemote(); | |
| 505 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 506 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | |
| 507 | ||
| 508 | const { runner } = createRunnerWithFakeGh({ | |
| 509 | failWith: new Error("Command not found: gh"), | |
| 510 | }); | |
| 511 | const manager = new GitManager({ | |
| 512 | runProcess: runner, | |
| 513 | textGenerator: createTextGenerator(), | |
| 514 | }); | |
| 515 | ||
| 516 | await expect( | |
| 517 | manager.runStackedAction({ | |
| 518 | cwd: repoDir, | |
| 519 | action: "commit_push_pr", | |
| 520 | }), | |
| 521 | ).rejects.toThrow("GitHub CLI (`gh`) is required"); | |
| 522 | }); | |
| 523 | ||
| 524 | it("surfaces gh auth errors with guidance", async () => { | |
| 525 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 526 | await initRepo(repoDir); | |
| 527 | await runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | |
| 528 | const remoteDir = await createBareRemote(); | |
| 529 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 530 | await runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | |
| 531 | ||
| 532 | const { runner } = createRunnerWithFakeGh({ | |
| 533 | failWith: new Error("authentication failed"), | |
| 534 | }); | |
| 535 | const manager = new GitManager({ | |
| 536 | runProcess: runner, | |
| 537 | textGenerator: createTextGenerator(), | |
| 538 | }); | |
| 539 | ||
| 540 | await expect( | |
| 541 | manager.runStackedAction({ | |
| 542 | cwd: repoDir, | |
| 543 | action: "commit_push_pr", | |
| 544 | }), | |
| 545 | ).rejects.toThrow("gh auth login"); | |
| 546 | }); | |
| 547 | }); |
apps/server/src/gitManager.ts deleted-524| ... | ... | @@ -1,524 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { | |
| 7 | gitRunStackedActionInputSchema, | |
| 8 | gitRunStackedActionResultSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | type GitStatusPrState, | |
| 11 | type GitRunStackedActionInput, | |
| 12 | type GitRunStackedActionResult, | |
| 13 | type GitStatusInput, | |
| 14 | type GitStatusResult, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | ||
| 17 | import type { | |
| 18 | CommitMessageGenerationResult, | |
| 19 | PrContentGenerationResult, | |
| 20 | TextGenerationService, | |
| 21 | } from "./coreServices"; | |
| 22 | import { CodexTextGenerator } from "./codexTextGenerator"; | |
| 23 | import { GitCoreService } from "./git"; | |
| 24 | import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; | |
| 25 | ||
| 26 | const GH_CLI_TIMEOUT_MS = 30_000; | |
| 27 | ||
| 28 | interface GitManagerDeps { | |
| 29 | runProcess?: ( | |
| 30 | command: string, | |
| 31 | args: readonly string[], | |
| 32 | options?: ProcessRunOptions, | |
| 33 | ) => Promise<ProcessRunResult>; | |
| 34 | textGenerator?: TextGenerationService; | |
| 35 | gitCore?: GitCoreService; | |
| 36 | } | |
| 37 | ||
| 38 | interface PullRequestInfo { | |
| 39 | number: number; | |
| 40 | title: string; | |
| 41 | url: string; | |
| 42 | baseRefName: string; | |
| 43 | headRefName: string; | |
| 44 | state: GitStatusPrState; | |
| 45 | updatedAt: string | null; | |
| 46 | } | |
| 47 | ||
| 48 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { | |
| 49 | if (!Array.isArray(raw)) return []; | |
| 50 | ||
| 51 | const parsed: PullRequestInfo[] = []; | |
| 52 | for (const entry of raw) { | |
| 53 | if (!entry || typeof entry !== "object") continue; | |
| 54 | const record = entry as Record<string, unknown>; | |
| 55 | const number = record.number; | |
| 56 | const title = record.title; | |
| 57 | const url = record.url; | |
| 58 | const baseRefName = record.baseRefName; | |
| 59 | const headRefName = record.headRefName; | |
| 60 | const state = record.state; | |
| 61 | const mergedAt = record.mergedAt; | |
| 62 | const updatedAt = record.updatedAt; | |
| 63 | if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { | |
| 64 | continue; | |
| 65 | } | |
| 66 | if ( | |
| 67 | typeof title !== "string" || | |
| 68 | typeof url !== "string" || | |
| 69 | typeof baseRefName !== "string" || | |
| 70 | typeof headRefName !== "string" | |
| 71 | ) { | |
| 72 | continue; | |
| 73 | } | |
| 74 | ||
| 75 | let normalizedState: GitStatusPrState; | |
| 76 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | |
| 77 | normalizedState = "merged"; | |
| 78 | } else if (state === "OPEN" || state === undefined || state === null) { | |
| 79 | normalizedState = "open"; | |
| 80 | } else if (state === "CLOSED") { | |
| 81 | normalizedState = "closed"; | |
| 82 | } else { | |
| 83 | continue; | |
| 84 | } | |
| 85 | ||
| 86 | const normalizedUpdatedAt = | |
| 87 | typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null; | |
| 88 | ||
| 89 | parsed.push({ | |
| 90 | number, | |
| 91 | title, | |
| 92 | url, | |
| 93 | baseRefName, | |
| 94 | headRefName, | |
| 95 | state: normalizedState, | |
| 96 | updatedAt: normalizedUpdatedAt, | |
| 97 | }); | |
| 98 | } | |
| 99 | return parsed; | |
| 100 | } | |
| 101 | ||
| 102 | function trimStdout(value: string): string { | |
| 103 | return value.trim(); | |
| 104 | } | |
| 105 | ||
| 106 | function limitContext(value: string, maxChars: number): string { | |
| 107 | if (value.length <= maxChars) return value; | |
| 108 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | |
| 109 | } | |
| 110 | ||
| 111 | function sanitizeCommitMessage( | |
| 112 | generated: CommitMessageGenerationResult, | |
| 113 | ): CommitMessageGenerationResult { | |
| 114 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | |
| 115 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | |
| 116 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | |
| 117 | return { | |
| 118 | subject: safeSubject, | |
| 119 | body: generated.body.trim(), | |
| 120 | }; | |
| 121 | } | |
| 122 | ||
| 123 | function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null { | |
| 124 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | |
| 125 | if (normalized.length === 0) { | |
| 126 | return null; | |
| 127 | } | |
| 128 | ||
| 129 | const [firstLine, ...rest] = normalized.split("\n"); | |
| 130 | const subject = firstLine?.trim() ?? ""; | |
| 131 | if (subject.length === 0) { | |
| 132 | return null; | |
| 133 | } | |
| 134 | ||
| 135 | return { | |
| 136 | subject, | |
| 137 | body: rest.join("\n").trim(), | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | function extractBranchFromRef(ref: string): string { | |
| 142 | const normalized = ref.trim(); | |
| 143 | ||
| 144 | if (normalized.startsWith("refs/remotes/")) { | |
| 145 | const withoutPrefix = normalized.slice("refs/remotes/".length); | |
| 146 | const firstSlash = withoutPrefix.indexOf("/"); | |
| 147 | if (firstSlash === -1) { | |
| 148 | return withoutPrefix.trim(); | |
| 149 | } | |
| 150 | return withoutPrefix.slice(firstSlash + 1).trim(); | |
| 151 | } | |
| 152 | ||
| 153 | const firstSlash = normalized.indexOf("/"); | |
| 154 | if (firstSlash === -1) { | |
| 155 | return normalized; | |
| 156 | } | |
| 157 | return normalized.slice(firstSlash + 1).trim(); | |
| 158 | } | |
| 159 | ||
| 160 | function asCommandNotFound(command: string, error: unknown): Error | undefined { | |
| 161 | if (!(error instanceof Error)) return undefined; | |
| 162 | if (!error.message.includes(`Command not found: ${command}`)) return undefined; | |
| 163 | if (command === "gh") { | |
| 164 | return new Error("GitHub CLI (`gh`) is required but not available on PATH."); | |
| 165 | } | |
| 166 | if (command === "codex") { | |
| 167 | return new Error("Codex CLI (`codex`) is required but not available on PATH."); | |
| 168 | } | |
| 169 | return new Error(`${command} is required but not available on PATH.`); | |
| 170 | } | |
| 171 | ||
| 172 | function normalizeGitHubAuthError(error: unknown): Error | undefined { | |
| 173 | if (!(error instanceof Error)) return undefined; | |
| 174 | const lower = error.message.toLowerCase(); | |
| 175 | if ( | |
| 176 | lower.includes("authentication failed") || | |
| 177 | lower.includes("not logged in") || | |
| 178 | lower.includes("gh auth login") || | |
| 179 | lower.includes("no oauth token") | |
| 180 | ) { | |
| 181 | return new Error("GitHub CLI is not authenticated. Run `gh auth login` and retry."); | |
| 182 | } | |
| 183 | return undefined; | |
| 184 | } | |
| 185 | ||
| 186 | function toStatusPr(pr: PullRequestInfo): { | |
| 187 | number: number; | |
| 188 | title: string; | |
| 189 | url: string; | |
| 190 | baseBranch: string; | |
| 191 | headBranch: string; | |
| 192 | state: GitStatusPrState; | |
| 193 | } { | |
| 194 | return { | |
| 195 | number: pr.number, | |
| 196 | title: pr.title, | |
| 197 | url: pr.url, | |
| 198 | baseBranch: pr.baseRefName, | |
| 199 | headBranch: pr.headRefName, | |
| 200 | state: pr.state, | |
| 201 | }; | |
| 202 | } | |
| 203 | ||
| 204 | export class GitManager { | |
| 205 | private readonly run: NonNullable<GitManagerDeps["runProcess"]>; | |
| 206 | private readonly textGenerator: TextGenerationService; | |
| 207 | private readonly gitCore: GitCoreService; | |
| 208 | ||
| 209 | constructor(deps: GitManagerDeps = {}) { | |
| 210 | this.run = deps.runProcess ?? runProcess; | |
| 211 | this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); | |
| 212 | this.gitCore = deps.gitCore ?? new GitCoreService(); | |
| 213 | } | |
| 214 | ||
| 215 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | |
| 216 | const input = gitStatusInputSchema.parse(raw); | |
| 217 | const details = await this.gitCore.statusDetails(input.cwd); | |
| 218 | ||
| 219 | let pr: ReturnType<typeof toStatusPr> | null = null; | |
| 220 | if (details.branch) { | |
| 221 | try { | |
| 222 | const existing = await this.findLatestPr(input.cwd, details.branch); | |
| 223 | if (existing) { | |
| 224 | pr = toStatusPr(existing); | |
| 225 | } | |
| 226 | } catch { | |
| 227 | // PR lookup is best-effort for status rendering. | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | return { | |
| 232 | branch: details.branch, | |
| 233 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 234 | workingTree: details.workingTree, | |
| 235 | hasUpstream: details.hasUpstream, | |
| 236 | aheadCount: details.aheadCount, | |
| 237 | behindCount: details.behindCount, | |
| 238 | pr, | |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | |
| 243 | const input = gitRunStackedActionInputSchema.parse(raw); | |
| 244 | const wantsPush = input.action !== "commit"; | |
| 245 | const wantsPr = input.action === "commit_push_pr"; | |
| 246 | ||
| 247 | const initialStatus = await this.gitCore.statusDetails(input.cwd); | |
| 248 | if (wantsPush && !initialStatus.branch) { | |
| 249 | throw new Error("Cannot push from detached HEAD."); | |
| 250 | } | |
| 251 | if (wantsPr && !initialStatus.branch) { | |
| 252 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 253 | } | |
| 254 | ||
| 255 | const commit = await this.runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); | |
| 256 | ||
| 257 | const push = wantsPush | |
| 258 | ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 259 | : { status: "skipped_not_requested" as const }; | |
| 260 | ||
| 261 | const pr = wantsPr | |
| 262 | ? await this.runPrStep(input.cwd, initialStatus.branch) | |
| 263 | : { status: "skipped_not_requested" as const }; | |
| 264 | ||
| 265 | return gitRunStackedActionResultSchema.parse({ | |
| 266 | action: input.action, | |
| 267 | commit, | |
| 268 | push, | |
| 269 | pr, | |
| 270 | }); | |
| 271 | } | |
| 272 | ||
| 273 | private async runCommitStep( | |
| 274 | cwd: string, | |
| 275 | branch: string | null, | |
| 276 | commitMessage?: string, | |
| 277 | ): Promise<{ | |
| 278 | status: "created" | "skipped_no_changes"; | |
| 279 | commitSha?: string | undefined; | |
| 280 | subject?: string | undefined; | |
| 281 | }> { | |
| 282 | const context = await this.gitCore.prepareCommitContext(cwd); | |
| 283 | if (!context) { | |
| 284 | return { status: "skipped_no_changes" }; | |
| 285 | } | |
| 286 | ||
| 287 | let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage( | |
| 288 | commitMessage ?? "", | |
| 289 | ); | |
| 290 | ||
| 291 | if (!generated) { | |
| 292 | try { | |
| 293 | generated = sanitizeCommitMessage( | |
| 294 | await this.textGenerator.generateCommitMessage({ | |
| 295 | cwd, | |
| 296 | branch, | |
| 297 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 298 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 299 | }), | |
| 300 | ); | |
| 301 | } catch (error) { | |
| 302 | throw asCommandNotFound("codex", error) ?? error; | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body); | |
| 307 | return { | |
| 308 | status: "created", | |
| 309 | commitSha, | |
| 310 | subject: generated.subject, | |
| 311 | }; | |
| 312 | } | |
| 313 | ||
| 314 | private async runPrStep( | |
| 315 | cwd: string, | |
| 316 | fallbackBranch: string | null, | |
| 317 | ): Promise<{ | |
| 318 | status: "created" | "opened_existing" | "skipped_not_requested"; | |
| 319 | url?: string | undefined; | |
| 320 | number?: number | undefined; | |
| 321 | baseBranch?: string | undefined; | |
| 322 | headBranch?: string | undefined; | |
| 323 | title?: string | undefined; | |
| 324 | }> { | |
| 325 | const details = await this.gitCore.statusDetails(cwd); | |
| 326 | const branch = details.branch ?? fallbackBranch; | |
| 327 | if (!branch) { | |
| 328 | throw new Error("Cannot create a pull request from detached HEAD."); | |
| 329 | } | |
| 330 | if (!details.hasUpstream) { | |
| 331 | throw new Error("Current branch has not been pushed. Push before creating a PR."); | |
| 332 | } | |
| 333 | ||
| 334 | const existing = await this.findOpenPr(cwd, branch); | |
| 335 | if (existing) { | |
| 336 | return { | |
| 337 | status: "opened_existing", | |
| 338 | url: existing.url, | |
| 339 | number: existing.number, | |
| 340 | baseBranch: existing.baseRefName, | |
| 341 | headBranch: existing.headRefName, | |
| 342 | title: existing.title, | |
| 343 | }; | |
| 344 | } | |
| 345 | ||
| 346 | const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef); | |
| 347 | const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch); | |
| 348 | ||
| 349 | let generated: PrContentGenerationResult; | |
| 350 | try { | |
| 351 | generated = await this.textGenerator.generatePrContent({ | |
| 352 | cwd, | |
| 353 | baseBranch, | |
| 354 | headBranch: branch, | |
| 355 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | |
| 356 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | |
| 357 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | |
| 358 | }); | |
| 359 | } catch (error) { | |
| 360 | throw asCommandNotFound("codex", error) ?? error; | |
| 361 | } | |
| 362 | ||
| 363 | const bodyFile = path.join(os.tmpdir(), `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | |
| 364 | await fs.writeFile(bodyFile, generated.body, "utf8"); | |
| 365 | ||
| 366 | try { | |
| 367 | await this.runGh(cwd, [ | |
| 368 | "pr", | |
| 369 | "create", | |
| 370 | "--base", | |
| 371 | baseBranch, | |
| 372 | "--head", | |
| 373 | branch, | |
| 374 | "--title", | |
| 375 | generated.title, | |
| 376 | "--body-file", | |
| 377 | bodyFile, | |
| 378 | ]); | |
| 379 | } finally { | |
| 380 | try { | |
| 381 | await fs.unlink(bodyFile); | |
| 382 | } catch { | |
| 383 | // Best-effort cleanup. | |
| 384 | } | |
| 385 | } | |
| 386 | ||
| 387 | const created = await this.findOpenPr(cwd, branch); | |
| 388 | ||
| 389 | if (!created) { | |
| 390 | return { | |
| 391 | status: "created", | |
| 392 | baseBranch, | |
| 393 | headBranch: branch, | |
| 394 | title: generated.title, | |
| 395 | }; | |
| 396 | } | |
| 397 | ||
| 398 | return { | |
| 399 | status: "created", | |
| 400 | url: created.url, | |
| 401 | number: created.number, | |
| 402 | baseBranch: created.baseRefName, | |
| 403 | headBranch: created.headRefName, | |
| 404 | title: created.title, | |
| 405 | }; | |
| 406 | } | |
| 407 | ||
| 408 | private async findOpenPr(cwd: string, branch: string): Promise<PullRequestInfo | null> { | |
| 409 | const stdout = await this.runGhStdout(cwd, [ | |
| 410 | "pr", | |
| 411 | "list", | |
| 412 | "--head", | |
| 413 | branch, | |
| 414 | "--state", | |
| 415 | "open", | |
| 416 | "--limit", | |
| 417 | "1", | |
| 418 | "--json", | |
| 419 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", | |
| 420 | ]); | |
| 421 | ||
| 422 | const raw = trimStdout(stdout); | |
| 423 | if (raw.length === 0) return null; | |
| 424 | ||
| 425 | let parsedJson: unknown; | |
| 426 | try { | |
| 427 | parsedJson = JSON.parse(raw); | |
| 428 | } catch { | |
| 429 | throw new Error("GitHub CLI returned invalid PR list JSON."); | |
| 430 | } | |
| 431 | ||
| 432 | const parsed = parsePullRequestList(parsedJson); | |
| 433 | return parsed[0] ?? null; | |
| 434 | } | |
| 435 | ||
| 436 | private async findLatestPr(cwd: string, branch: string): Promise<PullRequestInfo | null> { | |
| 437 | const stdout = await this.runGhStdout(cwd, [ | |
| 438 | "pr", | |
| 439 | "list", | |
| 440 | "--head", | |
| 441 | branch, | |
| 442 | "--state", | |
| 443 | "all", | |
| 444 | "--limit", | |
| 445 | "20", | |
| 446 | "--json", | |
| 447 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", | |
| 448 | ]); | |
| 449 | ||
| 450 | const raw = trimStdout(stdout); | |
| 451 | if (raw.length === 0) return null; | |
| 452 | ||
| 453 | let parsedJson: unknown; | |
| 454 | try { | |
| 455 | parsedJson = JSON.parse(raw); | |
| 456 | } catch { | |
| 457 | throw new Error("GitHub CLI returned invalid PR list JSON."); | |
| 458 | } | |
| 459 | ||
| 460 | const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => { | |
| 461 | const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; | |
| 462 | const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; | |
| 463 | return right - left; | |
| 464 | }); | |
| 465 | return parsed[0] ?? null; | |
| 466 | } | |
| 467 | ||
| 468 | private async resolveBaseBranch( | |
| 469 | cwd: string, | |
| 470 | branch: string, | |
| 471 | upstreamRef: string | null, | |
| 472 | ): Promise<string> { | |
| 473 | const configured = await this.gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | |
| 474 | if (configured) return configured; | |
| 475 | ||
| 476 | if (upstreamRef) { | |
| 477 | const upstreamBranch = extractBranchFromRef(upstreamRef); | |
| 478 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | |
| 479 | return upstreamBranch; | |
| 480 | } | |
| 481 | } | |
| 482 | ||
| 483 | try { | |
| 484 | const ghDefault = await this.runGhStdout(cwd, [ | |
| 485 | "repo", | |
| 486 | "view", | |
| 487 | "--json", | |
| 488 | "defaultBranchRef", | |
| 489 | "--jq", | |
| 490 | ".defaultBranchRef.name", | |
| 491 | ]); | |
| 492 | const defaultBranch = trimStdout(ghDefault); | |
| 493 | if (defaultBranch.length > 0) return defaultBranch; | |
| 494 | } catch { | |
| 495 | // Fall through to deterministic fallback. | |
| 496 | } | |
| 497 | ||
| 498 | return "main"; | |
| 499 | } | |
| 500 | ||
| 501 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { | |
| 502 | await this.runGhCommand(cwd, args); | |
| 503 | } | |
| 504 | ||
| 505 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { | |
| 506 | const result = await this.runGhCommand(cwd, args); | |
| 507 | return result.stdout; | |
| 508 | } | |
| 509 | ||
| 510 | private async runGhCommand(cwd: string, args: readonly string[]): Promise<ProcessRunResult> { | |
| 511 | try { | |
| 512 | return await this.run("gh", args, { | |
| 513 | cwd, | |
| 514 | timeoutMs: GH_CLI_TIMEOUT_MS, | |
| 515 | }); | |
| 516 | } catch (error) { | |
| 517 | throw ( | |
| 518 | asCommandNotFound("gh", error) ?? | |
| 519 | normalizeGitHubAuthError(error) ?? | |
| 520 | (error instanceof Error ? error : new Error("GitHub CLI command failed.")) | |
| 521 | ); | |
| 522 | } | |
| 523 | } | |
| 524 | } |
apps/server/src/index.ts+21-176| ... | ... | @@ -1,176 +1,21 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import net from "node:net"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { fileURLToPath } from "node:url"; | |
| 6 | ||
| 7 | import { fixPath } from "./fixPath"; | |
| 8 | import { createLogger } from "./logger"; | |
| 9 | import { ProjectRegistry } from "./projectRegistry"; | |
| 10 | import { createServer } from "./wsServer"; | |
| 11 | ||
| 12 | fixPath(); | |
| 13 | ||
| 14 | const DEFAULT_PORT = 3773; | |
| 15 | const cwd = process.cwd(); | |
| 16 | const logger = createLogger("server"); | |
| 17 | ||
| 18 | type RuntimeMode = "web" | "desktop"; | |
| 19 | ||
| 20 | function parseBooleanEnv(value: string | undefined): boolean | undefined { | |
| 21 | if (value === undefined) return undefined; | |
| 22 | const normalized = value.trim().toLowerCase(); | |
| 23 | if (["1", "true", "yes", "on"].includes(normalized)) return true; | |
| 24 | if (["0", "false", "no", "off"].includes(normalized)) return false; | |
| 25 | return undefined; | |
| 26 | } | |
| 27 | ||
| 28 | function parsePort(value: string | undefined): number | undefined { | |
| 29 | if (!value) return undefined; | |
| 30 | const parsed = Number(value); | |
| 31 | if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { | |
| 32 | throw new Error(`Invalid T3CODE_PORT: ${value}`); | |
| 33 | } | |
| 34 | return parsed; | |
| 35 | } | |
| 36 | ||
| 37 | function expandHomePath(input: string): string { | |
| 38 | if (input === "~") return os.homedir(); | |
| 39 | if (input.startsWith("~/")) { | |
| 40 | return path.join(os.homedir(), input.slice(2)); | |
| 41 | } | |
| 42 | return input; | |
| 43 | } | |
| 44 | ||
| 45 | function resolveStateDir(raw: string | undefined): string { | |
| 46 | if (!raw || raw.trim().length === 0) { | |
| 47 | return path.join(os.homedir(), ".t3", "userdata"); | |
| 48 | } | |
| 49 | return path.resolve(expandHomePath(raw.trim())); | |
| 50 | } | |
| 51 | ||
| 52 | async function findAvailablePort(preferred: number): Promise<number> { | |
| 53 | return new Promise((resolve, reject) => { | |
| 54 | const server = net.createServer(); | |
| 55 | server.listen(preferred, () => { | |
| 56 | server.close(() => resolve(preferred)); | |
| 57 | }); | |
| 58 | server.on("error", () => { | |
| 59 | // Preferred port busy — let the OS pick one | |
| 60 | const fallback = net.createServer(); | |
| 61 | fallback.listen(0, () => { | |
| 62 | const addr = fallback.address(); | |
| 63 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 64 | fallback.close(() => { | |
| 65 | if (port > 0) resolve(port); | |
| 66 | else reject(new Error("Could not find an available port.")); | |
| 67 | }); | |
| 68 | }); | |
| 69 | fallback.on("error", reject); | |
| 70 | }); | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | function resolveStaticDir(): string | undefined { | |
| 75 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 76 | ||
| 77 | // Check for bundled client (npm publish / npx scenario): | |
| 78 | // dist/client/ lives alongside dist/index.js | |
| 79 | const bundledClient = path.resolve(__dirname, "client"); | |
| 80 | try { | |
| 81 | const stat = fs.statSync(path.join(bundledClient, "index.html")); | |
| 82 | if (stat.isFile()) return bundledClient; | |
| 83 | } catch { | |
| 84 | // Not bundled — check monorepo layout | |
| 85 | } | |
| 86 | ||
| 87 | // Monorepo layout: apps/server/dist/index.js → apps/web/dist/ | |
| 88 | const monorepoClient = path.resolve(__dirname, "../../web/dist"); | |
| 89 | try { | |
| 90 | const stat = fs.statSync(path.join(monorepoClient, "index.html")); | |
| 91 | if (stat.isFile()) return monorepoClient; | |
| 92 | } catch { | |
| 93 | // Not found — probably dev mode, Vite will serve | |
| 94 | } | |
| 95 | ||
| 96 | return undefined; | |
| 97 | } | |
| 98 | ||
| 99 | async function main() { | |
| 100 | const mode: RuntimeMode = process.env.T3CODE_MODE === "desktop" ? "desktop" : "web"; | |
| 101 | const requestedPort = parsePort(process.env.T3CODE_PORT); | |
| 102 | const port = | |
| 103 | requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); | |
| 104 | const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); | |
| 105 | const projectRegistry = new ProjectRegistry(stateDir); | |
| 106 | const devUrl = process.env.VITE_DEV_SERVER_URL; | |
| 107 | const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; | |
| 108 | const authToken = process.env.T3CODE_AUTH_TOKEN; | |
| 109 | const staticDir = devUrl ? undefined : resolveStaticDir(); | |
| 110 | ||
| 111 | if (!devUrl && !staticDir) { | |
| 112 | logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { | |
| 113 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | const server = createServer({ | |
| 118 | port, | |
| 119 | host: mode === "desktop" ? "127.0.0.1" : undefined, | |
| 120 | cwd, | |
| 121 | staticDir, | |
| 122 | devUrl, | |
| 123 | projectRegistry, | |
| 124 | authToken, | |
| 125 | }); | |
| 126 | await server.start(); | |
| 127 | ||
| 128 | const url = `http://localhost:${port}`; | |
| 129 | logger.info("T3 Code running", { | |
| 130 | url, | |
| 131 | cwd, | |
| 132 | mode, | |
| 133 | stateDir, | |
| 134 | authEnabled: Boolean(authToken), | |
| 135 | }); | |
| 136 | ||
| 137 | // Open browser (dynamic import because `open` is ESM-only) | |
| 138 | if (!noBrowser) { | |
| 139 | try { | |
| 140 | const open = await import("open"); | |
| 141 | const target = devUrl ?? url; | |
| 142 | await open.default(target); | |
| 143 | } catch { | |
| 144 | logger.info("browser auto-open unavailable", { | |
| 145 | hint: `Open ${devUrl ?? url} in your browser.`, | |
| 146 | }); | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | // Graceful shutdown | |
| 151 | let shuttingDown = false; | |
| 152 | async function shutdown() { | |
| 153 | if (shuttingDown) return; | |
| 154 | shuttingDown = true; | |
| 155 | logger.info("shutting down"); | |
| 156 | try { | |
| 157 | await server.stop(); | |
| 158 | process.exit(0); | |
| 159 | } catch (error) { | |
| 160 | logger.error("failed to stop server cleanly", { error }); | |
| 161 | process.exit(1); | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | process.on("SIGINT", () => { | |
| 166 | void shutdown(); | |
| 167 | }); | |
| 168 | process.on("SIGTERM", () => { | |
| 169 | void shutdown(); | |
| 170 | }); | |
| 171 | } | |
| 172 | ||
| 173 | main().catch((err) => { | |
| 174 | logger.error("failed to start T3 Code", { error: err }); | |
| 175 | process.exit(1); | |
| 176 | }); | |
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { CliConfig, t3Cli } from "./main"; | |
| 7 | import { OpenLive } from "./open"; | |
| 8 | import { Command } from "effect/unstable/cli"; | |
| 9 | import { version } from "../package.json" with { type: "json" }; | |
| 10 | import { ServerLive } from "./wsServer"; | |
| 11 | import { NetService } from "./config"; | |
| 12 | ||
| 13 | const RuntimeLayer = Layer.empty.pipe( | |
| 14 | Layer.provideMerge(CliConfig.layer), | |
| 15 | Layer.provideMerge(ServerLive), | |
| 16 | Layer.provideMerge(OpenLive), | |
| 17 | Layer.provideMerge(NetService.layer), | |
| 18 | Layer.provideMerge(NodeServices.layer), | |
| 19 | ); | |
| 20 | ||
| 21 | Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); |
apps/server/src/keybindings.test.ts+456-191| ... | ... | @@ -1,225 +1,490 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { assert, afterEach, describe, expect, it, vi } from "vitest"; | |
| 1 | import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | import { assertFailure } from "@effect/vitest/utils"; | |
| 5 | import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 6 | import { ServerConfig, type ServerConfigShape } from "./config"; | |
| 5 | 7 | |
| 6 | 8 | import { |
| 9 | DEFAULT_KEYBINDINGS, | |
| 10 | Keybindings, | |
| 11 | KeybindingsConfigError, | |
| 12 | KeybindingsLive, | |
| 13 | ResolvedKeybindingFromConfig, | |
| 7 | 14 | compileResolvedKeybindingRule, |
| 15 | compileResolvedKeybindingsConfig, | |
| 8 | 16 | parseKeybindingShortcut, |
| 9 | upsertKeybindingRule, | |
| 10 | 17 | } from "./keybindings"; |
| 11 | 18 | |
| 12 | describe("server keybindings", () => { | |
| 13 | const tempDirs: string[] = []; | |
| 14 | const logger = { | |
| 15 | warn: vi.fn(), | |
| 16 | }; | |
| 17 | ||
| 18 | function makeTempDir(prefix: string): string { | |
| 19 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 20 | tempDirs.push(dir); | |
| 21 | return dir; | |
| 22 | } | |
| 23 | ||
| 24 | afterEach(() => { | |
| 25 | vi.restoreAllMocks(); | |
| 26 | logger.warn.mockReset(); | |
| 27 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 28 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 29 | } | |
| 30 | }); | |
| 19 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); | |
| 20 | const makeKeybindingsLayer = () => | |
| 21 | KeybindingsLive.pipe( | |
| 22 | Layer.provideMerge( | |
| 23 | Layer.effect( | |
| 24 | ServerConfig, | |
| 25 | Effect.gen(function* () { | |
| 26 | const fs = yield* FileSystem.FileSystem; | |
| 27 | const { join } = yield* Path.Path; | |
| 28 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" }); | |
| 29 | const configPath = join(dir, "keybindings.json"); | |
| 30 | return { keybindingsConfigPath: configPath } as ServerConfigShape; | |
| 31 | }), | |
| 32 | ), | |
| 33 | ), | |
| 34 | ); | |
| 31 | 35 | |
| 32 | it("parses shortcuts including plus key", () => { | |
| 33 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { | |
| 34 | key: "j", | |
| 35 | metaKey: false, | |
| 36 | ctrlKey: false, | |
| 37 | shiftKey: false, | |
| 38 | altKey: false, | |
| 39 | modKey: true, | |
| 40 | }); | |
| 41 | assert.deepEqual(parseKeybindingShortcut("mod++"), { | |
| 42 | key: "+", | |
| 43 | metaKey: false, | |
| 44 | ctrlKey: false, | |
| 45 | shiftKey: false, | |
| 46 | altKey: false, | |
| 47 | modKey: true, | |
| 48 | }); | |
| 36 | const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) => | |
| 37 | effect.pipe( | |
| 38 | Effect.mapError((error) => error.detail), | |
| 39 | Effect.result, | |
| 40 | ); | |
| 41 | ||
| 42 | const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) => | |
| 43 | Effect.gen(function* () { | |
| 44 | const fileSystem = yield* FileSystem.FileSystem; | |
| 45 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules); | |
| 46 | yield* fileSystem.writeFileString(configPath, encoded); | |
| 49 | 47 | }); |
| 50 | 48 | |
| 51 | it("compiles valid rule with parsed when AST", () => { | |
| 52 | const compiled = compileResolvedKeybindingRule({ | |
| 53 | key: "mod+d", | |
| 54 | command: "terminal.split", | |
| 55 | when: "terminalOpen && !terminalFocus", | |
| 56 | }); | |
| 49 | const readKeybindingsConfig = (configPath: string) => | |
| 50 | Effect.gen(function* () { | |
| 51 | const fileSystem = yield* FileSystem.FileSystem; | |
| 52 | const rawConfig = yield* fileSystem.readFileString(configPath); | |
| 53 | return yield* Schema.decodeUnknownEffect(KeybindingsConfigJson)(rawConfig); | |
| 54 | }); | |
| 57 | 55 | |
| 58 | assert.deepEqual(compiled, { | |
| 59 | command: "terminal.split", | |
| 60 | shortcut: { | |
| 61 | key: "d", | |
| 56 | it.layer(NodeServices.layer)("keybindings", (it) => { | |
| 57 | it.effect("parses shortcuts including plus key", () => | |
| 58 | Effect.sync(() => { | |
| 59 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { | |
| 60 | key: "j", | |
| 62 | 61 | metaKey: false, |
| 63 | 62 | ctrlKey: false, |
| 64 | 63 | shiftKey: false, |
| 65 | 64 | altKey: false, |
| 66 | 65 | modKey: true, |
| 67 | }, | |
| 68 | whenAst: { | |
| 69 | type: "and", | |
| 70 | left: { type: "identifier", name: "terminalOpen" }, | |
| 71 | right: { | |
| 72 | type: "not", | |
| 73 | node: { type: "identifier", name: "terminalFocus" }, | |
| 74 | }, | |
| 75 | }, | |
| 76 | }); | |
| 77 | }); | |
| 78 | ||
| 79 | it("rejects invalid rules", () => { | |
| 80 | assert.isNull( | |
| 81 | compileResolvedKeybindingRule({ | |
| 82 | key: "mod+shift+d+o", | |
| 83 | command: "terminal.new", | |
| 84 | }), | |
| 85 | ); | |
| 66 | }); | |
| 67 | assert.deepEqual(parseKeybindingShortcut("mod++"), { | |
| 68 | key: "+", | |
| 69 | metaKey: false, | |
| 70 | ctrlKey: false, | |
| 71 | shiftKey: false, | |
| 72 | altKey: false, | |
| 73 | modKey: true, | |
| 74 | }); | |
| 75 | }), | |
| 76 | ); | |
| 86 | 77 | |
| 87 | assert.isNull( | |
| 88 | compileResolvedKeybindingRule({ | |
| 78 | it.effect("compiles valid rule with parsed when AST", () => | |
| 79 | Effect.sync(() => { | |
| 80 | const compiled = compileResolvedKeybindingRule({ | |
| 89 | 81 | key: "mod+d", |
| 90 | 82 | command: "terminal.split", |
| 91 | when: "terminalFocus && (", | |
| 92 | }), | |
| 93 | ); | |
| 83 | when: "terminalOpen && !terminalFocus", | |
| 84 | }); | |
| 94 | 85 | |
| 95 | assert.isNull( | |
| 96 | compileResolvedKeybindingRule({ | |
| 97 | key: "mod+d", | |
| 86 | assert.deepEqual(compiled, { | |
| 98 | 87 | command: "terminal.split", |
| 99 | when: `${"!".repeat(300)}terminalFocus`, | |
| 100 | }), | |
| 101 | ); | |
| 102 | }); | |
| 88 | shortcut: { | |
| 89 | key: "d", | |
| 90 | metaKey: false, | |
| 91 | ctrlKey: false, | |
| 92 | shiftKey: false, | |
| 93 | altKey: false, | |
| 94 | modKey: true, | |
| 95 | }, | |
| 96 | whenAst: { | |
| 97 | type: "and", | |
| 98 | left: { type: "identifier", name: "terminalOpen" }, | |
| 99 | right: { | |
| 100 | type: "not", | |
| 101 | node: { type: "identifier", name: "terminalFocus" }, | |
| 102 | }, | |
| 103 | }, | |
| 104 | }); | |
| 105 | }), | |
| 106 | ); | |
| 103 | 107 | |
| 104 | it("upserts custom keybindings to ~/.t3/keybindings.json", () => { | |
| 105 | const fakeHome = makeTempDir("t3code-keybindings-upsert-"); | |
| 106 | const configDir = path.join(fakeHome, ".t3"); | |
| 107 | fs.mkdirSync(configDir, { recursive: true }); | |
| 108 | const configPath = path.join(configDir, "keybindings.json"); | |
| 109 | fs.writeFileSync( | |
| 110 | configPath, | |
| 111 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 112 | "utf8", | |
| 113 | ); | |
| 114 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 108 | it.effect("encodes resolved plus-key shortcuts", () => | |
| 109 | Effect.gen(function* () { | |
| 110 | const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({ | |
| 111 | command: "terminal.toggle", | |
| 112 | shortcut: { | |
| 113 | key: "+", | |
| 114 | metaKey: false, | |
| 115 | ctrlKey: false, | |
| 116 | shiftKey: false, | |
| 117 | altKey: false, | |
| 118 | modKey: true, | |
| 119 | }, | |
| 120 | }); | |
| 115 | 121 | |
| 116 | const resolved = upsertKeybindingRule(logger, { | |
| 117 | key: "mod+shift+r", | |
| 118 | command: "script.run-tests.run", | |
| 119 | }); | |
| 120 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 121 | key: string; | |
| 122 | command: string; | |
| 123 | }>; | |
| 124 | ||
| 125 | expect(persisted).toEqual([ | |
| 126 | { key: "mod+j", command: "terminal.toggle" }, | |
| 127 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 128 | ]); | |
| 129 | expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); | |
| 130 | }); | |
| 122 | assert.equal(encoded.key, "mod++"); | |
| 123 | assert.equal(encoded.command, "terminal.toggle"); | |
| 124 | }), | |
| 125 | ); | |
| 131 | 126 | |
| 132 | it("replaces existing custom keybinding for the same command", () => { | |
| 133 | const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); | |
| 134 | const configDir = path.join(fakeHome, ".t3"); | |
| 135 | fs.mkdirSync(configDir, { recursive: true }); | |
| 136 | const configPath = path.join(configDir, "keybindings.json"); | |
| 137 | fs.writeFileSync( | |
| 138 | configPath, | |
| 139 | JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), | |
| 140 | "utf8", | |
| 141 | ); | |
| 142 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 127 | it.effect("rejects invalid rules", () => | |
| 128 | Effect.sync(() => { | |
| 129 | assert.isNull( | |
| 130 | compileResolvedKeybindingRule({ | |
| 131 | key: "mod+shift+d+o", | |
| 132 | command: "terminal.new", | |
| 133 | }), | |
| 134 | ); | |
| 143 | 135 | |
| 144 | upsertKeybindingRule(logger, { | |
| 145 | key: "mod+shift+r", | |
| 146 | command: "script.run-tests.run", | |
| 147 | }); | |
| 136 | assert.isNull( | |
| 137 | compileResolvedKeybindingRule({ | |
| 138 | key: "mod+d", | |
| 139 | command: "terminal.split", | |
| 140 | when: "terminalFocus && (", | |
| 141 | }), | |
| 142 | ); | |
| 148 | 143 | |
| 149 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 150 | key: string; | |
| 151 | command: string; | |
| 152 | }>; | |
| 153 | expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); | |
| 154 | }); | |
| 144 | assert.isNull( | |
| 145 | compileResolvedKeybindingRule({ | |
| 146 | key: "mod+d", | |
| 147 | command: "terminal.split", | |
| 148 | when: `${"!".repeat(300)}terminalFocus`, | |
| 149 | }), | |
| 150 | ); | |
| 151 | }), | |
| 152 | ); | |
| 155 | 153 | |
| 156 | it("refuses to overwrite malformed keybindings config", () => { | |
| 157 | const fakeHome = makeTempDir("t3code-keybindings-malformed-"); | |
| 158 | const configDir = path.join(fakeHome, ".t3"); | |
| 159 | fs.mkdirSync(configDir, { recursive: true }); | |
| 160 | const configPath = path.join(configDir, "keybindings.json"); | |
| 161 | fs.writeFileSync(configPath, "{ not-json", "utf8"); | |
| 162 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 163 | ||
| 164 | expect(() => | |
| 165 | upsertKeybindingRule(logger, { | |
| 166 | key: "mod+shift+r", | |
| 167 | command: "script.run-tests.run", | |
| 168 | }), | |
| 169 | ).toThrow(/Unable to parse keybindings config/); | |
| 170 | ||
| 171 | expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json"); | |
| 172 | }); | |
| 154 | it.effect("bootstraps default keybindings when config file is missing", () => | |
| 155 | Effect.gen(function* () { | |
| 156 | const fs = yield* FileSystem.FileSystem; | |
| 157 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 158 | assert.isFalse(yield* fs.exists(keybindingsConfigPath)); | |
| 173 | 159 | |
| 174 | it("reports non-array config parse errors without duplicate prefix", () => { | |
| 175 | const fakeHome = makeTempDir("t3code-keybindings-non-array-"); | |
| 176 | const configDir = path.join(fakeHome, ".t3"); | |
| 177 | fs.mkdirSync(configDir, { recursive: true }); | |
| 178 | const configPath = path.join(configDir, "keybindings.json"); | |
| 179 | fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8"); | |
| 180 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 181 | ||
| 182 | expect(() => | |
| 183 | upsertKeybindingRule(logger, { | |
| 184 | key: "mod+shift+r", | |
| 185 | command: "script.run-tests.run", | |
| 186 | }), | |
| 187 | ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`)); | |
| 188 | ||
| 189 | expect(() => | |
| 190 | upsertKeybindingRule(logger, { | |
| 191 | key: "mod+shift+r", | |
| 192 | command: "script.run-tests.run", | |
| 193 | }), | |
| 194 | ).not.toThrow( | |
| 195 | new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`), | |
| 196 | ); | |
| 197 | }); | |
| 160 | yield* Effect.gen(function* () { | |
| 161 | const keybindings = yield* Keybindings; | |
| 162 | yield* keybindings.syncDefaultKeybindingsOnStartup; | |
| 163 | }); | |
| 164 | ||
| 165 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 166 | assert.deepEqual(persisted, DEFAULT_KEYBINDINGS); | |
| 167 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 168 | ); | |
| 169 | ||
| 170 | it.effect("uses defaults in runtime when config is malformed without overriding file", () => | |
| 171 | Effect.gen(function* () { | |
| 172 | const fs = yield* FileSystem.FileSystem; | |
| 173 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 174 | yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); | |
| 175 | ||
| 176 | const configState = yield* Effect.gen(function* () { | |
| 177 | const keybindings = yield* Keybindings; | |
| 178 | return yield* keybindings.loadConfigState; | |
| 179 | }); | |
| 180 | ||
| 181 | assert.deepEqual( | |
| 182 | configState.keybindings, | |
| 183 | compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS), | |
| 184 | ); | |
| 185 | assert.deepEqual(configState.issues, [ | |
| 186 | { | |
| 187 | kind: "keybindings.malformed-config", | |
| 188 | message: configState.issues[0]?.message ?? "", | |
| 189 | }, | |
| 190 | ]); | |
| 191 | assert.equal(yield* fs.readFileString(keybindingsConfigPath), "{ not-json"); | |
| 192 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 193 | ); | |
| 198 | 194 | |
| 199 | it("cleans up temp files when atomic keybinding write fails", () => { | |
| 200 | const fakeHome = makeTempDir("t3code-keybindings-atomic-"); | |
| 201 | const configDir = path.join(fakeHome, ".t3"); | |
| 202 | fs.mkdirSync(configDir, { recursive: true }); | |
| 203 | const configPath = path.join(configDir, "keybindings.json"); | |
| 204 | fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8"); | |
| 205 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 206 | vi.spyOn(fs, "renameSync").mockImplementation(() => { | |
| 207 | throw new Error("rename failed"); | |
| 195 | it.effect("ignores invalid entries in runtime and reports them as issues", () => | |
| 196 | Effect.gen(function* () { | |
| 197 | const fs = yield* FileSystem.FileSystem; | |
| 198 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 199 | yield* fs.writeFileString( | |
| 200 | keybindingsConfigPath, | |
| 201 | JSON.stringify([ | |
| 202 | { key: "mod+j", command: "terminal.toggle" }, | |
| 203 | { key: "mod+shift+d+o", command: "terminal.new" }, | |
| 204 | { key: "mod+x", command: "invalid.command" }, | |
| 205 | ]), | |
| 206 | ); | |
| 207 | ||
| 208 | const configState = yield* Effect.gen(function* () { | |
| 209 | const keybindings = yield* Keybindings; | |
| 210 | return yield* keybindings.loadConfigState; | |
| 211 | }); | |
| 212 | ||
| 213 | assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle")); | |
| 214 | assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === "invalid.command")); | |
| 215 | assert.deepEqual(configState.issues, [ | |
| 216 | { | |
| 217 | kind: "keybindings.invalid-entry", | |
| 218 | index: 1, | |
| 219 | message: configState.issues[0]?.message ?? "", | |
| 220 | }, | |
| 221 | { | |
| 222 | kind: "keybindings.invalid-entry", | |
| 223 | index: 2, | |
| 224 | message: configState.issues[1]?.message ?? "", | |
| 225 | }, | |
| 226 | ]); | |
| 227 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 228 | ); | |
| 229 | ||
| 230 | it.effect( | |
| 231 | "upserts missing default keybindings on startup without overriding existing command rules", | |
| 232 | () => | |
| 233 | Effect.gen(function* () { | |
| 234 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 235 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 236 | { key: "mod+shift+t", command: "terminal.toggle" }, | |
| 237 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 238 | ]); | |
| 239 | ||
| 240 | yield* Effect.gen(function* () { | |
| 241 | const keybindings = yield* Keybindings; | |
| 242 | yield* keybindings.syncDefaultKeybindingsOnStartup; | |
| 243 | }); | |
| 244 | ||
| 245 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 246 | const byCommand = new Map(persisted.map((entry) => [entry.command, entry])); | |
| 247 | ||
| 248 | const persistedToggle = byCommand.get("terminal.toggle"); | |
| 249 | assert.isNotNull(persistedToggle); | |
| 250 | assert.equal(persistedToggle?.key, "mod+shift+t"); | |
| 251 | assert.isFalse( | |
| 252 | persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), | |
| 253 | ); | |
| 254 | ||
| 255 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | |
| 256 | assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); | |
| 257 | } | |
| 258 | assert.isTrue(byCommand.has("script.run-tests.run")); | |
| 259 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 260 | ); | |
| 261 | ||
| 262 | it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => { | |
| 263 | const messages: string[] = []; | |
| 264 | const logger = Logger.make(({ message }) => { | |
| 265 | messages.push(String(message)); | |
| 208 | 266 | }); |
| 209 | 267 | |
| 210 | expect(() => | |
| 211 | upsertKeybindingRule(logger, { | |
| 212 | key: "mod+shift+r", | |
| 213 | command: "script.run-tests.run", | |
| 214 | }), | |
| 215 | ).toThrow(/rename failed/); | |
| 268 | return Effect.gen(function* () { | |
| 269 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 270 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 271 | { key: "mod+j", command: "script.custom-action.run" }, | |
| 272 | ]); | |
| 216 | 273 | |
| 217 | expect(fs.readFileSync(configPath, "utf8")).toBe( | |
| 218 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 274 | yield* Effect.gen(function* () { | |
| 275 | const keybindings = yield* Keybindings; | |
| 276 | yield* keybindings.syncDefaultKeybindingsOnStartup; | |
| 277 | }); | |
| 278 | ||
| 279 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 280 | assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); | |
| 281 | assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); | |
| 282 | ||
| 283 | assert.isTrue( | |
| 284 | messages.some((message) => | |
| 285 | message.includes("skipping default keybinding due to shortcut conflict"), | |
| 286 | ), | |
| 287 | ); | |
| 288 | }).pipe( | |
| 289 | Effect.provide( | |
| 290 | Layer.mergeAll( | |
| 291 | makeKeybindingsLayer(), | |
| 292 | Logger.layer([logger], { mergeWithExisting: false }), | |
| 293 | ), | |
| 294 | ), | |
| 219 | 295 | ); |
| 220 | const tempFiles = fs | |
| 221 | .readdirSync(configDir) | |
| 222 | .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp")); | |
| 223 | expect(tempFiles).toEqual([]); | |
| 224 | 296 | }); |
| 297 | ||
| 298 | it.effect("upserts custom keybindings to configured path", () => | |
| 299 | Effect.gen(function* () { | |
| 300 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 301 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 302 | { key: "mod+j", command: "terminal.toggle" }, | |
| 303 | ]); | |
| 304 | ||
| 305 | const resolved = yield* Effect.gen(function* () { | |
| 306 | const keybindings = yield* Keybindings; | |
| 307 | return yield* keybindings.upsertKeybindingRule({ | |
| 308 | key: "mod+shift+r", | |
| 309 | command: "script.run-tests.run", | |
| 310 | }); | |
| 311 | }); | |
| 312 | ||
| 313 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 314 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | |
| 315 | ||
| 316 | assert.deepEqual(persistedView, [ | |
| 317 | { key: "mod+j", command: "terminal.toggle" }, | |
| 318 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 319 | ]); | |
| 320 | assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); | |
| 321 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 322 | ); | |
| 323 | ||
| 324 | it.effect("replaces existing custom keybinding for the same command", () => | |
| 325 | Effect.gen(function* () { | |
| 326 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 327 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 328 | { key: "mod+r", command: "script.run-tests.run" }, | |
| 329 | ]); | |
| 330 | yield* Effect.gen(function* () { | |
| 331 | const keybindings = yield* Keybindings; | |
| 332 | return yield* keybindings.upsertKeybindingRule({ | |
| 333 | key: "mod+shift+r", | |
| 334 | command: "script.run-tests.run", | |
| 335 | }); | |
| 336 | }); | |
| 337 | ||
| 338 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 339 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | |
| 340 | assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); | |
| 341 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 342 | ); | |
| 343 | ||
| 344 | it.effect("refuses to overwrite malformed keybindings config", () => | |
| 345 | Effect.gen(function* () { | |
| 346 | const fs = yield* FileSystem.FileSystem; | |
| 347 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 348 | yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); | |
| 349 | ||
| 350 | const result = yield* Effect.gen(function* () { | |
| 351 | const keybindings = yield* Keybindings; | |
| 352 | return yield* keybindings.upsertKeybindingRule({ | |
| 353 | key: "mod+shift+r", | |
| 354 | command: "script.run-tests.run", | |
| 355 | }); | |
| 356 | }).pipe(toDetailResult); | |
| 357 | assertFailure(result, "expected JSON array"); | |
| 358 | ||
| 359 | const persistedRaw = yield* fs.readFileString(keybindingsConfigPath); | |
| 360 | assert.equal(persistedRaw, "{ not-json"); | |
| 361 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 362 | ); | |
| 363 | ||
| 364 | it.effect("reports non-array config parse errors without duplicate prefix", () => | |
| 365 | Effect.gen(function* () { | |
| 366 | const fs = yield* FileSystem.FileSystem; | |
| 367 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 368 | yield* fs.writeFileString( | |
| 369 | keybindingsConfigPath, | |
| 370 | '{"key":"mod+j","command":"terminal.toggle"}', | |
| 371 | ); | |
| 372 | ||
| 373 | const firstResult = yield* Effect.gen(function* () { | |
| 374 | const keybindings = yield* Keybindings; | |
| 375 | return yield* keybindings.upsertKeybindingRule({ | |
| 376 | key: "mod+shift+r", | |
| 377 | command: "script.run-tests.run", | |
| 378 | }); | |
| 379 | }).pipe(toDetailResult); | |
| 380 | assertFailure(firstResult, "expected JSON array"); | |
| 381 | ||
| 382 | const secondResult = yield* Effect.gen(function* () { | |
| 383 | const keybindings = yield* Keybindings; | |
| 384 | return yield* keybindings.upsertKeybindingRule({ | |
| 385 | key: "mod+shift+r", | |
| 386 | command: "script.run-tests.run", | |
| 387 | }); | |
| 388 | }).pipe(toDetailResult); | |
| 389 | assertFailure(secondResult, "expected JSON array"); | |
| 390 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 391 | ); | |
| 392 | ||
| 393 | it.effect("fails when config directory is not writable", () => | |
| 394 | Effect.gen(function* () { | |
| 395 | const fs = yield* FileSystem.FileSystem; | |
| 396 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 397 | const { dirname } = yield* Path.Path; | |
| 398 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 399 | { key: "mod+j", command: "terminal.toggle" }, | |
| 400 | ]); | |
| 401 | yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); | |
| 402 | ||
| 403 | const result = yield* Effect.gen(function* () { | |
| 404 | const keybindings = yield* Keybindings; | |
| 405 | return yield* keybindings.upsertKeybindingRule({ | |
| 406 | key: "mod+shift+r", | |
| 407 | command: "script.run-tests.run", | |
| 408 | }); | |
| 409 | }).pipe(toDetailResult); | |
| 410 | assertFailure(result, "failed to write keybindings config"); | |
| 411 | ||
| 412 | yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); | |
| 413 | ||
| 414 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 415 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | |
| 416 | assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); | |
| 417 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 418 | ); | |
| 419 | ||
| 420 | it.effect("caches loaded resolved config across repeated reads", () => | |
| 421 | Effect.gen(function* () { | |
| 422 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 423 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 424 | { key: "mod+j", command: "terminal.toggle" }, | |
| 425 | ]); | |
| 426 | ||
| 427 | const [first, second] = yield* Effect.gen(function* () { | |
| 428 | const keybindings = yield* Keybindings; | |
| 429 | const firstLoad = (yield* keybindings.loadConfigState).keybindings; | |
| 430 | const secondLoad = (yield* keybindings.loadConfigState).keybindings; | |
| 431 | return [firstLoad, secondLoad] as const; | |
| 432 | }); | |
| 433 | ||
| 434 | assert.deepEqual(first, second); | |
| 435 | assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); | |
| 436 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 437 | ); | |
| 438 | ||
| 439 | it.effect("updates cached resolved config after upsert", () => | |
| 440 | Effect.gen(function* () { | |
| 441 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 442 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | |
| 443 | { key: "mod+j", command: "terminal.toggle" }, | |
| 444 | ]); | |
| 445 | ||
| 446 | const loadedAfterUpsert = yield* Effect.gen(function* () { | |
| 447 | const keybindings = yield* Keybindings; | |
| 448 | yield* keybindings.loadConfigState; | |
| 449 | yield* keybindings.upsertKeybindingRule({ | |
| 450 | key: "mod+shift+r", | |
| 451 | command: "script.run-tests.run", | |
| 452 | }); | |
| 453 | return (yield* keybindings.loadConfigState).keybindings; | |
| 454 | }); | |
| 455 | ||
| 456 | assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); | |
| 457 | assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); | |
| 458 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 459 | ); | |
| 460 | ||
| 461 | it.effect("serializes concurrent upserts to avoid lost updates", () => | |
| 462 | Effect.gen(function* () { | |
| 463 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 464 | yield* writeKeybindingsConfig(keybindingsConfigPath, []); | |
| 465 | ||
| 466 | const commands = Array.from( | |
| 467 | { length: 20 }, | |
| 468 | (_, index): KeybindingCommand => `script.concurrent-${index}.run`, | |
| 469 | ); | |
| 470 | yield* Effect.gen(function* () { | |
| 471 | const keybindings = yield* Keybindings; | |
| 472 | yield* Effect.all( | |
| 473 | commands.map((command, index) => | |
| 474 | keybindings.upsertKeybindingRule({ | |
| 475 | key: `mod+${String.fromCharCode(97 + index)}`, | |
| 476 | command, | |
| 477 | }), | |
| 478 | ), | |
| 479 | { concurrency: "unbounded", discard: true }, | |
| 480 | ); | |
| 481 | }); | |
| 482 | ||
| 483 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 484 | const persistedCommands = new Set(persisted.map((entry) => entry.command)); | |
| 485 | for (const command of commands) { | |
| 486 | assert.isTrue(persistedCommands.has(command), `expected persisted command ${command}`); | |
| 487 | } | |
| 488 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 489 | ); | |
| 225 | 490 | }); |
apps/server/src/keybindings.ts+551-155| ... | ... | @@ -1,19 +1,55 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import type { | |
| 1 | /** | |
| 2 | * Keybindings - Keybinding configuration service definitions. | |
| 3 | * | |
| 4 | * Owns parsing, validation, merge, and persistence of user keybinding | |
| 5 | * configuration consumed by the server runtime. | |
| 6 | * | |
| 7 | * @module Keybindings | |
| 8 | */ | |
| 9 | import { | |
| 6 | 10 | KeybindingRule, |
| 7 | 11 | KeybindingsConfig, |
| 8 | 12 | KeybindingShortcut, |
| 9 | 13 | KeybindingWhenNode, |
| 14 | MAX_KEYBINDINGS_COUNT, | |
| 15 | MAX_WHEN_EXPRESSION_DEPTH, | |
| 10 | 16 | ResolvedKeybindingRule, |
| 11 | 17 | ResolvedKeybindingsConfig, |
| 18 | type ServerConfigIssue, | |
| 12 | 19 | } from "@t3tools/contracts"; |
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | interface KeybindingsLogger { | |
| 16 | warn(message: string, context?: Record<string, unknown>): void; | |
| 20 | import { Mutable } from "effect/Types"; | |
| 21 | import { | |
| 22 | Array, | |
| 23 | Cache, | |
| 24 | Cause, | |
| 25 | Effect, | |
| 26 | FileSystem, | |
| 27 | Path, | |
| 28 | Layer, | |
| 29 | Option, | |
| 30 | Predicate, | |
| 31 | PubSub, | |
| 32 | Schema, | |
| 33 | SchemaGetter, | |
| 34 | SchemaIssue, | |
| 35 | SchemaTransformation, | |
| 36 | ServiceMap, | |
| 37 | Stream, | |
| 38 | } from "effect"; | |
| 39 | import * as Semaphore from "effect/Semaphore"; | |
| 40 | import { ServerConfig } from "./config"; | |
| 41 | ||
| 42 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( | |
| 43 | "KeybindingsConfigParseError", | |
| 44 | { | |
| 45 | configPath: Schema.String, | |
| 46 | detail: Schema.String, | |
| 47 | cause: Schema.optional(Schema.Defect), | |
| 48 | }, | |
| 49 | ) { | |
| 50 | override get message(): string { | |
| 51 | return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; | |
| 52 | } | |
| 17 | 53 | } |
| 18 | 54 | |
| 19 | 55 | type WhenToken = |
| ... | ... | @@ -24,13 +60,12 @@ type WhenToken = |
| 24 | 60 | | { type: "lparen" } |
| 25 | 61 | | { type: "rparen" }; |
| 26 | 62 | |
| 27 | const MAX_WHEN_EXPRESSION_DEPTH = 256; | |
| 28 | ||
| 29 | 63 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 30 | 64 | { key: "mod+j", command: "terminal.toggle" }, |
| 31 | 65 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 32 | 66 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 33 | 67 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 68 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, | |
| 34 | 69 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 35 | 70 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 36 | 71 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| ... | ... | @@ -43,6 +78,7 @@ function normalizeKeyToken(token: string): string { |
| 43 | 78 | return token; |
| 44 | 79 | } |
| 45 | 80 | |
| 81 | /** @internal - Exported for testing */ | |
| 46 | 82 | export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { |
| 47 | 83 | const rawTokens = value |
| 48 | 84 | .toLowerCase() |
| ... | ... | @@ -156,7 +192,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { |
| 156 | 192 | return tokens; |
| 157 | 193 | } |
| 158 | 194 | |
| 159 | export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | |
| 195 | function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | |
| 160 | 196 | const tokens = tokenizeWhenExpression(expression); |
| 161 | 197 | if (!tokens || tokens.length === 0) return null; |
| 162 | 198 | let index = 0; |
| ... | ... | @@ -241,15 +277,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 241 | 277 | return ast; |
| 242 | 278 | } |
| 243 | 279 | |
| 280 | /** @internal - Exported for testing */ | |
| 244 | 281 | export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { |
| 245 | const key = rule.key.trim(); | |
| 246 | if (key.length === 0) return null; | |
| 247 | const shortcut = parseKeybindingShortcut(key); | |
| 282 | const shortcut = parseKeybindingShortcut(rule.key); | |
| 248 | 283 | if (!shortcut) return null; |
| 249 | 284 | |
| 250 | const when = rule.when?.trim(); | |
| 251 | if (when && when.length > 0) { | |
| 252 | const whenAst = parseKeybindingWhenExpression(when); | |
| 285 | if (rule.when !== undefined) { | |
| 286 | const whenAst = parseKeybindingWhenExpression(rule.when); | |
| 253 | 287 | if (!whenAst) return null; |
| 254 | 288 | return { |
| 255 | 289 | command: rule.command, |
| ... | ... | @@ -267,114 +301,148 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey |
| 267 | 301 | export function compileResolvedKeybindingsConfig( |
| 268 | 302 | config: KeybindingsConfig, |
| 269 | 303 | ): ResolvedKeybindingsConfig { |
| 270 | const compiled: ResolvedKeybindingsConfig = []; | |
| 304 | const compiled: Mutable<ResolvedKeybindingsConfig> = []; | |
| 271 | 305 | for (const rule of config) { |
| 272 | const resolved = compileResolvedKeybindingRule(rule); | |
| 273 | if (!resolved) continue; | |
| 274 | compiled.push(resolved); | |
| 306 | const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); | |
| 307 | if (result._tag === "Success") { | |
| 308 | compiled.push(result.value); | |
| 309 | } | |
| 275 | 310 | } |
| 276 | 311 | return compiled; |
| 277 | 312 | } |
| 278 | 313 | |
| 279 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | |
| 280 | const resolved: ResolvedKeybindingsConfig = []; | |
| 281 | for (const rule of DEFAULT_KEYBINDINGS) { | |
| 282 | const compiled = compileResolvedKeybindingRule(rule); | |
| 283 | if (!compiled) { | |
| 284 | throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); | |
| 285 | } | |
| 286 | resolved.push(compiled); | |
| 287 | } | |
| 288 | return resolved; | |
| 314 | export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( | |
| 315 | Schema.decodeTo( | |
| 316 | Schema.toType(ResolvedKeybindingRule), | |
| 317 | SchemaTransformation.transformOrFail({ | |
| 318 | decode: (rule) => | |
| 319 | Effect.succeed(compileResolvedKeybindingRule(rule)).pipe( | |
| 320 | Effect.filterOrFail( | |
| 321 | Predicate.isNotNull, | |
| 322 | () => | |
| 323 | new SchemaIssue.InvalidValue(Option.some(rule), { | |
| 324 | title: "Invalid keybinding rule", | |
| 325 | }), | |
| 326 | ), | |
| 327 | Effect.map((resolved) => resolved), | |
| 328 | ), | |
| 329 | ||
| 330 | encode: (resolved) => | |
| 331 | Effect.gen(function* () { | |
| 332 | const key = encodeShortcut(resolved.shortcut); | |
| 333 | if (!key) { | |
| 334 | return yield* Effect.fail( | |
| 335 | new SchemaIssue.InvalidValue(Option.some(resolved), { | |
| 336 | title: "Resolved shortcut cannot be encoded to key string", | |
| 337 | }), | |
| 338 | ); | |
| 339 | } | |
| 340 | ||
| 341 | const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined; | |
| 342 | return { | |
| 343 | key, | |
| 344 | command: resolved.command, | |
| 345 | when, | |
| 346 | }; | |
| 347 | }), | |
| 348 | }), | |
| 349 | ), | |
| 350 | ); | |
| 351 | ||
| 352 | export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check( | |
| 353 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | |
| 354 | ); | |
| 355 | ||
| 356 | function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { | |
| 357 | return ( | |
| 358 | left.command === right.command && | |
| 359 | left.key === right.key && | |
| 360 | (left.when ?? undefined) === (right.when ?? undefined) | |
| 361 | ); | |
| 362 | } | |
| 363 | ||
| 364 | function keybindingShortcutContext(rule: KeybindingRule): string | null { | |
| 365 | const parsed = parseKeybindingShortcut(rule.key); | |
| 366 | if (!parsed) return null; | |
| 367 | const encoded = encodeShortcut(parsed); | |
| 368 | if (!encoded) return null; | |
| 369 | return `${encoded}\u0000${rule.when ?? ""}`; | |
| 370 | } | |
| 371 | ||
| 372 | function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean { | |
| 373 | const leftContext = keybindingShortcutContext(left); | |
| 374 | const rightContext = keybindingShortcutContext(right); | |
| 375 | if (!leftContext || !rightContext) return false; | |
| 376 | return leftContext === rightContext; | |
| 289 | 377 | } |
| 290 | 378 | |
| 291 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | |
| 292 | const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); | |
| 293 | const MAX_KEYBINDINGS = 256; | |
| 379 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { | |
| 380 | const modifiers: string[] = []; | |
| 381 | if (shortcut.modKey) modifiers.push("mod"); | |
| 382 | if (shortcut.metaKey) modifiers.push("meta"); | |
| 383 | if (shortcut.ctrlKey) modifiers.push("ctrl"); | |
| 384 | if (shortcut.altKey) modifiers.push("alt"); | |
| 385 | if (shortcut.shiftKey) modifiers.push("shift"); | |
| 386 | if (!shortcut.key) return null; | |
| 387 | if (shortcut.key !== "+" && shortcut.key.includes("+")) return null; | |
| 388 | const key = shortcut.key === " " ? "space" : shortcut.key; | |
| 389 | return [...modifiers, key].join("+"); | |
| 390 | } | |
| 294 | 391 | |
| 295 | class KeybindingsConfigParseError extends Error { | |
| 296 | constructor(configPath: string, detail: string, options?: { cause?: unknown }) { | |
| 297 | super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options); | |
| 392 | function encodeWhenAst(node: KeybindingWhenNode): string { | |
| 393 | switch (node.type) { | |
| 394 | case "identifier": | |
| 395 | return node.name; | |
| 396 | case "not": | |
| 397 | return `!(${encodeWhenAst(node.node)})`; | |
| 398 | case "and": | |
| 399 | return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`; | |
| 400 | case "or": | |
| 401 | return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`; | |
| 298 | 402 | } |
| 299 | 403 | } |
| 300 | 404 | |
| 301 | function resolveKeybindingsConfigPath(): string { | |
| 302 | return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); | |
| 405 | const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); | |
| 406 | ||
| 407 | const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); | |
| 408 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); | |
| 409 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( | |
| 410 | SchemaGetter.stringifyJson({ space: 2 }), | |
| 411 | ); | |
| 412 | const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( | |
| 413 | Schema.encode({ | |
| 414 | decode: PrettyJsonString, | |
| 415 | encode: PrettyJsonString, | |
| 416 | }), | |
| 417 | ); | |
| 418 | ||
| 419 | export interface KeybindingsConfigState { | |
| 420 | readonly keybindings: ResolvedKeybindingsConfig; | |
| 421 | readonly issues: readonly ServerConfigIssue[]; | |
| 303 | 422 | } |
| 304 | 423 | |
| 305 | function loadCustomKeybindingsConfig( | |
| 306 | logger: KeybindingsLogger, | |
| 307 | options?: { | |
| 308 | throwOnUnreadableConfig?: boolean; | |
| 309 | }, | |
| 310 | ): KeybindingsConfig { | |
| 311 | const configPath = resolveKeybindingsConfigPath(); | |
| 312 | try { | |
| 313 | const raw = fs.readFileSync(configPath, "utf8"); | |
| 314 | const parsed = JSON.parse(raw) as unknown; | |
| 315 | if (!Array.isArray(parsed)) { | |
| 316 | if (options?.throwOnUnreadableConfig) { | |
| 317 | throw new KeybindingsConfigParseError(configPath, "expected JSON array"); | |
| 318 | } | |
| 319 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | |
| 320 | path: configPath, | |
| 321 | }); | |
| 322 | return []; | |
| 323 | } | |
| 424 | export interface KeybindingsChangeEvent { | |
| 425 | readonly issues: readonly ServerConfigIssue[]; | |
| 426 | } | |
| 324 | 427 | |
| 325 | const sanitized: KeybindingsConfig = []; | |
| 326 | let invalidEntries = 0; | |
| 327 | for (const entry of parsed) { | |
| 328 | const result = keybindingRuleSchema.safeParse(entry); | |
| 329 | if (result.success) { | |
| 330 | if (!compileResolvedKeybindingRule(result.data)) { | |
| 331 | invalidEntries += 1; | |
| 332 | continue; | |
| 333 | } | |
| 334 | sanitized.push(result.data); | |
| 335 | continue; | |
| 336 | } | |
| 337 | invalidEntries += 1; | |
| 338 | } | |
| 339 | if (invalidEntries > 0) { | |
| 340 | logger.warn("ignoring invalid keybinding entries", { | |
| 341 | path: configPath, | |
| 342 | invalidEntries, | |
| 343 | totalEntries: parsed.length, | |
| 344 | }); | |
| 345 | } | |
| 346 | return sanitized; | |
| 347 | } catch (error) { | |
| 348 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | |
| 349 | return []; | |
| 350 | } | |
| 351 | if (options?.throwOnUnreadableConfig) { | |
| 352 | if (error instanceof KeybindingsConfigParseError) { | |
| 353 | throw error; | |
| 354 | } | |
| 355 | throw new KeybindingsConfigParseError( | |
| 356 | configPath, | |
| 357 | error instanceof Error ? error.message : String(error), | |
| 358 | { cause: error }, | |
| 359 | ); | |
| 360 | } | |
| 361 | logger.warn("ignoring malformed keybindings config", { | |
| 362 | path: configPath, | |
| 363 | error: error instanceof Error ? error.message : String(error), | |
| 364 | }); | |
| 365 | return []; | |
| 366 | } | |
| 428 | function trimIssueMessage(message: string): string { | |
| 429 | const trimmed = message.trim(); | |
| 430 | return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration."; | |
| 367 | 431 | } |
| 368 | 432 | |
| 369 | function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void { | |
| 370 | const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; | |
| 371 | try { | |
| 372 | fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); | |
| 373 | fs.renameSync(tempPath, configPath); | |
| 374 | } catch (error) { | |
| 375 | fs.rmSync(tempPath, { force: true }); | |
| 376 | throw error; | |
| 377 | } | |
| 433 | function malformedConfigIssue(detail: string): ServerConfigIssue { | |
| 434 | return { | |
| 435 | kind: "keybindings.malformed-config", | |
| 436 | message: trimIssueMessage(detail), | |
| 437 | }; | |
| 438 | } | |
| 439 | ||
| 440 | function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { | |
| 441 | return { | |
| 442 | kind: "keybindings.invalid-entry", | |
| 443 | index, | |
| 444 | message: trimIssueMessage(detail), | |
| 445 | }; | |
| 378 | 446 | } |
| 379 | 447 | |
| 380 | 448 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { |
| ... | ... | @@ -388,60 +456,388 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve |
| 388 | 456 | ); |
| 389 | 457 | const merged = [...retainedDefaults, ...custom]; |
| 390 | 458 | |
| 391 | if (merged.length <= MAX_KEYBINDINGS) { | |
| 459 | if (merged.length <= MAX_KEYBINDINGS_COUNT) { | |
| 392 | 460 | return merged; |
| 393 | 461 | } |
| 394 | 462 | |
| 395 | 463 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. |
| 396 | return merged.slice(-MAX_KEYBINDINGS); | |
| 464 | return merged.slice(-MAX_KEYBINDINGS_COUNT); | |
| 397 | 465 | } |
| 398 | 466 | |
| 399 | export function loadResolvedKeybindingsConfig( | |
| 400 | logger: KeybindingsLogger, | |
| 401 | ): ResolvedKeybindingsConfig { | |
| 402 | const customConfig = loadCustomKeybindingsConfig(logger); | |
| 403 | const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); | |
| 404 | const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); | |
| 405 | const mergedBeforeCapLength = | |
| 406 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 407 | .length + compiledCustomConfig.length; | |
| 408 | const merged = mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 409 | if (mergedBeforeCapLength > MAX_KEYBINDINGS) { | |
| 410 | logger.warn("truncating merged keybindings config to max entries", { | |
| 411 | path: resolveKeybindingsConfigPath(), | |
| 412 | maxEntries: MAX_KEYBINDINGS, | |
| 413 | }); | |
| 414 | } | |
| 415 | return merged; | |
| 467 | /** | |
| 468 | * KeybindingsShape - Service API for keybinding configuration operations. | |
| 469 | */ | |
| 470 | export interface KeybindingsShape { | |
| 471 | /** | |
| 472 | * Ensure the on-disk keybindings file exists and includes all default | |
| 473 | * commands so newly-added defaults are backfilled on startup. | |
| 474 | */ | |
| 475 | readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>; | |
| 476 | ||
| 477 | /** | |
| 478 | * Load runtime keybindings state along with non-fatal configuration issues. | |
| 479 | */ | |
| 480 | readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>; | |
| 481 | ||
| 482 | /** | |
| 483 | * Stream keybindings config change events. | |
| 484 | */ | |
| 485 | readonly changes: Stream.Stream<KeybindingsChangeEvent>; | |
| 486 | ||
| 487 | /** | |
| 488 | * Upsert a keybinding rule and persist the resulting configuration. | |
| 489 | * | |
| 490 | * Writes config atomically and enforces the max rule count by truncating | |
| 491 | * oldest entries when needed. | |
| 492 | */ | |
| 493 | readonly upsertKeybindingRule: ( | |
| 494 | rule: KeybindingRule, | |
| 495 | ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>; | |
| 416 | 496 | } |
| 417 | 497 | |
| 418 | export function upsertKeybindingRule( | |
| 419 | logger: KeybindingsLogger, | |
| 420 | rawRule: unknown, | |
| 421 | ): ResolvedKeybindingsConfig { | |
| 422 | const rule = keybindingRuleSchema.parse(rawRule); | |
| 423 | if (!compileResolvedKeybindingRule(rule)) { | |
| 424 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | |
| 425 | } | |
| 498 | /** | |
| 499 | * Keybindings - Service tag for keybinding configuration operations. | |
| 500 | */ | |
| 501 | export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()( | |
| 502 | "t3/keybindings", | |
| 503 | ) {} | |
| 504 | ||
| 505 | const makeKeybindings = Effect.gen(function* () { | |
| 506 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 507 | const fs = yield* FileSystem.FileSystem; | |
| 508 | const path = yield* Path.Path; | |
| 509 | const upsertSemaphore = yield* Semaphore.make(1); | |
| 510 | const resolvedConfigCacheKey = "resolved" as const; | |
| 511 | const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>(); | |
| 512 | ||
| 513 | const emitChange = (issues: readonly ServerConfigIssue[]) => | |
| 514 | PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid); | |
| 515 | ||
| 516 | const readConfigExists = fs.exists(keybindingsConfigPath).pipe( | |
| 517 | Effect.mapError( | |
| 518 | (cause) => | |
| 519 | new KeybindingsConfigError({ | |
| 520 | configPath: keybindingsConfigPath, | |
| 521 | detail: "failed to access keybindings config", | |
| 522 | cause, | |
| 523 | }), | |
| 524 | ), | |
| 525 | ); | |
| 526 | ||
| 527 | const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe( | |
| 528 | Effect.mapError( | |
| 529 | (cause) => | |
| 530 | new KeybindingsConfigError({ | |
| 531 | configPath: keybindingsConfigPath, | |
| 532 | detail: "failed to read keybindings config", | |
| 533 | cause, | |
| 534 | }), | |
| 535 | ), | |
| 536 | ); | |
| 537 | ||
| 538 | const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< | |
| 539 | readonly KeybindingRule[], | |
| 540 | KeybindingsConfigError | |
| 541 | > { | |
| 542 | if (!(yield* readConfigExists)) { | |
| 543 | return []; | |
| 544 | } | |
| 426 | 545 | |
| 427 | const configPath = resolveKeybindingsConfigPath(); | |
| 428 | const customConfig = loadCustomKeybindingsConfig(logger, { | |
| 429 | throwOnUnreadableConfig: true, | |
| 546 | const rawConfig = yield* readRawConfig.pipe( | |
| 547 | Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), | |
| 548 | Effect.mapError( | |
| 549 | (cause) => | |
| 550 | new KeybindingsConfigError({ | |
| 551 | configPath: keybindingsConfigPath, | |
| 552 | detail: "expected JSON array", | |
| 553 | cause, | |
| 554 | }), | |
| 555 | ), | |
| 556 | ); | |
| 557 | ||
| 558 | return yield* Effect.forEach(rawConfig, (entry) => | |
| 559 | Effect.gen(function* () { | |
| 560 | const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); | |
| 561 | if (decodedRule._tag === "Failure") { | |
| 562 | yield* Effect.logWarning("ignoring invalid keybinding entry", { | |
| 563 | path: keybindingsConfigPath, | |
| 564 | entry, | |
| 565 | error: Cause.pretty(decodedRule.cause), | |
| 566 | }); | |
| 567 | return null; | |
| 568 | } | |
| 569 | const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); | |
| 570 | if (resolved._tag === "Failure") { | |
| 571 | yield* Effect.logWarning("ignoring invalid keybinding entry", { | |
| 572 | path: keybindingsConfigPath, | |
| 573 | entry, | |
| 574 | error: Cause.pretty(resolved.cause), | |
| 575 | }); | |
| 576 | return null; | |
| 577 | } | |
| 578 | return decodedRule.value; | |
| 579 | }), | |
| 580 | ).pipe(Effect.map(Array.filter(Predicate.isNotNull))); | |
| 430 | 581 | }); |
| 431 | const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; | |
| 432 | const cappedConfig = | |
| 433 | nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; | |
| 434 | ||
| 435 | if (nextConfig.length > MAX_KEYBINDINGS) { | |
| 436 | logger.warn("truncating keybindings config to max entries", { | |
| 437 | path: configPath, | |
| 438 | maxEntries: MAX_KEYBINDINGS, | |
| 439 | }); | |
| 440 | } | |
| 441 | 582 | |
| 442 | fs.mkdirSync(path.dirname(configPath), { recursive: true }); | |
| 443 | writeConfigAtomically(configPath, cappedConfig); | |
| 583 | const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< | |
| 584 | { | |
| 585 | readonly keybindings: readonly KeybindingRule[]; | |
| 586 | readonly issues: readonly ServerConfigIssue[]; | |
| 587 | }, | |
| 588 | KeybindingsConfigError | |
| 589 | > { | |
| 590 | if (!(yield* readConfigExists)) { | |
| 591 | return { keybindings: [], issues: [] }; | |
| 592 | } | |
| 444 | 593 | |
| 445 | const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); | |
| 446 | return mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 447 | } | |
| 594 | const rawConfig = yield* readRawConfig; | |
| 595 | const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig); | |
| 596 | if (decodedEntries._tag === "Failure") { | |
| 597 | const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`; | |
| 598 | return { | |
| 599 | keybindings: [], | |
| 600 | issues: [malformedConfigIssue(detail)], | |
| 601 | }; | |
| 602 | } | |
| 603 | ||
| 604 | const keybindings: KeybindingRule[] = []; | |
| 605 | const issues: ServerConfigIssue[] = []; | |
| 606 | for (const [index, entry] of decodedEntries.value.entries()) { | |
| 607 | const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); | |
| 608 | if (decodedRule._tag === "Failure") { | |
| 609 | const detail = Cause.pretty(decodedRule.cause); | |
| 610 | issues.push(invalidEntryIssue(index, detail)); | |
| 611 | yield* Effect.logWarning("ignoring invalid keybinding entry", { | |
| 612 | path: keybindingsConfigPath, | |
| 613 | index, | |
| 614 | entry, | |
| 615 | error: detail, | |
| 616 | }); | |
| 617 | continue; | |
| 618 | } | |
| 619 | ||
| 620 | const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); | |
| 621 | if (resolvedRule._tag === "Failure") { | |
| 622 | const detail = Cause.pretty(resolvedRule.cause); | |
| 623 | issues.push(invalidEntryIssue(index, detail)); | |
| 624 | yield* Effect.logWarning("ignoring invalid keybinding entry", { | |
| 625 | path: keybindingsConfigPath, | |
| 626 | index, | |
| 627 | entry, | |
| 628 | error: detail, | |
| 629 | }); | |
| 630 | continue; | |
| 631 | } | |
| 632 | keybindings.push(decodedRule.value); | |
| 633 | } | |
| 634 | ||
| 635 | return { keybindings, issues }; | |
| 636 | }); | |
| 637 | ||
| 638 | const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { | |
| 639 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; | |
| 640 | ||
| 641 | return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( | |
| 642 | Effect.map((encoded) => `${encoded}\n`), | |
| 643 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), | |
| 644 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), | |
| 645 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), | |
| 646 | Effect.mapError( | |
| 647 | (cause) => | |
| 648 | new KeybindingsConfigError({ | |
| 649 | configPath: keybindingsConfigPath, | |
| 650 | detail: "failed to write keybindings config", | |
| 651 | cause, | |
| 652 | }), | |
| 653 | ), | |
| 654 | ); | |
| 655 | }; | |
| 656 | ||
| 657 | const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( | |
| 658 | Effect.map(({ keybindings, issues }) => ({ | |
| 659 | keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)), | |
| 660 | issues, | |
| 661 | })), | |
| 662 | ); | |
| 663 | ||
| 664 | const resolvedConfigCache = yield* Cache.make< | |
| 665 | typeof resolvedConfigCacheKey, | |
| 666 | KeybindingsConfigState, | |
| 667 | KeybindingsConfigError | |
| 668 | >({ | |
| 669 | capacity: 1, | |
| 670 | lookup: () => loadConfigStateFromDisk, | |
| 671 | }); | |
| 672 | ||
| 673 | const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); | |
| 674 | ||
| 675 | const revalidateAndEmit = upsertSemaphore.withPermits(1)( | |
| 676 | Effect.gen(function* () { | |
| 677 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 678 | const configState = yield* loadConfigStateFromCacheOrDisk; | |
| 679 | yield* emitChange(configState.issues); | |
| 680 | }), | |
| 681 | ); | |
| 682 | ||
| 683 | const keybindingsConfigDir = path.dirname(keybindingsConfigPath); | |
| 684 | const keybindingsConfigFile = path.basename(keybindingsConfigPath); | |
| 685 | const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath); | |
| 686 | yield* fs | |
| 687 | .makeDirectory(keybindingsConfigDir, { recursive: true }) | |
| 688 | .pipe(Effect.orElseSucceed(() => undefined)); | |
| 689 | yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { | |
| 690 | const isTargetConfigEvent = | |
| 691 | event.path === keybindingsConfigFile || | |
| 692 | event.path === keybindingsConfigPath || | |
| 693 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; | |
| 694 | if (!isTargetConfigEvent) { | |
| 695 | return Effect.void; | |
| 696 | } | |
| 697 | return revalidateAndEmit.pipe( | |
| 698 | Effect.catch((error) => | |
| 699 | Effect.logWarning("failed to revalidate keybindings config after file update", { | |
| 700 | path: keybindingsConfigPath, | |
| 701 | detail: error.detail, | |
| 702 | cause: error.cause, | |
| 703 | }), | |
| 704 | ), | |
| 705 | ); | |
| 706 | }).pipe( | |
| 707 | Effect.catch((cause) => | |
| 708 | Effect.logWarning("keybindings config watcher stopped unexpectedly", { | |
| 709 | path: keybindingsConfigPath, | |
| 710 | cause, | |
| 711 | }), | |
| 712 | ), | |
| 713 | Effect.forkScoped, | |
| 714 | ); | |
| 715 | ||
| 716 | const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( | |
| 717 | Effect.gen(function* () { | |
| 718 | const configExists = yield* readConfigExists; | |
| 719 | if (!configExists) { | |
| 720 | yield* writeConfigAtomically(DEFAULT_KEYBINDINGS); | |
| 721 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 722 | return; | |
| 723 | } | |
| 724 | ||
| 725 | const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig(); | |
| 726 | if (runtimeConfig.issues.length > 0) { | |
| 727 | yield* Effect.logWarning( | |
| 728 | "skipping startup keybindings default sync because config has issues", | |
| 729 | { | |
| 730 | path: keybindingsConfigPath, | |
| 731 | issues: runtimeConfig.issues, | |
| 732 | }, | |
| 733 | ); | |
| 734 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 735 | return; | |
| 736 | } | |
| 737 | const customConfig = runtimeConfig.keybindings; | |
| 738 | const existingCommands = new Set(customConfig.map((entry) => entry.command)); | |
| 739 | const missingDefaults: KeybindingRule[] = []; | |
| 740 | const shortcutConflictWarnings: Array<{ | |
| 741 | defaultCommand: KeybindingRule["command"]; | |
| 742 | conflictingCommand: KeybindingRule["command"]; | |
| 743 | key: string; | |
| 744 | when: string | null; | |
| 745 | }> = []; | |
| 746 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | |
| 747 | if (existingCommands.has(defaultRule.command)) { | |
| 748 | continue; | |
| 749 | } | |
| 750 | const conflictingEntry = customConfig.find((entry) => | |
| 751 | hasSameShortcutContext(entry, defaultRule), | |
| 752 | ); | |
| 753 | if (conflictingEntry) { | |
| 754 | shortcutConflictWarnings.push({ | |
| 755 | defaultCommand: defaultRule.command, | |
| 756 | conflictingCommand: conflictingEntry.command, | |
| 757 | key: defaultRule.key, | |
| 758 | when: defaultRule.when ?? null, | |
| 759 | }); | |
| 760 | continue; | |
| 761 | } | |
| 762 | missingDefaults.push(defaultRule); | |
| 763 | } | |
| 764 | for (const conflict of shortcutConflictWarnings) { | |
| 765 | yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", { | |
| 766 | path: keybindingsConfigPath, | |
| 767 | defaultCommand: conflict.defaultCommand, | |
| 768 | conflictingCommand: conflict.conflictingCommand, | |
| 769 | key: conflict.key, | |
| 770 | when: conflict.when, | |
| 771 | reason: "shortcut context already used by existing rule", | |
| 772 | }); | |
| 773 | } | |
| 774 | if (missingDefaults.length === 0) { | |
| 775 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 776 | return; | |
| 777 | } | |
| 778 | ||
| 779 | const matchingDefaults = DEFAULT_KEYBINDINGS.filter((defaultRule) => | |
| 780 | customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)), | |
| 781 | ).map((rule) => rule.command); | |
| 782 | if (matchingDefaults.length > 0) { | |
| 783 | yield* Effect.logWarning("default keybinding rule already defined in user config", { | |
| 784 | path: keybindingsConfigPath, | |
| 785 | commands: matchingDefaults, | |
| 786 | }); | |
| 787 | } | |
| 788 | ||
| 789 | const nextConfig = [...customConfig, ...missingDefaults]; | |
| 790 | const cappedConfig = | |
| 791 | nextConfig.length > MAX_KEYBINDINGS_COUNT | |
| 792 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | |
| 793 | : nextConfig; | |
| 794 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | |
| 795 | yield* Effect.logWarning("truncating keybindings config to max entries", { | |
| 796 | path: keybindingsConfigPath, | |
| 797 | maxEntries: MAX_KEYBINDINGS_COUNT, | |
| 798 | }); | |
| 799 | } | |
| 800 | ||
| 801 | yield* writeConfigAtomically(cappedConfig); | |
| 802 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | |
| 803 | }), | |
| 804 | ); | |
| 805 | ||
| 806 | return { | |
| 807 | syncDefaultKeybindingsOnStartup, | |
| 808 | loadConfigState: loadConfigStateFromCacheOrDisk, | |
| 809 | changes: Stream.fromPubSub(changesPubSub), | |
| 810 | upsertKeybindingRule: (rule) => | |
| 811 | upsertSemaphore.withPermits(1)( | |
| 812 | Effect.gen(function* () { | |
| 813 | const customConfig = yield* loadWritableCustomKeybindingsConfig(); | |
| 814 | const nextConfig = [ | |
| 815 | ...customConfig.filter((entry) => entry.command !== rule.command), | |
| 816 | rule, | |
| 817 | ]; | |
| 818 | const cappedConfig = | |
| 819 | nextConfig.length > MAX_KEYBINDINGS_COUNT | |
| 820 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | |
| 821 | : nextConfig; | |
| 822 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | |
| 823 | yield* Effect.logWarning("truncating keybindings config to max entries", { | |
| 824 | path: keybindingsConfigPath, | |
| 825 | maxEntries: MAX_KEYBINDINGS_COUNT, | |
| 826 | }); | |
| 827 | } | |
| 828 | yield* writeConfigAtomically(cappedConfig); | |
| 829 | const nextResolved = mergeWithDefaultKeybindings( | |
| 830 | compileResolvedKeybindingsConfig(cappedConfig), | |
| 831 | ); | |
| 832 | yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { | |
| 833 | keybindings: nextResolved, | |
| 834 | issues: [], | |
| 835 | }); | |
| 836 | yield* emitChange([]); | |
| 837 | return nextResolved; | |
| 838 | }), | |
| 839 | ), | |
| 840 | } satisfies KeybindingsShape; | |
| 841 | }); | |
| 842 | ||
| 843 | export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings); |
apps/server/src/main.test.ts created+242| ... | ... | @@ -0,0 +1,242 @@ |
| 1 | import * as Http from "node:http"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, it, vi } from "@effect/vitest"; | |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Command from "effect/unstable/cli/Command"; | |
| 8 | import { beforeEach } from "vitest"; | |
| 9 | ||
| 10 | import { CliConfig, t3Cli, type CliConfigShape } from "./main"; | |
| 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; | |
| 12 | import { Open, type OpenShape } from "./open"; | |
| 13 | import { Server, type ServerShape } from "./wsServer"; | |
| 14 | ||
| 15 | const start = vi.fn(() => undefined); | |
| 16 | const stop = vi.fn(() => undefined); | |
| 17 | let resolvedConfig: ServerConfigShape | null = null; | |
| 18 | const serverStart = Effect.acquireRelease( | |
| 19 | Effect.gen(function* () { | |
| 20 | resolvedConfig = yield* ServerConfig; | |
| 21 | start(); | |
| 22 | return {} as unknown as Http.Server; | |
| 23 | }), | |
| 24 | () => Effect.sync(() => stop()), | |
| 25 | ); | |
| 26 | const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); | |
| 27 | ||
| 28 | // Shared service layer used by this CLI test suite. | |
| 29 | const testLayer = Layer.mergeAll( | |
| 30 | Layer.succeed(CliConfig, { | |
| 31 | cwd: "/tmp/t3-test-workspace", | |
| 32 | fixPath: Effect.void, | |
| 33 | resolveStaticDir: Effect.undefined, | |
| 34 | } satisfies CliConfigShape), | |
| 35 | Layer.succeed(NetService, { | |
| 36 | findAvailablePort, | |
| 37 | }), | |
| 38 | Layer.succeed(Server, { | |
| 39 | start: serverStart, | |
| 40 | stopSignal: Effect.void, | |
| 41 | } satisfies ServerShape), | |
| 42 | Layer.succeed(Open, { | |
| 43 | openBrowser: (_target: string) => Effect.void, | |
| 44 | openInEditor: () => Effect.void, | |
| 45 | } satisfies OpenShape), | |
| 46 | NodeServices.layer, | |
| 47 | ); | |
| 48 | ||
| 49 | const runCli = ( | |
| 50 | args: ReadonlyArray<string>, | |
| 51 | env: Record<string, string> = { T3CODE_NO_BROWSER: "true" }, | |
| 52 | ) => | |
| 53 | Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( | |
| 54 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), | |
| 55 | ); | |
| 56 | ||
| 57 | beforeEach(() => { | |
| 58 | vi.clearAllMocks(); | |
| 59 | resolvedConfig = null; | |
| 60 | start.mockImplementation(() => undefined); | |
| 61 | stop.mockImplementation(() => undefined); | |
| 62 | findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); | |
| 63 | }); | |
| 64 | ||
| 65 | it.layer(testLayer)("server cli", (it) => { | |
| 66 | it.effect("parses all CLI flags and wires scoped start/stop", () => | |
| 67 | Effect.gen(function* () { | |
| 68 | yield* runCli([ | |
| 69 | "--mode", | |
| 70 | "desktop", | |
| 71 | "--port", | |
| 72 | "4010", | |
| 73 | "--host", | |
| 74 | "0.0.0.0", | |
| 75 | "--state-dir", | |
| 76 | "/tmp/t3-cli-state", | |
| 77 | "--dev-url", | |
| 78 | "http://127.0.0.1:5173", | |
| 79 | "--no-browser", | |
| 80 | "--auth-token", | |
| 81 | "auth-secret", | |
| 82 | ]); | |
| 83 | ||
| 84 | assert.equal(start.mock.calls.length, 1); | |
| 85 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 86 | assert.equal(resolvedConfig?.port, 4010); | |
| 87 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 88 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); | |
| 89 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 90 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 91 | assert.equal(resolvedConfig?.authToken, "auth-secret"); | |
| 92 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 93 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 94 | assert.equal(stop.mock.calls.length, 1); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("supports --token as an alias for --auth-token", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | yield* runCli(["--token", "token-secret"]); | |
| 101 | ||
| 102 | assert.equal(start.mock.calls.length, 1); | |
| 103 | assert.equal(resolvedConfig?.authToken, "token-secret"); | |
| 104 | }), | |
| 105 | ); | |
| 106 | ||
| 107 | it.effect("uses env fallbacks when flags are not provided", () => | |
| 108 | Effect.gen(function* () { | |
| 109 | yield* runCli([], { | |
| 110 | T3CODE_MODE: "desktop", | |
| 111 | T3CODE_PORT: "4999", | |
| 112 | T3CODE_HOST: "100.88.10.4", | |
| 113 | T3CODE_STATE_DIR: "/tmp/t3-env-state", | |
| 114 | VITE_DEV_SERVER_URL: "http://localhost:5173", | |
| 115 | T3CODE_NO_BROWSER: "true", | |
| 116 | T3CODE_AUTH_TOKEN: "env-token", | |
| 117 | }); | |
| 118 | ||
| 119 | assert.equal(start.mock.calls.length, 1); | |
| 120 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 121 | assert.equal(resolvedConfig?.port, 4999); | |
| 122 | assert.equal(resolvedConfig?.host, "100.88.10.4"); | |
| 123 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); | |
| 124 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); | |
| 125 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 126 | assert.equal(resolvedConfig?.authToken, "env-token"); | |
| 127 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 128 | assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 129 | assert.equal(findAvailablePort.mock.calls.length, 0); | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | it.effect("prefers --mode over T3CODE_MODE", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); | |
| 136 | yield* runCli(["--mode", "web"], { | |
| 137 | T3CODE_MODE: "desktop", | |
| 138 | T3CODE_NO_BROWSER: "true", | |
| 139 | }); | |
| 140 | ||
| 141 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | |
| 142 | assert.equal(start.mock.calls.length, 1); | |
| 143 | assert.equal(resolvedConfig?.mode, "web"); | |
| 144 | assert.equal(resolvedConfig?.port, 4666); | |
| 145 | assert.equal(resolvedConfig?.host, undefined); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | yield* runCli(["--no-browser"], { | |
| 152 | T3CODE_NO_BROWSER: "false", | |
| 153 | }); | |
| 154 | ||
| 155 | assert.equal(start.mock.calls.length, 1); | |
| 156 | assert.equal(resolvedConfig?.noBrowser, true); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | it.effect("uses dynamic port discovery in web mode when port is omitted", () => | |
| 161 | Effect.gen(function* () { | |
| 162 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); | |
| 163 | yield* runCli([]); | |
| 164 | ||
| 165 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | |
| 166 | assert.equal(start.mock.calls.length, 1); | |
| 167 | assert.equal(resolvedConfig?.port, 5444); | |
| 168 | assert.equal(resolvedConfig?.mode, "web"); | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("uses fixed localhost defaults in desktop mode", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | yield* runCli([], { | |
| 175 | T3CODE_MODE: "desktop", | |
| 176 | T3CODE_NO_BROWSER: "true", | |
| 177 | }); | |
| 178 | ||
| 179 | assert.equal(findAvailablePort.mock.calls.length, 0); | |
| 180 | assert.equal(start.mock.calls.length, 1); | |
| 181 | assert.equal(resolvedConfig?.port, 3773); | |
| 182 | assert.equal(resolvedConfig?.host, "127.0.0.1"); | |
| 183 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 184 | }), | |
| 185 | ); | |
| 186 | ||
| 187 | it.effect("allows overriding desktop host with --host", () => | |
| 188 | Effect.gen(function* () { | |
| 189 | yield* runCli(["--host", "0.0.0.0"], { | |
| 190 | T3CODE_MODE: "desktop", | |
| 191 | T3CODE_NO_BROWSER: "true", | |
| 192 | }); | |
| 193 | ||
| 194 | assert.equal(start.mock.calls.length, 1); | |
| 195 | assert.equal(resolvedConfig?.mode, "desktop"); | |
| 196 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 197 | }), | |
| 198 | ); | |
| 199 | ||
| 200 | it.effect("supports CLI and env for bootstrap/log websocket toggles", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | yield* runCli(["--auto-bootstrap-project-from-cwd"], { | |
| 203 | T3CODE_MODE: "desktop", | |
| 204 | T3CODE_LOG_WS_EVENTS: "false", | |
| 205 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | |
| 206 | T3CODE_NO_BROWSER: "true", | |
| 207 | }); | |
| 208 | ||
| 209 | assert.equal(start.mock.calls.length, 1); | |
| 210 | assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); | |
| 211 | assert.equal(resolvedConfig?.logWebSocketEvents, false); | |
| 212 | }), | |
| 213 | ); | |
| 214 | ||
| 215 | it.effect("does not start server for invalid --mode values", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | yield* runCli(["--mode", "invalid"]); | |
| 218 | ||
| 219 | assert.equal(start.mock.calls.length, 0); | |
| 220 | assert.equal(stop.mock.calls.length, 0); | |
| 221 | }), | |
| 222 | ); | |
| 223 | ||
| 224 | it.effect("does not start server for invalid --dev-url values", () => | |
| 225 | Effect.gen(function* () { | |
| 226 | yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); | |
| 227 | ||
| 228 | assert.equal(start.mock.calls.length, 0); | |
| 229 | assert.equal(stop.mock.calls.length, 0); | |
| 230 | }), | |
| 231 | ); | |
| 232 | ||
| 233 | it.effect("does not start server for out-of-range --port values", () => | |
| 234 | Effect.gen(function* () { | |
| 235 | yield* runCli(["--port", "70000"]); | |
| 236 | ||
| 237 | // effect/unstable/cli renders help/errors for parse failures and returns success. | |
| 238 | assert.equal(start.mock.calls.length, 0); | |
| 239 | assert.equal(stop.mock.calls.length, 0); | |
| 240 | }), | |
| 241 | ); | |
| 242 | }); |
apps/server/src/main.ts created+321| ... | ... | @@ -0,0 +1,321 @@ |
| 1 | /** | |
| 2 | * CliConfig - CLI/runtime bootstrap service definitions. | |
| 3 | * | |
| 4 | * Defines startup-only service contracts used while resolving process config | |
| 5 | * and constructing server runtime layers. | |
| 6 | * | |
| 7 | * @module CliConfig | |
| 8 | */ | |
| 9 | import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; | |
| 10 | import { Command, Flag } from "effect/unstable/cli"; | |
| 11 | ||
| 12 | import { | |
| 13 | DEFAULT_PORT, | |
| 14 | NetService, | |
| 15 | resolveStaticDir, | |
| 16 | ServerConfig, | |
| 17 | type RuntimeMode, | |
| 18 | type ServerConfigShape, | |
| 19 | } from "./config"; | |
| 20 | import { fixPath, resolveStateDir } from "./os-jank"; | |
| 21 | import { Open } from "./open"; | |
| 22 | import * as SqlitePersistence from "./persistence/Layers/Sqlite"; | |
| 23 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 24 | import { Server } from "./wsServer"; | |
| 25 | ||
| 26 | export class StartupError extends Data.TaggedError("StartupError")<{ | |
| 27 | readonly message: string; | |
| 28 | readonly cause?: unknown; | |
| 29 | }> {} | |
| 30 | ||
| 31 | interface CliInput { | |
| 32 | readonly mode: Option.Option<RuntimeMode>; | |
| 33 | readonly port: Option.Option<number>; | |
| 34 | readonly host: Option.Option<string>; | |
| 35 | readonly stateDir: Option.Option<string>; | |
| 36 | readonly devUrl: Option.Option<URL>; | |
| 37 | readonly noBrowser: Option.Option<boolean>; | |
| 38 | readonly authToken: Option.Option<string>; | |
| 39 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | |
| 40 | readonly logWebSocketEvents: Option.Option<boolean>; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * CliConfigShape - Startup helpers required while building server layers. | |
| 45 | */ | |
| 46 | export interface CliConfigShape { | |
| 47 | /** | |
| 48 | * Current process working directory. | |
| 49 | */ | |
| 50 | readonly cwd: string; | |
| 51 | ||
| 52 | /** | |
| 53 | * Apply OS-specific PATH normalization. | |
| 54 | */ | |
| 55 | readonly fixPath: Effect.Effect<void>; | |
| 56 | ||
| 57 | /** | |
| 58 | * Resolve static web asset directory for server mode. | |
| 59 | */ | |
| 60 | readonly resolveStaticDir: Effect.Effect<string | undefined>; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * CliConfig - Service tag for startup CLI/runtime helpers. | |
| 65 | */ | |
| 66 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | |
| 67 | "t3/main/CliConfig", | |
| 68 | ) { | |
| 69 | static readonly layer = Layer.effect( | |
| 70 | CliConfig, | |
| 71 | Effect.gen(function* () { | |
| 72 | const fileSystem = yield* FileSystem.FileSystem; | |
| 73 | const path = yield* Path.Path; | |
| 74 | return { | |
| 75 | cwd: process.cwd(), | |
| 76 | fixPath: Effect.sync(fixPath), | |
| 77 | resolveStaticDir: resolveStaticDir().pipe( | |
| 78 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 79 | Effect.provideService(Path.Path, path), | |
| 80 | ), | |
| 81 | } satisfies CliConfigShape; | |
| 82 | }), | |
| 83 | ); | |
| 84 | } | |
| 85 | ||
| 86 | const CliEnvConfig = Config.all({ | |
| 87 | mode: Config.string("T3CODE_MODE").pipe( | |
| 88 | Config.option, | |
| 89 | Config.map( | |
| 90 | Option.match<RuntimeMode, string>({ | |
| 91 | onNone: () => "web", | |
| 92 | onSome: (value) => (value === "desktop" ? "desktop" : "web"), | |
| 93 | }), | |
| 94 | ), | |
| 95 | ), | |
| 96 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 97 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 98 | stateDir: Config.string("T3CODE_STATE_DIR").pipe( | |
| 99 | Config.option, | |
| 100 | Config.map(Option.getOrUndefined), | |
| 101 | ), | |
| 102 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), | |
| 103 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( | |
| 104 | Config.option, | |
| 105 | Config.map(Option.getOrUndefined), | |
| 106 | ), | |
| 107 | authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( | |
| 108 | Config.option, | |
| 109 | Config.map(Option.getOrUndefined), | |
| 110 | ), | |
| 111 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | |
| 112 | Config.option, | |
| 113 | Config.map(Option.getOrUndefined), | |
| 114 | ), | |
| 115 | logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( | |
| 116 | Config.option, | |
| 117 | Config.map(Option.getOrUndefined), | |
| 118 | ), | |
| 119 | }); | |
| 120 | ||
| 121 | const ServerConfigLive = (input: CliInput) => | |
| 122 | Layer.effect( | |
| 123 | ServerConfig, | |
| 124 | Effect.gen(function* () { | |
| 125 | const cliConfig = yield* CliConfig; | |
| 126 | const { findAvailablePort } = yield* NetService; | |
| 127 | const env = yield* CliEnvConfig.asEffect().pipe( | |
| 128 | Effect.mapError( | |
| 129 | (cause) => | |
| 130 | new StartupError({ message: "Failed to read environment configuration", cause }), | |
| 131 | ), | |
| 132 | ); | |
| 133 | ||
| 134 | const mode = Option.getOrElse(input.mode, () => env.mode); | |
| 135 | ||
| 136 | const port = yield* Option.match(input.port, { | |
| 137 | onSome: (value) => Effect.succeed(value), | |
| 138 | onNone: () => { | |
| 139 | if (env.port) { | |
| 140 | return Effect.succeed(env.port); | |
| 141 | } | |
| 142 | if (mode === "desktop") { | |
| 143 | return Effect.succeed(DEFAULT_PORT); | |
| 144 | } | |
| 145 | return findAvailablePort(DEFAULT_PORT); | |
| 146 | }, | |
| 147 | }); | |
| 148 | const stateDir = yield* resolveStateDir( | |
| 149 | Option.getOrUndefined(input.stateDir) ?? env.stateDir, | |
| 150 | ); | |
| 151 | const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); | |
| 152 | const noBrowser = Option.match(input.noBrowser, { | |
| 153 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 154 | // we still want env/mode fallbacks to apply. | |
| 155 | onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), | |
| 156 | onNone: () => env.noBrowser ?? mode === "desktop", | |
| 157 | }); | |
| 158 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | |
| 159 | const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { | |
| 160 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 161 | // we still want env/mode fallbacks to apply. | |
| 162 | onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), | |
| 163 | onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", | |
| 164 | }); | |
| 165 | const logWebSocketEvents = Option.match(input.logWebSocketEvents, { | |
| 166 | // effect/cli boolean flags parse to `false` when absent; in that case | |
| 167 | // we still want env/dev fallbacks to apply. | |
| 168 | onSome: (value) => (value ? true : (env.logWebSocketEvents ?? Boolean(devUrl))), | |
| 169 | onNone: () => env.logWebSocketEvents ?? Boolean(devUrl), | |
| 170 | }); | |
| 171 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; | |
| 172 | const { join } = yield* Path.Path; | |
| 173 | const keybindingsConfigPath = join(stateDir, "keybindings.json"); | |
| 174 | const host = | |
| 175 | Option.getOrUndefined(input.host) ?? | |
| 176 | env.host ?? | |
| 177 | (mode === "desktop" ? "127.0.0.1" : undefined); | |
| 178 | ||
| 179 | return { | |
| 180 | mode, | |
| 181 | port, | |
| 182 | cwd: cliConfig.cwd, | |
| 183 | keybindingsConfigPath, | |
| 184 | host, | |
| 185 | stateDir, | |
| 186 | staticDir, | |
| 187 | devUrl, | |
| 188 | noBrowser, | |
| 189 | authToken, | |
| 190 | autoBootstrapProjectFromCwd, | |
| 191 | logWebSocketEvents, | |
| 192 | } satisfies ServerConfigShape; | |
| 193 | }), | |
| 194 | ); | |
| 195 | ||
| 196 | const LayerLive = (input: CliInput) => | |
| 197 | Layer.empty.pipe( | |
| 198 | Layer.provideMerge(makeServerRuntimeServicesLayer()), | |
| 199 | Layer.provideMerge(makeServerProviderLayer()), | |
| 200 | Layer.provideMerge(SqlitePersistence.layerConfig), | |
| 201 | Layer.provideMerge(ServerConfigLive(input)), | |
| 202 | ); | |
| 203 | ||
| 204 | const isWildcardHost = (host: string | undefined): boolean => | |
| 205 | host === "0.0.0.0" || host === "::" || host === "[::]"; | |
| 206 | ||
| 207 | const formatHostForUrl = (host: string): string => | |
| 208 | host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; | |
| 209 | ||
| 210 | const makeServerProgram = (input: CliInput) => | |
| 211 | Effect.gen(function* () { | |
| 212 | const cliConfig = yield* CliConfig; | |
| 213 | const { start, stopSignal } = yield* Server; | |
| 214 | const openDeps = yield* Open; | |
| 215 | yield* cliConfig.fixPath; | |
| 216 | ||
| 217 | const config = yield* ServerConfig; | |
| 218 | ||
| 219 | if (!config.devUrl && !config.staticDir) { | |
| 220 | yield* Effect.logWarning( | |
| 221 | "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", | |
| 222 | { | |
| 223 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", | |
| 224 | }, | |
| 225 | ); | |
| 226 | } | |
| 227 | ||
| 228 | yield* start; | |
| 229 | ||
| 230 | const localUrl = `http://localhost:${config.port}`; | |
| 231 | const bindUrl = | |
| 232 | config.host && !isWildcardHost(config.host) | |
| 233 | ? `http://${formatHostForUrl(config.host)}:${config.port}` | |
| 234 | : localUrl; | |
| 235 | yield* Effect.logInfo("T3 Code running", { | |
| 236 | url: bindUrl, | |
| 237 | localUrl, | |
| 238 | bindHost: config.host ?? "default", | |
| 239 | cwd: config.cwd, | |
| 240 | mode: config.mode, | |
| 241 | stateDir: config.stateDir, | |
| 242 | authEnabled: Boolean(config.authToken), | |
| 243 | }); | |
| 244 | ||
| 245 | if (!config.noBrowser) { | |
| 246 | const target = config.devUrl?.toString() ?? bindUrl; | |
| 247 | yield* openDeps.openBrowser(target).pipe( | |
| 248 | Effect.catch(() => | |
| 249 | Effect.logInfo("browser auto-open unavailable", { | |
| 250 | hint: `Open ${target} in your browser.`, | |
| 251 | }), | |
| 252 | ), | |
| 253 | ); | |
| 254 | } | |
| 255 | ||
| 256 | return yield* stopSignal; | |
| 257 | }).pipe(Effect.provide(LayerLive(input))); | |
| 258 | ||
| 259 | /** | |
| 260 | * These flags mirrors the environment variables and the config shape. | |
| 261 | */ | |
| 262 | ||
| 263 | const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( | |
| 264 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), | |
| 265 | Flag.optional, | |
| 266 | ); | |
| 267 | const portFlag = Flag.integer("port").pipe( | |
| 268 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | |
| 269 | Flag.withDescription("Port for the HTTP/WebSocket server."), | |
| 270 | Flag.optional, | |
| 271 | ); | |
| 272 | const hostFlag = Flag.string("host").pipe( | |
| 273 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), | |
| 274 | Flag.optional, | |
| 275 | ); | |
| 276 | const stateDirFlag = Flag.string("state-dir").pipe( | |
| 277 | Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), | |
| 278 | Flag.optional, | |
| 279 | ); | |
| 280 | const devUrlFlag = Flag.string("dev-url").pipe( | |
| 281 | Flag.withSchema(Schema.URLFromString), | |
| 282 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | |
| 283 | Flag.optional, | |
| 284 | ); | |
| 285 | const noBrowserFlag = Flag.boolean("no-browser").pipe( | |
| 286 | Flag.withDescription("Disable automatic browser opening."), | |
| 287 | Flag.optional, | |
| 288 | ); | |
| 289 | const authTokenFlag = Flag.string("auth-token").pipe( | |
| 290 | Flag.withDescription("Auth token required for WebSocket connections."), | |
| 291 | Flag.withAlias("token"), | |
| 292 | Flag.optional, | |
| 293 | ); | |
| 294 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | |
| 295 | Flag.withDescription( | |
| 296 | "Create a project for the current working directory on startup when missing.", | |
| 297 | ), | |
| 298 | Flag.optional, | |
| 299 | ); | |
| 300 | const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | |
| 301 | Flag.withDescription( | |
| 302 | "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", | |
| 303 | ), | |
| 304 | Flag.withAlias("log-ws-events"), | |
| 305 | Flag.optional, | |
| 306 | ); | |
| 307 | ||
| 308 | export const t3Cli = Command.make("t3", { | |
| 309 | mode: modeFlag, | |
| 310 | port: portFlag, | |
| 311 | host: hostFlag, | |
| 312 | stateDir: stateDirFlag, | |
| 313 | devUrl: devUrlFlag, | |
| 314 | noBrowser: noBrowserFlag, | |
| 315 | authToken: authTokenFlag, | |
| 316 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | |
| 317 | logWebSocketEvents: logWebSocketEventsFlag, | |
| 318 | }).pipe( | |
| 319 | Command.withDescription("Run the T3 Code server."), | |
| 320 | Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), | |
| 321 | ); |
apps/server/src/open.test.ts created+95| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { launchDetached, resolveEditorLaunch } from "./open"; | |
| 4 | import { Effect } from "effect"; | |
| 5 | import { assertSuccess } from "@effect/vitest/utils"; | |
| 6 | ||
| 7 | describe("resolveEditorLaunch", () => { | |
| 8 | it.effect("returns cursor command for cursor editor", () => | |
| 9 | Effect.gen(function* () { | |
| 10 | const launch = yield* resolveEditorLaunch( | |
| 11 | { cwd: "/tmp/workspace", editor: "cursor" }, | |
| 12 | "darwin", | |
| 13 | ); | |
| 14 | assert.deepEqual(launch, { | |
| 15 | command: "cursor", | |
| 16 | args: ["/tmp/workspace"], | |
| 17 | }); | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | it.effect("uses --goto when cursor target includes line/column suffixes", () => | |
| 22 | Effect.gen(function* () { | |
| 23 | const lineOnly = yield* resolveEditorLaunch( | |
| 24 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, | |
| 25 | "darwin", | |
| 26 | ); | |
| 27 | assert.deepEqual(lineOnly, { | |
| 28 | command: "cursor", | |
| 29 | args: ["--goto", "/tmp/workspace/AGENTS.md:48"], | |
| 30 | }); | |
| 31 | ||
| 32 | const lineAndColumn = yield* resolveEditorLaunch( | |
| 33 | { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, | |
| 34 | "darwin", | |
| 35 | ); | |
| 36 | assert.deepEqual(lineAndColumn, { | |
| 37 | command: "cursor", | |
| 38 | args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], | |
| 39 | }); | |
| 40 | }), | |
| 41 | ); | |
| 42 | ||
| 43 | it.effect("maps file-manager editor to OS open commands", () => | |
| 44 | Effect.gen(function* () { | |
| 45 | const launch1 = yield* resolveEditorLaunch( | |
| 46 | { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 47 | "darwin", | |
| 48 | ); | |
| 49 | assert.deepEqual(launch1, { | |
| 50 | command: "open", | |
| 51 | args: ["/tmp/workspace"], | |
| 52 | }); | |
| 53 | ||
| 54 | const launch2 = yield* resolveEditorLaunch( | |
| 55 | { cwd: "C:\\workspace", editor: "file-manager" }, | |
| 56 | "win32", | |
| 57 | ); | |
| 58 | assert.deepEqual(launch2, { | |
| 59 | command: "explorer", | |
| 60 | args: ["C:\\workspace"], | |
| 61 | }); | |
| 62 | ||
| 63 | const launch3 = yield* resolveEditorLaunch( | |
| 64 | { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 65 | "linux", | |
| 66 | ); | |
| 67 | assert.deepEqual(launch3, { | |
| 68 | command: "xdg-open", | |
| 69 | args: ["/tmp/workspace"], | |
| 70 | }); | |
| 71 | }), | |
| 72 | ); | |
| 73 | }); | |
| 74 | ||
| 75 | describe("launchDetached", () => { | |
| 76 | it.effect("resolves when command can be spawned", () => | |
| 77 | Effect.gen(function* () { | |
| 78 | const result = yield* launchDetached({ | |
| 79 | command: process.execPath, | |
| 80 | args: ["-e", "process.exit(0)"], | |
| 81 | }).pipe(Effect.result); | |
| 82 | assertSuccess(result, undefined); | |
| 83 | }), | |
| 84 | ); | |
| 85 | ||
| 86 | it.effect("rejects when command does not exist", () => | |
| 87 | Effect.gen(function* () { | |
| 88 | const result = yield* launchDetached({ | |
| 89 | command: `t3code-no-such-command-${Date.now()}`, | |
| 90 | args: [], | |
| 91 | }).pipe(Effect.result); | |
| 92 | assert.equal(result._tag, "Failure"); | |
| 93 | }), | |
| 94 | ); | |
| 95 | }); |
apps/server/src/open.ts created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | /** | |
| 2 | * Open - Browser/editor launch service interface. | |
| 3 | * | |
| 4 | * Owns process launch helpers for opening URLs in a browser and workspace | |
| 5 | * paths in a configured editor. | |
| 6 | * | |
| 7 | * @module Open | |
| 8 | */ | |
| 9 | import { spawn } from "node:child_process"; | |
| 10 | ||
| 11 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 12 | import { ServiceMap, Schema, Effect, Layer } from "effect"; | |
| 13 | ||
| 14 | // ============================== | |
| 15 | // Definitions | |
| 16 | // ============================== | |
| 17 | ||
| 18 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { | |
| 19 | message: Schema.String, | |
| 20 | cause: Schema.optional(Schema.Defect), | |
| 21 | }) {} | |
| 22 | ||
| 23 | export interface OpenInEditorInput { | |
| 24 | readonly cwd: string; | |
| 25 | readonly editor: EditorId; | |
| 26 | } | |
| 27 | ||
| 28 | interface EditorLaunch { | |
| 29 | readonly command: string; | |
| 30 | readonly args: ReadonlyArray<string>; | |
| 31 | } | |
| 32 | ||
| 33 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | |
| 34 | ||
| 35 | function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { | |
| 36 | return editorId === "cursor" && LINE_COLUMN_SUFFIX_PATTERN.test(target); | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * OpenShape - Service API for browser and editor launch actions. | |
| 41 | */ | |
| 42 | export interface OpenShape { | |
| 43 | /** | |
| 44 | * Open a URL target in the default browser. | |
| 45 | */ | |
| 46 | readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>; | |
| 47 | ||
| 48 | /** | |
| 49 | * Open a workspace path in a selected editor integration. | |
| 50 | * | |
| 51 | * Launches the editor as a detached process so server startup is not blocked. | |
| 52 | */ | |
| 53 | readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Open - Service tag for browser/editor launch operations. | |
| 58 | */ | |
| 59 | export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} | |
| 60 | ||
| 61 | // ============================== | |
| 62 | // Implementations | |
| 63 | // ============================== | |
| 64 | ||
| 65 | export const resolveEditorLaunch = Effect.fnUntraced(function* ( | |
| 66 | input: OpenInEditorInput, | |
| 67 | platform: NodeJS.Platform = process.platform, | |
| 68 | ): Effect.fn.Return<EditorLaunch, OpenError> { | |
| 69 | const editorDef = EDITORS.find((editor) => editor.id === input.editor); | |
| 70 | if (!editorDef) { | |
| 71 | return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); | |
| 72 | } | |
| 73 | ||
| 74 | if (editorDef.command) { | |
| 75 | return shouldUseGotoFlag(editorDef.id, input.cwd) | |
| 76 | ? { command: editorDef.command, args: ["--goto", input.cwd] } | |
| 77 | : { command: editorDef.command, args: [input.cwd] }; | |
| 78 | } | |
| 79 | ||
| 80 | if (editorDef.id !== "file-manager") { | |
| 81 | return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); | |
| 82 | } | |
| 83 | ||
| 84 | switch (platform) { | |
| 85 | case "darwin": | |
| 86 | return { command: "open", args: [input.cwd] }; | |
| 87 | case "win32": | |
| 88 | return { command: "explorer", args: [input.cwd] }; | |
| 89 | default: | |
| 90 | return { command: "xdg-open", args: [input.cwd] }; | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | export const launchDetached = (launch: EditorLaunch) => | |
| 95 | Effect.callback<void, OpenError>((resume) => { | |
| 96 | let child; | |
| 97 | try { | |
| 98 | child = spawn(launch.command, [...launch.args], { | |
| 99 | detached: true, | |
| 100 | stdio: "ignore", | |
| 101 | }); | |
| 102 | } catch (error) { | |
| 103 | return resume( | |
| 104 | Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | const handleSpawn = () => { | |
| 109 | child.unref(); | |
| 110 | resume(Effect.void); | |
| 111 | }; | |
| 112 | ||
| 113 | child.once("spawn", handleSpawn); | |
| 114 | child.once("error", (cause) => | |
| 115 | resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), | |
| 116 | ); | |
| 117 | }); | |
| 118 | ||
| 119 | const make = Effect.gen(function* () { | |
| 120 | const open = yield* Effect.tryPromise({ | |
| 121 | try: () => import("open"), | |
| 122 | catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), | |
| 123 | }); | |
| 124 | ||
| 125 | return { | |
| 126 | openBrowser: (target) => | |
| 127 | Effect.tryPromise({ | |
| 128 | try: () => open.default(target), | |
| 129 | catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), | |
| 130 | }), | |
| 131 | openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), | |
| 132 | } satisfies OpenShape; | |
| 133 | }); | |
| 134 | ||
| 135 | export const OpenLive = Layer.effect(Open, make); |
apps/server/src/orchestration/Errors.ts created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | import { SchemaIssue, Schema } from "effect"; | |
| 2 | ||
| 3 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; | |
| 4 | ||
| 5 | export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()( | |
| 6 | "OrchestrationCommandJsonParseError", | |
| 7 | { | |
| 8 | detail: Schema.String, | |
| 9 | cause: Schema.optional(Schema.Defect), | |
| 10 | }, | |
| 11 | ) { | |
| 12 | override get message(): string { | |
| 13 | return `Invalid orchestration command JSON: ${this.detail}`; | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass<OrchestrationCommandDecodeError>()( | |
| 18 | "OrchestrationCommandDecodeError", | |
| 19 | { | |
| 20 | issue: Schema.String, | |
| 21 | cause: Schema.optional(Schema.Defect), | |
| 22 | }, | |
| 23 | ) { | |
| 24 | override get message(): string { | |
| 25 | return `Invalid orchestration command payload: ${this.issue}`; | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<OrchestrationCommandInvariantError>()( | |
| 30 | "OrchestrationCommandInvariantError", | |
| 31 | { | |
| 32 | commandType: Schema.String, | |
| 33 | detail: Schema.String, | |
| 34 | cause: Schema.optional(Schema.Defect), | |
| 35 | }, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return `Orchestration command invariant failed (${this.commandType}): ${this.detail}`; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()( | |
| 43 | "OrchestrationCommandPreviouslyRejectedError", | |
| 44 | { | |
| 45 | commandId: Schema.String, | |
| 46 | detail: Schema.String, | |
| 47 | cause: Schema.optional(Schema.Defect), | |
| 48 | }, | |
| 49 | ) { | |
| 50 | override get message(): string { | |
| 51 | return `Command previously rejected (${this.commandId}): ${this.detail}`; | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()( | |
| 56 | "OrchestrationProjectorDecodeError", | |
| 57 | { | |
| 58 | eventType: Schema.String, | |
| 59 | issue: Schema.String, | |
| 60 | cause: Schema.optional(Schema.Defect), | |
| 61 | }, | |
| 62 | ) { | |
| 63 | override get message(): string { | |
| 64 | return `Projector decode failed for ${this.eventType}: ${this.issue}`; | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<OrchestrationListenerCallbackError>()( | |
| 69 | "OrchestrationListenerCallbackError", | |
| 70 | { | |
| 71 | listener: Schema.Literals(["read-model", "domain-event"]), | |
| 72 | detail: Schema.String, | |
| 73 | cause: Schema.optional(Schema.Defect), | |
| 74 | }, | |
| 75 | ) { | |
| 76 | override get message(): string { | |
| 77 | return `Orchestration ${this.listener} listener failed: ${this.detail}`; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | export type OrchestrationDispatchError = | |
| 82 | | ProjectionRepositoryError | |
| 83 | | OrchestrationCommandInvariantError | |
| 84 | | OrchestrationCommandPreviouslyRejectedError | |
| 85 | | OrchestrationProjectorDecodeError | |
| 86 | | OrchestrationListenerCallbackError; | |
| 87 | ||
| 88 | export type OrchestrationEngineError = | |
| 89 | | OrchestrationDispatchError | |
| 90 | | OrchestrationCommandJsonParseError | |
| 91 | | OrchestrationCommandDecodeError; | |
| 92 | ||
| 93 | export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) { | |
| 94 | return new OrchestrationCommandDecodeError({ | |
| 95 | issue: SchemaIssue.makeFormatterDefault()(error.issue), | |
| 96 | cause: error, | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | export function toProjectorDecodeError(eventType: string) { | |
| 101 | return (error: Schema.SchemaError): OrchestrationProjectorDecodeError => | |
| 102 | new OrchestrationProjectorDecodeError({ | |
| 103 | eventType, | |
| 104 | issue: SchemaIssue.makeFormatterDefault()(error.issue), | |
| 105 | cause: error, | |
| 106 | }); | |
| 107 | } | |
| 108 | ||
| 109 | export function toOrchestrationJsonParseError(cause: unknown) { | |
| 110 | return new OrchestrationCommandJsonParseError({ | |
| 111 | detail: `Failed to parse orchestration command JSON`, | |
| 112 | cause, | |
| 113 | }); | |
| 114 | } | |
| 115 | ||
| 116 | export function toListenerCallbackError(listener: "read-model" | "domain-event") { | |
| 117 | return (cause: unknown): OrchestrationListenerCallbackError => | |
| 118 | new OrchestrationListenerCallbackError({ | |
| 119 | listener, | |
| 120 | detail: `Failed to invoke orchestration ${listener} listener`, | |
| 121 | cause, | |
| 122 | }); | |
| 123 | } |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts created+912| ... | ... | @@ -0,0 +1,912 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { execFileSync } from "node:child_process"; | |
| 5 | ||
| 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | CommandId, | |
| 9 | EventId, | |
| 10 | MessageId, | |
| 11 | ProjectId, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | ThreadId, | |
| 16 | TurnId, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 19 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | |
| 20 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 21 | ||
| 22 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; | |
| 23 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 24 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | |
| 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 27 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 28 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 29 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 30 | import { | |
| 31 | OrchestrationEngineService, | |
| 32 | type OrchestrationEngineShape, | |
| 33 | } from "../Services/OrchestrationEngine.ts"; | |
| 34 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 35 | import { | |
| 36 | ProviderService, | |
| 37 | type ProviderServiceShape, | |
| 38 | } from "../../provider/Services/ProviderService.ts"; | |
| 39 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; | |
| 40 | ||
| 41 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 42 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 43 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 44 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 45 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 46 | ||
| 47 | function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd = cwd) { | |
| 48 | const now = new Date().toISOString(); | |
| 49 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 50 | const rollbackConversation = vi.fn( | |
| 51 | (_input: { readonly sessionId: ProviderSessionId; readonly numTurns: number }) => Effect.void, | |
| 52 | ); | |
| 53 | ||
| 54 | const unsupported = <A>() => | |
| 55 | Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>; | |
| 56 | const listSessions = () => | |
| 57 | hasSession | |
| 58 | ? Effect.succeed([ | |
| 59 | { | |
| 60 | sessionId: asSessionId("sess-1"), | |
| 61 | provider: "codex", | |
| 62 | status: "ready", | |
| 63 | threadId: asProviderThreadId("provider-thread-1"), | |
| 64 | cwd: sessionCwd, | |
| 65 | createdAt: now, | |
| 66 | updatedAt: now, | |
| 67 | }, | |
| 68 | ] satisfies ReadonlyArray<ProviderSession>) | |
| 69 | : Effect.succeed([] as ReadonlyArray<ProviderSession>); | |
| 70 | const service: ProviderServiceShape = { | |
| 71 | startSession: () => unsupported(), | |
| 72 | sendTurn: () => unsupported(), | |
| 73 | interruptTurn: () => unsupported(), | |
| 74 | respondToRequest: () => unsupported(), | |
| 75 | stopSession: () => unsupported(), | |
| 76 | listSessions, | |
| 77 | rollbackConversation, | |
| 78 | stopAll: () => Effect.void, | |
| 79 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 80 | }; | |
| 81 | ||
| 82 | const emit = (event: ProviderRuntimeEvent): void => { | |
| 83 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | |
| 84 | }; | |
| 85 | ||
| 86 | return { | |
| 87 | service, | |
| 88 | rollbackConversation, | |
| 89 | emit, | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | async function waitForThread( | |
| 94 | engine: OrchestrationEngineShape, | |
| 95 | predicate: (thread: { | |
| 96 | latestTurn: { turnId: string } | null; | |
| 97 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; | |
| 98 | activities: ReadonlyArray<{ kind: string }>; | |
| 99 | }) => boolean, | |
| 100 | timeoutMs = 2000, | |
| 101 | ) { | |
| 102 | const deadline = Date.now() + timeoutMs; | |
| 103 | const poll = async (): Promise<{ | |
| 104 | latestTurn: { turnId: string } | null; | |
| 105 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; | |
| 106 | activities: ReadonlyArray<{ kind: string }>; | |
| 107 | }> => { | |
| 108 | const readModel = await Effect.runPromise(engine.getReadModel()); | |
| 109 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 110 | if (thread && predicate(thread)) { | |
| 111 | return thread; | |
| 112 | } | |
| 113 | if (Date.now() >= deadline) { | |
| 114 | throw new Error("Timed out waiting for thread state."); | |
| 115 | } | |
| 116 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 117 | return poll(); | |
| 118 | }; | |
| 119 | return poll(); | |
| 120 | } | |
| 121 | ||
| 122 | async function waitForEvent( | |
| 123 | engine: OrchestrationEngineShape, | |
| 124 | predicate: (event: { type: string }) => boolean, | |
| 125 | timeoutMs = 2000, | |
| 126 | ) { | |
| 127 | const deadline = Date.now() + timeoutMs; | |
| 128 | const poll = async () => { | |
| 129 | const events = await Effect.runPromise( | |
| 130 | Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), | |
| 131 | ); | |
| 132 | if (events.some(predicate)) { | |
| 133 | return events; | |
| 134 | } | |
| 135 | if (Date.now() >= deadline) { | |
| 136 | throw new Error("Timed out waiting for orchestration event."); | |
| 137 | } | |
| 138 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 139 | return poll(); | |
| 140 | }; | |
| 141 | return poll(); | |
| 142 | } | |
| 143 | ||
| 144 | function runGit(cwd: string, args: ReadonlyArray<string>) { | |
| 145 | return execFileSync("git", args, { | |
| 146 | cwd, | |
| 147 | stdio: ["ignore", "pipe", "pipe"], | |
| 148 | encoding: "utf8", | |
| 149 | }); | |
| 150 | } | |
| 151 | ||
| 152 | function createGitRepository() { | |
| 153 | const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-checkpoint-handler-")); | |
| 154 | runGit(cwd, ["init", "--initial-branch=main"]); | |
| 155 | runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 156 | runGit(cwd, ["config", "user.name", "Test User"]); | |
| 157 | fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); | |
| 158 | runGit(cwd, ["add", "."]); | |
| 159 | runGit(cwd, ["commit", "-m", "Initial"]); | |
| 160 | return cwd; | |
| 161 | } | |
| 162 | ||
| 163 | function gitRefExists(cwd: string, ref: string): boolean { | |
| 164 | try { | |
| 165 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); | |
| 166 | return true; | |
| 167 | } catch { | |
| 168 | return false; | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { | |
| 173 | return runGit(cwd, ["show", `${ref}:${filePath}`]); | |
| 174 | } | |
| 175 | ||
| 176 | async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) { | |
| 177 | const deadline = Date.now() + timeoutMs; | |
| 178 | const poll = async (): Promise<void> => { | |
| 179 | if (gitRefExists(cwd, ref)) { | |
| 180 | return; | |
| 181 | } | |
| 182 | if (Date.now() >= deadline) { | |
| 183 | throw new Error(`Timed out waiting for git ref '${ref}'.`); | |
| 184 | } | |
| 185 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 186 | return poll(); | |
| 187 | }; | |
| 188 | return poll(); | |
| 189 | } | |
| 190 | ||
| 191 | describe("CheckpointReactor", () => { | |
| 192 | let runtime: ManagedRuntime.ManagedRuntime< | |
| 193 | OrchestrationEngineService | CheckpointReactor | CheckpointStore, | |
| 194 | unknown | |
| 195 | > | null = null; | |
| 196 | let scope: Scope.Closeable | null = null; | |
| 197 | const tempDirs: string[] = []; | |
| 198 | ||
| 199 | afterEach(async () => { | |
| 200 | if (scope) { | |
| 201 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 202 | } | |
| 203 | scope = null; | |
| 204 | if (runtime) { | |
| 205 | await runtime.dispose(); | |
| 206 | } | |
| 207 | runtime = null; | |
| 208 | while (tempDirs.length > 0) { | |
| 209 | const dir = tempDirs.pop(); | |
| 210 | if (dir) { | |
| 211 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 212 | } | |
| 213 | } | |
| 214 | }); | |
| 215 | ||
| 216 | async function createHarness(options?: { | |
| 217 | readonly hasSession?: boolean; | |
| 218 | readonly seedFilesystemCheckpoints?: boolean; | |
| 219 | readonly projectWorkspaceRoot?: string; | |
| 220 | readonly threadWorktreePath?: string | null; | |
| 221 | readonly providerSessionCwd?: string; | |
| 222 | }) { | |
| 223 | const cwd = createGitRepository(); | |
| 224 | tempDirs.push(cwd); | |
| 225 | const provider = createProviderServiceHarness( | |
| 226 | cwd, | |
| 227 | options?.hasSession ?? true, | |
| 228 | options?.providerSessionCwd ?? cwd, | |
| 229 | ); | |
| 230 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 231 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 232 | Layer.provide(OrchestrationEventStoreLive), | |
| 233 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 234 | Layer.provide(SqlitePersistenceMemory), | |
| 235 | ); | |
| 236 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | |
| 237 | const layer = CheckpointReactorLive.pipe( | |
| 238 | Layer.provideMerge(orchestrationLayer), | |
| 239 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | |
| 240 | Layer.provideMerge(checkpointStoreLayer), | |
| 241 | ); | |
| 242 | ||
| 243 | runtime = ManagedRuntime.make(layer); | |
| 244 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 245 | const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); | |
| 246 | const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); | |
| 247 | scope = await Effect.runPromise(Scope.make("sequential")); | |
| 248 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); | |
| 249 | await Effect.runPromise(Effect.sleep("10 millis")); | |
| 250 | ||
| 251 | const createdAt = new Date().toISOString(); | |
| 252 | await Effect.runPromise( | |
| 253 | engine.dispatch({ | |
| 254 | type: "project.create", | |
| 255 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 256 | projectId: asProjectId("project-1"), | |
| 257 | title: "Test Project", | |
| 258 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, | |
| 259 | defaultModel: "gpt-5-codex", | |
| 260 | createdAt, | |
| 261 | }), | |
| 262 | ); | |
| 263 | await Effect.runPromise( | |
| 264 | engine.dispatch({ | |
| 265 | type: "thread.create", | |
| 266 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 267 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 268 | projectId: asProjectId("project-1"), | |
| 269 | title: "Thread", | |
| 270 | model: "gpt-5-codex", | |
| 271 | branch: null, | |
| 272 | worktreePath: options?.threadWorktreePath ?? cwd, | |
| 273 | createdAt, | |
| 274 | }), | |
| 275 | ); | |
| 276 | ||
| 277 | if (options?.seedFilesystemCheckpoints ?? true) { | |
| 278 | await runtime.runPromise( | |
| 279 | checkpointStore.captureCheckpoint({ | |
| 280 | cwd, | |
| 281 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 282 | }), | |
| 283 | ); | |
| 284 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | |
| 285 | await runtime.runPromise( | |
| 286 | checkpointStore.captureCheckpoint({ | |
| 287 | cwd, | |
| 288 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 289 | }), | |
| 290 | ); | |
| 291 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | |
| 292 | await runtime.runPromise( | |
| 293 | checkpointStore.captureCheckpoint({ | |
| 294 | cwd, | |
| 295 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 296 | }), | |
| 297 | ); | |
| 298 | } | |
| 299 | ||
| 300 | return { | |
| 301 | engine, | |
| 302 | provider, | |
| 303 | cwd, | |
| 304 | }; | |
| 305 | } | |
| 306 | ||
| 307 | it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { | |
| 308 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | |
| 309 | const createdAt = new Date().toISOString(); | |
| 310 | ||
| 311 | await Effect.runPromise( | |
| 312 | harness.engine.dispatch({ | |
| 313 | type: "thread.session.set", | |
| 314 | commandId: CommandId.makeUnsafe("cmd-session-set-capture"), | |
| 315 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 316 | session: { | |
| 317 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 318 | status: "ready", | |
| 319 | providerName: "codex", | |
| 320 | providerSessionId: asSessionId("sess-1"), | |
| 321 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 322 | approvalPolicy: "on-request", | |
| 323 | sandboxMode: "workspace-write", | |
| 324 | activeTurnId: null, | |
| 325 | lastError: null, | |
| 326 | updatedAt: createdAt, | |
| 327 | }, | |
| 328 | createdAt, | |
| 329 | }), | |
| 330 | ); | |
| 331 | ||
| 332 | harness.provider.emit({ | |
| 333 | type: "turn.started", | |
| 334 | eventId: EventId.makeUnsafe("evt-turn-started-1"), | |
| 335 | provider: "codex", | |
| 336 | sessionId: asSessionId("sess-1"), | |
| 337 | createdAt: new Date().toISOString(), | |
| 338 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 339 | turnId: asProviderTurnId("turn-1"), | |
| 340 | }); | |
| 341 | await waitForGitRefExists( | |
| 342 | harness.cwd, | |
| 343 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 344 | ); | |
| 345 | ||
| 346 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 347 | harness.provider.emit({ | |
| 348 | type: "turn.completed", | |
| 349 | eventId: EventId.makeUnsafe("evt-turn-completed-1"), | |
| 350 | provider: "codex", | |
| 351 | sessionId: asSessionId("sess-1"), | |
| 352 | createdAt: new Date().toISOString(), | |
| 353 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 354 | turnId: asProviderTurnId("turn-1"), | |
| 355 | status: "completed", | |
| 356 | }); | |
| 357 | ||
| 358 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | |
| 359 | const thread = await waitForThread( | |
| 360 | harness.engine, | |
| 361 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, | |
| 362 | ); | |
| 363 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 364 | expect( | |
| 365 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), | |
| 366 | ).toBe(true); | |
| 367 | expect( | |
| 368 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), | |
| 369 | ).toBe(true); | |
| 370 | expect( | |
| 371 | gitShowFileAtRef( | |
| 372 | harness.cwd, | |
| 373 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 374 | "README.md", | |
| 375 | ), | |
| 376 | ).toBe("v1\n"); | |
| 377 | expect( | |
| 378 | gitShowFileAtRef( | |
| 379 | harness.cwd, | |
| 380 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 381 | "README.md", | |
| 382 | ), | |
| 383 | ).toBe("v2\n"); | |
| 384 | }); | |
| 385 | ||
| 386 | it("ignores auxiliary thread turn completion while primary turn is active", async () => { | |
| 387 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | |
| 388 | const createdAt = new Date().toISOString(); | |
| 389 | ||
| 390 | await Effect.runPromise( | |
| 391 | harness.engine.dispatch({ | |
| 392 | type: "thread.session.set", | |
| 393 | commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), | |
| 394 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 395 | session: { | |
| 396 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 397 | status: "running", | |
| 398 | providerName: "codex", | |
| 399 | providerSessionId: asSessionId("sess-1"), | |
| 400 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 401 | approvalPolicy: "on-request", | |
| 402 | sandboxMode: "workspace-write", | |
| 403 | activeTurnId: asTurnId("turn-main"), | |
| 404 | lastError: null, | |
| 405 | updatedAt: createdAt, | |
| 406 | }, | |
| 407 | createdAt, | |
| 408 | }), | |
| 409 | ); | |
| 410 | ||
| 411 | harness.provider.emit({ | |
| 412 | type: "turn.started", | |
| 413 | eventId: EventId.makeUnsafe("evt-turn-started-main"), | |
| 414 | provider: "codex", | |
| 415 | sessionId: asSessionId("sess-1"), | |
| 416 | createdAt: new Date().toISOString(), | |
| 417 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 418 | turnId: asProviderTurnId("turn-main"), | |
| 419 | }); | |
| 420 | await waitForGitRefExists( | |
| 421 | harness.cwd, | |
| 422 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 423 | ); | |
| 424 | ||
| 425 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 426 | ||
| 427 | harness.provider.emit({ | |
| 428 | type: "turn.completed", | |
| 429 | eventId: EventId.makeUnsafe("evt-turn-completed-aux"), | |
| 430 | provider: "codex", | |
| 431 | sessionId: asSessionId("sess-1"), | |
| 432 | createdAt: new Date().toISOString(), | |
| 433 | threadId: ProviderThreadId.makeUnsafe("provider-thread-aux"), | |
| 434 | turnId: asProviderTurnId("turn-aux"), | |
| 435 | status: "completed", | |
| 436 | }); | |
| 437 | ||
| 438 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 439 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 440 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 441 | expect(midThread?.checkpoints).toHaveLength(0); | |
| 442 | ||
| 443 | harness.provider.emit({ | |
| 444 | type: "turn.completed", | |
| 445 | eventId: EventId.makeUnsafe("evt-turn-completed-main"), | |
| 446 | provider: "codex", | |
| 447 | sessionId: asSessionId("sess-1"), | |
| 448 | createdAt: new Date().toISOString(), | |
| 449 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 450 | turnId: asProviderTurnId("turn-main"), | |
| 451 | status: "completed", | |
| 452 | }); | |
| 453 | ||
| 454 | const thread = await waitForThread( | |
| 455 | harness.engine, | |
| 456 | (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, | |
| 457 | ); | |
| 458 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 459 | }); | |
| 460 | ||
| 461 | it("appends capture failure activity when turn diff summary cannot be derived", async () => { | |
| 462 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | |
| 463 | const createdAt = new Date().toISOString(); | |
| 464 | ||
| 465 | await Effect.runPromise( | |
| 466 | harness.engine.dispatch({ | |
| 467 | type: "thread.session.set", | |
| 468 | commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), | |
| 469 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 470 | session: { | |
| 471 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 472 | status: "ready", | |
| 473 | providerName: "codex", | |
| 474 | providerSessionId: asSessionId("sess-1"), | |
| 475 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 476 | approvalPolicy: "on-request", | |
| 477 | sandboxMode: "workspace-write", | |
| 478 | activeTurnId: null, | |
| 479 | lastError: null, | |
| 480 | updatedAt: createdAt, | |
| 481 | }, | |
| 482 | createdAt, | |
| 483 | }), | |
| 484 | ); | |
| 485 | ||
| 486 | harness.provider.emit({ | |
| 487 | type: "turn.completed", | |
| 488 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), | |
| 489 | provider: "codex", | |
| 490 | sessionId: asSessionId("sess-1"), | |
| 491 | createdAt: new Date().toISOString(), | |
| 492 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 493 | turnId: asProviderTurnId("turn-missing-baseline"), | |
| 494 | status: "completed", | |
| 495 | }); | |
| 496 | ||
| 497 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | |
| 498 | const thread = await waitForThread( | |
| 499 | harness.engine, | |
| 500 | (entry) => | |
| 501 | entry.checkpoints.length === 1 && | |
| 502 | entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), | |
| 503 | ); | |
| 504 | ||
| 505 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 506 | expect( | |
| 507 | thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), | |
| 508 | ).toBe(true); | |
| 509 | }); | |
| 510 | ||
| 511 | it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { | |
| 512 | const harness = await createHarness({ | |
| 513 | hasSession: false, | |
| 514 | seedFilesystemCheckpoints: false, | |
| 515 | threadWorktreePath: null, | |
| 516 | }); | |
| 517 | ||
| 518 | await Effect.runPromise( | |
| 519 | harness.engine.dispatch({ | |
| 520 | type: "thread.turn.start", | |
| 521 | commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), | |
| 522 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 523 | message: { | |
| 524 | messageId: MessageId.makeUnsafe("message-user-1"), | |
| 525 | role: "user", | |
| 526 | text: "start turn", | |
| 527 | attachments: [], | |
| 528 | }, | |
| 529 | approvalPolicy: "on-request", | |
| 530 | sandboxMode: "workspace-write", | |
| 531 | createdAt: new Date().toISOString(), | |
| 532 | }), | |
| 533 | ); | |
| 534 | ||
| 535 | await waitForGitRefExists( | |
| 536 | harness.cwd, | |
| 537 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 538 | ); | |
| 539 | expect( | |
| 540 | gitShowFileAtRef( | |
| 541 | harness.cwd, | |
| 542 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 543 | "README.md", | |
| 544 | ), | |
| 545 | ).toBe("v1\n"); | |
| 546 | }); | |
| 547 | ||
| 548 | it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { | |
| 549 | const harness = await createHarness({ | |
| 550 | hasSession: false, | |
| 551 | seedFilesystemCheckpoints: false, | |
| 552 | threadWorktreePath: null, | |
| 553 | }); | |
| 554 | const createdAt = new Date().toISOString(); | |
| 555 | ||
| 556 | await Effect.runPromise( | |
| 557 | harness.engine.dispatch({ | |
| 558 | type: "thread.session.set", | |
| 559 | commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), | |
| 560 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 561 | session: { | |
| 562 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 563 | status: "running", | |
| 564 | providerName: "codex", | |
| 565 | providerSessionId: asSessionId("sess-missing"), | |
| 566 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), | |
| 567 | approvalPolicy: "on-request", | |
| 568 | sandboxMode: "workspace-write", | |
| 569 | activeTurnId: asTurnId("turn-missing-cwd"), | |
| 570 | lastError: null, | |
| 571 | updatedAt: createdAt, | |
| 572 | }, | |
| 573 | createdAt, | |
| 574 | }), | |
| 575 | ); | |
| 576 | ||
| 577 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); | |
| 578 | harness.provider.emit({ | |
| 579 | type: "turn.completed", | |
| 580 | eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), | |
| 581 | provider: "codex", | |
| 582 | sessionId: asSessionId("sess-missing"), | |
| 583 | createdAt: new Date().toISOString(), | |
| 584 | threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), | |
| 585 | turnId: asProviderTurnId("turn-missing-cwd"), | |
| 586 | status: "completed", | |
| 587 | }); | |
| 588 | ||
| 589 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | |
| 590 | expect( | |
| 591 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), | |
| 592 | ).toBe(true); | |
| 593 | expect( | |
| 594 | gitShowFileAtRef( | |
| 595 | harness.cwd, | |
| 596 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 597 | "README.md", | |
| 598 | ), | |
| 599 | ).toBe("v2\n"); | |
| 600 | }); | |
| 601 | ||
| 602 | it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => { | |
| 603 | const harness = await createHarness(); | |
| 604 | const createdAt = new Date().toISOString(); | |
| 605 | ||
| 606 | await Effect.runPromise( | |
| 607 | harness.engine.dispatch({ | |
| 608 | type: "thread.session.set", | |
| 609 | commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), | |
| 610 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 611 | session: { | |
| 612 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 613 | status: "ready", | |
| 614 | providerName: "codex", | |
| 615 | providerSessionId: asSessionId("sess-1"), | |
| 616 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 617 | approvalPolicy: "on-request", | |
| 618 | sandboxMode: "workspace-write", | |
| 619 | activeTurnId: null, | |
| 620 | lastError: null, | |
| 621 | updatedAt: createdAt, | |
| 622 | }, | |
| 623 | createdAt, | |
| 624 | }), | |
| 625 | ); | |
| 626 | ||
| 627 | harness.provider.emit({ | |
| 628 | type: "checkpoint.captured", | |
| 629 | eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), | |
| 630 | provider: "codex", | |
| 631 | sessionId: asSessionId("sess-1"), | |
| 632 | createdAt: new Date().toISOString(), | |
| 633 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 634 | turnId: asProviderTurnId("turn-3"), | |
| 635 | turnCount: 3, | |
| 636 | status: "completed", | |
| 637 | }); | |
| 638 | ||
| 639 | const thread = await waitForThread(harness.engine, (entry) => | |
| 640 | entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), | |
| 641 | ); | |
| 642 | expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( | |
| 643 | true, | |
| 644 | ); | |
| 645 | }); | |
| 646 | ||
| 647 | it("continues processing runtime events after a single checkpoint runtime failure", async () => { | |
| 648 | const nonRepositorySessionCwd = fs.mkdtempSync( | |
| 649 | path.join(os.tmpdir(), "t3-checkpoint-runtime-non-repo-"), | |
| 650 | ); | |
| 651 | tempDirs.push(nonRepositorySessionCwd); | |
| 652 | ||
| 653 | const harness = await createHarness({ | |
| 654 | seedFilesystemCheckpoints: false, | |
| 655 | providerSessionCwd: nonRepositorySessionCwd, | |
| 656 | }); | |
| 657 | const createdAt = new Date().toISOString(); | |
| 658 | ||
| 659 | await Effect.runPromise( | |
| 660 | harness.engine.dispatch({ | |
| 661 | type: "thread.session.set", | |
| 662 | commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"), | |
| 663 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 664 | session: { | |
| 665 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 666 | status: "ready", | |
| 667 | providerName: "codex", | |
| 668 | providerSessionId: asSessionId("sess-1"), | |
| 669 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 670 | approvalPolicy: "on-request", | |
| 671 | sandboxMode: "workspace-write", | |
| 672 | activeTurnId: null, | |
| 673 | lastError: null, | |
| 674 | updatedAt: createdAt, | |
| 675 | }, | |
| 676 | createdAt, | |
| 677 | }), | |
| 678 | ); | |
| 679 | ||
| 680 | harness.provider.emit({ | |
| 681 | type: "checkpoint.captured", | |
| 682 | eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), | |
| 683 | provider: "codex", | |
| 684 | sessionId: asSessionId("sess-1"), | |
| 685 | createdAt: new Date().toISOString(), | |
| 686 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 687 | turnId: asProviderTurnId("turn-runtime-failure"), | |
| 688 | turnCount: 1, | |
| 689 | status: "completed", | |
| 690 | }); | |
| 691 | ||
| 692 | harness.provider.emit({ | |
| 693 | type: "turn.started", | |
| 694 | eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), | |
| 695 | provider: "codex", | |
| 696 | sessionId: asSessionId("sess-1"), | |
| 697 | createdAt: new Date().toISOString(), | |
| 698 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 699 | turnId: asProviderTurnId("turn-after-runtime-failure"), | |
| 700 | }); | |
| 701 | ||
| 702 | await waitForGitRefExists( | |
| 703 | harness.cwd, | |
| 704 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 705 | ); | |
| 706 | expect( | |
| 707 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), | |
| 708 | ).toBe(true); | |
| 709 | }); | |
| 710 | ||
| 711 | it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { | |
| 712 | const harness = await createHarness(); | |
| 713 | const createdAt = new Date().toISOString(); | |
| 714 | ||
| 715 | await Effect.runPromise( | |
| 716 | harness.engine.dispatch({ | |
| 717 | type: "thread.session.set", | |
| 718 | commandId: CommandId.makeUnsafe("cmd-session-set"), | |
| 719 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 720 | session: { | |
| 721 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 722 | status: "ready", | |
| 723 | providerName: "codex", | |
| 724 | providerSessionId: asSessionId("sess-1"), | |
| 725 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 726 | approvalPolicy: "on-request", | |
| 727 | sandboxMode: "workspace-write", | |
| 728 | activeTurnId: null, | |
| 729 | lastError: null, | |
| 730 | updatedAt: createdAt, | |
| 731 | }, | |
| 732 | createdAt, | |
| 733 | }), | |
| 734 | ); | |
| 735 | ||
| 736 | await Effect.runPromise( | |
| 737 | harness.engine.dispatch({ | |
| 738 | type: "thread.turn.diff.complete", | |
| 739 | commandId: CommandId.makeUnsafe("cmd-diff-1"), | |
| 740 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 741 | turnId: asTurnId("turn-1"), | |
| 742 | completedAt: createdAt, | |
| 743 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 744 | status: "ready", | |
| 745 | files: [], | |
| 746 | checkpointTurnCount: 1, | |
| 747 | createdAt, | |
| 748 | }), | |
| 749 | ); | |
| 750 | await Effect.runPromise( | |
| 751 | harness.engine.dispatch({ | |
| 752 | type: "thread.turn.diff.complete", | |
| 753 | commandId: CommandId.makeUnsafe("cmd-diff-2"), | |
| 754 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 755 | turnId: asTurnId("turn-2"), | |
| 756 | completedAt: createdAt, | |
| 757 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 758 | status: "ready", | |
| 759 | files: [], | |
| 760 | checkpointTurnCount: 2, | |
| 761 | createdAt, | |
| 762 | }), | |
| 763 | ); | |
| 764 | ||
| 765 | await Effect.runPromise( | |
| 766 | harness.engine.dispatch({ | |
| 767 | type: "thread.checkpoint.revert", | |
| 768 | commandId: CommandId.makeUnsafe("cmd-revert-request"), | |
| 769 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 770 | turnCount: 1, | |
| 771 | createdAt, | |
| 772 | }), | |
| 773 | ); | |
| 774 | ||
| 775 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); | |
| 776 | const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); | |
| 777 | ||
| 778 | expect(thread.latestTurn?.turnId).toBe("turn-1"); | |
| 779 | expect(thread.checkpoints).toHaveLength(1); | |
| 780 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | |
| 781 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); | |
| 782 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ | |
| 783 | sessionId: asSessionId("sess-1"), | |
| 784 | numTurns: 1, | |
| 785 | }); | |
| 786 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); | |
| 787 | expect( | |
| 788 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), | |
| 789 | ).toBe(false); | |
| 790 | }); | |
| 791 | ||
| 792 | it("processes consecutive revert requests with deterministic rollback sequencing", async () => { | |
| 793 | const harness = await createHarness(); | |
| 794 | const createdAt = new Date().toISOString(); | |
| 795 | ||
| 796 | await Effect.runPromise( | |
| 797 | harness.engine.dispatch({ | |
| 798 | type: "thread.session.set", | |
| 799 | commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"), | |
| 800 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 801 | session: { | |
| 802 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 803 | status: "ready", | |
| 804 | providerName: "codex", | |
| 805 | providerSessionId: asSessionId("sess-1"), | |
| 806 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 807 | approvalPolicy: "on-request", | |
| 808 | sandboxMode: "workspace-write", | |
| 809 | activeTurnId: null, | |
| 810 | lastError: null, | |
| 811 | updatedAt: createdAt, | |
| 812 | }, | |
| 813 | createdAt, | |
| 814 | }), | |
| 815 | ); | |
| 816 | ||
| 817 | await Effect.runPromise( | |
| 818 | harness.engine.dispatch({ | |
| 819 | type: "thread.turn.diff.complete", | |
| 820 | commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"), | |
| 821 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 822 | turnId: asTurnId("turn-1"), | |
| 823 | completedAt: createdAt, | |
| 824 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 825 | status: "ready", | |
| 826 | files: [], | |
| 827 | checkpointTurnCount: 1, | |
| 828 | createdAt, | |
| 829 | }), | |
| 830 | ); | |
| 831 | await Effect.runPromise( | |
| 832 | harness.engine.dispatch({ | |
| 833 | type: "thread.turn.diff.complete", | |
| 834 | commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"), | |
| 835 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 836 | turnId: asTurnId("turn-2"), | |
| 837 | completedAt: createdAt, | |
| 838 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 839 | status: "ready", | |
| 840 | files: [], | |
| 841 | checkpointTurnCount: 2, | |
| 842 | createdAt, | |
| 843 | }), | |
| 844 | ); | |
| 845 | ||
| 846 | await Effect.runPromise( | |
| 847 | harness.engine.dispatch({ | |
| 848 | type: "thread.checkpoint.revert", | |
| 849 | commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"), | |
| 850 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 851 | turnCount: 1, | |
| 852 | createdAt, | |
| 853 | }), | |
| 854 | ); | |
| 855 | await Effect.runPromise( | |
| 856 | harness.engine.dispatch({ | |
| 857 | type: "thread.checkpoint.revert", | |
| 858 | commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"), | |
| 859 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 860 | turnCount: 0, | |
| 861 | createdAt, | |
| 862 | }), | |
| 863 | ); | |
| 864 | ||
| 865 | const deadline = Date.now() + 2000; | |
| 866 | const waitForRollbackCalls = async (): Promise<void> => { | |
| 867 | if (harness.provider.rollbackConversation.mock.calls.length >= 2) { | |
| 868 | return; | |
| 869 | } | |
| 870 | if (Date.now() >= deadline) { | |
| 871 | throw new Error("Timed out waiting for rollbackConversation calls."); | |
| 872 | } | |
| 873 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 874 | return waitForRollbackCalls(); | |
| 875 | }; | |
| 876 | await waitForRollbackCalls(); | |
| 877 | ||
| 878 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); | |
| 879 | expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ | |
| 880 | sessionId: asSessionId("sess-1"), | |
| 881 | numTurns: 1, | |
| 882 | }); | |
| 883 | expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ | |
| 884 | sessionId: asSessionId("sess-1"), | |
| 885 | numTurns: 1, | |
| 886 | }); | |
| 887 | }); | |
| 888 | ||
| 889 | it("appends an error activity when revert is requested without an active session", async () => { | |
| 890 | const harness = await createHarness({ hasSession: false }); | |
| 891 | const createdAt = new Date().toISOString(); | |
| 892 | ||
| 893 | await Effect.runPromise( | |
| 894 | harness.engine.dispatch({ | |
| 895 | type: "thread.checkpoint.revert", | |
| 896 | commandId: CommandId.makeUnsafe("cmd-revert-no-session"), | |
| 897 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 898 | turnCount: 1, | |
| 899 | createdAt, | |
| 900 | }), | |
| 901 | ); | |
| 902 | ||
| 903 | const thread = await waitForThread(harness.engine, (entry) => | |
| 904 | entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), | |
| 905 | ); | |
| 906 | ||
| 907 | expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( | |
| 908 | true, | |
| 909 | ); | |
| 910 | expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); | |
| 911 | }); | |
| 912 | }); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts created+737| ... | ... | @@ -0,0 +1,737 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProviderSessionId, | |
| 6 | ProviderThreadId, | |
| 7 | ThreadId, | |
| 8 | TurnId, | |
| 9 | type OrchestrationEvent, | |
| 10 | type ProviderRuntimeEvent, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { Cause, Effect, Layer, Option, Queue, Stream } from "effect"; | |
| 13 | ||
| 14 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | |
| 15 | import { | |
| 16 | checkpointRefForThreadTurn, | |
| 17 | resolveThreadWorkspaceCwd, | |
| 18 | } from "../../checkpointing/Utils.ts"; | |
| 19 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 21 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; | |
| 22 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 23 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | |
| 24 | import { OrchestrationDispatchError } from "../Errors.ts"; | |
| 25 | ||
| 26 | type ReactorInput = | |
| 27 | | { | |
| 28 | readonly source: "runtime"; | |
| 29 | readonly event: ProviderRuntimeEvent; | |
| 30 | } | |
| 31 | | { | |
| 32 | readonly source: "domain"; | |
| 33 | readonly event: OrchestrationEvent; | |
| 34 | }; | |
| 35 | ||
| 36 | function toTurnId(value: string | undefined): TurnId | null { | |
| 37 | return value === undefined ? null : TurnId.makeUnsafe(value); | |
| 38 | } | |
| 39 | ||
| 40 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 41 | return value === undefined ? null : ProviderThreadId.makeUnsafe(value); | |
| 42 | } | |
| 43 | ||
| 44 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { | |
| 45 | if (left === null || left === undefined || right === null || right === undefined) { | |
| 46 | return false; | |
| 47 | } | |
| 48 | return left === right; | |
| 49 | } | |
| 50 | ||
| 51 | function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { | |
| 52 | switch (status) { | |
| 53 | case "failed": | |
| 54 | return "error"; | |
| 55 | case "cancelled": | |
| 56 | case "interrupted": | |
| 57 | return "missing"; | |
| 58 | case "completed": | |
| 59 | default: | |
| 60 | return "ready"; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | const serverCommandId = (tag: string): CommandId => | |
| 65 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 66 | ||
| 67 | const make = Effect.gen(function* () { | |
| 68 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 69 | const providerService = yield* ProviderService; | |
| 70 | const checkpointStore = yield* CheckpointStore; | |
| 71 | ||
| 72 | const appendRevertFailureActivity = (input: { | |
| 73 | readonly threadId: ThreadId; | |
| 74 | readonly turnCount: number; | |
| 75 | readonly detail: string; | |
| 76 | readonly createdAt: string; | |
| 77 | }) => | |
| 78 | orchestrationEngine.dispatch({ | |
| 79 | type: "thread.activity.append", | |
| 80 | commandId: serverCommandId("checkpoint-revert-failure"), | |
| 81 | threadId: input.threadId, | |
| 82 | activity: { | |
| 83 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 84 | tone: "error", | |
| 85 | kind: "checkpoint.revert.failed", | |
| 86 | summary: "Checkpoint revert failed", | |
| 87 | payload: { | |
| 88 | turnCount: input.turnCount, | |
| 89 | detail: input.detail, | |
| 90 | }, | |
| 91 | turnId: null, | |
| 92 | createdAt: input.createdAt, | |
| 93 | }, | |
| 94 | createdAt: input.createdAt, | |
| 95 | }); | |
| 96 | ||
| 97 | const appendCaptureFailureActivity = (input: { | |
| 98 | readonly sessionId: ProviderSessionId; | |
| 99 | readonly turnId: TurnId | null; | |
| 100 | readonly detail: string; | |
| 101 | readonly createdAt: string; | |
| 102 | }) => | |
| 103 | Effect.gen(function* () { | |
| 104 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 105 | const thread = readModel.threads.find( | |
| 106 | (entry) => entry.session?.providerSessionId === input.sessionId, | |
| 107 | ); | |
| 108 | if (!thread) { | |
| 109 | return; | |
| 110 | } | |
| 111 | yield* orchestrationEngine.dispatch({ | |
| 112 | type: "thread.activity.append", | |
| 113 | commandId: serverCommandId("checkpoint-capture-failure"), | |
| 114 | threadId: thread.id, | |
| 115 | activity: { | |
| 116 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 117 | tone: "error", | |
| 118 | kind: "checkpoint.capture.failed", | |
| 119 | summary: "Checkpoint capture failed", | |
| 120 | payload: { | |
| 121 | detail: input.detail, | |
| 122 | }, | |
| 123 | turnId: input.turnId, | |
| 124 | createdAt: input.createdAt, | |
| 125 | }, | |
| 126 | createdAt: input.createdAt, | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( | |
| 131 | threadId: ThreadId, | |
| 132 | ): Effect.fn.Return< | |
| 133 | Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> | |
| 134 | > { | |
| 135 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 136 | const thread = readModel.threads.find((entry) => entry.id === threadId); | |
| 137 | const projectedSessionId = thread?.session?.providerSessionId ?? null; | |
| 138 | const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined; | |
| 139 | ||
| 140 | const sessions = yield* providerService.listSessions(); | |
| 141 | ||
| 142 | const findSessionWithCwd = ( | |
| 143 | session: (typeof sessions)[number] | undefined, | |
| 144 | ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { | |
| 145 | if (!session?.cwd) { | |
| 146 | return Option.none(); | |
| 147 | } | |
| 148 | return Option.some({ sessionId: session.sessionId, cwd: session.cwd }); | |
| 149 | }; | |
| 150 | ||
| 151 | if (projectedSessionId !== null) { | |
| 152 | const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId); | |
| 153 | const fromProjected = findSessionWithCwd(projectedSession); | |
| 154 | if (Option.isSome(fromProjected)) { | |
| 155 | return fromProjected; | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | if (projectedProviderThreadId) { | |
| 160 | const matchedSession = sessions.find( | |
| 161 | (session) => session.threadId === projectedProviderThreadId, | |
| 162 | ); | |
| 163 | const fromProviderThread = findSessionWithCwd(matchedSession); | |
| 164 | if (Option.isSome(fromProviderThread)) { | |
| 165 | return fromProviderThread; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | return Option.none(); | |
| 170 | }); | |
| 171 | ||
| 172 | const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( | |
| 173 | event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>, | |
| 174 | ) { | |
| 175 | const turnId = toTurnId(event.turnId); | |
| 176 | if (!turnId) { | |
| 177 | return; | |
| 178 | } | |
| 179 | ||
| 180 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 181 | const thread = readModel.threads.find( | |
| 182 | (entry) => entry.session?.providerSessionId === event.sessionId, | |
| 183 | ); | |
| 184 | if (!thread) { | |
| 185 | return; | |
| 186 | } | |
| 187 | ||
| 188 | const projectedProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 189 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 190 | if ( | |
| 191 | projectedProviderThreadId !== null && | |
| 192 | eventProviderThreadId !== null && | |
| 193 | !sameId(projectedProviderThreadId, eventProviderThreadId) | |
| 194 | ) { | |
| 195 | return; | |
| 196 | } | |
| 197 | ||
| 198 | // When a primary turn is active, only that turn may produce completion checkpoints. | |
| 199 | if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) { | |
| 200 | return; | |
| 201 | } | |
| 202 | ||
| 203 | if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId)) { | |
| 204 | return; | |
| 205 | } | |
| 206 | ||
| 207 | const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); | |
| 208 | const checkpointCwd = | |
| 209 | Option.match(sessionRuntime, { | |
| 210 | onNone: () => undefined, | |
| 211 | onSome: (runtime) => runtime.cwd, | |
| 212 | }) ?? | |
| 213 | resolveThreadWorkspaceCwd({ | |
| 214 | thread, | |
| 215 | projects: readModel.projects, | |
| 216 | }); | |
| 217 | if (!checkpointCwd) { | |
| 218 | yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", { | |
| 219 | threadId: thread.id, | |
| 220 | turnId, | |
| 221 | }); | |
| 222 | return; | |
| 223 | } | |
| 224 | ||
| 225 | const currentTurnCount = thread.checkpoints.reduce( | |
| 226 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), | |
| 227 | 0, | |
| 228 | ); | |
| 229 | const nextTurnCount = currentTurnCount + 1; | |
| 230 | const fromTurnCount = Math.max(0, nextTurnCount - 1); | |
| 231 | const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount); | |
| 232 | const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount); | |
| 233 | ||
| 234 | const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({ | |
| 235 | cwd: checkpointCwd, | |
| 236 | checkpointRef: fromCheckpointRef, | |
| 237 | }); | |
| 238 | if (!fromCheckpointExists) { | |
| 239 | yield* Effect.logWarning("checkpoint completion missing pre-turn baseline", { | |
| 240 | threadId: thread.id, | |
| 241 | turnId, | |
| 242 | fromTurnCount, | |
| 243 | }); | |
| 244 | } | |
| 245 | ||
| 246 | yield* checkpointStore.captureCheckpoint({ | |
| 247 | cwd: checkpointCwd, | |
| 248 | checkpointRef: targetCheckpointRef, | |
| 249 | }); | |
| 250 | ||
| 251 | const files = yield* checkpointStore | |
| 252 | .diffCheckpoints({ | |
| 253 | cwd: checkpointCwd, | |
| 254 | fromCheckpointRef, | |
| 255 | toCheckpointRef: targetCheckpointRef, | |
| 256 | fallbackFromToHead: false, | |
| 257 | }) | |
| 258 | .pipe( | |
| 259 | Effect.map((diff) => | |
| 260 | parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ | |
| 261 | path: file.path, | |
| 262 | kind: "modified" as const, | |
| 263 | additions: file.additions, | |
| 264 | deletions: file.deletions, | |
| 265 | })), | |
| 266 | ), | |
| 267 | Effect.tapError((error) => | |
| 268 | appendCaptureFailureActivity({ | |
| 269 | sessionId: event.sessionId, | |
| 270 | turnId, | |
| 271 | detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, | |
| 272 | createdAt: event.createdAt, | |
| 273 | }), | |
| 274 | ), | |
| 275 | Effect.catch((error) => | |
| 276 | Effect.logWarning("failed to derive checkpoint file summary", { | |
| 277 | threadId: thread.id, | |
| 278 | turnId, | |
| 279 | turnCount: nextTurnCount, | |
| 280 | detail: error.message, | |
| 281 | }).pipe(Effect.as([])), | |
| 282 | ), | |
| 283 | ); | |
| 284 | ||
| 285 | const assistantMessageId = | |
| 286 | thread.messages | |
| 287 | .toReversed() | |
| 288 | .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ?? | |
| 289 | MessageId.makeUnsafe(`assistant:${turnId}`); | |
| 290 | ||
| 291 | const now = event.createdAt; | |
| 292 | yield* orchestrationEngine.dispatch({ | |
| 293 | type: "thread.turn.diff.complete", | |
| 294 | commandId: serverCommandId("checkpoint-turn-diff-complete"), | |
| 295 | threadId: thread.id, | |
| 296 | turnId, | |
| 297 | completedAt: now, | |
| 298 | checkpointRef: targetCheckpointRef, | |
| 299 | status: checkpointStatusFromRuntime(event.status), | |
| 300 | files, | |
| 301 | assistantMessageId, | |
| 302 | checkpointTurnCount: nextTurnCount, | |
| 303 | createdAt: now, | |
| 304 | }); | |
| 305 | ||
| 306 | yield* orchestrationEngine.dispatch({ | |
| 307 | type: "thread.activity.append", | |
| 308 | commandId: serverCommandId("checkpoint-captured-activity"), | |
| 309 | threadId: thread.id, | |
| 310 | activity: { | |
| 311 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 312 | tone: "info", | |
| 313 | kind: "checkpoint.captured", | |
| 314 | summary: "Checkpoint captured", | |
| 315 | payload: { | |
| 316 | turnCount: nextTurnCount, | |
| 317 | ...(event.status !== undefined ? { status: event.status } : {}), | |
| 318 | }, | |
| 319 | turnId, | |
| 320 | createdAt: now, | |
| 321 | }, | |
| 322 | createdAt: now, | |
| 323 | }); | |
| 324 | }); | |
| 325 | ||
| 326 | const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* ( | |
| 327 | event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>, | |
| 328 | ) { | |
| 329 | const turnId = toTurnId(event.turnId); | |
| 330 | if (!turnId) { | |
| 331 | return; | |
| 332 | } | |
| 333 | ||
| 334 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 335 | const thread = readModel.threads.find( | |
| 336 | (entry) => entry.session?.providerSessionId === event.sessionId, | |
| 337 | ); | |
| 338 | if (!thread) { | |
| 339 | return; | |
| 340 | } | |
| 341 | ||
| 342 | const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ | |
| 343 | thread, | |
| 344 | projects: readModel.projects, | |
| 345 | }); | |
| 346 | const checkpointCwd = | |
| 347 | checkpointCwdFromThreadOrProject ?? | |
| 348 | Option.match(yield* resolveSessionRuntimeForThread(thread.id), { | |
| 349 | onNone: () => undefined, | |
| 350 | onSome: (runtime) => runtime.cwd, | |
| 351 | }); | |
| 352 | if (!checkpointCwd) { | |
| 353 | yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { | |
| 354 | threadId: thread.id, | |
| 355 | turnId, | |
| 356 | }); | |
| 357 | return; | |
| 358 | } | |
| 359 | ||
| 360 | const currentTurnCount = thread.checkpoints.reduce( | |
| 361 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), | |
| 362 | 0, | |
| 363 | ); | |
| 364 | const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount); | |
| 365 | const baselineExists = yield* checkpointStore.hasCheckpointRef({ | |
| 366 | cwd: checkpointCwd, | |
| 367 | checkpointRef: baselineCheckpointRef, | |
| 368 | }); | |
| 369 | if (baselineExists) { | |
| 370 | return; | |
| 371 | } | |
| 372 | ||
| 373 | yield* checkpointStore.captureCheckpoint({ | |
| 374 | cwd: checkpointCwd, | |
| 375 | checkpointRef: baselineCheckpointRef, | |
| 376 | }); | |
| 377 | }); | |
| 378 | ||
| 379 | const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( | |
| 380 | event: Extract< | |
| 381 | OrchestrationEvent, | |
| 382 | { type: "thread.turn-start-requested" | "thread.message-sent" } | |
| 383 | >, | |
| 384 | ) { | |
| 385 | if (event.type === "thread.message-sent") { | |
| 386 | if ( | |
| 387 | event.payload.role !== "user" || | |
| 388 | event.payload.streaming || | |
| 389 | event.payload.turnId !== null | |
| 390 | ) { | |
| 391 | return; | |
| 392 | } | |
| 393 | } | |
| 394 | ||
| 395 | const threadId = event.payload.threadId; | |
| 396 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 397 | const thread = readModel.threads.find((entry) => entry.id === threadId); | |
| 398 | if (!thread) { | |
| 399 | return; | |
| 400 | } | |
| 401 | ||
| 402 | const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ | |
| 403 | thread, | |
| 404 | projects: readModel.projects, | |
| 405 | }); | |
| 406 | const checkpointCwd = | |
| 407 | checkpointCwdFromThreadOrProject ?? | |
| 408 | Option.match(yield* resolveSessionRuntimeForThread(threadId), { | |
| 409 | onNone: () => undefined, | |
| 410 | onSome: (runtime) => runtime.cwd, | |
| 411 | }); | |
| 412 | if (!checkpointCwd) { | |
| 413 | yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { | |
| 414 | threadId, | |
| 415 | }); | |
| 416 | return; | |
| 417 | } | |
| 418 | ||
| 419 | const currentTurnCount = thread.checkpoints.reduce( | |
| 420 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), | |
| 421 | 0, | |
| 422 | ); | |
| 423 | const baselineCheckpointRef = checkpointRefForThreadTurn(threadId, currentTurnCount); | |
| 424 | const baselineExists = yield* checkpointStore.hasCheckpointRef({ | |
| 425 | cwd: checkpointCwd, | |
| 426 | checkpointRef: baselineCheckpointRef, | |
| 427 | }); | |
| 428 | if (baselineExists) { | |
| 429 | return; | |
| 430 | } | |
| 431 | ||
| 432 | yield* checkpointStore.captureCheckpoint({ | |
| 433 | cwd: checkpointCwd, | |
| 434 | checkpointRef: baselineCheckpointRef, | |
| 435 | }); | |
| 436 | }); | |
| 437 | ||
| 438 | const handleRevertRequested = Effect.fnUntraced(function* ( | |
| 439 | event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>, | |
| 440 | ) { | |
| 441 | const now = new Date().toISOString(); | |
| 442 | ||
| 443 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 444 | const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId); | |
| 445 | if (!thread) { | |
| 446 | yield* appendRevertFailureActivity({ | |
| 447 | threadId: event.payload.threadId, | |
| 448 | turnCount: event.payload.turnCount, | |
| 449 | detail: "Thread was not found in read model.", | |
| 450 | createdAt: now, | |
| 451 | }).pipe(Effect.catch(() => Effect.void)); | |
| 452 | return; | |
| 453 | } | |
| 454 | ||
| 455 | const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId); | |
| 456 | if (Option.isNone(sessionRuntime)) { | |
| 457 | yield* appendRevertFailureActivity({ | |
| 458 | threadId: event.payload.threadId, | |
| 459 | turnCount: event.payload.turnCount, | |
| 460 | detail: "No active provider session with workspace cwd is bound to this thread.", | |
| 461 | createdAt: now, | |
| 462 | }).pipe(Effect.catch(() => Effect.void)); | |
| 463 | return; | |
| 464 | } | |
| 465 | ||
| 466 | const currentTurnCount = thread.checkpoints.reduce( | |
| 467 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), | |
| 468 | 0, | |
| 469 | ); | |
| 470 | ||
| 471 | if (event.payload.turnCount > currentTurnCount) { | |
| 472 | yield* appendRevertFailureActivity({ | |
| 473 | threadId: event.payload.threadId, | |
| 474 | turnCount: event.payload.turnCount, | |
| 475 | detail: `Checkpoint turn count ${event.payload.turnCount} exceeds current turn count ${currentTurnCount}.`, | |
| 476 | createdAt: now, | |
| 477 | }).pipe(Effect.catch(() => Effect.void)); | |
| 478 | return; | |
| 479 | } | |
| 480 | ||
| 481 | const targetCheckpointRef = | |
| 482 | event.payload.turnCount === 0 | |
| 483 | ? checkpointRefForThreadTurn(event.payload.threadId, 0) | |
| 484 | : thread.checkpoints.find( | |
| 485 | (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount, | |
| 486 | )?.checkpointRef; | |
| 487 | ||
| 488 | if (!targetCheckpointRef) { | |
| 489 | yield* appendRevertFailureActivity({ | |
| 490 | threadId: event.payload.threadId, | |
| 491 | turnCount: event.payload.turnCount, | |
| 492 | detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`, | |
| 493 | createdAt: now, | |
| 494 | }).pipe(Effect.catch(() => Effect.void)); | |
| 495 | return; | |
| 496 | } | |
| 497 | ||
| 498 | const restored = yield* checkpointStore.restoreCheckpoint({ | |
| 499 | cwd: sessionRuntime.value.cwd, | |
| 500 | checkpointRef: targetCheckpointRef, | |
| 501 | fallbackToHead: event.payload.turnCount === 0, | |
| 502 | }); | |
| 503 | if (!restored) { | |
| 504 | yield* appendRevertFailureActivity({ | |
| 505 | threadId: event.payload.threadId, | |
| 506 | turnCount: event.payload.turnCount, | |
| 507 | detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, | |
| 508 | createdAt: now, | |
| 509 | }).pipe(Effect.catch(() => Effect.void)); | |
| 510 | return; | |
| 511 | } | |
| 512 | ||
| 513 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); | |
| 514 | if (rolledBackTurns > 0) { | |
| 515 | yield* providerService.rollbackConversation({ | |
| 516 | sessionId: sessionRuntime.value.sessionId, | |
| 517 | numTurns: rolledBackTurns, | |
| 518 | }); | |
| 519 | } | |
| 520 | ||
| 521 | const staleCheckpointRefs = thread.checkpoints | |
| 522 | .filter((checkpoint) => checkpoint.checkpointTurnCount > event.payload.turnCount) | |
| 523 | .map((checkpoint) => checkpoint.checkpointRef); | |
| 524 | ||
| 525 | if (staleCheckpointRefs.length > 0) { | |
| 526 | yield* checkpointStore.deleteCheckpointRefs({ | |
| 527 | cwd: sessionRuntime.value.cwd, | |
| 528 | checkpointRefs: staleCheckpointRefs, | |
| 529 | }); | |
| 530 | } | |
| 531 | ||
| 532 | yield* orchestrationEngine | |
| 533 | .dispatch({ | |
| 534 | type: "thread.revert.complete", | |
| 535 | commandId: serverCommandId("checkpoint-revert-complete"), | |
| 536 | threadId: event.payload.threadId, | |
| 537 | turnCount: event.payload.turnCount, | |
| 538 | createdAt: now, | |
| 539 | }) | |
| 540 | .pipe( | |
| 541 | Effect.catch((error) => | |
| 542 | appendRevertFailureActivity({ | |
| 543 | threadId: event.payload.threadId, | |
| 544 | turnCount: event.payload.turnCount, | |
| 545 | detail: error.message, | |
| 546 | createdAt: now, | |
| 547 | }), | |
| 548 | ), | |
| 549 | Effect.asVoid, | |
| 550 | ); | |
| 551 | }); | |
| 552 | ||
| 553 | const processDomainEvent = Effect.fnUntraced(function* (event: OrchestrationEvent) { | |
| 554 | if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") { | |
| 555 | yield* ensurePreTurnBaselineFromDomainTurnStart(event); | |
| 556 | return; | |
| 557 | } | |
| 558 | ||
| 559 | if (event.type === "thread.checkpoint-revert-requested") { | |
| 560 | yield* handleRevertRequested(event).pipe( | |
| 561 | Effect.catch((error) => | |
| 562 | appendRevertFailureActivity({ | |
| 563 | threadId: event.payload.threadId, | |
| 564 | turnCount: event.payload.turnCount, | |
| 565 | detail: error.message, | |
| 566 | createdAt: new Date().toISOString(), | |
| 567 | }), | |
| 568 | ), | |
| 569 | ); | |
| 570 | } | |
| 571 | }); | |
| 572 | ||
| 573 | const processRuntimeEvent = Effect.fnUntraced(function* (event: ProviderRuntimeEvent) { | |
| 574 | if (event.type === "turn.started") { | |
| 575 | yield* ensurePreTurnBaselineFromTurnStart(event); | |
| 576 | return; | |
| 577 | } | |
| 578 | ||
| 579 | if (event.type === "turn.completed") { | |
| 580 | const turnId = toTurnId(event.turnId); | |
| 581 | yield* captureCheckpointFromTurnCompletion(event).pipe( | |
| 582 | Effect.catch((error) => | |
| 583 | appendCaptureFailureActivity({ | |
| 584 | sessionId: event.sessionId, | |
| 585 | turnId, | |
| 586 | detail: error.message, | |
| 587 | createdAt: new Date().toISOString(), | |
| 588 | }).pipe(Effect.catch(() => Effect.void)), | |
| 589 | ), | |
| 590 | ); | |
| 591 | return; | |
| 592 | } | |
| 593 | ||
| 594 | if (event.type === "checkpoint.captured") { | |
| 595 | const turnId = toTurnId(event.turnId); | |
| 596 | if (!turnId) { | |
| 597 | return; | |
| 598 | } | |
| 599 | ||
| 600 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 601 | const thread = readModel.threads.find( | |
| 602 | (entry) => entry.session?.providerSessionId === event.sessionId, | |
| 603 | ); | |
| 604 | if (!thread) { | |
| 605 | return; | |
| 606 | } | |
| 607 | ||
| 608 | if ( | |
| 609 | thread.checkpoints.some( | |
| 610 | (checkpoint) => | |
| 611 | checkpoint.turnId === turnId || checkpoint.checkpointTurnCount === event.turnCount, | |
| 612 | ) | |
| 613 | ) { | |
| 614 | return; | |
| 615 | } | |
| 616 | ||
| 617 | const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); | |
| 618 | if (Option.isNone(sessionRuntime)) { | |
| 619 | return; | |
| 620 | } | |
| 621 | ||
| 622 | const fromTurnCount = Math.max(0, event.turnCount - 1); | |
| 623 | const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount); | |
| 624 | const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, event.turnCount); | |
| 625 | ||
| 626 | const targetExists = yield* checkpointStore.hasCheckpointRef({ | |
| 627 | cwd: sessionRuntime.value.cwd, | |
| 628 | checkpointRef: targetCheckpointRef, | |
| 629 | }); | |
| 630 | if (!targetExists) { | |
| 631 | yield* checkpointStore.captureCheckpoint({ | |
| 632 | cwd: sessionRuntime.value.cwd, | |
| 633 | checkpointRef: targetCheckpointRef, | |
| 634 | }); | |
| 635 | } | |
| 636 | ||
| 637 | const files = yield* checkpointStore | |
| 638 | .diffCheckpoints({ | |
| 639 | cwd: sessionRuntime.value.cwd, | |
| 640 | fromCheckpointRef, | |
| 641 | toCheckpointRef: targetCheckpointRef, | |
| 642 | fallbackFromToHead: false, | |
| 643 | }) | |
| 644 | .pipe( | |
| 645 | Effect.map((diff) => | |
| 646 | parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ | |
| 647 | path: file.path, | |
| 648 | kind: "modified" as const, | |
| 649 | additions: file.additions, | |
| 650 | deletions: file.deletions, | |
| 651 | })), | |
| 652 | ), | |
| 653 | Effect.catch(() => Effect.succeed([])), | |
| 654 | ); | |
| 655 | ||
| 656 | const assistantMessageId = | |
| 657 | thread.messages | |
| 658 | .toReversed() | |
| 659 | .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ?? | |
| 660 | MessageId.makeUnsafe(`assistant:${turnId}`); | |
| 661 | ||
| 662 | yield* orchestrationEngine.dispatch({ | |
| 663 | type: "thread.turn.diff.complete", | |
| 664 | commandId: serverCommandId("checkpoint-runtime-captured-diff-complete"), | |
| 665 | threadId: thread.id, | |
| 666 | turnId, | |
| 667 | completedAt: event.createdAt, | |
| 668 | checkpointRef: targetCheckpointRef, | |
| 669 | status: checkpointStatusFromRuntime(event.status), | |
| 670 | files, | |
| 671 | assistantMessageId, | |
| 672 | checkpointTurnCount: event.turnCount, | |
| 673 | createdAt: event.createdAt, | |
| 674 | }); | |
| 675 | } | |
| 676 | }); | |
| 677 | ||
| 678 | const processInput = ( | |
| 679 | input: ReactorInput, | |
| 680 | ): Effect.Effect<void, CheckpointStoreError | OrchestrationDispatchError, never> => | |
| 681 | input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event); | |
| 682 | ||
| 683 | const processInputSafely = (input: ReactorInput) => | |
| 684 | processInput(input).pipe( | |
| 685 | Effect.catchCause((cause) => { | |
| 686 | if (Cause.hasInterruptsOnly(cause)) { | |
| 687 | return Effect.failCause(cause); | |
| 688 | } | |
| 689 | return Effect.logWarning("checkpoint reactor failed to process input", { | |
| 690 | source: input.source, | |
| 691 | eventType: input.event.type, | |
| 692 | cause: Cause.pretty(cause), | |
| 693 | }); | |
| 694 | }), | |
| 695 | ); | |
| 696 | ||
| 697 | const start: CheckpointReactorShape["start"] = Effect.gen(function* () { | |
| 698 | const queue = yield* Queue.unbounded<ReactorInput>(); | |
| 699 | yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); | |
| 700 | ||
| 701 | yield* Effect.forkScoped( | |
| 702 | Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInputSafely))), | |
| 703 | ); | |
| 704 | ||
| 705 | yield* Effect.forkScoped( | |
| 706 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 707 | if ( | |
| 708 | event.type !== "thread.turn-start-requested" && | |
| 709 | event.type !== "thread.message-sent" && | |
| 710 | event.type !== "thread.checkpoint-revert-requested" | |
| 711 | ) { | |
| 712 | return Effect.void; | |
| 713 | } | |
| 714 | return Queue.offer(queue, { source: "domain", event }).pipe(Effect.asVoid); | |
| 715 | }), | |
| 716 | ); | |
| 717 | ||
| 718 | yield* Effect.forkScoped( | |
| 719 | Stream.runForEach(providerService.streamEvents, (event) => { | |
| 720 | if ( | |
| 721 | event.type !== "turn.started" && | |
| 722 | event.type !== "turn.completed" && | |
| 723 | event.type !== "checkpoint.captured" | |
| 724 | ) { | |
| 725 | return Effect.void; | |
| 726 | } | |
| 727 | return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid); | |
| 728 | }), | |
| 729 | ); | |
| 730 | }); | |
| 731 | ||
| 732 | return { | |
| 733 | start, | |
| 734 | } satisfies CheckpointReactorShape; | |
| 735 | }); | |
| 736 | ||
| 737 | export const CheckpointReactorLive = Layer.effect(CheckpointReactor, make); |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts created+652| ... | ... | @@ -0,0 +1,652 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | CommandId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | type OrchestrationEvent, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; | |
| 11 | import { describe, expect, it } from "vitest"; | |
| 12 | ||
| 13 | import { PersistenceSqlError } from "../../persistence/Errors.ts"; | |
| 14 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 15 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 16 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 17 | import { | |
| 18 | OrchestrationEventStore, | |
| 19 | type OrchestrationEventStoreShape, | |
| 20 | } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 21 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 22 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 23 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 24 | import { | |
| 25 | OrchestrationProjectionPipeline, | |
| 26 | type OrchestrationProjectionPipelineShape, | |
| 27 | } from "../Services/ProjectionPipeline.ts"; | |
| 28 | ||
| 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 30 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 31 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 32 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 33 | ||
| 34 | async function createOrchestrationSystem() { | |
| 35 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 36 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 37 | Layer.provide(OrchestrationEventStoreLive), | |
| 38 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 39 | Layer.provide(SqlitePersistenceMemory), | |
| 40 | ); | |
| 41 | const runtime = ManagedRuntime.make(orchestrationLayer); | |
| 42 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 43 | return { | |
| 44 | engine, | |
| 45 | run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect), | |
| 46 | dispose: () => runtime.dispose(), | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | function now() { | |
| 51 | return new Date().toISOString(); | |
| 52 | } | |
| 53 | ||
| 54 | describe("OrchestrationEngine", () => { | |
| 55 | it("returns deterministic read models for repeated reads", async () => { | |
| 56 | const createdAt = now(); | |
| 57 | const system = await createOrchestrationSystem(); | |
| 58 | const { engine } = system; | |
| 59 | ||
| 60 | await system.run( | |
| 61 | engine.dispatch({ | |
| 62 | type: "project.create", | |
| 63 | commandId: CommandId.makeUnsafe("cmd-project-1-create"), | |
| 64 | projectId: asProjectId("project-1"), | |
| 65 | title: "Project 1", | |
| 66 | workspaceRoot: "/tmp/project-1", | |
| 67 | defaultModel: "gpt-5-codex", | |
| 68 | createdAt, | |
| 69 | }), | |
| 70 | ); | |
| 71 | await system.run( | |
| 72 | engine.dispatch({ | |
| 73 | type: "thread.create", | |
| 74 | commandId: CommandId.makeUnsafe("cmd-thread-1-create"), | |
| 75 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 76 | projectId: asProjectId("project-1"), | |
| 77 | title: "Thread", | |
| 78 | model: "gpt-5-codex", | |
| 79 | branch: null, | |
| 80 | worktreePath: null, | |
| 81 | createdAt, | |
| 82 | }), | |
| 83 | ); | |
| 84 | await system.run( | |
| 85 | engine.dispatch({ | |
| 86 | type: "thread.turn.start", | |
| 87 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), | |
| 88 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 89 | message: { | |
| 90 | messageId: asMessageId("msg-1"), | |
| 91 | role: "user", | |
| 92 | text: "hello", | |
| 93 | attachments: [], | |
| 94 | }, | |
| 95 | approvalPolicy: "on-request", | |
| 96 | sandboxMode: "workspace-write", | |
| 97 | createdAt, | |
| 98 | }), | |
| 99 | ); | |
| 100 | ||
| 101 | const readModelA = await system.run(engine.getReadModel()); | |
| 102 | const readModelB = await system.run(engine.getReadModel()); | |
| 103 | expect(readModelB).toEqual(readModelA); | |
| 104 | await system.dispose(); | |
| 105 | }); | |
| 106 | ||
| 107 | it("replays append-only events from sequence", async () => { | |
| 108 | const system = await createOrchestrationSystem(); | |
| 109 | const { engine } = system; | |
| 110 | const createdAt = now(); | |
| 111 | ||
| 112 | await system.run( | |
| 113 | engine.dispatch({ | |
| 114 | type: "project.create", | |
| 115 | commandId: CommandId.makeUnsafe("cmd-project-replay-create"), | |
| 116 | projectId: asProjectId("project-replay"), | |
| 117 | title: "Replay Project", | |
| 118 | workspaceRoot: "/tmp/project-replay", | |
| 119 | defaultModel: "gpt-5-codex", | |
| 120 | createdAt, | |
| 121 | }), | |
| 122 | ); | |
| 123 | await system.run( | |
| 124 | engine.dispatch({ | |
| 125 | type: "thread.create", | |
| 126 | commandId: CommandId.makeUnsafe("cmd-thread-replay-create"), | |
| 127 | threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 128 | projectId: asProjectId("project-replay"), | |
| 129 | title: "replay", | |
| 130 | model: "gpt-5-codex", | |
| 131 | branch: null, | |
| 132 | worktreePath: null, | |
| 133 | createdAt, | |
| 134 | }), | |
| 135 | ); | |
| 136 | await system.run( | |
| 137 | engine.dispatch({ | |
| 138 | type: "thread.delete", | |
| 139 | commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"), | |
| 140 | threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 141 | }), | |
| 142 | ); | |
| 143 | ||
| 144 | const events = await system.run( | |
| 145 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 146 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), | |
| 147 | ), | |
| 148 | ); | |
| 149 | expect(events.map((event) => event.type)).toEqual([ | |
| 150 | "project.created", | |
| 151 | "thread.created", | |
| 152 | "thread.deleted", | |
| 153 | ]); | |
| 154 | await system.dispose(); | |
| 155 | }); | |
| 156 | ||
| 157 | it("streams persisted domain events in order", async () => { | |
| 158 | const system = await createOrchestrationSystem(); | |
| 159 | const { engine } = system; | |
| 160 | const createdAt = now(); | |
| 161 | ||
| 162 | await system.run( | |
| 163 | engine.dispatch({ | |
| 164 | type: "project.create", | |
| 165 | commandId: CommandId.makeUnsafe("cmd-project-stream-create"), | |
| 166 | projectId: asProjectId("project-stream"), | |
| 167 | title: "Stream Project", | |
| 168 | workspaceRoot: "/tmp/project-stream", | |
| 169 | defaultModel: "gpt-5-codex", | |
| 170 | createdAt, | |
| 171 | }), | |
| 172 | ); | |
| 173 | ||
| 174 | const eventTypes: string[] = []; | |
| 175 | await system.run( | |
| 176 | Effect.gen(function* () { | |
| 177 | const eventQueue = yield* Queue.unbounded<OrchestrationEvent>(); | |
| 178 | yield* Effect.forkScoped( | |
| 179 | Stream.take(engine.streamDomainEvents, 2).pipe( | |
| 180 | Stream.runForEach((event) => Queue.offer(eventQueue, event).pipe(Effect.asVoid)), | |
| 181 | ), | |
| 182 | ); | |
| 183 | yield* Effect.sleep("10 millis"); | |
| 184 | yield* engine.dispatch({ | |
| 185 | type: "thread.create", | |
| 186 | commandId: CommandId.makeUnsafe("cmd-stream-thread-create"), | |
| 187 | threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 188 | projectId: asProjectId("project-stream"), | |
| 189 | title: "domain-stream", | |
| 190 | model: "gpt-5-codex", | |
| 191 | branch: null, | |
| 192 | worktreePath: null, | |
| 193 | createdAt, | |
| 194 | }); | |
| 195 | yield* engine.dispatch({ | |
| 196 | type: "thread.meta.update", | |
| 197 | commandId: CommandId.makeUnsafe("cmd-stream-thread-update"), | |
| 198 | threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 199 | title: "domain-stream-updated", | |
| 200 | }); | |
| 201 | eventTypes.push((yield* Queue.take(eventQueue)).type); | |
| 202 | eventTypes.push((yield* Queue.take(eventQueue)).type); | |
| 203 | }).pipe(Effect.scoped), | |
| 204 | ); | |
| 205 | ||
| 206 | expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); | |
| 207 | await system.dispose(); | |
| 208 | }); | |
| 209 | ||
| 210 | it("stores completed checkpoint summaries even when no files changed", async () => { | |
| 211 | const system = await createOrchestrationSystem(); | |
| 212 | const { engine } = system; | |
| 213 | const createdAt = now(); | |
| 214 | ||
| 215 | await system.run( | |
| 216 | engine.dispatch({ | |
| 217 | type: "project.create", | |
| 218 | commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"), | |
| 219 | projectId: asProjectId("project-turn-diff"), | |
| 220 | title: "Turn Diff Project", | |
| 221 | workspaceRoot: "/tmp/project-turn-diff", | |
| 222 | defaultModel: "gpt-5-codex", | |
| 223 | createdAt, | |
| 224 | }), | |
| 225 | ); | |
| 226 | await system.run( | |
| 227 | engine.dispatch({ | |
| 228 | type: "thread.create", | |
| 229 | commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"), | |
| 230 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 231 | projectId: asProjectId("project-turn-diff"), | |
| 232 | title: "Turn diff thread", | |
| 233 | model: "gpt-5-codex", | |
| 234 | branch: null, | |
| 235 | worktreePath: null, | |
| 236 | createdAt, | |
| 237 | }), | |
| 238 | ); | |
| 239 | await system.run( | |
| 240 | engine.dispatch({ | |
| 241 | type: "thread.turn.diff.complete", | |
| 242 | commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"), | |
| 243 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 244 | turnId: asTurnId("turn-1"), | |
| 245 | completedAt: createdAt, | |
| 246 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), | |
| 247 | status: "ready", | |
| 248 | files: [], | |
| 249 | checkpointTurnCount: 1, | |
| 250 | createdAt, | |
| 251 | }), | |
| 252 | ); | |
| 253 | ||
| 254 | const thread = (await system.run(engine.getReadModel())).threads.find( | |
| 255 | (entry) => entry.id === "thread-turn-diff", | |
| 256 | ); | |
| 257 | expect(thread?.checkpoints).toEqual([ | |
| 258 | { | |
| 259 | turnId: asTurnId("turn-1"), | |
| 260 | checkpointTurnCount: 1, | |
| 261 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), | |
| 262 | status: "ready", | |
| 263 | files: [], | |
| 264 | assistantMessageId: null, | |
| 265 | completedAt: createdAt, | |
| 266 | }, | |
| 267 | ]); | |
| 268 | await system.dispose(); | |
| 269 | }); | |
| 270 | ||
| 271 | it("keeps processing queued commands after a storage failure", async () => { | |
| 272 | type StoredEvent = | |
| 273 | ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any> | |
| 274 | ? A | |
| 275 | : never; | |
| 276 | const events: StoredEvent[] = []; | |
| 277 | let nextSequence = 1; | |
| 278 | let shouldFailFirstAppend = true; | |
| 279 | ||
| 280 | const flakyStore: OrchestrationEventStoreShape = { | |
| 281 | append(event) { | |
| 282 | if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) { | |
| 283 | shouldFailFirstAppend = false; | |
| 284 | return Effect.fail( | |
| 285 | new PersistenceSqlError({ | |
| 286 | operation: "test.append", | |
| 287 | detail: "append failed", | |
| 288 | }), | |
| 289 | ); | |
| 290 | } | |
| 291 | const savedEvent = { | |
| 292 | ...event, | |
| 293 | sequence: nextSequence, | |
| 294 | } as StoredEvent; | |
| 295 | nextSequence += 1; | |
| 296 | events.push(savedEvent); | |
| 297 | return Effect.succeed(savedEvent); | |
| 298 | }, | |
| 299 | readFromSequence(sequenceExclusive) { | |
| 300 | return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 301 | }, | |
| 302 | readAll() { | |
| 303 | return Stream.fromIterable(events); | |
| 304 | }, | |
| 305 | }; | |
| 306 | ||
| 307 | const runtime = ManagedRuntime.make( | |
| 308 | OrchestrationEngineLive.pipe( | |
| 309 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 310 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | |
| 311 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 312 | Layer.provide(SqlitePersistenceMemory), | |
| 313 | ), | |
| 314 | ); | |
| 315 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 316 | const createdAt = now(); | |
| 317 | ||
| 318 | await runtime.runPromise( | |
| 319 | engine.dispatch({ | |
| 320 | type: "project.create", | |
| 321 | commandId: CommandId.makeUnsafe("cmd-project-flaky-create"), | |
| 322 | projectId: asProjectId("project-flaky"), | |
| 323 | title: "Flaky Project", | |
| 324 | workspaceRoot: "/tmp/project-flaky", | |
| 325 | defaultModel: "gpt-5-codex", | |
| 326 | createdAt, | |
| 327 | }), | |
| 328 | ); | |
| 329 | ||
| 330 | await expect( | |
| 331 | runtime.runPromise( | |
| 332 | engine.dispatch({ | |
| 333 | type: "thread.create", | |
| 334 | commandId: CommandId.makeUnsafe("cmd-flaky-1"), | |
| 335 | threadId: ThreadId.makeUnsafe("thread-flaky-fail"), | |
| 336 | projectId: asProjectId("project-flaky"), | |
| 337 | title: "flaky-fail", | |
| 338 | model: "gpt-5-codex", | |
| 339 | branch: null, | |
| 340 | worktreePath: null, | |
| 341 | createdAt, | |
| 342 | }), | |
| 343 | ), | |
| 344 | ).rejects.toThrow("append failed"); | |
| 345 | ||
| 346 | const result = await runtime.runPromise( | |
| 347 | engine.dispatch({ | |
| 348 | type: "thread.create", | |
| 349 | commandId: CommandId.makeUnsafe("cmd-flaky-2"), | |
| 350 | threadId: ThreadId.makeUnsafe("thread-flaky-ok"), | |
| 351 | projectId: asProjectId("project-flaky"), | |
| 352 | title: "flaky-ok", | |
| 353 | model: "gpt-5-codex", | |
| 354 | branch: null, | |
| 355 | worktreePath: null, | |
| 356 | createdAt, | |
| 357 | }), | |
| 358 | ); | |
| 359 | ||
| 360 | expect(result.sequence).toBe(2); | |
| 361 | expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); | |
| 362 | await runtime.dispose(); | |
| 363 | }); | |
| 364 | ||
| 365 | it("rolls back all events for a multi-event command when projection fails mid-dispatch", async () => { | |
| 366 | let shouldFailRequestedProjection = true; | |
| 367 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { | |
| 368 | bootstrap: Effect.void, | |
| 369 | projectEvent: (event) => { | |
| 370 | if ( | |
| 371 | shouldFailRequestedProjection && | |
| 372 | event.commandId === CommandId.makeUnsafe("cmd-turn-start-atomic") && | |
| 373 | event.type === "thread.turn-start-requested" | |
| 374 | ) { | |
| 375 | shouldFailRequestedProjection = false; | |
| 376 | return Effect.fail( | |
| 377 | new PersistenceSqlError({ | |
| 378 | operation: "test.projection", | |
| 379 | detail: "projection failed", | |
| 380 | }), | |
| 381 | ); | |
| 382 | } | |
| 383 | return Effect.void; | |
| 384 | }, | |
| 385 | }; | |
| 386 | ||
| 387 | const runtime = ManagedRuntime.make( | |
| 388 | OrchestrationEngineLive.pipe( | |
| 389 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | |
| 390 | Layer.provide(OrchestrationEventStoreLive), | |
| 391 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 392 | Layer.provide(SqlitePersistenceMemory), | |
| 393 | ), | |
| 394 | ); | |
| 395 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 396 | const createdAt = now(); | |
| 397 | ||
| 398 | await runtime.runPromise( | |
| 399 | engine.dispatch({ | |
| 400 | type: "project.create", | |
| 401 | commandId: CommandId.makeUnsafe("cmd-project-atomic-create"), | |
| 402 | projectId: asProjectId("project-atomic"), | |
| 403 | title: "Atomic Project", | |
| 404 | workspaceRoot: "/tmp/project-atomic", | |
| 405 | defaultModel: "gpt-5-codex", | |
| 406 | createdAt, | |
| 407 | }), | |
| 408 | ); | |
| 409 | await runtime.runPromise( | |
| 410 | engine.dispatch({ | |
| 411 | type: "thread.create", | |
| 412 | commandId: CommandId.makeUnsafe("cmd-thread-atomic-create"), | |
| 413 | threadId: ThreadId.makeUnsafe("thread-atomic"), | |
| 414 | projectId: asProjectId("project-atomic"), | |
| 415 | title: "atomic", | |
| 416 | model: "gpt-5-codex", | |
| 417 | branch: null, | |
| 418 | worktreePath: null, | |
| 419 | createdAt, | |
| 420 | }), | |
| 421 | ); | |
| 422 | ||
| 423 | const turnStartCommand = { | |
| 424 | type: "thread.turn.start" as const, | |
| 425 | commandId: CommandId.makeUnsafe("cmd-turn-start-atomic"), | |
| 426 | threadId: ThreadId.makeUnsafe("thread-atomic"), | |
| 427 | message: { | |
| 428 | messageId: asMessageId("msg-atomic-1"), | |
| 429 | role: "user" as const, | |
| 430 | text: "hello", | |
| 431 | attachments: [], | |
| 432 | }, | |
| 433 | approvalPolicy: "on-request" as const, | |
| 434 | sandboxMode: "workspace-write" as const, | |
| 435 | createdAt, | |
| 436 | }; | |
| 437 | ||
| 438 | await expect(runtime.runPromise(engine.dispatch(turnStartCommand))).rejects.toThrow( | |
| 439 | "projection failed", | |
| 440 | ); | |
| 441 | ||
| 442 | const eventsAfterFailure = await runtime.runPromise( | |
| 443 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 444 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), | |
| 445 | ), | |
| 446 | ); | |
| 447 | expect(eventsAfterFailure.map((event) => event.type)).toEqual([ | |
| 448 | "project.created", | |
| 449 | "thread.created", | |
| 450 | ]); | |
| 451 | expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); | |
| 452 | ||
| 453 | const retryResult = await runtime.runPromise(engine.dispatch(turnStartCommand)); | |
| 454 | expect(retryResult.sequence).toBe(4); | |
| 455 | ||
| 456 | const eventsAfterRetry = await runtime.runPromise( | |
| 457 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 458 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), | |
| 459 | ), | |
| 460 | ); | |
| 461 | expect(eventsAfterRetry.map((event) => event.type)).toEqual([ | |
| 462 | "project.created", | |
| 463 | "thread.created", | |
| 464 | "thread.message-sent", | |
| 465 | "thread.turn-start-requested", | |
| 466 | ]); | |
| 467 | expect( | |
| 468 | eventsAfterRetry.filter((event) => event.commandId === turnStartCommand.commandId), | |
| 469 | ).toHaveLength(2); | |
| 470 | ||
| 471 | await runtime.dispose(); | |
| 472 | }); | |
| 473 | ||
| 474 | it("reconciles in-memory state when append persists but projection fails", async () => { | |
| 475 | type StoredEvent = | |
| 476 | ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any> | |
| 477 | ? A | |
| 478 | : never; | |
| 479 | const events: StoredEvent[] = []; | |
| 480 | let nextSequence = 1; | |
| 481 | ||
| 482 | const nonTransactionalStore: OrchestrationEventStoreShape = { | |
| 483 | append(event) { | |
| 484 | const savedEvent = { | |
| 485 | ...event, | |
| 486 | sequence: nextSequence, | |
| 487 | } as StoredEvent; | |
| 488 | nextSequence += 1; | |
| 489 | events.push(savedEvent); | |
| 490 | return Effect.succeed(savedEvent); | |
| 491 | }, | |
| 492 | readFromSequence(sequenceExclusive) { | |
| 493 | return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 494 | }, | |
| 495 | readAll() { | |
| 496 | return Stream.fromIterable(events); | |
| 497 | }, | |
| 498 | }; | |
| 499 | ||
| 500 | let shouldFailProjection = true; | |
| 501 | const flakyProjectionPipeline: OrchestrationProjectionPipelineShape = { | |
| 502 | bootstrap: Effect.void, | |
| 503 | projectEvent: (event) => { | |
| 504 | if ( | |
| 505 | shouldFailProjection && | |
| 506 | event.commandId === CommandId.makeUnsafe("cmd-thread-meta-sync-fail") | |
| 507 | ) { | |
| 508 | shouldFailProjection = false; | |
| 509 | return Effect.fail( | |
| 510 | new PersistenceSqlError({ | |
| 511 | operation: "test.projection", | |
| 512 | detail: "projection failed", | |
| 513 | }), | |
| 514 | ); | |
| 515 | } | |
| 516 | return Effect.void; | |
| 517 | }, | |
| 518 | }; | |
| 519 | ||
| 520 | const runtime = ManagedRuntime.make( | |
| 521 | OrchestrationEngineLive.pipe( | |
| 522 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), | |
| 523 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), | |
| 524 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 525 | Layer.provide(SqlitePersistenceMemory), | |
| 526 | ), | |
| 527 | ); | |
| 528 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 529 | const createdAt = now(); | |
| 530 | ||
| 531 | await runtime.runPromise( | |
| 532 | engine.dispatch({ | |
| 533 | type: "project.create", | |
| 534 | commandId: CommandId.makeUnsafe("cmd-project-sync-create"), | |
| 535 | projectId: asProjectId("project-sync"), | |
| 536 | title: "Sync Project", | |
| 537 | workspaceRoot: "/tmp/project-sync", | |
| 538 | defaultModel: "gpt-5-codex", | |
| 539 | createdAt, | |
| 540 | }), | |
| 541 | ); | |
| 542 | await runtime.runPromise( | |
| 543 | engine.dispatch({ | |
| 544 | type: "thread.create", | |
| 545 | commandId: CommandId.makeUnsafe("cmd-thread-sync-create"), | |
| 546 | threadId: ThreadId.makeUnsafe("thread-sync"), | |
| 547 | projectId: asProjectId("project-sync"), | |
| 548 | title: "sync-before", | |
| 549 | model: "gpt-5-codex", | |
| 550 | branch: null, | |
| 551 | worktreePath: null, | |
| 552 | createdAt, | |
| 553 | }), | |
| 554 | ); | |
| 555 | ||
| 556 | await expect( | |
| 557 | runtime.runPromise( | |
| 558 | engine.dispatch({ | |
| 559 | type: "thread.meta.update", | |
| 560 | commandId: CommandId.makeUnsafe("cmd-thread-meta-sync-fail"), | |
| 561 | threadId: ThreadId.makeUnsafe("thread-sync"), | |
| 562 | title: "sync-after-failed-projection", | |
| 563 | }), | |
| 564 | ), | |
| 565 | ).rejects.toThrow("projection failed"); | |
| 566 | ||
| 567 | const readModelAfterFailure = await runtime.runPromise(engine.getReadModel()); | |
| 568 | const updatedThread = readModelAfterFailure.threads.find( | |
| 569 | (thread) => thread.id === "thread-sync", | |
| 570 | ); | |
| 571 | expect(readModelAfterFailure.snapshotSequence).toBe(3); | |
| 572 | expect(updatedThread?.title).toBe("sync-after-failed-projection"); | |
| 573 | ||
| 574 | await runtime.dispose(); | |
| 575 | }); | |
| 576 | ||
| 577 | it("fails command dispatch when command invariants are violated", async () => { | |
| 578 | const system = await createOrchestrationSystem(); | |
| 579 | const { engine } = system; | |
| 580 | ||
| 581 | await expect( | |
| 582 | system.run( | |
| 583 | engine.dispatch({ | |
| 584 | type: "thread.turn.start", | |
| 585 | commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"), | |
| 586 | threadId: ThreadId.makeUnsafe("thread-missing"), | |
| 587 | message: { | |
| 588 | messageId: asMessageId("msg-missing"), | |
| 589 | role: "user", | |
| 590 | text: "hello", | |
| 591 | attachments: [], | |
| 592 | }, | |
| 593 | approvalPolicy: "on-request", | |
| 594 | sandboxMode: "workspace-write", | |
| 595 | createdAt: now(), | |
| 596 | }), | |
| 597 | ), | |
| 598 | ).rejects.toThrow("Thread 'thread-missing' does not exist"); | |
| 599 | ||
| 600 | await system.dispose(); | |
| 601 | }); | |
| 602 | ||
| 603 | it("rejects duplicate thread creation", async () => { | |
| 604 | const system = await createOrchestrationSystem(); | |
| 605 | const { engine } = system; | |
| 606 | const createdAt = now(); | |
| 607 | ||
| 608 | await system.run( | |
| 609 | engine.dispatch({ | |
| 610 | type: "project.create", | |
| 611 | commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"), | |
| 612 | projectId: asProjectId("project-duplicate"), | |
| 613 | title: "Duplicate Project", | |
| 614 | workspaceRoot: "/tmp/project-duplicate", | |
| 615 | defaultModel: "gpt-5-codex", | |
| 616 | createdAt, | |
| 617 | }), | |
| 618 | ); | |
| 619 | ||
| 620 | await system.run( | |
| 621 | engine.dispatch({ | |
| 622 | type: "thread.create", | |
| 623 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"), | |
| 624 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 625 | projectId: asProjectId("project-duplicate"), | |
| 626 | title: "duplicate", | |
| 627 | model: "gpt-5-codex", | |
| 628 | branch: null, | |
| 629 | worktreePath: null, | |
| 630 | createdAt, | |
| 631 | }), | |
| 632 | ); | |
| 633 | ||
| 634 | await expect( | |
| 635 | system.run( | |
| 636 | engine.dispatch({ | |
| 637 | type: "thread.create", | |
| 638 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"), | |
| 639 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 640 | projectId: asProjectId("project-duplicate"), | |
| 641 | title: "duplicate", | |
| 642 | model: "gpt-5-codex", | |
| 643 | branch: null, | |
| 644 | worktreePath: null, | |
| 645 | createdAt, | |
| 646 | }), | |
| 647 | ), | |
| 648 | ).rejects.toThrow("already exists"); | |
| 649 | ||
| 650 | await system.dispose(); | |
| 651 | }); | |
| 652 | }); |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts created+246| ... | ... | @@ -0,0 +1,246 @@ |
| 1 | import type { | |
| 2 | OrchestrationEvent, | |
| 3 | OrchestrationReadModel, | |
| 4 | ProjectId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { OrchestrationCommand } from "@t3tools/contracts"; | |
| 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; | |
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 10 | ||
| 11 | import { toPersistenceSqlError } from "../../persistence/Errors.ts"; | |
| 12 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 13 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | |
| 14 | import { | |
| 15 | OrchestrationCommandInvariantError, | |
| 16 | OrchestrationCommandPreviouslyRejectedError, | |
| 17 | type OrchestrationDispatchError, | |
| 18 | } from "../Errors.ts"; | |
| 19 | import { decideOrchestrationCommand } from "../decider.ts"; | |
| 20 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; | |
| 21 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | |
| 22 | import { | |
| 23 | OrchestrationEngineService, | |
| 24 | type OrchestrationEngineShape, | |
| 25 | } from "../Services/OrchestrationEngine.ts"; | |
| 26 | ||
| 27 | interface CommandEnvelope { | |
| 28 | command: OrchestrationCommand; | |
| 29 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | |
| 30 | } | |
| 31 | ||
| 32 | function commandToAggregateRef(command: OrchestrationCommand): { | |
| 33 | readonly aggregateKind: "project" | "thread"; | |
| 34 | readonly aggregateId: ProjectId | ThreadId; | |
| 35 | } { | |
| 36 | switch (command.type) { | |
| 37 | case "project.create": | |
| 38 | case "project.meta.update": | |
| 39 | case "project.delete": | |
| 40 | return { | |
| 41 | aggregateKind: "project", | |
| 42 | aggregateId: command.projectId, | |
| 43 | }; | |
| 44 | default: | |
| 45 | return { | |
| 46 | aggregateKind: "thread", | |
| 47 | aggregateId: command.threadId, | |
| 48 | }; | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | function formatDispatchError(error: OrchestrationDispatchError): string { | |
| 53 | if (error instanceof Error && error.message.length > 0) { | |
| 54 | return error.message; | |
| 55 | } | |
| 56 | return String(error); | |
| 57 | } | |
| 58 | ||
| 59 | const makeOrchestrationEngine = Effect.gen(function* () { | |
| 60 | const sql = yield* SqlClient.SqlClient; | |
| 61 | const eventStore = yield* OrchestrationEventStore; | |
| 62 | const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; | |
| 63 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 64 | ||
| 65 | let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 66 | ||
| 67 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 68 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 69 | ||
| 70 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => { | |
| 71 | const dispatchStartSequence = readModel.snapshotSequence; | |
| 72 | const reconcileReadModelAfterDispatchFailure = Effect.gen(function* () { | |
| 73 | const persistedEvents = yield* Stream.runCollect( | |
| 74 | eventStore.readFromSequence(dispatchStartSequence), | |
| 75 | ).pipe(Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk))); | |
| 76 | if (persistedEvents.length === 0) { | |
| 77 | return; | |
| 78 | } | |
| 79 | ||
| 80 | let nextReadModel = readModel; | |
| 81 | for (const persistedEvent of persistedEvents) { | |
| 82 | nextReadModel = yield* projectEvent(nextReadModel, persistedEvent); | |
| 83 | } | |
| 84 | readModel = nextReadModel; | |
| 85 | ||
| 86 | for (const persistedEvent of persistedEvents) { | |
| 87 | yield* PubSub.publish(eventPubSub, persistedEvent); | |
| 88 | } | |
| 89 | }); | |
| 90 | ||
| 91 | return Effect.gen(function* () { | |
| 92 | const existingReceipt = yield* commandReceiptRepository.getByCommandId({ | |
| 93 | commandId: envelope.command.commandId, | |
| 94 | }); | |
| 95 | if (Option.isSome(existingReceipt)) { | |
| 96 | if (existingReceipt.value.status === "accepted") { | |
| 97 | yield* Deferred.succeed(envelope.result, { | |
| 98 | sequence: existingReceipt.value.resultSequence, | |
| 99 | }); | |
| 100 | return; | |
| 101 | } | |
| 102 | yield* Deferred.fail( | |
| 103 | envelope.result, | |
| 104 | new OrchestrationCommandPreviouslyRejectedError({ | |
| 105 | commandId: envelope.command.commandId, | |
| 106 | detail: existingReceipt.value.error ?? "Previously rejected.", | |
| 107 | }), | |
| 108 | ); | |
| 109 | return; | |
| 110 | } | |
| 111 | ||
| 112 | const eventBase = yield* decideOrchestrationCommand({ | |
| 113 | command: envelope.command, | |
| 114 | readModel, | |
| 115 | }); | |
| 116 | const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; | |
| 117 | const committedCommand = yield* sql | |
| 118 | .withTransaction( | |
| 119 | Effect.gen(function* () { | |
| 120 | const committedEvents: OrchestrationEvent[] = []; | |
| 121 | let nextReadModel = readModel; | |
| 122 | ||
| 123 | for (const nextEvent of eventBases) { | |
| 124 | const savedEvent = yield* eventStore.append(nextEvent); | |
| 125 | nextReadModel = yield* projectEvent(nextReadModel, savedEvent); | |
| 126 | yield* projectionPipeline.projectEvent(savedEvent); | |
| 127 | committedEvents.push(savedEvent); | |
| 128 | } | |
| 129 | ||
| 130 | const lastSavedEvent = committedEvents.at(-1) ?? null; | |
| 131 | if (lastSavedEvent === null) { | |
| 132 | return yield* new OrchestrationCommandInvariantError({ | |
| 133 | commandType: envelope.command.type, | |
| 134 | detail: "Command produced no events.", | |
| 135 | }); | |
| 136 | } | |
| 137 | ||
| 138 | yield* commandReceiptRepository.upsert({ | |
| 139 | commandId: envelope.command.commandId, | |
| 140 | aggregateKind: lastSavedEvent.aggregateKind, | |
| 141 | aggregateId: lastSavedEvent.aggregateId, | |
| 142 | acceptedAt: lastSavedEvent.occurredAt, | |
| 143 | resultSequence: lastSavedEvent.sequence, | |
| 144 | status: "accepted", | |
| 145 | error: null, | |
| 146 | }); | |
| 147 | ||
| 148 | return { | |
| 149 | committedEvents, | |
| 150 | lastSequence: lastSavedEvent.sequence, | |
| 151 | nextReadModel, | |
| 152 | } as const; | |
| 153 | }), | |
| 154 | ) | |
| 155 | .pipe( | |
| 156 | Effect.catchTag("SqlError", (sqlError) => | |
| 157 | Effect.fail( | |
| 158 | toPersistenceSqlError("OrchestrationEngine.processEnvelope:transaction")(sqlError), | |
| 159 | ), | |
| 160 | ), | |
| 161 | ); | |
| 162 | ||
| 163 | readModel = committedCommand.nextReadModel; | |
| 164 | for (const event of committedCommand.committedEvents) { | |
| 165 | yield* PubSub.publish(eventPubSub, event); | |
| 166 | } | |
| 167 | yield* Deferred.succeed(envelope.result, { sequence: committedCommand.lastSequence }); | |
| 168 | }).pipe( | |
| 169 | Effect.catch((error) => | |
| 170 | Effect.gen(function* () { | |
| 171 | yield* reconcileReadModelAfterDispatchFailure.pipe( | |
| 172 | Effect.catch(() => | |
| 173 | Effect.logWarning( | |
| 174 | "failed to reconcile orchestration read model after dispatch failure", | |
| 175 | ).pipe( | |
| 176 | Effect.annotateLogs({ | |
| 177 | commandId: envelope.command.commandId, | |
| 178 | snapshotSequence: readModel.snapshotSequence, | |
| 179 | }), | |
| 180 | ), | |
| 181 | ), | |
| 182 | ); | |
| 183 | ||
| 184 | if (Schema.is(OrchestrationCommandInvariantError)(error)) { | |
| 185 | const aggregateRef = commandToAggregateRef(envelope.command); | |
| 186 | yield* commandReceiptRepository | |
| 187 | .upsert({ | |
| 188 | commandId: envelope.command.commandId, | |
| 189 | aggregateKind: aggregateRef.aggregateKind, | |
| 190 | aggregateId: aggregateRef.aggregateId, | |
| 191 | acceptedAt: new Date().toISOString(), | |
| 192 | resultSequence: readModel.snapshotSequence, | |
| 193 | status: "rejected", | |
| 194 | error: formatDispatchError(error), | |
| 195 | }) | |
| 196 | .pipe(Effect.catch(() => Effect.void)); | |
| 197 | } | |
| 198 | yield* Deferred.fail(envelope.result, error); | |
| 199 | }), | |
| 200 | ), | |
| 201 | ); | |
| 202 | }; | |
| 203 | ||
| 204 | yield* projectionPipeline.bootstrap; | |
| 205 | ||
| 206 | // bootstrap in-memory read model from event store | |
| 207 | yield* Stream.runForEach(eventStore.readAll(), (event) => | |
| 208 | Effect.gen(function* () { | |
| 209 | readModel = yield* projectEvent(readModel, event); | |
| 210 | }), | |
| 211 | ); | |
| 212 | ||
| 213 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 214 | yield* Effect.forkScoped(worker); | |
| 215 | yield* Effect.log("orchestration engine started").pipe( | |
| 216 | Effect.annotateLogs({ sequence: readModel.snapshotSequence }), | |
| 217 | ); | |
| 218 | ||
| 219 | const getReadModel: OrchestrationEngineShape["getReadModel"] = () => | |
| 220 | Effect.sync((): OrchestrationReadModel => readModel); | |
| 221 | ||
| 222 | const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) => | |
| 223 | eventStore.readFromSequence(fromSequenceExclusive); | |
| 224 | ||
| 225 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 226 | Effect.gen(function* () { | |
| 227 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); | |
| 228 | yield* Queue.offer(commandQueue, { command, result }); | |
| 229 | return yield* Deferred.await(result); | |
| 230 | }); | |
| 231 | ||
| 232 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = | |
| 233 | Stream.fromPubSub(eventPubSub); | |
| 234 | ||
| 235 | return { | |
| 236 | getReadModel, | |
| 237 | readEvents, | |
| 238 | dispatch, | |
| 239 | streamDomainEvents, | |
| 240 | } satisfies OrchestrationEngineShape; | |
| 241 | }); | |
| 242 | ||
| 243 | export const OrchestrationEngineLive = Layer.effect( | |
| 244 | OrchestrationEngineService, | |
| 245 | makeOrchestrationEngine, | |
| 246 | ); |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; | |
| 2 | import { afterEach, describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 5 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 6 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 7 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | |
| 8 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | |
| 9 | ||
| 10 | describe("OrchestrationReactor", () => { | |
| 11 | let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null; | |
| 12 | ||
| 13 | afterEach(async () => { | |
| 14 | if (runtime) { | |
| 15 | await runtime.dispose(); | |
| 16 | } | |
| 17 | runtime = null; | |
| 18 | }); | |
| 19 | ||
| 20 | it("starts provider ingestion, provider command, and checkpoint reactors", async () => { | |
| 21 | const started: string[] = []; | |
| 22 | ||
| 23 | runtime = ManagedRuntime.make( | |
| 24 | Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe( | |
| 25 | Layer.provideMerge( | |
| 26 | Layer.succeed(ProviderRuntimeIngestionService, { | |
| 27 | start: Effect.sync(() => { | |
| 28 | started.push("provider-runtime-ingestion"); | |
| 29 | }), | |
| 30 | }), | |
| 31 | ), | |
| 32 | Layer.provideMerge( | |
| 33 | Layer.succeed(ProviderCommandReactor, { | |
| 34 | start: Effect.sync(() => { | |
| 35 | started.push("provider-command-reactor"); | |
| 36 | }), | |
| 37 | }), | |
| 38 | ), | |
| 39 | Layer.provideMerge( | |
| 40 | Layer.succeed(CheckpointReactor, { | |
| 41 | start: Effect.sync(() => { | |
| 42 | started.push("checkpoint-reactor"); | |
| 43 | }), | |
| 44 | }), | |
| 45 | ), | |
| 46 | ), | |
| 47 | ); | |
| 48 | ||
| 49 | const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor)); | |
| 50 | const scope = await Effect.runPromise(Scope.make("sequential")); | |
| 51 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); | |
| 52 | ||
| 53 | expect(started).toEqual([ | |
| 54 | "provider-runtime-ingestion", | |
| 55 | "provider-command-reactor", | |
| 56 | "checkpoint-reactor", | |
| 57 | ]); | |
| 58 | ||
| 59 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 60 | }); | |
| 61 | }); |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | OrchestrationReactor, | |
| 5 | type OrchestrationReactorShape, | |
| 6 | } from "../Services/OrchestrationReactor.ts"; | |
| 7 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 8 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 9 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 10 | ||
| 11 | export const makeOrchestrationReactor = Effect.gen(function* () { | |
| 12 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; | |
| 13 | const providerCommandReactor = yield* ProviderCommandReactor; | |
| 14 | const checkpointReactor = yield* CheckpointReactor; | |
| 15 | ||
| 16 | const start: OrchestrationReactorShape["start"] = Effect.gen(function* () { | |
| 17 | yield* providerRuntimeIngestion.start; | |
| 18 | yield* providerCommandReactor.start; | |
| 19 | yield* checkpointReactor.start; | |
| 20 | }); | |
| 21 | ||
| 22 | return { | |
| 23 | start, | |
| 24 | } satisfies OrchestrationReactorShape; | |
| 25 | }); | |
| 26 | ||
| 27 | export const OrchestrationReactorLive = Layer.effect( | |
| 28 | OrchestrationReactor, | |
| 29 | makeOrchestrationReactor, | |
| 30 | ); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts created+974| ... | ... | @@ -0,0 +1,974 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | CommandId, | |
| 4 | CorrelationId, | |
| 5 | EventId, | |
| 6 | MessageId, | |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 9 | ProjectId, | |
| 10 | ThreadId, | |
| 11 | TurnId, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 14 | import { assert, it } from "@effect/vitest"; | |
| 15 | import { Effect, Layer } from "effect"; | |
| 16 | import fs from "node:fs"; | |
| 17 | import os from "node:os"; | |
| 18 | import path from "node:path"; | |
| 19 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 20 | ||
| 21 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 22 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 23 | import { | |
| 24 | makeSqlitePersistenceLive, | |
| 25 | SqlitePersistenceMemory, | |
| 26 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 27 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 28 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 29 | import { | |
| 30 | ORCHESTRATION_PROJECTOR_NAMES, | |
| 31 | OrchestrationProjectionPipelineLive, | |
| 32 | } from "./ProjectionPipeline.ts"; | |
| 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | |
| 35 | ||
| 36 | const projectionLayer = it.layer( | |
| 37 | OrchestrationProjectionPipelineLive.pipe( | |
| 38 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 39 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 40 | ), | |
| 41 | ); | |
| 42 | ||
| 43 | projectionLayer("OrchestrationProjectionPipeline", (it) => { | |
| 44 | it.effect("bootstraps all projection states and writes projection rows", () => | |
| 45 | Effect.gen(function* () { | |
| 46 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 47 | const eventStore = yield* OrchestrationEventStore; | |
| 48 | const sql = yield* SqlClient.SqlClient; | |
| 49 | const now = new Date().toISOString(); | |
| 50 | ||
| 51 | yield* eventStore.append({ | |
| 52 | type: "project.created", | |
| 53 | eventId: EventId.makeUnsafe("evt-1"), | |
| 54 | aggregateKind: "project", | |
| 55 | aggregateId: ProjectId.makeUnsafe("project-1"), | |
| 56 | occurredAt: now, | |
| 57 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 58 | causationEventId: null, | |
| 59 | correlationId: CommandId.makeUnsafe("cmd-1"), | |
| 60 | metadata: {}, | |
| 61 | payload: { | |
| 62 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 63 | title: "Project 1", | |
| 64 | workspaceRoot: "/tmp/project-1", | |
| 65 | defaultModel: null, | |
| 66 | scripts: [], | |
| 67 | createdAt: now, | |
| 68 | updatedAt: now, | |
| 69 | }, | |
| 70 | }); | |
| 71 | ||
| 72 | yield* eventStore.append({ | |
| 73 | type: "thread.created", | |
| 74 | eventId: EventId.makeUnsafe("evt-2"), | |
| 75 | aggregateKind: "thread", | |
| 76 | aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 77 | occurredAt: now, | |
| 78 | commandId: CommandId.makeUnsafe("cmd-2"), | |
| 79 | causationEventId: null, | |
| 80 | correlationId: CommandId.makeUnsafe("cmd-2"), | |
| 81 | metadata: {}, | |
| 82 | payload: { | |
| 83 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 84 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 85 | title: "Thread 1", | |
| 86 | model: "gpt-5-codex", | |
| 87 | branch: null, | |
| 88 | worktreePath: null, | |
| 89 | createdAt: now, | |
| 90 | updatedAt: now, | |
| 91 | }, | |
| 92 | }); | |
| 93 | ||
| 94 | yield* eventStore.append({ | |
| 95 | type: "thread.message-sent", | |
| 96 | eventId: EventId.makeUnsafe("evt-3"), | |
| 97 | aggregateKind: "thread", | |
| 98 | aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 99 | occurredAt: now, | |
| 100 | commandId: CommandId.makeUnsafe("cmd-3"), | |
| 101 | causationEventId: null, | |
| 102 | correlationId: CommandId.makeUnsafe("cmd-3"), | |
| 103 | metadata: {}, | |
| 104 | payload: { | |
| 105 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 106 | messageId: MessageId.makeUnsafe("message-1"), | |
| 107 | role: "assistant", | |
| 108 | text: "hello", | |
| 109 | turnId: null, | |
| 110 | streaming: false, | |
| 111 | createdAt: now, | |
| 112 | updatedAt: now, | |
| 113 | }, | |
| 114 | }); | |
| 115 | ||
| 116 | yield* projectionPipeline.bootstrap; | |
| 117 | ||
| 118 | const projectRows = yield* sql<{ | |
| 119 | readonly projectId: string; | |
| 120 | readonly title: string; | |
| 121 | readonly scriptsJson: string; | |
| 122 | }>` | |
| 123 | SELECT | |
| 124 | project_id AS "projectId", | |
| 125 | title, | |
| 126 | scripts_json AS "scriptsJson" | |
| 127 | FROM projection_projects | |
| 128 | `; | |
| 129 | assert.deepEqual(projectRows, [ | |
| 130 | { projectId: "project-1", title: "Project 1", scriptsJson: "[]" }, | |
| 131 | ]); | |
| 132 | ||
| 133 | const messageRows = yield* sql<{ | |
| 134 | readonly messageId: string; | |
| 135 | readonly text: string; | |
| 136 | }>` | |
| 137 | SELECT | |
| 138 | message_id AS "messageId", | |
| 139 | text | |
| 140 | FROM projection_thread_messages | |
| 141 | `; | |
| 142 | assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); | |
| 143 | ||
| 144 | const stateRows = yield* sql<{ | |
| 145 | readonly projector: string; | |
| 146 | readonly lastAppliedSequence: number; | |
| 147 | }>` | |
| 148 | SELECT | |
| 149 | projector, | |
| 150 | last_applied_sequence AS "lastAppliedSequence" | |
| 151 | FROM projection_state | |
| 152 | ORDER BY projector ASC | |
| 153 | `; | |
| 154 | assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length); | |
| 155 | for (const row of stateRows) { | |
| 156 | assert.equal(row.lastAppliedSequence, 3); | |
| 157 | } | |
| 158 | }), | |
| 159 | ); | |
| 160 | ||
| 161 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => | |
| 162 | Effect.gen(function* () { | |
| 163 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 164 | const eventStore = yield* OrchestrationEventStore; | |
| 165 | const sql = yield* SqlClient.SqlClient; | |
| 166 | const now = new Date().toISOString(); | |
| 167 | ||
| 168 | yield* eventStore.append({ | |
| 169 | type: "project.created", | |
| 170 | eventId: EventId.makeUnsafe("evt-a1"), | |
| 171 | aggregateKind: "project", | |
| 172 | aggregateId: ProjectId.makeUnsafe("project-a"), | |
| 173 | occurredAt: now, | |
| 174 | commandId: CommandId.makeUnsafe("cmd-a1"), | |
| 175 | causationEventId: null, | |
| 176 | correlationId: CorrelationId.makeUnsafe("cmd-a1"), | |
| 177 | metadata: {}, | |
| 178 | payload: { | |
| 179 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 180 | title: "Project A", | |
| 181 | workspaceRoot: "/tmp/project-a", | |
| 182 | defaultModel: null, | |
| 183 | scripts: [], | |
| 184 | createdAt: now, | |
| 185 | updatedAt: now, | |
| 186 | }, | |
| 187 | }); | |
| 188 | ||
| 189 | yield* eventStore.append({ | |
| 190 | type: "thread.created", | |
| 191 | eventId: EventId.makeUnsafe("evt-a2"), | |
| 192 | aggregateKind: "thread", | |
| 193 | aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 194 | occurredAt: now, | |
| 195 | commandId: CommandId.makeUnsafe("cmd-a2"), | |
| 196 | causationEventId: null, | |
| 197 | correlationId: CorrelationId.makeUnsafe("cmd-a2"), | |
| 198 | metadata: {}, | |
| 199 | payload: { | |
| 200 | threadId: ThreadId.makeUnsafe("thread-a"), | |
| 201 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 202 | title: "Thread A", | |
| 203 | model: "gpt-5-codex", | |
| 204 | branch: null, | |
| 205 | worktreePath: null, | |
| 206 | createdAt: now, | |
| 207 | updatedAt: now, | |
| 208 | }, | |
| 209 | }); | |
| 210 | ||
| 211 | yield* eventStore.append({ | |
| 212 | type: "thread.message-sent", | |
| 213 | eventId: EventId.makeUnsafe("evt-a3"), | |
| 214 | aggregateKind: "thread", | |
| 215 | aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 216 | occurredAt: now, | |
| 217 | commandId: CommandId.makeUnsafe("cmd-a3"), | |
| 218 | causationEventId: null, | |
| 219 | correlationId: CorrelationId.makeUnsafe("cmd-a3"), | |
| 220 | metadata: {}, | |
| 221 | payload: { | |
| 222 | threadId: ThreadId.makeUnsafe("thread-a"), | |
| 223 | messageId: MessageId.makeUnsafe("message-a"), | |
| 224 | role: "assistant", | |
| 225 | text: "hello", | |
| 226 | turnId: null, | |
| 227 | streaming: false, | |
| 228 | createdAt: now, | |
| 229 | updatedAt: now, | |
| 230 | }, | |
| 231 | }); | |
| 232 | ||
| 233 | yield* projectionPipeline.bootstrap; | |
| 234 | ||
| 235 | yield* eventStore.append({ | |
| 236 | type: "thread.message-sent", | |
| 237 | eventId: EventId.makeUnsafe("evt-a4"), | |
| 238 | aggregateKind: "thread", | |
| 239 | aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 240 | occurredAt: now, | |
| 241 | commandId: CommandId.makeUnsafe("cmd-a4"), | |
| 242 | causationEventId: null, | |
| 243 | correlationId: CorrelationId.makeUnsafe("cmd-a4"), | |
| 244 | metadata: {}, | |
| 245 | payload: { | |
| 246 | threadId: ThreadId.makeUnsafe("thread-a"), | |
| 247 | messageId: MessageId.makeUnsafe("message-a"), | |
| 248 | role: "assistant", | |
| 249 | text: " world", | |
| 250 | turnId: null, | |
| 251 | streaming: true, | |
| 252 | createdAt: now, | |
| 253 | updatedAt: now, | |
| 254 | }, | |
| 255 | }); | |
| 256 | ||
| 257 | yield* projectionPipeline.bootstrap; | |
| 258 | yield* projectionPipeline.bootstrap; | |
| 259 | ||
| 260 | const messageRows = yield* sql<{ readonly text: string }>` | |
| 261 | SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' | |
| 262 | `; | |
| 263 | assert.deepEqual(messageRows, [{ text: "hello world" }]); | |
| 264 | ||
| 265 | const stateRows = yield* sql<{ | |
| 266 | readonly projector: string; | |
| 267 | readonly lastAppliedSequence: number; | |
| 268 | }>` | |
| 269 | SELECT | |
| 270 | projector, | |
| 271 | last_applied_sequence AS "lastAppliedSequence" | |
| 272 | FROM projection_state | |
| 273 | `; | |
| 274 | const maxSequenceRows = yield* sql<{ readonly maxSequence: number }>` | |
| 275 | SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events | |
| 276 | `; | |
| 277 | const maxSequence = maxSequenceRows[0]?.maxSequence ?? 0; | |
| 278 | for (const row of stateRows) { | |
| 279 | assert.equal(row.lastAppliedSequence, maxSequence); | |
| 280 | } | |
| 281 | }), | |
| 282 | ); | |
| 283 | ||
| 284 | it.effect("keeps accumulated assistant text when completion payload text is empty", () => | |
| 285 | Effect.gen(function* () { | |
| 286 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 287 | const eventStore = yield* OrchestrationEventStore; | |
| 288 | const sql = yield* SqlClient.SqlClient; | |
| 289 | const now = new Date().toISOString(); | |
| 290 | ||
| 291 | yield* eventStore.append({ | |
| 292 | type: "project.created", | |
| 293 | eventId: EventId.makeUnsafe("evt-empty-1"), | |
| 294 | aggregateKind: "project", | |
| 295 | aggregateId: ProjectId.makeUnsafe("project-empty"), | |
| 296 | occurredAt: now, | |
| 297 | commandId: CommandId.makeUnsafe("cmd-empty-1"), | |
| 298 | causationEventId: null, | |
| 299 | correlationId: CorrelationId.makeUnsafe("cmd-empty-1"), | |
| 300 | metadata: {}, | |
| 301 | payload: { | |
| 302 | projectId: ProjectId.makeUnsafe("project-empty"), | |
| 303 | title: "Project Empty", | |
| 304 | workspaceRoot: "/tmp/project-empty", | |
| 305 | defaultModel: null, | |
| 306 | scripts: [], | |
| 307 | createdAt: now, | |
| 308 | updatedAt: now, | |
| 309 | }, | |
| 310 | }); | |
| 311 | ||
| 312 | yield* eventStore.append({ | |
| 313 | type: "thread.created", | |
| 314 | eventId: EventId.makeUnsafe("evt-empty-2"), | |
| 315 | aggregateKind: "thread", | |
| 316 | aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 317 | occurredAt: now, | |
| 318 | commandId: CommandId.makeUnsafe("cmd-empty-2"), | |
| 319 | causationEventId: null, | |
| 320 | correlationId: CorrelationId.makeUnsafe("cmd-empty-2"), | |
| 321 | metadata: {}, | |
| 322 | payload: { | |
| 323 | threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 324 | projectId: ProjectId.makeUnsafe("project-empty"), | |
| 325 | title: "Thread Empty", | |
| 326 | model: "gpt-5-codex", | |
| 327 | branch: null, | |
| 328 | worktreePath: null, | |
| 329 | createdAt: now, | |
| 330 | updatedAt: now, | |
| 331 | }, | |
| 332 | }); | |
| 333 | ||
| 334 | yield* eventStore.append({ | |
| 335 | type: "thread.message-sent", | |
| 336 | eventId: EventId.makeUnsafe("evt-empty-3"), | |
| 337 | aggregateKind: "thread", | |
| 338 | aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 339 | occurredAt: now, | |
| 340 | commandId: CommandId.makeUnsafe("cmd-empty-3"), | |
| 341 | causationEventId: null, | |
| 342 | correlationId: CorrelationId.makeUnsafe("cmd-empty-3"), | |
| 343 | metadata: {}, | |
| 344 | payload: { | |
| 345 | threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 346 | messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 347 | role: "assistant", | |
| 348 | text: "Hello", | |
| 349 | turnId: null, | |
| 350 | streaming: true, | |
| 351 | createdAt: now, | |
| 352 | updatedAt: now, | |
| 353 | }, | |
| 354 | }); | |
| 355 | ||
| 356 | yield* eventStore.append({ | |
| 357 | type: "thread.message-sent", | |
| 358 | eventId: EventId.makeUnsafe("evt-empty-4"), | |
| 359 | aggregateKind: "thread", | |
| 360 | aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 361 | occurredAt: now, | |
| 362 | commandId: CommandId.makeUnsafe("cmd-empty-4"), | |
| 363 | causationEventId: null, | |
| 364 | correlationId: CorrelationId.makeUnsafe("cmd-empty-4"), | |
| 365 | metadata: {}, | |
| 366 | payload: { | |
| 367 | threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 368 | messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 369 | role: "assistant", | |
| 370 | text: " world", | |
| 371 | turnId: null, | |
| 372 | streaming: true, | |
| 373 | createdAt: now, | |
| 374 | updatedAt: now, | |
| 375 | }, | |
| 376 | }); | |
| 377 | ||
| 378 | yield* eventStore.append({ | |
| 379 | type: "thread.message-sent", | |
| 380 | eventId: EventId.makeUnsafe("evt-empty-5"), | |
| 381 | aggregateKind: "thread", | |
| 382 | aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 383 | occurredAt: now, | |
| 384 | commandId: CommandId.makeUnsafe("cmd-empty-5"), | |
| 385 | causationEventId: null, | |
| 386 | correlationId: CorrelationId.makeUnsafe("cmd-empty-5"), | |
| 387 | metadata: {}, | |
| 388 | payload: { | |
| 389 | threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 390 | messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 391 | role: "assistant", | |
| 392 | text: "", | |
| 393 | turnId: null, | |
| 394 | streaming: false, | |
| 395 | createdAt: now, | |
| 396 | updatedAt: now, | |
| 397 | }, | |
| 398 | }); | |
| 399 | ||
| 400 | yield* projectionPipeline.bootstrap; | |
| 401 | ||
| 402 | const messageRows = yield* sql<{ readonly text: string; readonly isStreaming: unknown }>` | |
| 403 | SELECT | |
| 404 | text, | |
| 405 | is_streaming AS "isStreaming" | |
| 406 | FROM projection_thread_messages | |
| 407 | WHERE message_id = 'assistant-empty' | |
| 408 | `; | |
| 409 | assert.equal(messageRows.length, 1); | |
| 410 | assert.equal(messageRows[0]?.text, "Hello world"); | |
| 411 | assert.isFalse(Boolean(messageRows[0]?.isStreaming)); | |
| 412 | }), | |
| 413 | ); | |
| 414 | ||
| 415 | it.effect( | |
| 416 | "resolves turn-count conflicts when checkpoint completion rewrites provisional turns", | |
| 417 | () => | |
| 418 | Effect.gen(function* () { | |
| 419 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 420 | const eventStore = yield* OrchestrationEventStore; | |
| 421 | const sql = yield* SqlClient.SqlClient; | |
| 422 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | |
| 423 | eventStore | |
| 424 | .append(event) | |
| 425 | .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); | |
| 426 | ||
| 427 | yield* appendAndProject({ | |
| 428 | type: "project.created", | |
| 429 | eventId: EventId.makeUnsafe("evt-conflict-1"), | |
| 430 | aggregateKind: "project", | |
| 431 | aggregateId: ProjectId.makeUnsafe("project-conflict"), | |
| 432 | occurredAt: "2026-02-26T13:00:00.000Z", | |
| 433 | commandId: CommandId.makeUnsafe("cmd-conflict-1"), | |
| 434 | causationEventId: null, | |
| 435 | correlationId: CorrelationId.makeUnsafe("cmd-conflict-1"), | |
| 436 | metadata: {}, | |
| 437 | payload: { | |
| 438 | projectId: ProjectId.makeUnsafe("project-conflict"), | |
| 439 | title: "Project Conflict", | |
| 440 | workspaceRoot: "/tmp/project-conflict", | |
| 441 | defaultModel: null, | |
| 442 | scripts: [], | |
| 443 | createdAt: "2026-02-26T13:00:00.000Z", | |
| 444 | updatedAt: "2026-02-26T13:00:00.000Z", | |
| 445 | }, | |
| 446 | }); | |
| 447 | ||
| 448 | yield* appendAndProject({ | |
| 449 | type: "thread.created", | |
| 450 | eventId: EventId.makeUnsafe("evt-conflict-2"), | |
| 451 | aggregateKind: "thread", | |
| 452 | aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 453 | occurredAt: "2026-02-26T13:00:01.000Z", | |
| 454 | commandId: CommandId.makeUnsafe("cmd-conflict-2"), | |
| 455 | causationEventId: null, | |
| 456 | correlationId: CorrelationId.makeUnsafe("cmd-conflict-2"), | |
| 457 | metadata: {}, | |
| 458 | payload: { | |
| 459 | threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 460 | projectId: ProjectId.makeUnsafe("project-conflict"), | |
| 461 | title: "Thread Conflict", | |
| 462 | model: "gpt-5-codex", | |
| 463 | branch: null, | |
| 464 | worktreePath: null, | |
| 465 | createdAt: "2026-02-26T13:00:01.000Z", | |
| 466 | updatedAt: "2026-02-26T13:00:01.000Z", | |
| 467 | }, | |
| 468 | }); | |
| 469 | ||
| 470 | yield* appendAndProject({ | |
| 471 | type: "thread.turn-interrupt-requested", | |
| 472 | eventId: EventId.makeUnsafe("evt-conflict-3"), | |
| 473 | aggregateKind: "thread", | |
| 474 | aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 475 | occurredAt: "2026-02-26T13:00:02.000Z", | |
| 476 | commandId: CommandId.makeUnsafe("cmd-conflict-3"), | |
| 477 | causationEventId: null, | |
| 478 | correlationId: CorrelationId.makeUnsafe("cmd-conflict-3"), | |
| 479 | metadata: {}, | |
| 480 | payload: { | |
| 481 | threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 482 | turnId: TurnId.makeUnsafe("turn-interrupted"), | |
| 483 | createdAt: "2026-02-26T13:00:02.000Z", | |
| 484 | }, | |
| 485 | }); | |
| 486 | ||
| 487 | yield* appendAndProject({ | |
| 488 | type: "thread.message-sent", | |
| 489 | eventId: EventId.makeUnsafe("evt-conflict-4"), | |
| 490 | aggregateKind: "thread", | |
| 491 | aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 492 | occurredAt: "2026-02-26T13:00:03.000Z", | |
| 493 | commandId: CommandId.makeUnsafe("cmd-conflict-4"), | |
| 494 | causationEventId: null, | |
| 495 | correlationId: CorrelationId.makeUnsafe("cmd-conflict-4"), | |
| 496 | metadata: {}, | |
| 497 | payload: { | |
| 498 | threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 499 | messageId: MessageId.makeUnsafe("assistant-conflict"), | |
| 500 | role: "assistant", | |
| 501 | text: "done", | |
| 502 | turnId: TurnId.makeUnsafe("turn-completed"), | |
| 503 | streaming: false, | |
| 504 | createdAt: "2026-02-26T13:00:03.000Z", | |
| 505 | updatedAt: "2026-02-26T13:00:03.000Z", | |
| 506 | }, | |
| 507 | }); | |
| 508 | ||
| 509 | yield* appendAndProject({ | |
| 510 | type: "thread.turn-diff-completed", | |
| 511 | eventId: EventId.makeUnsafe("evt-conflict-5"), | |
| 512 | aggregateKind: "thread", | |
| 513 | aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 514 | occurredAt: "2026-02-26T13:00:04.000Z", | |
| 515 | commandId: CommandId.makeUnsafe("cmd-conflict-5"), | |
| 516 | causationEventId: null, | |
| 517 | correlationId: CorrelationId.makeUnsafe("cmd-conflict-5"), | |
| 518 | metadata: {}, | |
| 519 | payload: { | |
| 520 | threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 521 | turnId: TurnId.makeUnsafe("turn-completed"), | |
| 522 | checkpointTurnCount: 1, | |
| 523 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-conflict/turn/1"), | |
| 524 | status: "ready", | |
| 525 | files: [], | |
| 526 | assistantMessageId: MessageId.makeUnsafe("assistant-conflict"), | |
| 527 | completedAt: "2026-02-26T13:00:04.000Z", | |
| 528 | }, | |
| 529 | }); | |
| 530 | ||
| 531 | const turnRows = yield* sql<{ | |
| 532 | readonly turnId: string; | |
| 533 | readonly checkpointTurnCount: number | null; | |
| 534 | readonly status: string; | |
| 535 | }>` | |
| 536 | SELECT | |
| 537 | turn_id AS "turnId", | |
| 538 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 539 | state AS "status" | |
| 540 | FROM projection_turns | |
| 541 | WHERE thread_id = 'thread-conflict' | |
| 542 | ORDER BY | |
| 543 | CASE | |
| 544 | WHEN checkpoint_turn_count IS NULL THEN 1 | |
| 545 | ELSE 0 | |
| 546 | END ASC, | |
| 547 | checkpoint_turn_count ASC, | |
| 548 | requested_at ASC | |
| 549 | `; | |
| 550 | assert.deepEqual(turnRows, [ | |
| 551 | { turnId: "turn-completed", checkpointTurnCount: 1, status: "completed" }, | |
| 552 | { turnId: "turn-interrupted", checkpointTurnCount: null, status: "interrupted" }, | |
| 553 | ]); | |
| 554 | }), | |
| 555 | ); | |
| 556 | ||
| 557 | it.effect("does not fallback-retain messages whose turnId is removed by revert", () => | |
| 558 | Effect.gen(function* () { | |
| 559 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 560 | const eventStore = yield* OrchestrationEventStore; | |
| 561 | const sql = yield* SqlClient.SqlClient; | |
| 562 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | |
| 563 | eventStore | |
| 564 | .append(event) | |
| 565 | .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); | |
| 566 | ||
| 567 | yield* appendAndProject({ | |
| 568 | type: "project.created", | |
| 569 | eventId: EventId.makeUnsafe("evt-revert-1"), | |
| 570 | aggregateKind: "project", | |
| 571 | aggregateId: ProjectId.makeUnsafe("project-revert"), | |
| 572 | occurredAt: "2026-02-26T12:00:00.000Z", | |
| 573 | commandId: CommandId.makeUnsafe("cmd-revert-1"), | |
| 574 | causationEventId: null, | |
| 575 | correlationId: CorrelationId.makeUnsafe("cmd-revert-1"), | |
| 576 | metadata: {}, | |
| 577 | payload: { | |
| 578 | projectId: ProjectId.makeUnsafe("project-revert"), | |
| 579 | title: "Project Revert", | |
| 580 | workspaceRoot: "/tmp/project-revert", | |
| 581 | defaultModel: null, | |
| 582 | scripts: [], | |
| 583 | createdAt: "2026-02-26T12:00:00.000Z", | |
| 584 | updatedAt: "2026-02-26T12:00:00.000Z", | |
| 585 | }, | |
| 586 | }); | |
| 587 | ||
| 588 | yield* appendAndProject({ | |
| 589 | type: "thread.created", | |
| 590 | eventId: EventId.makeUnsafe("evt-revert-2"), | |
| 591 | aggregateKind: "thread", | |
| 592 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 593 | occurredAt: "2026-02-26T12:00:01.000Z", | |
| 594 | commandId: CommandId.makeUnsafe("cmd-revert-2"), | |
| 595 | causationEventId: null, | |
| 596 | correlationId: CorrelationId.makeUnsafe("cmd-revert-2"), | |
| 597 | metadata: {}, | |
| 598 | payload: { | |
| 599 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 600 | projectId: ProjectId.makeUnsafe("project-revert"), | |
| 601 | title: "Thread Revert", | |
| 602 | model: "gpt-5-codex", | |
| 603 | branch: null, | |
| 604 | worktreePath: null, | |
| 605 | createdAt: "2026-02-26T12:00:01.000Z", | |
| 606 | updatedAt: "2026-02-26T12:00:01.000Z", | |
| 607 | }, | |
| 608 | }); | |
| 609 | ||
| 610 | yield* appendAndProject({ | |
| 611 | type: "thread.turn-diff-completed", | |
| 612 | eventId: EventId.makeUnsafe("evt-revert-3"), | |
| 613 | aggregateKind: "thread", | |
| 614 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 615 | occurredAt: "2026-02-26T12:00:02.000Z", | |
| 616 | commandId: CommandId.makeUnsafe("cmd-revert-3"), | |
| 617 | causationEventId: null, | |
| 618 | correlationId: CorrelationId.makeUnsafe("cmd-revert-3"), | |
| 619 | metadata: {}, | |
| 620 | payload: { | |
| 621 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 622 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 623 | checkpointTurnCount: 1, | |
| 624 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"), | |
| 625 | status: "ready", | |
| 626 | files: [], | |
| 627 | assistantMessageId: MessageId.makeUnsafe("assistant-keep"), | |
| 628 | completedAt: "2026-02-26T12:00:02.000Z", | |
| 629 | }, | |
| 630 | }); | |
| 631 | ||
| 632 | yield* appendAndProject({ | |
| 633 | type: "thread.message-sent", | |
| 634 | eventId: EventId.makeUnsafe("evt-revert-4"), | |
| 635 | aggregateKind: "thread", | |
| 636 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 637 | occurredAt: "2026-02-26T12:00:02.100Z", | |
| 638 | commandId: CommandId.makeUnsafe("cmd-revert-4"), | |
| 639 | causationEventId: null, | |
| 640 | correlationId: CorrelationId.makeUnsafe("cmd-revert-4"), | |
| 641 | metadata: {}, | |
| 642 | payload: { | |
| 643 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 644 | messageId: MessageId.makeUnsafe("assistant-keep"), | |
| 645 | role: "assistant", | |
| 646 | text: "kept", | |
| 647 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 648 | streaming: false, | |
| 649 | createdAt: "2026-02-26T12:00:02.100Z", | |
| 650 | updatedAt: "2026-02-26T12:00:02.100Z", | |
| 651 | }, | |
| 652 | }); | |
| 653 | ||
| 654 | yield* appendAndProject({ | |
| 655 | type: "thread.turn-diff-completed", | |
| 656 | eventId: EventId.makeUnsafe("evt-revert-5"), | |
| 657 | aggregateKind: "thread", | |
| 658 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 659 | occurredAt: "2026-02-26T12:00:03.000Z", | |
| 660 | commandId: CommandId.makeUnsafe("cmd-revert-5"), | |
| 661 | causationEventId: null, | |
| 662 | correlationId: CorrelationId.makeUnsafe("cmd-revert-5"), | |
| 663 | metadata: {}, | |
| 664 | payload: { | |
| 665 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 666 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 667 | checkpointTurnCount: 2, | |
| 668 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"), | |
| 669 | status: "ready", | |
| 670 | files: [], | |
| 671 | assistantMessageId: MessageId.makeUnsafe("assistant-remove"), | |
| 672 | completedAt: "2026-02-26T12:00:03.000Z", | |
| 673 | }, | |
| 674 | }); | |
| 675 | ||
| 676 | yield* appendAndProject({ | |
| 677 | type: "thread.message-sent", | |
| 678 | eventId: EventId.makeUnsafe("evt-revert-6"), | |
| 679 | aggregateKind: "thread", | |
| 680 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 681 | occurredAt: "2026-02-26T12:00:03.050Z", | |
| 682 | commandId: CommandId.makeUnsafe("cmd-revert-6"), | |
| 683 | causationEventId: null, | |
| 684 | correlationId: CorrelationId.makeUnsafe("cmd-revert-6"), | |
| 685 | metadata: {}, | |
| 686 | payload: { | |
| 687 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 688 | messageId: MessageId.makeUnsafe("user-remove"), | |
| 689 | role: "user", | |
| 690 | text: "removed", | |
| 691 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 692 | streaming: false, | |
| 693 | createdAt: "2026-02-26T12:00:03.050Z", | |
| 694 | updatedAt: "2026-02-26T12:00:03.050Z", | |
| 695 | }, | |
| 696 | }); | |
| 697 | ||
| 698 | yield* appendAndProject({ | |
| 699 | type: "thread.message-sent", | |
| 700 | eventId: EventId.makeUnsafe("evt-revert-7"), | |
| 701 | aggregateKind: "thread", | |
| 702 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 703 | occurredAt: "2026-02-26T12:00:03.100Z", | |
| 704 | commandId: CommandId.makeUnsafe("cmd-revert-7"), | |
| 705 | causationEventId: null, | |
| 706 | correlationId: CorrelationId.makeUnsafe("cmd-revert-7"), | |
| 707 | metadata: {}, | |
| 708 | payload: { | |
| 709 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 710 | messageId: MessageId.makeUnsafe("assistant-remove"), | |
| 711 | role: "assistant", | |
| 712 | text: "removed", | |
| 713 | turnId: TurnId.makeUnsafe("turn-2"), | |
| 714 | streaming: false, | |
| 715 | createdAt: "2026-02-26T12:00:03.100Z", | |
| 716 | updatedAt: "2026-02-26T12:00:03.100Z", | |
| 717 | }, | |
| 718 | }); | |
| 719 | ||
| 720 | yield* appendAndProject({ | |
| 721 | type: "thread.reverted", | |
| 722 | eventId: EventId.makeUnsafe("evt-revert-8"), | |
| 723 | aggregateKind: "thread", | |
| 724 | aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 725 | occurredAt: "2026-02-26T12:00:04.000Z", | |
| 726 | commandId: CommandId.makeUnsafe("cmd-revert-8"), | |
| 727 | causationEventId: null, | |
| 728 | correlationId: CorrelationId.makeUnsafe("cmd-revert-8"), | |
| 729 | metadata: {}, | |
| 730 | payload: { | |
| 731 | threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 732 | turnCount: 1, | |
| 733 | }, | |
| 734 | }); | |
| 735 | ||
| 736 | const messageRows = yield* sql<{ | |
| 737 | readonly messageId: string; | |
| 738 | readonly turnId: string | null; | |
| 739 | readonly role: string; | |
| 740 | }>` | |
| 741 | SELECT | |
| 742 | message_id AS "messageId", | |
| 743 | turn_id AS "turnId", | |
| 744 | role | |
| 745 | FROM projection_thread_messages | |
| 746 | WHERE thread_id = 'thread-revert' | |
| 747 | ORDER BY created_at ASC, message_id ASC | |
| 748 | `; | |
| 749 | assert.deepEqual(messageRows, [ | |
| 750 | { | |
| 751 | messageId: "assistant-keep", | |
| 752 | turnId: "turn-1", | |
| 753 | role: "assistant", | |
| 754 | }, | |
| 755 | ]); | |
| 756 | }), | |
| 757 | ); | |
| 758 | }); | |
| 759 | ||
| 760 | it.effect("restores pending turn-start metadata across projection pipeline restart", () => | |
| 761 | Effect.gen(function* () { | |
| 762 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-")); | |
| 763 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 764 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 765 | const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe( | |
| 766 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 767 | Layer.provideMerge(persistenceLayer), | |
| 768 | ); | |
| 769 | const secondProjectionLayer = OrchestrationProjectionPipelineLive.pipe( | |
| 770 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 771 | Layer.provideMerge(persistenceLayer), | |
| 772 | ); | |
| 773 | ||
| 774 | const threadId = ThreadId.makeUnsafe("thread-restart"); | |
| 775 | const turnId = TurnId.makeUnsafe("turn-restart"); | |
| 776 | const messageId = MessageId.makeUnsafe("message-restart"); | |
| 777 | const turnStartedAt = "2026-02-26T14:00:00.000Z"; | |
| 778 | const sessionSetAt = "2026-02-26T14:00:05.000Z"; | |
| 779 | ||
| 780 | yield* Effect.gen(function* () { | |
| 781 | const eventStore = yield* OrchestrationEventStore; | |
| 782 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 783 | ||
| 784 | yield* eventStore.append({ | |
| 785 | type: "thread.turn-start-requested", | |
| 786 | eventId: EventId.makeUnsafe("evt-restart-1"), | |
| 787 | aggregateKind: "thread", | |
| 788 | aggregateId: threadId, | |
| 789 | occurredAt: turnStartedAt, | |
| 790 | commandId: CommandId.makeUnsafe("cmd-restart-1"), | |
| 791 | causationEventId: null, | |
| 792 | correlationId: CorrelationId.makeUnsafe("cmd-restart-1"), | |
| 793 | metadata: {}, | |
| 794 | payload: { | |
| 795 | threadId, | |
| 796 | messageId, | |
| 797 | approvalPolicy: "on-request", | |
| 798 | sandboxMode: "workspace-write", | |
| 799 | createdAt: turnStartedAt, | |
| 800 | }, | |
| 801 | }); | |
| 802 | ||
| 803 | yield* projectionPipeline.bootstrap; | |
| 804 | }).pipe(Effect.provide(firstProjectionLayer)); | |
| 805 | ||
| 806 | const turnRows = yield* Effect.gen(function* () { | |
| 807 | const eventStore = yield* OrchestrationEventStore; | |
| 808 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 809 | const sql = yield* SqlClient.SqlClient; | |
| 810 | ||
| 811 | yield* eventStore.append({ | |
| 812 | type: "thread.session-set", | |
| 813 | eventId: EventId.makeUnsafe("evt-restart-2"), | |
| 814 | aggregateKind: "thread", | |
| 815 | aggregateId: threadId, | |
| 816 | occurredAt: sessionSetAt, | |
| 817 | commandId: CommandId.makeUnsafe("cmd-restart-2"), | |
| 818 | causationEventId: null, | |
| 819 | correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), | |
| 820 | metadata: { | |
| 821 | providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), | |
| 822 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), | |
| 823 | }, | |
| 824 | payload: { | |
| 825 | threadId, | |
| 826 | session: { | |
| 827 | threadId, | |
| 828 | status: "running", | |
| 829 | providerName: "codex", | |
| 830 | providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"), | |
| 831 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"), | |
| 832 | approvalPolicy: "on-request", | |
| 833 | sandboxMode: "workspace-write", | |
| 834 | activeTurnId: turnId, | |
| 835 | lastError: null, | |
| 836 | updatedAt: sessionSetAt, | |
| 837 | }, | |
| 838 | }, | |
| 839 | }); | |
| 840 | ||
| 841 | yield* projectionPipeline.bootstrap; | |
| 842 | ||
| 843 | const pendingRows = yield* sql<{ readonly threadId: string }>` | |
| 844 | SELECT thread_id AS "threadId" | |
| 845 | FROM projection_turns | |
| 846 | WHERE thread_id = ${threadId} | |
| 847 | AND turn_id IS NULL | |
| 848 | AND state = 'pending' | |
| 849 | `; | |
| 850 | assert.deepEqual(pendingRows, []); | |
| 851 | ||
| 852 | return yield* sql<{ | |
| 853 | readonly turnId: string; | |
| 854 | readonly userMessageId: string | null; | |
| 855 | readonly startedAt: string; | |
| 856 | }>` | |
| 857 | SELECT | |
| 858 | turn_id AS "turnId", | |
| 859 | pending_message_id AS "userMessageId", | |
| 860 | started_at AS "startedAt" | |
| 861 | FROM projection_turns | |
| 862 | WHERE turn_id = ${turnId} | |
| 863 | `; | |
| 864 | }).pipe(Effect.provide(secondProjectionLayer)); | |
| 865 | ||
| 866 | assert.deepEqual(turnRows, [ | |
| 867 | { | |
| 868 | turnId: "turn-restart", | |
| 869 | userMessageId: "message-restart", | |
| 870 | startedAt: turnStartedAt, | |
| 871 | }, | |
| 872 | ]); | |
| 873 | ||
| 874 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 875 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 876 | ); | |
| 877 | ||
| 878 | const engineLayer = it.layer( | |
| 879 | OrchestrationEngineLive.pipe( | |
| 880 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 881 | Layer.provide(OrchestrationEventStoreLive), | |
| 882 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 883 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 884 | ), | |
| 885 | ); | |
| 886 | ||
| 887 | engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | |
| 888 | it.effect("projects dispatched engine events immediately", () => | |
| 889 | Effect.gen(function* () { | |
| 890 | const engine = yield* OrchestrationEngineService; | |
| 891 | const sql = yield* SqlClient.SqlClient; | |
| 892 | const createdAt = new Date().toISOString(); | |
| 893 | ||
| 894 | yield* engine.dispatch({ | |
| 895 | type: "project.create", | |
| 896 | commandId: CommandId.makeUnsafe("cmd-live-project"), | |
| 897 | projectId: ProjectId.makeUnsafe("project-live"), | |
| 898 | title: "Live Project", | |
| 899 | workspaceRoot: "/tmp/project-live", | |
| 900 | defaultModel: "gpt-5-codex", | |
| 901 | createdAt, | |
| 902 | }); | |
| 903 | ||
| 904 | const projectRows = yield* sql<{ readonly title: string; readonly scriptsJson: string }>` | |
| 905 | SELECT | |
| 906 | title, | |
| 907 | scripts_json AS "scriptsJson" | |
| 908 | FROM projection_projects | |
| 909 | WHERE project_id = 'project-live' | |
| 910 | `; | |
| 911 | assert.deepEqual(projectRows, [{ title: "Live Project", scriptsJson: "[]" }]); | |
| 912 | ||
| 913 | const projectorRows = yield* sql<{ readonly lastAppliedSequence: number }>` | |
| 914 | SELECT | |
| 915 | last_applied_sequence AS "lastAppliedSequence" | |
| 916 | FROM projection_state | |
| 917 | WHERE projector = 'projection.projects' | |
| 918 | `; | |
| 919 | assert.deepEqual(projectorRows, [{ lastAppliedSequence: 1 }]); | |
| 920 | }), | |
| 921 | ); | |
| 922 | ||
| 923 | it.effect("projects persist updated scripts from project.meta.update", () => | |
| 924 | Effect.gen(function* () { | |
| 925 | const engine = yield* OrchestrationEngineService; | |
| 926 | const sql = yield* SqlClient.SqlClient; | |
| 927 | const createdAt = new Date().toISOString(); | |
| 928 | ||
| 929 | yield* engine.dispatch({ | |
| 930 | type: "project.create", | |
| 931 | commandId: CommandId.makeUnsafe("cmd-scripts-project-create"), | |
| 932 | projectId: ProjectId.makeUnsafe("project-scripts"), | |
| 933 | title: "Scripts Project", | |
| 934 | workspaceRoot: "/tmp/project-scripts", | |
| 935 | defaultModel: "gpt-5-codex", | |
| 936 | createdAt, | |
| 937 | }); | |
| 938 | ||
| 939 | yield* engine.dispatch({ | |
| 940 | type: "project.meta.update", | |
| 941 | commandId: CommandId.makeUnsafe("cmd-scripts-project-update"), | |
| 942 | projectId: ProjectId.makeUnsafe("project-scripts"), | |
| 943 | scripts: [ | |
| 944 | { | |
| 945 | id: "script-1", | |
| 946 | name: "Build", | |
| 947 | command: "bun run build", | |
| 948 | icon: "build", | |
| 949 | runOnWorktreeCreate: false, | |
| 950 | }, | |
| 951 | ], | |
| 952 | defaultModel: "gpt-5", | |
| 953 | }); | |
| 954 | ||
| 955 | const projectRows = yield* sql<{ | |
| 956 | readonly scriptsJson: string; | |
| 957 | readonly defaultModel: string; | |
| 958 | }>` | |
| 959 | SELECT | |
| 960 | scripts_json AS "scriptsJson", | |
| 961 | default_model AS "defaultModel" | |
| 962 | FROM projection_projects | |
| 963 | WHERE project_id = 'project-scripts' | |
| 964 | `; | |
| 965 | assert.deepEqual(projectRows, [ | |
| 966 | { | |
| 967 | scriptsJson: | |
| 968 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', | |
| 969 | defaultModel: "gpt-5", | |
| 970 | }, | |
| 971 | ]); | |
| 972 | }), | |
| 973 | ); | |
| 974 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts created+912| ... | ... | @@ -0,0 +1,912 @@ |
| 1 | import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 6 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 7 | import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; | |
| 8 | import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; | |
| 9 | import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; | |
| 10 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; | |
| 11 | import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; | |
| 12 | import { | |
| 13 | type ProjectionThreadMessage, | |
| 14 | ProjectionThreadMessageRepository, | |
| 15 | } from "../../persistence/Services/ProjectionThreadMessages.ts"; | |
| 16 | import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; | |
| 17 | import { | |
| 18 | type ProjectionTurn, | |
| 19 | ProjectionTurnRepository, | |
| 20 | } from "../../persistence/Services/ProjectionTurns.ts"; | |
| 21 | import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; | |
| 22 | import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; | |
| 23 | import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; | |
| 24 | import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; | |
| 25 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; | |
| 26 | import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; | |
| 27 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; | |
| 28 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; | |
| 29 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; | |
| 30 | import { | |
| 31 | OrchestrationProjectionPipeline, | |
| 32 | type OrchestrationProjectionPipelineShape, | |
| 33 | } from "../Services/ProjectionPipeline.ts"; | |
| 34 | ||
| 35 | export const ORCHESTRATION_PROJECTOR_NAMES = { | |
| 36 | projects: "projection.projects", | |
| 37 | threads: "projection.threads", | |
| 38 | threadMessages: "projection.thread-messages", | |
| 39 | threadActivities: "projection.thread-activities", | |
| 40 | threadSessions: "projection.thread-sessions", | |
| 41 | threadTurns: "projection.thread-turns", | |
| 42 | checkpoints: "projection.checkpoints", | |
| 43 | pendingApprovals: "projection.pending-approvals", | |
| 44 | } as const; | |
| 45 | ||
| 46 | type ProjectorName = | |
| 47 | (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; | |
| 48 | ||
| 49 | interface ProjectorDefinition { | |
| 50 | readonly name: ProjectorName; | |
| 51 | readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 52 | } | |
| 53 | ||
| 54 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { | |
| 55 | if (typeof payload !== "object" || payload === null) { | |
| 56 | return null; | |
| 57 | } | |
| 58 | const requestId = (payload as Record<string, unknown>).requestId; | |
| 59 | return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; | |
| 60 | } | |
| 61 | ||
| 62 | function retainProjectionMessagesAfterRevert( | |
| 63 | messages: ReadonlyArray<ProjectionThreadMessage>, | |
| 64 | turns: ReadonlyArray<ProjectionTurn>, | |
| 65 | turnCount: number, | |
| 66 | ): ReadonlyArray<ProjectionThreadMessage> { | |
| 67 | const retainedMessageIds = new Set<string>(); | |
| 68 | const retainedTurnIds = new Set<string>(); | |
| 69 | const keptTurns = turns.filter( | |
| 70 | (turn) => | |
| 71 | turn.turnId !== null && | |
| 72 | turn.checkpointTurnCount !== null && | |
| 73 | turn.checkpointTurnCount <= turnCount, | |
| 74 | ); | |
| 75 | for (const turn of keptTurns) { | |
| 76 | if (turn.turnId !== null) { | |
| 77 | retainedTurnIds.add(turn.turnId); | |
| 78 | } | |
| 79 | if (turn.pendingMessageId !== null) { | |
| 80 | retainedMessageIds.add(turn.pendingMessageId); | |
| 81 | } | |
| 82 | if (turn.assistantMessageId !== null) { | |
| 83 | retainedMessageIds.add(turn.assistantMessageId); | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | for (const message of messages) { | |
| 88 | if (message.role === "system") { | |
| 89 | retainedMessageIds.add(message.messageId); | |
| 90 | continue; | |
| 91 | } | |
| 92 | if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { | |
| 93 | retainedMessageIds.add(message.messageId); | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | const retainedUserCount = messages.filter( | |
| 98 | (message) => message.role === "user" && retainedMessageIds.has(message.messageId), | |
| 99 | ).length; | |
| 100 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); | |
| 101 | if (missingUserCount > 0) { | |
| 102 | const fallbackUserMessages = messages | |
| 103 | .filter( | |
| 104 | (message) => | |
| 105 | message.role === "user" && | |
| 106 | !retainedMessageIds.has(message.messageId) && | |
| 107 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 108 | ) | |
| 109 | .toSorted( | |
| 110 | (left, right) => | |
| 111 | left.createdAt.localeCompare(right.createdAt) || | |
| 112 | left.messageId.localeCompare(right.messageId), | |
| 113 | ) | |
| 114 | .slice(0, missingUserCount); | |
| 115 | for (const message of fallbackUserMessages) { | |
| 116 | retainedMessageIds.add(message.messageId); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | const retainedAssistantCount = messages.filter( | |
| 121 | (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), | |
| 122 | ).length; | |
| 123 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); | |
| 124 | if (missingAssistantCount > 0) { | |
| 125 | const fallbackAssistantMessages = messages | |
| 126 | .filter( | |
| 127 | (message) => | |
| 128 | message.role === "assistant" && | |
| 129 | !retainedMessageIds.has(message.messageId) && | |
| 130 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 131 | ) | |
| 132 | .toSorted( | |
| 133 | (left, right) => | |
| 134 | left.createdAt.localeCompare(right.createdAt) || | |
| 135 | left.messageId.localeCompare(right.messageId), | |
| 136 | ) | |
| 137 | .slice(0, missingAssistantCount); | |
| 138 | for (const message of fallbackAssistantMessages) { | |
| 139 | retainedMessageIds.add(message.messageId); | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | return messages.filter((message) => retainedMessageIds.has(message.messageId)); | |
| 144 | } | |
| 145 | ||
| 146 | function retainProjectionActivitiesAfterRevert( | |
| 147 | activities: ReadonlyArray<ProjectionThreadActivity>, | |
| 148 | turns: ReadonlyArray<ProjectionTurn>, | |
| 149 | turnCount: number, | |
| 150 | ): ReadonlyArray<ProjectionThreadActivity> { | |
| 151 | const retainedTurnIds = new Set<string>( | |
| 152 | turns | |
| 153 | .filter( | |
| 154 | (turn) => | |
| 155 | turn.turnId !== null && | |
| 156 | turn.checkpointTurnCount !== null && | |
| 157 | turn.checkpointTurnCount <= turnCount, | |
| 158 | ) | |
| 159 | .flatMap((turn) => (turn.turnId === null ? [] : [turn.turnId])), | |
| 160 | ); | |
| 161 | return activities.filter( | |
| 162 | (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), | |
| 163 | ); | |
| 164 | } | |
| 165 | ||
| 166 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | |
| 167 | const sql = yield* SqlClient.SqlClient; | |
| 168 | const eventStore = yield* OrchestrationEventStore; | |
| 169 | const projectionStateRepository = yield* ProjectionStateRepository; | |
| 170 | const projectionProjectRepository = yield* ProjectionProjectRepository; | |
| 171 | const projectionThreadRepository = yield* ProjectionThreadRepository; | |
| 172 | const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; | |
| 173 | const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; | |
| 174 | const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; | |
| 175 | const projectionTurnRepository = yield* ProjectionTurnRepository; | |
| 176 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; | |
| 177 | ||
| 178 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => | |
| 179 | Effect.gen(function* () { | |
| 180 | switch (event.type) { | |
| 181 | case "project.created": | |
| 182 | yield* projectionProjectRepository.upsert({ | |
| 183 | projectId: event.payload.projectId, | |
| 184 | title: event.payload.title, | |
| 185 | workspaceRoot: event.payload.workspaceRoot, | |
| 186 | defaultModel: event.payload.defaultModel, | |
| 187 | scripts: event.payload.scripts, | |
| 188 | createdAt: event.payload.createdAt, | |
| 189 | updatedAt: event.payload.updatedAt, | |
| 190 | deletedAt: null, | |
| 191 | }); | |
| 192 | return; | |
| 193 | ||
| 194 | case "project.meta-updated": { | |
| 195 | const existingRow = yield* projectionProjectRepository.getById({ | |
| 196 | projectId: event.payload.projectId, | |
| 197 | }); | |
| 198 | if (Option.isNone(existingRow)) { | |
| 199 | return; | |
| 200 | } | |
| 201 | yield* projectionProjectRepository.upsert({ | |
| 202 | ...existingRow.value, | |
| 203 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | |
| 204 | ...(event.payload.workspaceRoot !== undefined | |
| 205 | ? { workspaceRoot: event.payload.workspaceRoot } | |
| 206 | : {}), | |
| 207 | ...(event.payload.defaultModel !== undefined | |
| 208 | ? { defaultModel: event.payload.defaultModel } | |
| 209 | : {}), | |
| 210 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), | |
| 211 | updatedAt: event.payload.updatedAt, | |
| 212 | }); | |
| 213 | return; | |
| 214 | } | |
| 215 | ||
| 216 | case "project.deleted": { | |
| 217 | const existingRow = yield* projectionProjectRepository.getById({ | |
| 218 | projectId: event.payload.projectId, | |
| 219 | }); | |
| 220 | if (Option.isNone(existingRow)) { | |
| 221 | return; | |
| 222 | } | |
| 223 | yield* projectionProjectRepository.upsert({ | |
| 224 | ...existingRow.value, | |
| 225 | deletedAt: event.payload.deletedAt, | |
| 226 | updatedAt: event.payload.deletedAt, | |
| 227 | }); | |
| 228 | return; | |
| 229 | } | |
| 230 | ||
| 231 | default: | |
| 232 | return; | |
| 233 | } | |
| 234 | }); | |
| 235 | ||
| 236 | const applyThreadsProjection: ProjectorDefinition["apply"] = (event) => | |
| 237 | Effect.gen(function* () { | |
| 238 | switch (event.type) { | |
| 239 | case "thread.created": | |
| 240 | yield* projectionThreadRepository.upsert({ | |
| 241 | threadId: event.payload.threadId, | |
| 242 | projectId: event.payload.projectId, | |
| 243 | title: event.payload.title, | |
| 244 | model: event.payload.model, | |
| 245 | branch: event.payload.branch, | |
| 246 | worktreePath: event.payload.worktreePath, | |
| 247 | latestTurnId: null, | |
| 248 | createdAt: event.payload.createdAt, | |
| 249 | updatedAt: event.payload.updatedAt, | |
| 250 | deletedAt: null, | |
| 251 | }); | |
| 252 | return; | |
| 253 | ||
| 254 | case "thread.meta-updated": { | |
| 255 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 256 | threadId: event.payload.threadId, | |
| 257 | }); | |
| 258 | if (Option.isNone(existingRow)) { | |
| 259 | return; | |
| 260 | } | |
| 261 | yield* projectionThreadRepository.upsert({ | |
| 262 | ...existingRow.value, | |
| 263 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | |
| 264 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | |
| 265 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | |
| 266 | ...(event.payload.worktreePath !== undefined | |
| 267 | ? { worktreePath: event.payload.worktreePath } | |
| 268 | : {}), | |
| 269 | updatedAt: event.payload.updatedAt, | |
| 270 | }); | |
| 271 | return; | |
| 272 | } | |
| 273 | ||
| 274 | case "thread.deleted": { | |
| 275 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 276 | threadId: event.payload.threadId, | |
| 277 | }); | |
| 278 | if (Option.isNone(existingRow)) { | |
| 279 | return; | |
| 280 | } | |
| 281 | yield* projectionThreadRepository.upsert({ | |
| 282 | ...existingRow.value, | |
| 283 | deletedAt: event.payload.deletedAt, | |
| 284 | updatedAt: event.payload.deletedAt, | |
| 285 | }); | |
| 286 | return; | |
| 287 | } | |
| 288 | ||
| 289 | case "thread.message-sent": | |
| 290 | case "thread.activity-appended": { | |
| 291 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 292 | threadId: event.payload.threadId, | |
| 293 | }); | |
| 294 | if (Option.isNone(existingRow)) { | |
| 295 | return; | |
| 296 | } | |
| 297 | yield* projectionThreadRepository.upsert({ | |
| 298 | ...existingRow.value, | |
| 299 | updatedAt: event.occurredAt, | |
| 300 | }); | |
| 301 | return; | |
| 302 | } | |
| 303 | ||
| 304 | case "thread.session-set": { | |
| 305 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 306 | threadId: event.payload.threadId, | |
| 307 | }); | |
| 308 | if (Option.isNone(existingRow)) { | |
| 309 | return; | |
| 310 | } | |
| 311 | yield* projectionThreadRepository.upsert({ | |
| 312 | ...existingRow.value, | |
| 313 | latestTurnId: event.payload.session.activeTurnId, | |
| 314 | updatedAt: event.occurredAt, | |
| 315 | }); | |
| 316 | return; | |
| 317 | } | |
| 318 | ||
| 319 | case "thread.turn-diff-completed": { | |
| 320 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 321 | threadId: event.payload.threadId, | |
| 322 | }); | |
| 323 | if (Option.isNone(existingRow)) { | |
| 324 | return; | |
| 325 | } | |
| 326 | yield* projectionThreadRepository.upsert({ | |
| 327 | ...existingRow.value, | |
| 328 | latestTurnId: event.payload.turnId, | |
| 329 | updatedAt: event.occurredAt, | |
| 330 | }); | |
| 331 | return; | |
| 332 | } | |
| 333 | ||
| 334 | case "thread.reverted": { | |
| 335 | const existingRow = yield* projectionThreadRepository.getById({ | |
| 336 | threadId: event.payload.threadId, | |
| 337 | }); | |
| 338 | if (Option.isNone(existingRow)) { | |
| 339 | return; | |
| 340 | } | |
| 341 | yield* projectionThreadRepository.upsert({ | |
| 342 | ...existingRow.value, | |
| 343 | latestTurnId: null, | |
| 344 | updatedAt: event.occurredAt, | |
| 345 | }); | |
| 346 | return; | |
| 347 | } | |
| 348 | ||
| 349 | default: | |
| 350 | return; | |
| 351 | } | |
| 352 | }); | |
| 353 | ||
| 354 | const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) => | |
| 355 | Effect.gen(function* () { | |
| 356 | switch (event.type) { | |
| 357 | case "thread.message-sent": { | |
| 358 | const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ | |
| 359 | threadId: event.payload.threadId, | |
| 360 | }); | |
| 361 | const existingMessage = existingRows.find( | |
| 362 | (row) => row.messageId === event.payload.messageId, | |
| 363 | ); | |
| 364 | const nextText = | |
| 365 | existingMessage && event.payload.streaming | |
| 366 | ? `${existingMessage.text}${event.payload.text}` | |
| 367 | : existingMessage && event.payload.text.length === 0 | |
| 368 | ? existingMessage.text | |
| 369 | : event.payload.text; | |
| 370 | yield* projectionThreadMessageRepository.upsert({ | |
| 371 | messageId: event.payload.messageId, | |
| 372 | threadId: event.payload.threadId, | |
| 373 | turnId: event.payload.turnId, | |
| 374 | role: event.payload.role, | |
| 375 | text: nextText, | |
| 376 | isStreaming: event.payload.streaming, | |
| 377 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, | |
| 378 | updatedAt: event.payload.updatedAt, | |
| 379 | }); | |
| 380 | return; | |
| 381 | } | |
| 382 | ||
| 383 | case "thread.reverted": { | |
| 384 | const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ | |
| 385 | threadId: event.payload.threadId, | |
| 386 | }); | |
| 387 | if (existingRows.length === 0) { | |
| 388 | return; | |
| 389 | } | |
| 390 | ||
| 391 | const existingTurns = yield* projectionTurnRepository.listByThreadId({ | |
| 392 | threadId: event.payload.threadId, | |
| 393 | }); | |
| 394 | const keptRows = retainProjectionMessagesAfterRevert( | |
| 395 | existingRows, | |
| 396 | existingTurns, | |
| 397 | event.payload.turnCount, | |
| 398 | ); | |
| 399 | if (keptRows.length === existingRows.length) { | |
| 400 | return; | |
| 401 | } | |
| 402 | ||
| 403 | yield* projectionThreadMessageRepository.deleteByThreadId({ | |
| 404 | threadId: event.payload.threadId, | |
| 405 | }); | |
| 406 | yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { | |
| 407 | concurrency: 1, | |
| 408 | }).pipe(Effect.asVoid); | |
| 409 | return; | |
| 410 | } | |
| 411 | ||
| 412 | default: | |
| 413 | return; | |
| 414 | } | |
| 415 | }); | |
| 416 | ||
| 417 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) => | |
| 418 | Effect.gen(function* () { | |
| 419 | switch (event.type) { | |
| 420 | case "thread.activity-appended": | |
| 421 | yield* projectionThreadActivityRepository.upsert({ | |
| 422 | activityId: event.payload.activity.id, | |
| 423 | threadId: event.payload.threadId, | |
| 424 | turnId: event.payload.activity.turnId, | |
| 425 | tone: event.payload.activity.tone, | |
| 426 | kind: event.payload.activity.kind, | |
| 427 | summary: event.payload.activity.summary, | |
| 428 | payload: event.payload.activity.payload, | |
| 429 | createdAt: event.payload.activity.createdAt, | |
| 430 | }); | |
| 431 | return; | |
| 432 | ||
| 433 | case "thread.reverted": { | |
| 434 | const existingRows = yield* projectionThreadActivityRepository.listByThreadId({ | |
| 435 | threadId: event.payload.threadId, | |
| 436 | }); | |
| 437 | if (existingRows.length === 0) { | |
| 438 | return; | |
| 439 | } | |
| 440 | const existingTurns = yield* projectionTurnRepository.listByThreadId({ | |
| 441 | threadId: event.payload.threadId, | |
| 442 | }); | |
| 443 | const keptRows = retainProjectionActivitiesAfterRevert( | |
| 444 | existingRows, | |
| 445 | existingTurns, | |
| 446 | event.payload.turnCount, | |
| 447 | ); | |
| 448 | if (keptRows.length === existingRows.length) { | |
| 449 | return; | |
| 450 | } | |
| 451 | yield* projectionThreadActivityRepository.deleteByThreadId({ | |
| 452 | threadId: event.payload.threadId, | |
| 453 | }); | |
| 454 | yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, { | |
| 455 | concurrency: 1, | |
| 456 | }).pipe(Effect.asVoid); | |
| 457 | return; | |
| 458 | } | |
| 459 | ||
| 460 | default: | |
| 461 | return; | |
| 462 | } | |
| 463 | }); | |
| 464 | ||
| 465 | const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) => | |
| 466 | Effect.gen(function* () { | |
| 467 | if (event.type !== "thread.session-set") { | |
| 468 | return; | |
| 469 | } | |
| 470 | yield* projectionThreadSessionRepository.upsert({ | |
| 471 | threadId: event.payload.threadId, | |
| 472 | status: event.payload.session.status, | |
| 473 | providerName: event.payload.session.providerName, | |
| 474 | providerSessionId: event.payload.session.providerSessionId, | |
| 475 | providerThreadId: event.payload.session.providerThreadId, | |
| 476 | approvalPolicy: event.payload.session.approvalPolicy, | |
| 477 | sandboxMode: event.payload.session.sandboxMode, | |
| 478 | activeTurnId: event.payload.session.activeTurnId, | |
| 479 | lastError: event.payload.session.lastError, | |
| 480 | updatedAt: event.payload.session.updatedAt, | |
| 481 | }); | |
| 482 | }); | |
| 483 | ||
| 484 | const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) => | |
| 485 | Effect.gen(function* () { | |
| 486 | switch (event.type) { | |
| 487 | case "thread.turn-start-requested": { | |
| 488 | yield* projectionTurnRepository.replacePendingTurnStart({ | |
| 489 | threadId: event.payload.threadId, | |
| 490 | messageId: event.payload.messageId, | |
| 491 | requestedAt: event.payload.createdAt, | |
| 492 | }); | |
| 493 | return; | |
| 494 | } | |
| 495 | ||
| 496 | case "thread.session-set": { | |
| 497 | const turnId = event.payload.session.activeTurnId; | |
| 498 | if (turnId === null || event.payload.session.status !== "running") { | |
| 499 | return; | |
| 500 | } | |
| 501 | ||
| 502 | const existingTurn = yield* projectionTurnRepository.getByTurnId({ | |
| 503 | threadId: event.payload.threadId, | |
| 504 | turnId, | |
| 505 | }); | |
| 506 | const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ | |
| 507 | threadId: event.payload.threadId, | |
| 508 | }); | |
| 509 | if (Option.isSome(existingTurn)) { | |
| 510 | const nextState = | |
| 511 | existingTurn.value.state === "completed" || existingTurn.value.state === "error" | |
| 512 | ? existingTurn.value.state | |
| 513 | : "running"; | |
| 514 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 515 | ...existingTurn.value, | |
| 516 | state: nextState, | |
| 517 | pendingMessageId: | |
| 518 | existingTurn.value.pendingMessageId ?? | |
| 519 | (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null), | |
| 520 | startedAt: | |
| 521 | existingTurn.value.startedAt ?? | |
| 522 | (Option.isSome(pendingTurnStart) | |
| 523 | ? pendingTurnStart.value.requestedAt | |
| 524 | : event.occurredAt), | |
| 525 | requestedAt: | |
| 526 | existingTurn.value.requestedAt ?? | |
| 527 | (Option.isSome(pendingTurnStart) | |
| 528 | ? pendingTurnStart.value.requestedAt | |
| 529 | : event.occurredAt), | |
| 530 | }); | |
| 531 | } else { | |
| 532 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 533 | turnId, | |
| 534 | threadId: event.payload.threadId, | |
| 535 | pendingMessageId: Option.isSome(pendingTurnStart) | |
| 536 | ? pendingTurnStart.value.messageId | |
| 537 | : null, | |
| 538 | assistantMessageId: null, | |
| 539 | state: "running", | |
| 540 | requestedAt: Option.isSome(pendingTurnStart) | |
| 541 | ? pendingTurnStart.value.requestedAt | |
| 542 | : event.occurredAt, | |
| 543 | startedAt: Option.isSome(pendingTurnStart) | |
| 544 | ? pendingTurnStart.value.requestedAt | |
| 545 | : event.occurredAt, | |
| 546 | completedAt: null, | |
| 547 | checkpointTurnCount: null, | |
| 548 | checkpointRef: null, | |
| 549 | checkpointStatus: null, | |
| 550 | checkpointFiles: [], | |
| 551 | }); | |
| 552 | } | |
| 553 | ||
| 554 | yield* projectionTurnRepository.deletePendingTurnStartByThreadId({ | |
| 555 | threadId: event.payload.threadId, | |
| 556 | }); | |
| 557 | return; | |
| 558 | } | |
| 559 | ||
| 560 | case "thread.message-sent": { | |
| 561 | if (event.payload.turnId === null || event.payload.role !== "assistant") { | |
| 562 | return; | |
| 563 | } | |
| 564 | const existingTurn = yield* projectionTurnRepository.getByTurnId({ | |
| 565 | threadId: event.payload.threadId, | |
| 566 | turnId: event.payload.turnId, | |
| 567 | }); | |
| 568 | if (Option.isSome(existingTurn)) { | |
| 569 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 570 | ...existingTurn.value, | |
| 571 | assistantMessageId: event.payload.messageId, | |
| 572 | state: event.payload.streaming | |
| 573 | ? existingTurn.value.state | |
| 574 | : existingTurn.value.state === "interrupted" | |
| 575 | ? "interrupted" | |
| 576 | : existingTurn.value.state === "error" | |
| 577 | ? "error" | |
| 578 | : "completed", | |
| 579 | completedAt: event.payload.streaming | |
| 580 | ? existingTurn.value.completedAt | |
| 581 | : (existingTurn.value.completedAt ?? event.payload.updatedAt), | |
| 582 | startedAt: existingTurn.value.startedAt ?? event.payload.createdAt, | |
| 583 | requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt, | |
| 584 | }); | |
| 585 | return; | |
| 586 | } | |
| 587 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 588 | turnId: event.payload.turnId, | |
| 589 | threadId: event.payload.threadId, | |
| 590 | pendingMessageId: null, | |
| 591 | assistantMessageId: event.payload.messageId, | |
| 592 | state: event.payload.streaming ? "running" : "completed", | |
| 593 | requestedAt: event.payload.createdAt, | |
| 594 | startedAt: event.payload.createdAt, | |
| 595 | completedAt: event.payload.streaming ? null : event.payload.updatedAt, | |
| 596 | checkpointTurnCount: null, | |
| 597 | checkpointRef: null, | |
| 598 | checkpointStatus: null, | |
| 599 | checkpointFiles: [], | |
| 600 | }); | |
| 601 | return; | |
| 602 | } | |
| 603 | ||
| 604 | case "thread.turn-interrupt-requested": { | |
| 605 | if (event.payload.turnId === undefined) { | |
| 606 | return; | |
| 607 | } | |
| 608 | const existingTurn = yield* projectionTurnRepository.getByTurnId({ | |
| 609 | threadId: event.payload.threadId, | |
| 610 | turnId: event.payload.turnId, | |
| 611 | }); | |
| 612 | if (Option.isSome(existingTurn)) { | |
| 613 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 614 | ...existingTurn.value, | |
| 615 | state: "interrupted", | |
| 616 | completedAt: existingTurn.value.completedAt ?? event.payload.createdAt, | |
| 617 | startedAt: existingTurn.value.startedAt ?? event.payload.createdAt, | |
| 618 | requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt, | |
| 619 | }); | |
| 620 | return; | |
| 621 | } | |
| 622 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 623 | turnId: event.payload.turnId, | |
| 624 | threadId: event.payload.threadId, | |
| 625 | pendingMessageId: null, | |
| 626 | assistantMessageId: null, | |
| 627 | state: "interrupted", | |
| 628 | requestedAt: event.payload.createdAt, | |
| 629 | startedAt: event.payload.createdAt, | |
| 630 | completedAt: event.payload.createdAt, | |
| 631 | checkpointTurnCount: null, | |
| 632 | checkpointRef: null, | |
| 633 | checkpointStatus: null, | |
| 634 | checkpointFiles: [], | |
| 635 | }); | |
| 636 | return; | |
| 637 | } | |
| 638 | ||
| 639 | case "thread.turn-diff-completed": { | |
| 640 | const existingTurn = yield* projectionTurnRepository.getByTurnId({ | |
| 641 | threadId: event.payload.threadId, | |
| 642 | turnId: event.payload.turnId, | |
| 643 | }); | |
| 644 | const nextState = event.payload.status === "error" ? "error" : "completed"; | |
| 645 | yield* projectionTurnRepository.clearCheckpointTurnConflict({ | |
| 646 | threadId: event.payload.threadId, | |
| 647 | turnId: event.payload.turnId, | |
| 648 | checkpointTurnCount: event.payload.checkpointTurnCount, | |
| 649 | }); | |
| 650 | ||
| 651 | if (Option.isSome(existingTurn)) { | |
| 652 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 653 | ...existingTurn.value, | |
| 654 | assistantMessageId: event.payload.assistantMessageId, | |
| 655 | state: nextState, | |
| 656 | checkpointTurnCount: event.payload.checkpointTurnCount, | |
| 657 | checkpointRef: event.payload.checkpointRef, | |
| 658 | checkpointStatus: event.payload.status, | |
| 659 | checkpointFiles: event.payload.files, | |
| 660 | startedAt: existingTurn.value.startedAt ?? event.payload.completedAt, | |
| 661 | requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt, | |
| 662 | completedAt: event.payload.completedAt, | |
| 663 | }); | |
| 664 | return; | |
| 665 | } | |
| 666 | yield* projectionTurnRepository.upsertByTurnId({ | |
| 667 | turnId: event.payload.turnId, | |
| 668 | threadId: event.payload.threadId, | |
| 669 | pendingMessageId: null, | |
| 670 | assistantMessageId: event.payload.assistantMessageId, | |
| 671 | state: nextState, | |
| 672 | requestedAt: event.payload.completedAt, | |
| 673 | startedAt: event.payload.completedAt, | |
| 674 | completedAt: event.payload.completedAt, | |
| 675 | checkpointTurnCount: event.payload.checkpointTurnCount, | |
| 676 | checkpointRef: event.payload.checkpointRef, | |
| 677 | checkpointStatus: event.payload.status, | |
| 678 | checkpointFiles: event.payload.files, | |
| 679 | }); | |
| 680 | return; | |
| 681 | } | |
| 682 | ||
| 683 | case "thread.reverted": { | |
| 684 | const existingTurns = yield* projectionTurnRepository.listByThreadId({ | |
| 685 | threadId: event.payload.threadId, | |
| 686 | }); | |
| 687 | const keptTurns = existingTurns.filter( | |
| 688 | (turn) => | |
| 689 | turn.turnId !== null && | |
| 690 | turn.checkpointTurnCount !== null && | |
| 691 | turn.checkpointTurnCount <= event.payload.turnCount, | |
| 692 | ); | |
| 693 | yield* projectionTurnRepository.deleteByThreadId({ | |
| 694 | threadId: event.payload.threadId, | |
| 695 | }); | |
| 696 | yield* Effect.forEach( | |
| 697 | keptTurns, | |
| 698 | (turn) => | |
| 699 | turn.turnId === null | |
| 700 | ? Effect.void | |
| 701 | : projectionTurnRepository.upsertByTurnId({ | |
| 702 | ...turn, | |
| 703 | turnId: turn.turnId, | |
| 704 | }), | |
| 705 | { concurrency: 1 }, | |
| 706 | ).pipe(Effect.asVoid); | |
| 707 | return; | |
| 708 | } | |
| 709 | ||
| 710 | default: | |
| 711 | return; | |
| 712 | } | |
| 713 | }); | |
| 714 | ||
| 715 | const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; | |
| 716 | ||
| 717 | const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) => | |
| 718 | Effect.gen(function* () { | |
| 719 | switch (event.type) { | |
| 720 | case "thread.activity-appended": { | |
| 721 | const requestId = | |
| 722 | extractActivityRequestId(event.payload.activity.payload) ?? | |
| 723 | event.metadata.requestId ?? | |
| 724 | null; | |
| 725 | if (requestId === null) { | |
| 726 | return; | |
| 727 | } | |
| 728 | const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({ | |
| 729 | requestId, | |
| 730 | }); | |
| 731 | if (event.payload.activity.kind === "approval.resolved") { | |
| 732 | const resolvedDecisionRaw = | |
| 733 | typeof event.payload.activity.payload === "object" && | |
| 734 | event.payload.activity.payload !== null && | |
| 735 | "decision" in event.payload.activity.payload | |
| 736 | ? (event.payload.activity.payload as { decision?: unknown }).decision | |
| 737 | : null; | |
| 738 | const resolvedDecision = | |
| 739 | resolvedDecisionRaw === "accept" || | |
| 740 | resolvedDecisionRaw === "acceptForSession" || | |
| 741 | resolvedDecisionRaw === "decline" || | |
| 742 | resolvedDecisionRaw === "cancel" | |
| 743 | ? resolvedDecisionRaw | |
| 744 | : null; | |
| 745 | yield* projectionPendingApprovalRepository.upsert({ | |
| 746 | requestId, | |
| 747 | threadId: Option.isSome(existingRow) | |
| 748 | ? existingRow.value.threadId | |
| 749 | : event.payload.threadId, | |
| 750 | turnId: Option.isSome(existingRow) | |
| 751 | ? existingRow.value.turnId | |
| 752 | : event.payload.activity.turnId, | |
| 753 | status: "resolved", | |
| 754 | decision: resolvedDecision, | |
| 755 | createdAt: Option.isSome(existingRow) | |
| 756 | ? existingRow.value.createdAt | |
| 757 | : event.payload.activity.createdAt, | |
| 758 | resolvedAt: event.payload.activity.createdAt, | |
| 759 | }); | |
| 760 | return; | |
| 761 | } | |
| 762 | if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { | |
| 763 | return; | |
| 764 | } | |
| 765 | yield* projectionPendingApprovalRepository.upsert({ | |
| 766 | requestId, | |
| 767 | threadId: event.payload.threadId, | |
| 768 | turnId: event.payload.activity.turnId, | |
| 769 | status: "pending", | |
| 770 | decision: null, | |
| 771 | createdAt: Option.isSome(existingRow) | |
| 772 | ? existingRow.value.createdAt | |
| 773 | : event.payload.activity.createdAt, | |
| 774 | resolvedAt: null, | |
| 775 | }); | |
| 776 | return; | |
| 777 | } | |
| 778 | ||
| 779 | case "thread.approval-response-requested": { | |
| 780 | const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({ | |
| 781 | requestId: event.payload.requestId, | |
| 782 | }); | |
| 783 | yield* projectionPendingApprovalRepository.upsert({ | |
| 784 | requestId: event.payload.requestId, | |
| 785 | threadId: Option.isSome(existingRow) | |
| 786 | ? existingRow.value.threadId | |
| 787 | : event.payload.threadId, | |
| 788 | turnId: Option.isSome(existingRow) ? existingRow.value.turnId : null, | |
| 789 | status: "resolved", | |
| 790 | decision: event.payload.decision, | |
| 791 | createdAt: Option.isSome(existingRow) | |
| 792 | ? existingRow.value.createdAt | |
| 793 | : event.payload.createdAt, | |
| 794 | resolvedAt: event.payload.createdAt, | |
| 795 | }); | |
| 796 | return; | |
| 797 | } | |
| 798 | ||
| 799 | default: | |
| 800 | return; | |
| 801 | } | |
| 802 | }); | |
| 803 | ||
| 804 | const projectors: ReadonlyArray<ProjectorDefinition> = [ | |
| 805 | { | |
| 806 | name: ORCHESTRATION_PROJECTOR_NAMES.projects, | |
| 807 | apply: applyProjectsProjection, | |
| 808 | }, | |
| 809 | { | |
| 810 | name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, | |
| 811 | apply: applyThreadMessagesProjection, | |
| 812 | }, | |
| 813 | { | |
| 814 | name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, | |
| 815 | apply: applyThreadActivitiesProjection, | |
| 816 | }, | |
| 817 | { | |
| 818 | name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, | |
| 819 | apply: applyThreadSessionsProjection, | |
| 820 | }, | |
| 821 | { | |
| 822 | name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, | |
| 823 | apply: applyThreadTurnsProjection, | |
| 824 | }, | |
| 825 | { | |
| 826 | name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints, | |
| 827 | apply: applyCheckpointsProjection, | |
| 828 | }, | |
| 829 | { | |
| 830 | name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, | |
| 831 | apply: applyPendingApprovalsProjection, | |
| 832 | }, | |
| 833 | { | |
| 834 | name: ORCHESTRATION_PROJECTOR_NAMES.threads, | |
| 835 | apply: applyThreadsProjection, | |
| 836 | }, | |
| 837 | ]; | |
| 838 | ||
| 839 | const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => | |
| 840 | sql.withTransaction( | |
| 841 | projector.apply(event).pipe( | |
| 842 | Effect.flatMap(() => | |
| 843 | projectionStateRepository.upsert({ | |
| 844 | projector: projector.name, | |
| 845 | lastAppliedSequence: event.sequence, | |
| 846 | updatedAt: event.occurredAt, | |
| 847 | }), | |
| 848 | ), | |
| 849 | ), | |
| 850 | ); | |
| 851 | ||
| 852 | const bootstrapProjector = (projector: ProjectorDefinition) => | |
| 853 | projectionStateRepository | |
| 854 | .getByProjector({ | |
| 855 | projector: projector.name, | |
| 856 | }) | |
| 857 | .pipe( | |
| 858 | Effect.flatMap((stateRow) => | |
| 859 | Stream.runForEach( | |
| 860 | eventStore.readFromSequence( | |
| 861 | Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, | |
| 862 | ), | |
| 863 | (event) => runProjectorForEvent(projector, event), | |
| 864 | ), | |
| 865 | ), | |
| 866 | ); | |
| 867 | ||
| 868 | const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => | |
| 869 | Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { | |
| 870 | concurrency: 1, | |
| 871 | }).pipe( | |
| 872 | Effect.asVoid, | |
| 873 | Effect.catchTag("SqlError", (sqlError) => | |
| 874 | Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), | |
| 875 | ), | |
| 876 | ); | |
| 877 | ||
| 878 | const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( | |
| 879 | projectors, | |
| 880 | bootstrapProjector, | |
| 881 | { concurrency: 1 }, | |
| 882 | ).pipe( | |
| 883 | Effect.asVoid, | |
| 884 | Effect.tap(() => | |
| 885 | Effect.log("orchestration projection pipeline bootstrapped").pipe( | |
| 886 | Effect.annotateLogs({ projectors: projectors.length }), | |
| 887 | ), | |
| 888 | ), | |
| 889 | Effect.catchTag("SqlError", (sqlError) => | |
| 890 | Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)), | |
| 891 | ), | |
| 892 | ); | |
| 893 | ||
| 894 | return { | |
| 895 | bootstrap, | |
| 896 | projectEvent, | |
| 897 | } satisfies OrchestrationProjectionPipelineShape; | |
| 898 | }); | |
| 899 | ||
| 900 | export const OrchestrationProjectionPipelineLive = Layer.effect( | |
| 901 | OrchestrationProjectionPipeline, | |
| 902 | makeOrchestrationProjectionPipeline, | |
| 903 | ).pipe( | |
| 904 | Layer.provideMerge(ProjectionProjectRepositoryLive), | |
| 905 | Layer.provideMerge(ProjectionThreadRepositoryLive), | |
| 906 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), | |
| 907 | Layer.provideMerge(ProjectionThreadActivityRepositoryLive), | |
| 908 | Layer.provideMerge(ProjectionThreadSessionRepositoryLive), | |
| 909 | Layer.provideMerge(ProjectionTurnRepositoryLive), | |
| 910 | Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), | |
| 911 | Layer.provideMerge(ProjectionStateRepositoryLive), | |
| 912 | ); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts created+306| ... | ... | @@ -0,0 +1,306 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ProviderSessionId, | |
| 7 | ProviderThreadId, | |
| 8 | ThreadId, | |
| 9 | TurnId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { assert, it } from "@effect/vitest"; | |
| 12 | import { Effect, Layer } from "effect"; | |
| 13 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 14 | ||
| 15 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 16 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | |
| 17 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | |
| 18 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | |
| 19 | ||
| 20 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 21 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 22 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 23 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 24 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 25 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 26 | ProviderSessionId.makeUnsafe(value); | |
| 27 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 28 | ||
| 29 | const projectionSnapshotLayer = it.layer( | |
| 30 | OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 31 | ); | |
| 32 | ||
| 33 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | |
| 34 | it.effect("hydrates read model from projection tables and computes snapshot sequence", () => | |
| 35 | Effect.gen(function* () { | |
| 36 | const snapshotQuery = yield* ProjectionSnapshotQuery; | |
| 37 | const sql = yield* SqlClient.SqlClient; | |
| 38 | ||
| 39 | yield* sql`DELETE FROM projection_projects`; | |
| 40 | yield* sql`DELETE FROM projection_state`; | |
| 41 | yield* sql`DELETE FROM projection_turns`; | |
| 42 | ||
| 43 | yield* sql` | |
| 44 | INSERT INTO projection_projects ( | |
| 45 | project_id, | |
| 46 | title, | |
| 47 | workspace_root, | |
| 48 | default_model, | |
| 49 | scripts_json, | |
| 50 | created_at, | |
| 51 | updated_at, | |
| 52 | deleted_at | |
| 53 | ) | |
| 54 | VALUES ( | |
| 55 | 'project-1', | |
| 56 | 'Project 1', | |
| 57 | '/tmp/project-1', | |
| 58 | 'gpt-5-codex', | |
| 59 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', | |
| 60 | '2026-02-24T00:00:00.000Z', | |
| 61 | '2026-02-24T00:00:01.000Z', | |
| 62 | NULL | |
| 63 | ) | |
| 64 | `; | |
| 65 | ||
| 66 | yield* sql` | |
| 67 | INSERT INTO projection_threads ( | |
| 68 | thread_id, | |
| 69 | project_id, | |
| 70 | title, | |
| 71 | model, | |
| 72 | branch, | |
| 73 | worktree_path, | |
| 74 | latest_turn_id, | |
| 75 | created_at, | |
| 76 | updated_at, | |
| 77 | deleted_at | |
| 78 | ) | |
| 79 | VALUES ( | |
| 80 | 'thread-1', | |
| 81 | 'project-1', | |
| 82 | 'Thread 1', | |
| 83 | 'gpt-5-codex', | |
| 84 | NULL, | |
| 85 | NULL, | |
| 86 | 'turn-1', | |
| 87 | '2026-02-24T00:00:02.000Z', | |
| 88 | '2026-02-24T00:00:03.000Z', | |
| 89 | NULL | |
| 90 | ) | |
| 91 | `; | |
| 92 | ||
| 93 | yield* sql` | |
| 94 | INSERT INTO projection_thread_messages ( | |
| 95 | message_id, | |
| 96 | thread_id, | |
| 97 | turn_id, | |
| 98 | role, | |
| 99 | text, | |
| 100 | is_streaming, | |
| 101 | created_at, | |
| 102 | updated_at | |
| 103 | ) | |
| 104 | VALUES ( | |
| 105 | 'message-1', | |
| 106 | 'thread-1', | |
| 107 | 'turn-1', | |
| 108 | 'assistant', | |
| 109 | 'hello from projection', | |
| 110 | 0, | |
| 111 | '2026-02-24T00:00:04.000Z', | |
| 112 | '2026-02-24T00:00:05.000Z' | |
| 113 | ) | |
| 114 | `; | |
| 115 | ||
| 116 | yield* sql` | |
| 117 | INSERT INTO projection_thread_activities ( | |
| 118 | activity_id, | |
| 119 | thread_id, | |
| 120 | turn_id, | |
| 121 | tone, | |
| 122 | kind, | |
| 123 | summary, | |
| 124 | payload_json, | |
| 125 | created_at | |
| 126 | ) | |
| 127 | VALUES ( | |
| 128 | 'activity-1', | |
| 129 | 'thread-1', | |
| 130 | 'turn-1', | |
| 131 | 'info', | |
| 132 | 'runtime.note', | |
| 133 | 'provider started', | |
| 134 | '{"stage":"start"}', | |
| 135 | '2026-02-24T00:00:06.000Z' | |
| 136 | ) | |
| 137 | `; | |
| 138 | ||
| 139 | yield* sql` | |
| 140 | INSERT INTO projection_thread_sessions ( | |
| 141 | thread_id, | |
| 142 | status, | |
| 143 | provider_name, | |
| 144 | provider_session_id, | |
| 145 | provider_thread_id, | |
| 146 | approval_policy, | |
| 147 | sandbox_mode, | |
| 148 | active_turn_id, | |
| 149 | last_error, | |
| 150 | updated_at | |
| 151 | ) | |
| 152 | VALUES ( | |
| 153 | 'thread-1', | |
| 154 | 'running', | |
| 155 | 'codex', | |
| 156 | 'provider-session-1', | |
| 157 | 'provider-thread-1', | |
| 158 | 'on-request', | |
| 159 | 'workspace-write', | |
| 160 | 'turn-1', | |
| 161 | NULL, | |
| 162 | '2026-02-24T00:00:07.000Z' | |
| 163 | ) | |
| 164 | `; | |
| 165 | ||
| 166 | yield* sql` | |
| 167 | INSERT INTO projection_turns ( | |
| 168 | thread_id, | |
| 169 | turn_id, | |
| 170 | pending_message_id, | |
| 171 | assistant_message_id, | |
| 172 | state, | |
| 173 | requested_at, | |
| 174 | started_at, | |
| 175 | completed_at, | |
| 176 | checkpoint_turn_count, | |
| 177 | checkpoint_ref, | |
| 178 | checkpoint_status, | |
| 179 | checkpoint_files_json | |
| 180 | ) | |
| 181 | VALUES ( | |
| 182 | 'thread-1', | |
| 183 | 'turn-1', | |
| 184 | NULL, | |
| 185 | 'message-1', | |
| 186 | 'completed', | |
| 187 | '2026-02-24T00:00:08.000Z', | |
| 188 | '2026-02-24T00:00:08.000Z', | |
| 189 | '2026-02-24T00:00:08.000Z', | |
| 190 | 1, | |
| 191 | 'checkpoint-1', | |
| 192 | 'ready', | |
| 193 | '[{"path":"README.md","kind":"modified","additions":2,"deletions":1}]' | |
| 194 | ) | |
| 195 | `; | |
| 196 | ||
| 197 | let sequence = 5; | |
| 198 | for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { | |
| 199 | yield* sql` | |
| 200 | INSERT INTO projection_state ( | |
| 201 | projector, | |
| 202 | last_applied_sequence, | |
| 203 | updated_at | |
| 204 | ) | |
| 205 | VALUES ( | |
| 206 | ${projector}, | |
| 207 | ${sequence}, | |
| 208 | '2026-02-24T00:00:09.000Z' | |
| 209 | ) | |
| 210 | `; | |
| 211 | sequence += 1; | |
| 212 | } | |
| 213 | ||
| 214 | const snapshot = yield* snapshotQuery.getSnapshot(); | |
| 215 | ||
| 216 | assert.equal(snapshot.snapshotSequence, 5); | |
| 217 | assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z"); | |
| 218 | assert.deepEqual(snapshot.projects, [ | |
| 219 | { | |
| 220 | id: asProjectId("project-1"), | |
| 221 | title: "Project 1", | |
| 222 | workspaceRoot: "/tmp/project-1", | |
| 223 | defaultModel: "gpt-5-codex", | |
| 224 | scripts: [ | |
| 225 | { | |
| 226 | id: "script-1", | |
| 227 | name: "Build", | |
| 228 | command: "bun run build", | |
| 229 | icon: "build", | |
| 230 | runOnWorktreeCreate: false, | |
| 231 | }, | |
| 232 | ], | |
| 233 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 234 | updatedAt: "2026-02-24T00:00:01.000Z", | |
| 235 | deletedAt: null, | |
| 236 | }, | |
| 237 | ]); | |
| 238 | assert.deepEqual(snapshot.threads, [ | |
| 239 | { | |
| 240 | id: ThreadId.makeUnsafe("thread-1"), | |
| 241 | projectId: asProjectId("project-1"), | |
| 242 | title: "Thread 1", | |
| 243 | model: "gpt-5-codex", | |
| 244 | branch: null, | |
| 245 | worktreePath: null, | |
| 246 | latestTurn: { | |
| 247 | turnId: asTurnId("turn-1"), | |
| 248 | state: "completed", | |
| 249 | requestedAt: "2026-02-24T00:00:08.000Z", | |
| 250 | startedAt: "2026-02-24T00:00:08.000Z", | |
| 251 | completedAt: "2026-02-24T00:00:08.000Z", | |
| 252 | assistantMessageId: asMessageId("message-1"), | |
| 253 | }, | |
| 254 | createdAt: "2026-02-24T00:00:02.000Z", | |
| 255 | updatedAt: "2026-02-24T00:00:03.000Z", | |
| 256 | deletedAt: null, | |
| 257 | messages: [ | |
| 258 | { | |
| 259 | id: asMessageId("message-1"), | |
| 260 | role: "assistant", | |
| 261 | text: "hello from projection", | |
| 262 | turnId: asTurnId("turn-1"), | |
| 263 | streaming: false, | |
| 264 | createdAt: "2026-02-24T00:00:04.000Z", | |
| 265 | updatedAt: "2026-02-24T00:00:05.000Z", | |
| 266 | }, | |
| 267 | ], | |
| 268 | activities: [ | |
| 269 | { | |
| 270 | id: asEventId("activity-1"), | |
| 271 | tone: "info", | |
| 272 | kind: "runtime.note", | |
| 273 | summary: "provider started", | |
| 274 | payload: { stage: "start" }, | |
| 275 | turnId: asTurnId("turn-1"), | |
| 276 | createdAt: "2026-02-24T00:00:06.000Z", | |
| 277 | }, | |
| 278 | ], | |
| 279 | checkpoints: [ | |
| 280 | { | |
| 281 | turnId: asTurnId("turn-1"), | |
| 282 | checkpointTurnCount: 1, | |
| 283 | checkpointRef: asCheckpointRef("checkpoint-1"), | |
| 284 | status: "ready", | |
| 285 | files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], | |
| 286 | assistantMessageId: asMessageId("message-1"), | |
| 287 | completedAt: "2026-02-24T00:00:08.000Z", | |
| 288 | }, | |
| 289 | ], | |
| 290 | session: { | |
| 291 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 292 | status: "running", | |
| 293 | providerName: "codex", | |
| 294 | providerSessionId: asProviderSessionId("provider-session-1"), | |
| 295 | providerThreadId: asProviderThreadId("provider-thread-1"), | |
| 296 | approvalPolicy: "on-request", | |
| 297 | sandboxMode: "workspace-write", | |
| 298 | activeTurnId: asTurnId("turn-1"), | |
| 299 | lastError: null, | |
| 300 | updatedAt: "2026-02-24T00:00:07.000Z", | |
| 301 | }, | |
| 302 | }, | |
| 303 | ]); | |
| 304 | }), | |
| 305 | ); | |
| 306 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts created+520| ... | ... | @@ -0,0 +1,520 @@ |
| 1 | import { | |
| 2 | IsoDateTime, | |
| 3 | MessageId, | |
| 4 | OrchestrationCheckpointFile, | |
| 5 | OrchestrationReadModel, | |
| 6 | ProjectScript, | |
| 7 | TurnId, | |
| 8 | type OrchestrationCheckpointSummary, | |
| 9 | type OrchestrationLatestTurn, | |
| 10 | type OrchestrationMessage, | |
| 11 | type OrchestrationProject, | |
| 12 | type OrchestrationSession, | |
| 13 | type OrchestrationThread, | |
| 14 | type OrchestrationThreadActivity, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 17 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 18 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 19 | ||
| 20 | import { | |
| 21 | isPersistenceError, | |
| 22 | toPersistenceDecodeError, | |
| 23 | toPersistenceSqlError, | |
| 24 | type ProjectionRepositoryError, | |
| 25 | } from "../../persistence/Errors.ts"; | |
| 26 | import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; | |
| 27 | import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; | |
| 28 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; | |
| 29 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; | |
| 30 | import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; | |
| 31 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; | |
| 32 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; | |
| 33 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | |
| 34 | import { | |
| 35 | ProjectionSnapshotQuery, | |
| 36 | type ProjectionSnapshotQueryShape, | |
| 37 | } from "../Services/ProjectionSnapshotQuery.ts"; | |
| 38 | ||
| 39 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); | |
| 40 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | |
| 41 | Struct.assign({ | |
| 42 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | |
| 43 | }), | |
| 44 | ); | |
| 45 | const ProjectionThreadDbRowSchema = ProjectionThread; | |
| 46 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 47 | Struct.assign({ | |
| 48 | isStreaming: Schema.Number, | |
| 49 | }), | |
| 50 | ); | |
| 51 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | |
| 52 | Struct.assign({ | |
| 53 | payload: Schema.fromJsonString(Schema.Unknown), | |
| 54 | }), | |
| 55 | ); | |
| 56 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; | |
| 57 | const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( | |
| 58 | Struct.assign({ | |
| 59 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 60 | }), | |
| 61 | ); | |
| 62 | const ProjectionLatestTurnDbRowSchema = Schema.Struct({ | |
| 63 | threadId: ProjectionThread.fields.threadId, | |
| 64 | turnId: TurnId, | |
| 65 | state: Schema.String, | |
| 66 | requestedAt: IsoDateTime, | |
| 67 | startedAt: Schema.NullOr(IsoDateTime), | |
| 68 | completedAt: Schema.NullOr(IsoDateTime), | |
| 69 | assistantMessageId: Schema.NullOr(MessageId), | |
| 70 | }); | |
| 71 | const ProjectionStateDbRowSchema = ProjectionState; | |
| 72 | ||
| 73 | const REQUIRED_SNAPSHOT_PROJECTORS = [ | |
| 74 | ORCHESTRATION_PROJECTOR_NAMES.projects, | |
| 75 | ORCHESTRATION_PROJECTOR_NAMES.threads, | |
| 76 | ORCHESTRATION_PROJECTOR_NAMES.threadMessages, | |
| 77 | ORCHESTRATION_PROJECTOR_NAMES.threadActivities, | |
| 78 | ORCHESTRATION_PROJECTOR_NAMES.threadSessions, | |
| 79 | ORCHESTRATION_PROJECTOR_NAMES.checkpoints, | |
| 80 | ] as const; | |
| 81 | ||
| 82 | function maxIso(left: string | null, right: string): string { | |
| 83 | if (left === null) { | |
| 84 | return right; | |
| 85 | } | |
| 86 | return left > right ? left : right; | |
| 87 | } | |
| 88 | ||
| 89 | function computeSnapshotSequence( | |
| 90 | stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>, | |
| 91 | ): number { | |
| 92 | if (stateRows.length === 0) { | |
| 93 | return 0; | |
| 94 | } | |
| 95 | const sequenceByProjector = new Map( | |
| 96 | stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const), | |
| 97 | ); | |
| 98 | ||
| 99 | let minSequence = Number.POSITIVE_INFINITY; | |
| 100 | for (const projector of REQUIRED_SNAPSHOT_PROJECTORS) { | |
| 101 | const sequence = sequenceByProjector.get(projector); | |
| 102 | if (sequence === undefined) { | |
| 103 | return 0; | |
| 104 | } | |
| 105 | if (sequence < minSequence) { | |
| 106 | minSequence = sequence; | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | return Number.isFinite(minSequence) ? minSequence : 0; | |
| 111 | } | |
| 112 | ||
| 113 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 114 | return (cause: unknown): ProjectionRepositoryError => | |
| 115 | Schema.isSchemaError(cause) | |
| 116 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 117 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 118 | } | |
| 119 | ||
| 120 | const makeProjectionSnapshotQuery = Effect.gen(function* () { | |
| 121 | const sql = yield* SqlClient.SqlClient; | |
| 122 | ||
| 123 | const listProjectRows = SqlSchema.findAll({ | |
| 124 | Request: Schema.Void, | |
| 125 | Result: ProjectionProjectDbRowSchema, | |
| 126 | execute: () => | |
| 127 | sql` | |
| 128 | SELECT | |
| 129 | project_id AS "projectId", | |
| 130 | title, | |
| 131 | workspace_root AS "workspaceRoot", | |
| 132 | default_model AS "defaultModel", | |
| 133 | scripts_json AS "scripts", | |
| 134 | created_at AS "createdAt", | |
| 135 | updated_at AS "updatedAt", | |
| 136 | deleted_at AS "deletedAt" | |
| 137 | FROM projection_projects | |
| 138 | ORDER BY created_at ASC, project_id ASC | |
| 139 | `, | |
| 140 | }); | |
| 141 | ||
| 142 | const listThreadRows = SqlSchema.findAll({ | |
| 143 | Request: Schema.Void, | |
| 144 | Result: ProjectionThreadDbRowSchema, | |
| 145 | execute: () => | |
| 146 | sql` | |
| 147 | SELECT | |
| 148 | thread_id AS "threadId", | |
| 149 | project_id AS "projectId", | |
| 150 | title, | |
| 151 | model, | |
| 152 | branch, | |
| 153 | worktree_path AS "worktreePath", | |
| 154 | latest_turn_id AS "latestTurnId", | |
| 155 | created_at AS "createdAt", | |
| 156 | updated_at AS "updatedAt", | |
| 157 | deleted_at AS "deletedAt" | |
| 158 | FROM projection_threads | |
| 159 | ORDER BY created_at ASC, thread_id ASC | |
| 160 | `, | |
| 161 | }); | |
| 162 | ||
| 163 | const listThreadMessageRows = SqlSchema.findAll({ | |
| 164 | Request: Schema.Void, | |
| 165 | Result: ProjectionThreadMessageDbRowSchema, | |
| 166 | execute: () => | |
| 167 | sql` | |
| 168 | SELECT | |
| 169 | message_id AS "messageId", | |
| 170 | thread_id AS "threadId", | |
| 171 | turn_id AS "turnId", | |
| 172 | role, | |
| 173 | text, | |
| 174 | is_streaming AS "isStreaming", | |
| 175 | created_at AS "createdAt", | |
| 176 | updated_at AS "updatedAt" | |
| 177 | FROM projection_thread_messages | |
| 178 | ORDER BY thread_id ASC, created_at ASC, message_id ASC | |
| 179 | `, | |
| 180 | }); | |
| 181 | ||
| 182 | const listThreadActivityRows = SqlSchema.findAll({ | |
| 183 | Request: Schema.Void, | |
| 184 | Result: ProjectionThreadActivityDbRowSchema, | |
| 185 | execute: () => | |
| 186 | sql` | |
| 187 | SELECT | |
| 188 | activity_id AS "activityId", | |
| 189 | thread_id AS "threadId", | |
| 190 | turn_id AS "turnId", | |
| 191 | tone, | |
| 192 | kind, | |
| 193 | summary, | |
| 194 | payload_json AS "payload", | |
| 195 | created_at AS "createdAt" | |
| 196 | FROM projection_thread_activities | |
| 197 | ORDER BY thread_id ASC, created_at ASC, activity_id ASC | |
| 198 | `, | |
| 199 | }); | |
| 200 | ||
| 201 | const listThreadSessionRows = SqlSchema.findAll({ | |
| 202 | Request: Schema.Void, | |
| 203 | Result: ProjectionThreadSessionDbRowSchema, | |
| 204 | execute: () => | |
| 205 | sql` | |
| 206 | SELECT | |
| 207 | thread_id AS "threadId", | |
| 208 | status, | |
| 209 | provider_name AS "providerName", | |
| 210 | provider_session_id AS "providerSessionId", | |
| 211 | provider_thread_id AS "providerThreadId", | |
| 212 | approval_policy AS "approvalPolicy", | |
| 213 | sandbox_mode AS "sandboxMode", | |
| 214 | active_turn_id AS "activeTurnId", | |
| 215 | last_error AS "lastError", | |
| 216 | updated_at AS "updatedAt" | |
| 217 | FROM projection_thread_sessions | |
| 218 | ORDER BY thread_id ASC | |
| 219 | `, | |
| 220 | }); | |
| 221 | ||
| 222 | const listCheckpointRows = SqlSchema.findAll({ | |
| 223 | Request: Schema.Void, | |
| 224 | Result: ProjectionCheckpointDbRowSchema, | |
| 225 | execute: () => | |
| 226 | sql` | |
| 227 | SELECT | |
| 228 | thread_id AS "threadId", | |
| 229 | turn_id AS "turnId", | |
| 230 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 231 | checkpoint_ref AS "checkpointRef", | |
| 232 | checkpoint_status AS "status", | |
| 233 | checkpoint_files_json AS "files", | |
| 234 | assistant_message_id AS "assistantMessageId", | |
| 235 | completed_at AS "completedAt" | |
| 236 | FROM projection_turns | |
| 237 | WHERE checkpoint_turn_count IS NOT NULL | |
| 238 | ORDER BY thread_id ASC, checkpoint_turn_count ASC | |
| 239 | `, | |
| 240 | }); | |
| 241 | ||
| 242 | const listLatestTurnRows = SqlSchema.findAll({ | |
| 243 | Request: Schema.Void, | |
| 244 | Result: ProjectionLatestTurnDbRowSchema, | |
| 245 | execute: () => | |
| 246 | sql` | |
| 247 | SELECT | |
| 248 | thread_id AS "threadId", | |
| 249 | turn_id AS "turnId", | |
| 250 | state, | |
| 251 | requested_at AS "requestedAt", | |
| 252 | started_at AS "startedAt", | |
| 253 | completed_at AS "completedAt", | |
| 254 | assistant_message_id AS "assistantMessageId" | |
| 255 | FROM projection_turns | |
| 256 | WHERE turn_id IS NOT NULL | |
| 257 | ORDER BY thread_id ASC, requested_at DESC, turn_id DESC | |
| 258 | `, | |
| 259 | }); | |
| 260 | ||
| 261 | const listProjectionStateRows = SqlSchema.findAll({ | |
| 262 | Request: Schema.Void, | |
| 263 | Result: ProjectionStateDbRowSchema, | |
| 264 | execute: () => | |
| 265 | sql` | |
| 266 | SELECT | |
| 267 | projector, | |
| 268 | last_applied_sequence AS "lastAppliedSequence", | |
| 269 | updated_at AS "updatedAt" | |
| 270 | FROM projection_state | |
| 271 | `, | |
| 272 | }); | |
| 273 | ||
| 274 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => | |
| 275 | sql | |
| 276 | .withTransaction( | |
| 277 | Effect.gen(function* () { | |
| 278 | const [ | |
| 279 | projectRows, | |
| 280 | threadRows, | |
| 281 | messageRows, | |
| 282 | activityRows, | |
| 283 | sessionRows, | |
| 284 | checkpointRows, | |
| 285 | latestTurnRows, | |
| 286 | stateRows, | |
| 287 | ] = yield* Effect.all([ | |
| 288 | listProjectRows(undefined).pipe( | |
| 289 | Effect.mapError( | |
| 290 | toPersistenceSqlOrDecodeError( | |
| 291 | "ProjectionSnapshotQuery.getSnapshot:listProjects:query", | |
| 292 | "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", | |
| 293 | ), | |
| 294 | ), | |
| 295 | ), | |
| 296 | listThreadRows(undefined).pipe( | |
| 297 | Effect.mapError( | |
| 298 | toPersistenceSqlOrDecodeError( | |
| 299 | "ProjectionSnapshotQuery.getSnapshot:listThreads:query", | |
| 300 | "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", | |
| 301 | ), | |
| 302 | ), | |
| 303 | ), | |
| 304 | listThreadMessageRows(undefined).pipe( | |
| 305 | Effect.mapError( | |
| 306 | toPersistenceSqlOrDecodeError( | |
| 307 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", | |
| 308 | "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", | |
| 309 | ), | |
| 310 | ), | |
| 311 | ), | |
| 312 | listThreadActivityRows(undefined).pipe( | |
| 313 | Effect.mapError( | |
| 314 | toPersistenceSqlOrDecodeError( | |
| 315 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", | |
| 316 | "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", | |
| 317 | ), | |
| 318 | ), | |
| 319 | ), | |
| 320 | listThreadSessionRows(undefined).pipe( | |
| 321 | Effect.mapError( | |
| 322 | toPersistenceSqlOrDecodeError( | |
| 323 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", | |
| 324 | "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", | |
| 325 | ), | |
| 326 | ), | |
| 327 | ), | |
| 328 | listCheckpointRows(undefined).pipe( | |
| 329 | Effect.mapError( | |
| 330 | toPersistenceSqlOrDecodeError( | |
| 331 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", | |
| 332 | "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", | |
| 333 | ), | |
| 334 | ), | |
| 335 | ), | |
| 336 | listLatestTurnRows(undefined).pipe( | |
| 337 | Effect.mapError( | |
| 338 | toPersistenceSqlOrDecodeError( | |
| 339 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", | |
| 340 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", | |
| 341 | ), | |
| 342 | ), | |
| 343 | ), | |
| 344 | listProjectionStateRows(undefined).pipe( | |
| 345 | Effect.mapError( | |
| 346 | toPersistenceSqlOrDecodeError( | |
| 347 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | |
| 348 | "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | |
| 349 | ), | |
| 350 | ), | |
| 351 | ), | |
| 352 | ]); | |
| 353 | ||
| 354 | const messagesByThread = new Map<string, Array<OrchestrationMessage>>(); | |
| 355 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); | |
| 356 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); | |
| 357 | const sessionsByThread = new Map<string, OrchestrationSession>(); | |
| 358 | const latestTurnByThread = new Map<string, OrchestrationLatestTurn>(); | |
| 359 | ||
| 360 | let updatedAt: string | null = null; | |
| 361 | ||
| 362 | for (const row of projectRows) { | |
| 363 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 364 | } | |
| 365 | for (const row of threadRows) { | |
| 366 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 367 | } | |
| 368 | for (const row of stateRows) { | |
| 369 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 370 | } | |
| 371 | ||
| 372 | for (const row of messageRows) { | |
| 373 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 374 | const threadMessages = messagesByThread.get(row.threadId) ?? []; | |
| 375 | threadMessages.push({ | |
| 376 | id: row.messageId, | |
| 377 | role: row.role, | |
| 378 | text: row.text, | |
| 379 | turnId: row.turnId, | |
| 380 | streaming: row.isStreaming === 1, | |
| 381 | createdAt: row.createdAt, | |
| 382 | updatedAt: row.updatedAt, | |
| 383 | }); | |
| 384 | messagesByThread.set(row.threadId, threadMessages); | |
| 385 | } | |
| 386 | ||
| 387 | for (const row of activityRows) { | |
| 388 | updatedAt = maxIso(updatedAt, row.createdAt); | |
| 389 | const threadActivities = activitiesByThread.get(row.threadId) ?? []; | |
| 390 | threadActivities.push({ | |
| 391 | id: row.activityId, | |
| 392 | tone: row.tone, | |
| 393 | kind: row.kind, | |
| 394 | summary: row.summary, | |
| 395 | payload: row.payload, | |
| 396 | turnId: row.turnId, | |
| 397 | createdAt: row.createdAt, | |
| 398 | }); | |
| 399 | activitiesByThread.set(row.threadId, threadActivities); | |
| 400 | } | |
| 401 | ||
| 402 | for (const row of checkpointRows) { | |
| 403 | updatedAt = maxIso(updatedAt, row.completedAt); | |
| 404 | const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; | |
| 405 | threadCheckpoints.push({ | |
| 406 | turnId: row.turnId, | |
| 407 | checkpointTurnCount: row.checkpointTurnCount, | |
| 408 | checkpointRef: row.checkpointRef, | |
| 409 | status: row.status, | |
| 410 | files: row.files, | |
| 411 | assistantMessageId: row.assistantMessageId, | |
| 412 | completedAt: row.completedAt, | |
| 413 | }); | |
| 414 | checkpointsByThread.set(row.threadId, threadCheckpoints); | |
| 415 | } | |
| 416 | ||
| 417 | for (const row of latestTurnRows) { | |
| 418 | updatedAt = maxIso(updatedAt, row.requestedAt); | |
| 419 | if (row.startedAt !== null) { | |
| 420 | updatedAt = maxIso(updatedAt, row.startedAt); | |
| 421 | } | |
| 422 | if (row.completedAt !== null) { | |
| 423 | updatedAt = maxIso(updatedAt, row.completedAt); | |
| 424 | } | |
| 425 | if (latestTurnByThread.has(row.threadId)) { | |
| 426 | continue; | |
| 427 | } | |
| 428 | latestTurnByThread.set(row.threadId, { | |
| 429 | turnId: row.turnId, | |
| 430 | state: | |
| 431 | row.state === "error" | |
| 432 | ? "error" | |
| 433 | : row.state === "interrupted" | |
| 434 | ? "interrupted" | |
| 435 | : row.state === "completed" | |
| 436 | ? "completed" | |
| 437 | : "running", | |
| 438 | requestedAt: row.requestedAt, | |
| 439 | startedAt: row.startedAt, | |
| 440 | completedAt: row.completedAt, | |
| 441 | assistantMessageId: row.assistantMessageId, | |
| 442 | }); | |
| 443 | } | |
| 444 | ||
| 445 | for (const row of sessionRows) { | |
| 446 | updatedAt = maxIso(updatedAt, row.updatedAt); | |
| 447 | sessionsByThread.set(row.threadId, { | |
| 448 | threadId: row.threadId, | |
| 449 | status: row.status, | |
| 450 | providerName: row.providerName, | |
| 451 | providerSessionId: row.providerSessionId, | |
| 452 | providerThreadId: row.providerThreadId, | |
| 453 | approvalPolicy: row.approvalPolicy, | |
| 454 | sandboxMode: row.sandboxMode, | |
| 455 | activeTurnId: row.activeTurnId, | |
| 456 | lastError: row.lastError, | |
| 457 | updatedAt: row.updatedAt, | |
| 458 | }); | |
| 459 | } | |
| 460 | ||
| 461 | const projects: Array<OrchestrationProject> = projectRows.map((row) => ({ | |
| 462 | id: row.projectId, | |
| 463 | title: row.title, | |
| 464 | workspaceRoot: row.workspaceRoot, | |
| 465 | defaultModel: row.defaultModel, | |
| 466 | scripts: row.scripts, | |
| 467 | createdAt: row.createdAt, | |
| 468 | updatedAt: row.updatedAt, | |
| 469 | deletedAt: row.deletedAt, | |
| 470 | })); | |
| 471 | ||
| 472 | const threads: Array<OrchestrationThread> = threadRows.map((row) => ({ | |
| 473 | id: row.threadId, | |
| 474 | projectId: row.projectId, | |
| 475 | title: row.title, | |
| 476 | model: row.model, | |
| 477 | branch: row.branch, | |
| 478 | worktreePath: row.worktreePath, | |
| 479 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, | |
| 480 | createdAt: row.createdAt, | |
| 481 | updatedAt: row.updatedAt, | |
| 482 | deletedAt: row.deletedAt, | |
| 483 | messages: messagesByThread.get(row.threadId) ?? [], | |
| 484 | activities: activitiesByThread.get(row.threadId) ?? [], | |
| 485 | checkpoints: checkpointsByThread.get(row.threadId) ?? [], | |
| 486 | session: sessionsByThread.get(row.threadId) ?? null, | |
| 487 | })); | |
| 488 | ||
| 489 | const snapshot = { | |
| 490 | snapshotSequence: computeSnapshotSequence(stateRows), | |
| 491 | projects, | |
| 492 | threads, | |
| 493 | updatedAt: updatedAt ?? new Date(0).toISOString(), | |
| 494 | }; | |
| 495 | ||
| 496 | return yield* decodeReadModel(snapshot).pipe( | |
| 497 | Effect.mapError( | |
| 498 | toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), | |
| 499 | ), | |
| 500 | ); | |
| 501 | }), | |
| 502 | ) | |
| 503 | .pipe( | |
| 504 | Effect.mapError((error) => { | |
| 505 | if (isPersistenceError(error)) { | |
| 506 | return error; | |
| 507 | } | |
| 508 | return toPersistenceSqlError("ProjectionSnapshotQuery.getSnapshot:query")(error); | |
| 509 | }), | |
| 510 | ); | |
| 511 | ||
| 512 | return { | |
| 513 | getSnapshot, | |
| 514 | } satisfies ProjectionSnapshotQueryShape; | |
| 515 | }); | |
| 516 | ||
| 517 | export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( | |
| 518 | ProjectionSnapshotQuery, | |
| 519 | makeProjectionSnapshotQuery, | |
| 520 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts created+493| ... | ... | @@ -0,0 +1,493 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | ApprovalRequestId, | |
| 4 | CommandId, | |
| 5 | MessageId, | |
| 6 | ProjectId, | |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 9 | ProviderTurnId, | |
| 10 | ThreadId, | |
| 11 | TurnId, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | |
| 14 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 15 | ||
| 16 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 17 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 18 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 19 | import { | |
| 20 | ProviderService, | |
| 21 | type ProviderServiceShape, | |
| 22 | } from "../../provider/Services/ProviderService.ts"; | |
| 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 25 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; | |
| 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 27 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 28 | ||
| 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 31 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 32 | const asApprovalRequestId = (value: string): ApprovalRequestId => | |
| 33 | ApprovalRequestId.makeUnsafe(value); | |
| 34 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 35 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 36 | ||
| 37 | async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { | |
| 38 | const deadline = Date.now() + timeoutMs; | |
| 39 | const poll = async (): Promise<void> => { | |
| 40 | if (predicate()) { | |
| 41 | return; | |
| 42 | } | |
| 43 | if (Date.now() >= deadline) { | |
| 44 | throw new Error("Timed out waiting for expectation."); | |
| 45 | } | |
| 46 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 47 | return poll(); | |
| 48 | }; | |
| 49 | ||
| 50 | return poll(); | |
| 51 | } | |
| 52 | ||
| 53 | describe("ProviderCommandReactor", () => { | |
| 54 | let runtime: ManagedRuntime.ManagedRuntime< | |
| 55 | OrchestrationEngineService | ProviderCommandReactor, | |
| 56 | unknown | |
| 57 | > | null = null; | |
| 58 | let scope: Scope.Closeable | null = null; | |
| 59 | ||
| 60 | afterEach(async () => { | |
| 61 | if (scope) { | |
| 62 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 63 | } | |
| 64 | scope = null; | |
| 65 | if (runtime) { | |
| 66 | await runtime.dispose(); | |
| 67 | } | |
| 68 | runtime = null; | |
| 69 | }); | |
| 70 | ||
| 71 | async function createHarness() { | |
| 72 | const now = new Date().toISOString(); | |
| 73 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 74 | let nextSessionIndex = 1; | |
| 75 | const startSession = vi.fn((_: unknown, __: unknown) => { | |
| 76 | const sessionIndex = nextSessionIndex++; | |
| 77 | return Effect.succeed({ | |
| 78 | sessionId: asSessionId(`sess-${sessionIndex}`), | |
| 79 | provider: "codex" as const, | |
| 80 | status: "ready" as const, | |
| 81 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), | |
| 82 | createdAt: now, | |
| 83 | updatedAt: now, | |
| 84 | }); | |
| 85 | }); | |
| 86 | const sendTurn = vi.fn((_: unknown) => | |
| 87 | Effect.succeed({ | |
| 88 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 89 | turnId: asProviderTurnId("provider-turn-1"), | |
| 90 | }), | |
| 91 | ); | |
| 92 | const interruptTurn = vi.fn((_: unknown) => Effect.void); | |
| 93 | const respondToRequest = vi.fn((_: unknown) => Effect.void); | |
| 94 | const stopSession = vi.fn((_: unknown) => Effect.void); | |
| 95 | ||
| 96 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | |
| 97 | const service: ProviderServiceShape = { | |
| 98 | startSession: startSession as ProviderServiceShape["startSession"], | |
| 99 | sendTurn: sendTurn as ProviderServiceShape["sendTurn"], | |
| 100 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], | |
| 101 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], | |
| 102 | stopSession: stopSession as ProviderServiceShape["stopSession"], | |
| 103 | listSessions: () => Effect.succeed([]), | |
| 104 | rollbackConversation: () => unsupported(), | |
| 105 | stopAll: () => Effect.void, | |
| 106 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 107 | }; | |
| 108 | ||
| 109 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 110 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 111 | Layer.provide(OrchestrationEventStoreLive), | |
| 112 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 113 | Layer.provide(SqlitePersistenceMemory), | |
| 114 | ); | |
| 115 | const layer = ProviderCommandReactorLive.pipe( | |
| 116 | Layer.provideMerge(orchestrationLayer), | |
| 117 | Layer.provideMerge(Layer.succeed(ProviderService, service)), | |
| 118 | ); | |
| 119 | runtime = ManagedRuntime.make(layer); | |
| 120 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 121 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); | |
| 122 | scope = await Effect.runPromise(Scope.make("sequential")); | |
| 123 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); | |
| 124 | await Effect.runPromise(Effect.sleep("10 millis")); | |
| 125 | ||
| 126 | await Effect.runPromise( | |
| 127 | engine.dispatch({ | |
| 128 | type: "project.create", | |
| 129 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 130 | projectId: asProjectId("project-1"), | |
| 131 | title: "Provider Project", | |
| 132 | workspaceRoot: "/tmp/provider-project", | |
| 133 | defaultModel: "gpt-5-codex", | |
| 134 | createdAt: now, | |
| 135 | }), | |
| 136 | ); | |
| 137 | await Effect.runPromise( | |
| 138 | engine.dispatch({ | |
| 139 | type: "thread.create", | |
| 140 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 141 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 142 | projectId: asProjectId("project-1"), | |
| 143 | title: "Thread", | |
| 144 | model: "gpt-5-codex", | |
| 145 | branch: null, | |
| 146 | worktreePath: null, | |
| 147 | createdAt: now, | |
| 148 | }), | |
| 149 | ); | |
| 150 | ||
| 151 | return { | |
| 152 | engine, | |
| 153 | startSession, | |
| 154 | sendTurn, | |
| 155 | interruptTurn, | |
| 156 | respondToRequest, | |
| 157 | stopSession, | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { | |
| 162 | const harness = await createHarness(); | |
| 163 | const now = new Date().toISOString(); | |
| 164 | ||
| 165 | await Effect.runPromise( | |
| 166 | harness.engine.dispatch({ | |
| 167 | type: "thread.turn.start", | |
| 168 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), | |
| 169 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 170 | message: { | |
| 171 | messageId: asMessageId("user-message-1"), | |
| 172 | role: "user", | |
| 173 | text: "hello reactor", | |
| 174 | attachments: [], | |
| 175 | }, | |
| 176 | approvalPolicy: "on-request", | |
| 177 | sandboxMode: "workspace-write", | |
| 178 | createdAt: now, | |
| 179 | }), | |
| 180 | ); | |
| 181 | ||
| 182 | await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| 183 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 184 | expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); | |
| 185 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | |
| 186 | cwd: "/tmp/provider-project", | |
| 187 | model: "gpt-5-codex", | |
| 188 | approvalPolicy: "on-request", | |
| 189 | sandboxMode: "workspace-write", | |
| 190 | }); | |
| 191 | ||
| 192 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 193 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 194 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 195 | expect(thread?.session?.approvalPolicy).toBe("on-request"); | |
| 196 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); | |
| 197 | }); | |
| 198 | ||
| 199 | it("reuses the same provider session when runtime mode is unchanged", async () => { | |
| 200 | const harness = await createHarness(); | |
| 201 | const now = new Date().toISOString(); | |
| 202 | ||
| 203 | await Effect.runPromise( | |
| 204 | harness.engine.dispatch({ | |
| 205 | type: "thread.turn.start", | |
| 206 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), | |
| 207 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 208 | message: { | |
| 209 | messageId: asMessageId("user-message-unchanged-1"), | |
| 210 | role: "user", | |
| 211 | text: "first", | |
| 212 | attachments: [], | |
| 213 | }, | |
| 214 | approvalPolicy: "on-request", | |
| 215 | sandboxMode: "workspace-write", | |
| 216 | createdAt: now, | |
| 217 | }), | |
| 218 | ); | |
| 219 | ||
| 220 | await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| 221 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 222 | ||
| 223 | await Effect.runPromise( | |
| 224 | harness.engine.dispatch({ | |
| 225 | type: "thread.turn.start", | |
| 226 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), | |
| 227 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 228 | message: { | |
| 229 | messageId: asMessageId("user-message-unchanged-2"), | |
| 230 | role: "user", | |
| 231 | text: "second", | |
| 232 | attachments: [], | |
| 233 | }, | |
| 234 | approvalPolicy: "on-request", | |
| 235 | sandboxMode: "workspace-write", | |
| 236 | createdAt: now, | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | |
| 241 | expect(harness.startSession.mock.calls.length).toBe(1); | |
| 242 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 243 | }); | |
| 244 | ||
| 245 | it("restarts the provider session when runtime mode changes", async () => { | |
| 246 | const harness = await createHarness(); | |
| 247 | const now = new Date().toISOString(); | |
| 248 | ||
| 249 | await Effect.runPromise( | |
| 250 | harness.engine.dispatch({ | |
| 251 | type: "thread.turn.start", | |
| 252 | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-1"), | |
| 253 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 254 | message: { | |
| 255 | messageId: asMessageId("user-message-runtime-mode-1"), | |
| 256 | role: "user", | |
| 257 | text: "first", | |
| 258 | attachments: [], | |
| 259 | }, | |
| 260 | approvalPolicy: "never", | |
| 261 | sandboxMode: "danger-full-access", | |
| 262 | createdAt: now, | |
| 263 | }), | |
| 264 | ); | |
| 265 | ||
| 266 | await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| 267 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 268 | ||
| 269 | await Effect.runPromise( | |
| 270 | harness.engine.dispatch({ | |
| 271 | type: "thread.turn.start", | |
| 272 | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-2"), | |
| 273 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 274 | message: { | |
| 275 | messageId: asMessageId("user-message-runtime-mode-2"), | |
| 276 | role: "user", | |
| 277 | text: "second", | |
| 278 | attachments: [], | |
| 279 | }, | |
| 280 | approvalPolicy: "on-request", | |
| 281 | sandboxMode: "workspace-write", | |
| 282 | createdAt: now, | |
| 283 | }), | |
| 284 | ); | |
| 285 | ||
| 286 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | |
| 287 | await waitFor(() => harness.startSession.mock.calls.length === 2); | |
| 288 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | |
| 289 | ||
| 290 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | |
| 291 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | |
| 292 | resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 293 | approvalPolicy: "on-request", | |
| 294 | sandboxMode: "workspace-write", | |
| 295 | }); | |
| 296 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ | |
| 297 | sessionId: asSessionId("sess-2"), | |
| 298 | }); | |
| 299 | ||
| 300 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 301 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 302 | expect(thread?.session?.providerSessionId).toBe("sess-2"); | |
| 303 | expect(thread?.session?.approvalPolicy).toBe("on-request"); | |
| 304 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); | |
| 305 | }); | |
| 306 | ||
| 307 | it("does not stop the active session when restart fails before rebind", async () => { | |
| 308 | const harness = await createHarness(); | |
| 309 | const now = new Date().toISOString(); | |
| 310 | ||
| 311 | await Effect.runPromise( | |
| 312 | harness.engine.dispatch({ | |
| 313 | type: "thread.turn.start", | |
| 314 | commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-1"), | |
| 315 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 316 | message: { | |
| 317 | messageId: asMessageId("user-message-restart-failure-1"), | |
| 318 | role: "user", | |
| 319 | text: "first", | |
| 320 | attachments: [], | |
| 321 | }, | |
| 322 | approvalPolicy: "never", | |
| 323 | sandboxMode: "danger-full-access", | |
| 324 | createdAt: now, | |
| 325 | }), | |
| 326 | ); | |
| 327 | ||
| 328 | await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| 329 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | |
| 330 | ||
| 331 | harness.startSession.mockImplementationOnce((_: unknown, __: unknown) => | |
| 332 | Effect.fail(new Error("simulated restart failure")) as never, | |
| 333 | ); | |
| 334 | ||
| 335 | await Effect.runPromise( | |
| 336 | harness.engine.dispatch({ | |
| 337 | type: "thread.turn.start", | |
| 338 | commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"), | |
| 339 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 340 | message: { | |
| 341 | messageId: asMessageId("user-message-restart-failure-2"), | |
| 342 | role: "user", | |
| 343 | text: "second", | |
| 344 | attachments: [], | |
| 345 | }, | |
| 346 | approvalPolicy: "on-request", | |
| 347 | sandboxMode: "workspace-write", | |
| 348 | createdAt: now, | |
| 349 | }), | |
| 350 | ); | |
| 351 | ||
| 352 | await waitFor(() => harness.startSession.mock.calls.length === 2); | |
| 353 | await Effect.runPromise(Effect.sleep("30 millis")); | |
| 354 | ||
| 355 | expect(harness.stopSession.mock.calls.length).toBe(0); | |
| 356 | expect(harness.sendTurn.mock.calls.length).toBe(1); | |
| 357 | ||
| 358 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 359 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 360 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | |
| 361 | expect(thread?.session?.approvalPolicy).toBe("never"); | |
| 362 | expect(thread?.session?.sandboxMode).toBe("danger-full-access"); | |
| 363 | }); | |
| 364 | ||
| 365 | it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { | |
| 366 | const harness = await createHarness(); | |
| 367 | const now = new Date().toISOString(); | |
| 368 | ||
| 369 | await Effect.runPromise( | |
| 370 | harness.engine.dispatch({ | |
| 371 | type: "thread.session.set", | |
| 372 | commandId: CommandId.makeUnsafe("cmd-session-set"), | |
| 373 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 374 | session: { | |
| 375 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 376 | status: "running", | |
| 377 | providerName: "codex", | |
| 378 | providerSessionId: asSessionId("sess-1"), | |
| 379 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 380 | approvalPolicy: "on-request", | |
| 381 | sandboxMode: "workspace-write", | |
| 382 | activeTurnId: asTurnId("turn-1"), | |
| 383 | lastError: null, | |
| 384 | updatedAt: now, | |
| 385 | }, | |
| 386 | createdAt: now, | |
| 387 | }), | |
| 388 | ); | |
| 389 | ||
| 390 | await Effect.runPromise( | |
| 391 | harness.engine.dispatch({ | |
| 392 | type: "thread.turn.interrupt", | |
| 393 | commandId: CommandId.makeUnsafe("cmd-turn-interrupt"), | |
| 394 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 395 | turnId: asTurnId("turn-1"), | |
| 396 | createdAt: now, | |
| 397 | }), | |
| 398 | ); | |
| 399 | ||
| 400 | await waitFor(() => harness.interruptTurn.mock.calls.length === 1); | |
| 401 | expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ | |
| 402 | sessionId: "sess-1", | |
| 403 | }); | |
| 404 | }); | |
| 405 | ||
| 406 | it("reacts to thread.approval.respond by forwarding provider approval response", async () => { | |
| 407 | const harness = await createHarness(); | |
| 408 | const now = new Date().toISOString(); | |
| 409 | ||
| 410 | await Effect.runPromise( | |
| 411 | harness.engine.dispatch({ | |
| 412 | type: "thread.session.set", | |
| 413 | commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"), | |
| 414 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 415 | session: { | |
| 416 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 417 | status: "running", | |
| 418 | providerName: "codex", | |
| 419 | providerSessionId: asSessionId("sess-1"), | |
| 420 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 421 | approvalPolicy: "on-request", | |
| 422 | sandboxMode: "workspace-write", | |
| 423 | activeTurnId: null, | |
| 424 | lastError: null, | |
| 425 | updatedAt: now, | |
| 426 | }, | |
| 427 | createdAt: now, | |
| 428 | }), | |
| 429 | ); | |
| 430 | ||
| 431 | await Effect.runPromise( | |
| 432 | harness.engine.dispatch({ | |
| 433 | type: "thread.approval.respond", | |
| 434 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), | |
| 435 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 436 | requestId: asApprovalRequestId("approval-request-1"), | |
| 437 | decision: "accept", | |
| 438 | createdAt: now, | |
| 439 | }), | |
| 440 | ); | |
| 441 | ||
| 442 | await waitFor(() => harness.respondToRequest.mock.calls.length === 1); | |
| 443 | expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ | |
| 444 | sessionId: "sess-1", | |
| 445 | requestId: "approval-request-1", | |
| 446 | decision: "accept", | |
| 447 | }); | |
| 448 | }); | |
| 449 | ||
| 450 | it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { | |
| 451 | const harness = await createHarness(); | |
| 452 | const now = new Date().toISOString(); | |
| 453 | ||
| 454 | await Effect.runPromise( | |
| 455 | harness.engine.dispatch({ | |
| 456 | type: "thread.session.set", | |
| 457 | commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"), | |
| 458 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 459 | session: { | |
| 460 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 461 | status: "ready", | |
| 462 | providerName: "codex", | |
| 463 | providerSessionId: asSessionId("sess-1"), | |
| 464 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 465 | approvalPolicy: "on-request", | |
| 466 | sandboxMode: "workspace-write", | |
| 467 | activeTurnId: null, | |
| 468 | lastError: null, | |
| 469 | updatedAt: now, | |
| 470 | }, | |
| 471 | createdAt: now, | |
| 472 | }), | |
| 473 | ); | |
| 474 | ||
| 475 | await Effect.runPromise( | |
| 476 | harness.engine.dispatch({ | |
| 477 | type: "thread.session.stop", | |
| 478 | commandId: CommandId.makeUnsafe("cmd-session-stop"), | |
| 479 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 480 | createdAt: now, | |
| 481 | }), | |
| 482 | ); | |
| 483 | ||
| 484 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | |
| 485 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 486 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 487 | expect(thread?.session).not.toBeNull(); | |
| 488 | expect(thread?.session?.status).toBe("stopped"); | |
| 489 | expect(thread?.session?.providerSessionId).toBeNull(); | |
| 490 | expect(thread?.session?.providerThreadId).toBeNull(); | |
| 491 | expect(thread?.session?.activeTurnId).toBeNull(); | |
| 492 | }); | |
| 493 | }); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts created+430| ... | ... | @@ -0,0 +1,430 @@ |
| 1 | import { | |
| 2 | type ChatAttachment, | |
| 3 | CommandId, | |
| 4 | EventId, | |
| 5 | type OrchestrationEvent, | |
| 6 | type ProviderApprovalPolicy, | |
| 7 | type ProviderKind, | |
| 8 | type ProviderSandboxMode, | |
| 9 | type OrchestrationSession, | |
| 10 | type ThreadId, | |
| 11 | type ProviderSession, | |
| 12 | type ProviderThreadId, | |
| 13 | type TurnId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | |
| 16 | ||
| 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | |
| 18 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 19 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 20 | import { | |
| 21 | ProviderCommandReactor, | |
| 22 | type ProviderCommandReactorShape, | |
| 23 | } from "../Services/ProviderCommandReactor.ts"; | |
| 24 | ||
| 25 | type ProviderIntentEvent = Extract< | |
| 26 | OrchestrationEvent, | |
| 27 | { | |
| 28 | type: | |
| 29 | | "thread.turn-start-requested" | |
| 30 | | "thread.turn-interrupt-requested" | |
| 31 | | "thread.approval-response-requested" | |
| 32 | | "thread.session-stop-requested"; | |
| 33 | } | |
| 34 | >; | |
| 35 | ||
| 36 | function toNonEmptyProviderInput(value: string | undefined): string | undefined { | |
| 37 | const normalized = value?.trim(); | |
| 38 | return normalized && normalized.length > 0 ? normalized : undefined; | |
| 39 | } | |
| 40 | ||
| 41 | function mapProviderSessionStatusToOrchestrationStatus( | |
| 42 | status: "connecting" | "ready" | "running" | "error" | "closed", | |
| 43 | ): OrchestrationSession["status"] { | |
| 44 | switch (status) { | |
| 45 | case "connecting": | |
| 46 | return "starting"; | |
| 47 | case "running": | |
| 48 | return "running"; | |
| 49 | case "error": | |
| 50 | return "error"; | |
| 51 | case "closed": | |
| 52 | return "stopped"; | |
| 53 | case "ready": | |
| 54 | default: | |
| 55 | return "ready"; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | const turnStartKeyForEvent = (event: ProviderIntentEvent): string => | |
| 60 | event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; | |
| 61 | ||
| 62 | const serverCommandId = (tag: string): CommandId => | |
| 63 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 64 | ||
| 65 | const HANDLED_TURN_START_KEY_MAX = 10_000; | |
| 66 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); | |
| 67 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; | |
| 68 | const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; | |
| 69 | ||
| 70 | const make = Effect.gen(function* () { | |
| 71 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 72 | const providerService = yield* ProviderService; | |
| 73 | const handledTurnStartKeys = yield* Cache.make<string, true>({ | |
| 74 | capacity: HANDLED_TURN_START_KEY_MAX, | |
| 75 | timeToLive: HANDLED_TURN_START_KEY_TTL, | |
| 76 | lookup: () => Effect.succeed(true), | |
| 77 | }); | |
| 78 | ||
| 79 | const hasHandledTurnStartRecently = (key: string) => | |
| 80 | Cache.getOption(handledTurnStartKeys, key).pipe( | |
| 81 | Effect.flatMap((cached) => | |
| 82 | Cache.set(handledTurnStartKeys, key, true).pipe(Effect.as(Option.isSome(cached))), | |
| 83 | ), | |
| 84 | ); | |
| 85 | ||
| 86 | const appendProviderFailureActivity = (input: { | |
| 87 | readonly threadId: ThreadId; | |
| 88 | readonly kind: | |
| 89 | | "provider.turn.start.failed" | |
| 90 | | "provider.turn.interrupt.failed" | |
| 91 | | "provider.approval.respond.failed" | |
| 92 | | "provider.session.stop.failed"; | |
| 93 | readonly summary: string; | |
| 94 | readonly detail: string; | |
| 95 | readonly turnId: TurnId | null; | |
| 96 | readonly createdAt: string; | |
| 97 | }) => | |
| 98 | orchestrationEngine.dispatch({ | |
| 99 | type: "thread.activity.append", | |
| 100 | commandId: serverCommandId("provider-failure-activity"), | |
| 101 | threadId: input.threadId, | |
| 102 | activity: { | |
| 103 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 104 | tone: "error", | |
| 105 | kind: input.kind, | |
| 106 | summary: input.summary, | |
| 107 | payload: { | |
| 108 | detail: input.detail, | |
| 109 | }, | |
| 110 | turnId: input.turnId, | |
| 111 | createdAt: input.createdAt, | |
| 112 | }, | |
| 113 | createdAt: input.createdAt, | |
| 114 | }); | |
| 115 | ||
| 116 | const setThreadSession = (input: { | |
| 117 | readonly threadId: ThreadId; | |
| 118 | readonly session: OrchestrationSession; | |
| 119 | readonly createdAt: string; | |
| 120 | }) => | |
| 121 | orchestrationEngine.dispatch({ | |
| 122 | type: "thread.session.set", | |
| 123 | commandId: serverCommandId("provider-session-set"), | |
| 124 | threadId: input.threadId, | |
| 125 | session: input.session, | |
| 126 | createdAt: input.createdAt, | |
| 127 | }); | |
| 128 | ||
| 129 | const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { | |
| 130 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 131 | return readModel.threads.find((entry) => entry.id === threadId); | |
| 132 | }); | |
| 133 | ||
| 134 | const ensureSessionForThread = Effect.fnUntraced(function* ( | |
| 135 | threadId: ThreadId, | |
| 136 | createdAt: string, | |
| 137 | options?: { | |
| 138 | readonly approvalPolicy?: ProviderApprovalPolicy; | |
| 139 | readonly sandboxMode?: ProviderSandboxMode; | |
| 140 | }, | |
| 141 | ) { | |
| 142 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 143 | const thread = readModel.threads.find((entry) => entry.id === threadId); | |
| 144 | if (!thread) { | |
| 145 | return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); | |
| 146 | } | |
| 147 | ||
| 148 | const desiredApprovalPolicy = | |
| 149 | options?.approvalPolicy ?? thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY; | |
| 150 | const desiredSandboxMode = | |
| 151 | options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; | |
| 152 | const preferredProvider: ProviderKind | undefined = | |
| 153 | thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" | |
| 154 | ? thread.session.providerName | |
| 155 | : undefined; | |
| 156 | const effectiveCwd = resolveThreadWorkspaceCwd({ | |
| 157 | thread, | |
| 158 | projects: readModel.projects, | |
| 159 | }); | |
| 160 | ||
| 161 | const startProviderSession = (resumeThreadId?: ProviderThreadId | null) => | |
| 162 | providerService.startSession(threadId, { | |
| 163 | ...(preferredProvider ? { provider: preferredProvider } : {}), | |
| 164 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | |
| 165 | ...(thread.model ? { model: thread.model } : {}), | |
| 166 | ...(resumeThreadId ? { resumeThreadId } : {}), | |
| 167 | approvalPolicy: desiredApprovalPolicy, | |
| 168 | sandboxMode: desiredSandboxMode, | |
| 169 | }); | |
| 170 | ||
| 171 | const bindSessionToThread = (session: ProviderSession) => | |
| 172 | setThreadSession({ | |
| 173 | threadId, | |
| 174 | session: { | |
| 175 | threadId, | |
| 176 | status: mapProviderSessionStatusToOrchestrationStatus(session.status), | |
| 177 | providerName: session.provider, | |
| 178 | providerSessionId: session.sessionId, | |
| 179 | providerThreadId: session.threadId ?? null, | |
| 180 | approvalPolicy: desiredApprovalPolicy, | |
| 181 | sandboxMode: desiredSandboxMode, | |
| 182 | // Provider turn ids are not orchestration turn ids. | |
| 183 | activeTurnId: null, | |
| 184 | lastError: session.lastError ?? null, | |
| 185 | updatedAt: session.updatedAt, | |
| 186 | }, | |
| 187 | createdAt, | |
| 188 | }); | |
| 189 | ||
| 190 | const existingSessionId = thread.session?.providerSessionId; | |
| 191 | if (existingSessionId) { | |
| 192 | const approvalPolicyChanged = | |
| 193 | options?.approvalPolicy !== undefined && options.approvalPolicy !== thread.session?.approvalPolicy; | |
| 194 | const sandboxModeChanged = | |
| 195 | options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; | |
| 196 | ||
| 197 | if (!approvalPolicyChanged && !sandboxModeChanged) { | |
| 198 | return existingSessionId; | |
| 199 | } | |
| 200 | ||
| 201 | const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null); | |
| 202 | yield* bindSessionToThread(restartedSession); | |
| 203 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( | |
| 204 | Effect.catchCause((cause) => | |
| 205 | Effect.logWarning("provider command reactor failed to stop superseded provider session", { | |
| 206 | threadId, | |
| 207 | sessionId: existingSessionId, | |
| 208 | cause: Cause.pretty(cause), | |
| 209 | }), | |
| 210 | ), | |
| 211 | ); | |
| 212 | return restartedSession.sessionId; | |
| 213 | } | |
| 214 | ||
| 215 | const startedSession = yield* startProviderSession(); | |
| 216 | yield* bindSessionToThread(startedSession); | |
| 217 | return startedSession.sessionId; | |
| 218 | }); | |
| 219 | ||
| 220 | const sendTurnForThread = Effect.fnUntraced(function* (input: { | |
| 221 | readonly threadId: ThreadId; | |
| 222 | readonly messageText: string; | |
| 223 | readonly attachments?: ReadonlyArray<ChatAttachment>; | |
| 224 | readonly model?: string; | |
| 225 | readonly effort?: string; | |
| 226 | readonly approvalPolicy: ProviderApprovalPolicy; | |
| 227 | readonly sandboxMode: ProviderSandboxMode; | |
| 228 | readonly createdAt: string; | |
| 229 | }) { | |
| 230 | const thread = yield* resolveThread(input.threadId); | |
| 231 | if (!thread) { | |
| 232 | return; | |
| 233 | } | |
| 234 | const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { | |
| 235 | approvalPolicy: input.approvalPolicy, | |
| 236 | sandboxMode: input.sandboxMode, | |
| 237 | }); | |
| 238 | const normalizedInput = toNonEmptyProviderInput(input.messageText); | |
| 239 | const normalizedAttachments = input.attachments ?? []; | |
| 240 | ||
| 241 | yield* providerService.sendTurn({ | |
| 242 | sessionId, | |
| 243 | ...(normalizedInput ? { input: normalizedInput } : {}), | |
| 244 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), | |
| 245 | ...(input.model !== undefined ? { model: input.model } : {}), | |
| 246 | ...(input.effort !== undefined ? { effort: input.effort } : {}), | |
| 247 | }); | |
| 248 | }); | |
| 249 | ||
| 250 | const processTurnStartRequested = Effect.fnUntraced(function* ( | |
| 251 | event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>, | |
| 252 | ) { | |
| 253 | const key = turnStartKeyForEvent(event); | |
| 254 | if (yield* hasHandledTurnStartRecently(key)) { | |
| 255 | return; | |
| 256 | } | |
| 257 | ||
| 258 | const thread = yield* resolveThread(event.payload.threadId); | |
| 259 | if (!thread) { | |
| 260 | return; | |
| 261 | } | |
| 262 | ||
| 263 | const message = thread.messages.find((entry) => entry.id === event.payload.messageId); | |
| 264 | if (!message || message.role !== "user") { | |
| 265 | yield* appendProviderFailureActivity({ | |
| 266 | threadId: event.payload.threadId, | |
| 267 | kind: "provider.turn.start.failed", | |
| 268 | summary: "Provider turn start failed", | |
| 269 | detail: `User message '${event.payload.messageId}' was not found for turn start request.`, | |
| 270 | turnId: null, | |
| 271 | createdAt: event.payload.createdAt, | |
| 272 | }); | |
| 273 | return; | |
| 274 | } | |
| 275 | ||
| 276 | yield* sendTurnForThread({ | |
| 277 | threadId: event.payload.threadId, | |
| 278 | messageText: message.text, | |
| 279 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 280 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | |
| 281 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), | |
| 282 | approvalPolicy: event.payload.approvalPolicy, | |
| 283 | sandboxMode: event.payload.sandboxMode, | |
| 284 | createdAt: event.payload.createdAt, | |
| 285 | }); | |
| 286 | }); | |
| 287 | ||
| 288 | const processTurnInterruptRequested = Effect.fnUntraced(function* ( | |
| 289 | event: Extract<ProviderIntentEvent, { type: "thread.turn-interrupt-requested" }>, | |
| 290 | ) { | |
| 291 | const thread = yield* resolveThread(event.payload.threadId); | |
| 292 | if (!thread) { | |
| 293 | return; | |
| 294 | } | |
| 295 | const sessionId = thread.session?.providerSessionId; | |
| 296 | if (!sessionId) { | |
| 297 | return yield* appendProviderFailureActivity({ | |
| 298 | threadId: event.payload.threadId, | |
| 299 | kind: "provider.turn.interrupt.failed", | |
| 300 | summary: "Provider turn interrupt failed", | |
| 301 | detail: "No active provider session is bound to this thread.", | |
| 302 | turnId: event.payload.turnId ?? null, | |
| 303 | createdAt: event.payload.createdAt, | |
| 304 | }); | |
| 305 | } | |
| 306 | ||
| 307 | // Orchestration turn ids are not provider turn ids, so interrupt by session. | |
| 308 | yield* providerService.interruptTurn({ sessionId }); | |
| 309 | }); | |
| 310 | ||
| 311 | const processApprovalResponseRequested = Effect.fnUntraced(function* ( | |
| 312 | event: Extract<ProviderIntentEvent, { type: "thread.approval-response-requested" }>, | |
| 313 | ) { | |
| 314 | const thread = yield* resolveThread(event.payload.threadId); | |
| 315 | if (!thread) { | |
| 316 | return; | |
| 317 | } | |
| 318 | const sessionId = thread.session?.providerSessionId; | |
| 319 | if (!sessionId) { | |
| 320 | return yield* appendProviderFailureActivity({ | |
| 321 | threadId: event.payload.threadId, | |
| 322 | kind: "provider.approval.respond.failed", | |
| 323 | summary: "Provider approval response failed", | |
| 324 | detail: "No active provider session is bound to this thread.", | |
| 325 | turnId: null, | |
| 326 | createdAt: event.payload.createdAt, | |
| 327 | }); | |
| 328 | } | |
| 329 | ||
| 330 | yield* providerService.respondToRequest({ | |
| 331 | sessionId, | |
| 332 | requestId: event.payload.requestId, | |
| 333 | decision: event.payload.decision, | |
| 334 | }); | |
| 335 | }); | |
| 336 | ||
| 337 | const processSessionStopRequested = Effect.fnUntraced(function* ( | |
| 338 | event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>, | |
| 339 | ) { | |
| 340 | const thread = yield* resolveThread(event.payload.threadId); | |
| 341 | if (!thread) { | |
| 342 | return; | |
| 343 | } | |
| 344 | ||
| 345 | const now = event.payload.createdAt; | |
| 346 | const sessionId = thread.session?.providerSessionId; | |
| 347 | ||
| 348 | if (sessionId) { | |
| 349 | yield* providerService.stopSession({ sessionId }); | |
| 350 | } | |
| 351 | ||
| 352 | yield* setThreadSession({ | |
| 353 | threadId: thread.id, | |
| 354 | session: { | |
| 355 | threadId: thread.id, | |
| 356 | status: "stopped", | |
| 357 | providerName: thread.session?.providerName ?? null, | |
| 358 | providerSessionId: null, | |
| 359 | providerThreadId: null, | |
| 360 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 361 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 362 | activeTurnId: null, | |
| 363 | lastError: thread.session?.lastError ?? null, | |
| 364 | updatedAt: now, | |
| 365 | }, | |
| 366 | createdAt: now, | |
| 367 | }); | |
| 368 | }); | |
| 369 | ||
| 370 | const processDomainEvent = (event: ProviderIntentEvent) => | |
| 371 | Effect.gen(function* () { | |
| 372 | switch (event.type) { | |
| 373 | case "thread.turn-start-requested": | |
| 374 | yield* processTurnStartRequested(event); | |
| 375 | return; | |
| 376 | case "thread.turn-interrupt-requested": | |
| 377 | yield* processTurnInterruptRequested(event); | |
| 378 | return; | |
| 379 | case "thread.approval-response-requested": | |
| 380 | yield* processApprovalResponseRequested(event); | |
| 381 | return; | |
| 382 | case "thread.session-stop-requested": | |
| 383 | yield* processSessionStopRequested(event); | |
| 384 | return; | |
| 385 | } | |
| 386 | }); | |
| 387 | ||
| 388 | const processDomainEventSafely = (event: ProviderIntentEvent) => | |
| 389 | processDomainEvent(event).pipe( | |
| 390 | Effect.catchCause((cause) => { | |
| 391 | if (Cause.hasInterruptsOnly(cause)) { | |
| 392 | return Effect.failCause(cause); | |
| 393 | } | |
| 394 | return Effect.logWarning("provider command reactor failed to process event", { | |
| 395 | eventType: event.type, | |
| 396 | cause: Cause.pretty(cause), | |
| 397 | }); | |
| 398 | }), | |
| 399 | ); | |
| 400 | ||
| 401 | const start: ProviderCommandReactorShape["start"] = Effect.gen(function* () { | |
| 402 | const queue = yield* Queue.unbounded<ProviderIntentEvent>(); | |
| 403 | yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); | |
| 404 | ||
| 405 | yield* Effect.forkScoped( | |
| 406 | Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEventSafely))), | |
| 407 | ); | |
| 408 | ||
| 409 | yield* Effect.forkScoped( | |
| 410 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 411 | if ( | |
| 412 | event.type !== "thread.turn-start-requested" && | |
| 413 | event.type !== "thread.turn-interrupt-requested" && | |
| 414 | event.type !== "thread.approval-response-requested" && | |
| 415 | event.type !== "thread.session-stop-requested" | |
| 416 | ) { | |
| 417 | return Effect.void; | |
| 418 | } | |
| 419 | ||
| 420 | return Queue.offer(queue, event).pipe(Effect.asVoid); | |
| 421 | }), | |
| 422 | ); | |
| 423 | }); | |
| 424 | ||
| 425 | return { | |
| 426 | start, | |
| 427 | } satisfies ProviderCommandReactorShape; | |
| 428 | }); | |
| 429 | ||
| 430 | export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts created+751| ... | ... | @@ -0,0 +1,751 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | EventId, | |
| 5 | MessageId, | |
| 6 | ProjectId, | |
| 7 | ProviderItemId, | |
| 8 | ProviderSessionId, | |
| 9 | ProviderThreadId, | |
| 10 | ProviderTurnId, | |
| 11 | ThreadId, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | |
| 14 | import { afterEach, describe, expect, it } from "vitest"; | |
| 15 | ||
| 16 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 17 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 18 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 19 | import { | |
| 20 | ProviderService, | |
| 21 | type ProviderServiceShape, | |
| 22 | } from "../../provider/Services/ProviderService.ts"; | |
| 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 25 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; | |
| 26 | import { | |
| 27 | OrchestrationEngineService, | |
| 28 | type OrchestrationEngineShape, | |
| 29 | } from "../Services/OrchestrationEngine.ts"; | |
| 30 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 31 | ||
| 32 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 33 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 34 | const asProviderThreadId = (value: string): ProviderThreadId => | |
| 35 | ProviderThreadId.makeUnsafe(value); | |
| 36 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 37 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 38 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 39 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 40 | ||
| 41 | function createProviderServiceHarness() { | |
| 42 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 43 | ||
| 44 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | |
| 45 | const service: ProviderServiceShape = { | |
| 46 | startSession: () => unsupported(), | |
| 47 | sendTurn: () => unsupported(), | |
| 48 | interruptTurn: () => unsupported(), | |
| 49 | respondToRequest: () => unsupported(), | |
| 50 | stopSession: () => unsupported(), | |
| 51 | listSessions: () => Effect.succeed([]), | |
| 52 | rollbackConversation: () => unsupported(), | |
| 53 | stopAll: () => Effect.void, | |
| 54 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 55 | }; | |
| 56 | ||
| 57 | const emit = (event: ProviderRuntimeEvent): void => { | |
| 58 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | |
| 59 | }; | |
| 60 | ||
| 61 | return { | |
| 62 | service, | |
| 63 | emit, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | async function waitForThread( | |
| 68 | engine: OrchestrationEngineShape, | |
| 69 | predicate: (thread: { | |
| 70 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; | |
| 71 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; | |
| 72 | activities: ReadonlyArray<{ kind: string }>; | |
| 73 | }) => boolean, | |
| 74 | timeoutMs = 2000, | |
| 75 | ) { | |
| 76 | const deadline = Date.now() + timeoutMs; | |
| 77 | const poll = async (): Promise<{ | |
| 78 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; | |
| 79 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; | |
| 80 | activities: ReadonlyArray<{ kind: string }>; | |
| 81 | }> => { | |
| 82 | const readModel = await Effect.runPromise(engine.getReadModel()); | |
| 83 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 84 | if (thread && predicate(thread)) { | |
| 85 | return thread; | |
| 86 | } | |
| 87 | if (Date.now() >= deadline) { | |
| 88 | throw new Error("Timed out waiting for thread state"); | |
| 89 | } | |
| 90 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 91 | return poll(); | |
| 92 | }; | |
| 93 | return poll(); | |
| 94 | } | |
| 95 | ||
| 96 | describe("ProviderRuntimeIngestion", () => { | |
| 97 | let runtime: ManagedRuntime.ManagedRuntime< | |
| 98 | OrchestrationEngineService | ProviderRuntimeIngestionService, | |
| 99 | unknown | |
| 100 | > | null = null; | |
| 101 | let scope: Scope.Closeable | null = null; | |
| 102 | ||
| 103 | afterEach(async () => { | |
| 104 | if (scope) { | |
| 105 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 106 | } | |
| 107 | scope = null; | |
| 108 | if (runtime) { | |
| 109 | await runtime.dispose(); | |
| 110 | } | |
| 111 | runtime = null; | |
| 112 | }); | |
| 113 | ||
| 114 | async function createHarness() { | |
| 115 | const provider = createProviderServiceHarness(); | |
| 116 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 117 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 118 | Layer.provide(OrchestrationEventStoreLive), | |
| 119 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 120 | Layer.provide(SqlitePersistenceMemory), | |
| 121 | ); | |
| 122 | const layer = ProviderRuntimeIngestionLive.pipe( | |
| 123 | Layer.provideMerge(orchestrationLayer), | |
| 124 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | |
| 125 | ); | |
| 126 | runtime = ManagedRuntime.make(layer); | |
| 127 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | |
| 128 | const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); | |
| 129 | scope = await Effect.runPromise(Scope.make("sequential")); | |
| 130 | await Effect.runPromise(ingestion.start.pipe(Scope.provide(scope))); | |
| 131 | await Effect.runPromise(Effect.sleep("10 millis")); | |
| 132 | ||
| 133 | const createdAt = new Date().toISOString(); | |
| 134 | await Effect.runPromise( | |
| 135 | engine.dispatch({ | |
| 136 | type: "project.create", | |
| 137 | commandId: CommandId.makeUnsafe("cmd-provider-project-create"), | |
| 138 | projectId: asProjectId("project-1"), | |
| 139 | title: "Provider Project", | |
| 140 | workspaceRoot: "/tmp/provider-project", | |
| 141 | defaultModel: "gpt-5-codex", | |
| 142 | createdAt, | |
| 143 | }), | |
| 144 | ); | |
| 145 | await Effect.runPromise( | |
| 146 | engine.dispatch({ | |
| 147 | type: "thread.create", | |
| 148 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 149 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 150 | projectId: asProjectId("project-1"), | |
| 151 | title: "Thread", | |
| 152 | model: "gpt-5-codex", | |
| 153 | branch: null, | |
| 154 | worktreePath: null, | |
| 155 | createdAt, | |
| 156 | }), | |
| 157 | ); | |
| 158 | await Effect.runPromise( | |
| 159 | engine.dispatch({ | |
| 160 | type: "thread.session.set", | |
| 161 | commandId: CommandId.makeUnsafe("cmd-session-seed"), | |
| 162 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 163 | session: { | |
| 164 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 165 | status: "ready", | |
| 166 | providerName: "codex", | |
| 167 | providerSessionId: asSessionId("sess-1"), | |
| 168 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 169 | approvalPolicy: "on-request", | |
| 170 | sandboxMode: "workspace-write", | |
| 171 | activeTurnId: null, | |
| 172 | updatedAt: createdAt, | |
| 173 | lastError: null, | |
| 174 | }, | |
| 175 | createdAt, | |
| 176 | }), | |
| 177 | ); | |
| 178 | ||
| 179 | return { | |
| 180 | engine, | |
| 181 | emit: provider.emit, | |
| 182 | }; | |
| 183 | } | |
| 184 | ||
| 185 | it("maps turn started/completed events into thread session updates", async () => { | |
| 186 | const harness = await createHarness(); | |
| 187 | const now = new Date().toISOString(); | |
| 188 | ||
| 189 | harness.emit({ | |
| 190 | type: "turn.started", | |
| 191 | eventId: asEventId("evt-turn-started"), | |
| 192 | provider: "codex", | |
| 193 | sessionId: asSessionId("sess-1"), | |
| 194 | createdAt: now, | |
| 195 | turnId: asProviderTurnId("turn-1"), | |
| 196 | }); | |
| 197 | ||
| 198 | await waitForThread( | |
| 199 | harness.engine, | |
| 200 | (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1", | |
| 201 | ); | |
| 202 | ||
| 203 | harness.emit({ | |
| 204 | type: "turn.completed", | |
| 205 | eventId: asEventId("evt-turn-completed"), | |
| 206 | provider: "codex", | |
| 207 | sessionId: asSessionId("sess-1"), | |
| 208 | createdAt: new Date().toISOString(), | |
| 209 | turnId: asProviderTurnId("turn-1"), | |
| 210 | status: "failed", | |
| 211 | errorMessage: "turn failed", | |
| 212 | }); | |
| 213 | ||
| 214 | const thread = await waitForThread( | |
| 215 | harness.engine, | |
| 216 | (entry) => | |
| 217 | entry.session?.status === "error" && | |
| 218 | entry.session?.activeTurnId === null && | |
| 219 | entry.session?.lastError === "turn failed", | |
| 220 | ); | |
| 221 | expect(thread.session?.status).toBe("error"); | |
| 222 | expect(thread.session?.lastError).toBe("turn failed"); | |
| 223 | }); | |
| 224 | ||
| 225 | it("ignores auxiliary turn completions from a different provider thread", async () => { | |
| 226 | const harness = await createHarness(); | |
| 227 | const now = new Date().toISOString(); | |
| 228 | ||
| 229 | harness.emit({ | |
| 230 | type: "turn.started", | |
| 231 | eventId: asEventId("evt-turn-started-primary"), | |
| 232 | provider: "codex", | |
| 233 | sessionId: asSessionId("sess-1"), | |
| 234 | createdAt: now, | |
| 235 | threadId: asProviderThreadId("provider-thread-1"), | |
| 236 | turnId: asProviderTurnId("turn-primary"), | |
| 237 | }); | |
| 238 | ||
| 239 | await waitForThread( | |
| 240 | harness.engine, | |
| 241 | (thread) => | |
| 242 | thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary", | |
| 243 | ); | |
| 244 | ||
| 245 | harness.emit({ | |
| 246 | type: "turn.completed", | |
| 247 | eventId: asEventId("evt-turn-completed-aux"), | |
| 248 | provider: "codex", | |
| 249 | sessionId: asSessionId("sess-1"), | |
| 250 | createdAt: new Date().toISOString(), | |
| 251 | threadId: asProviderThreadId("provider-thread-aux"), | |
| 252 | turnId: asProviderTurnId("turn-aux"), | |
| 253 | status: "completed", | |
| 254 | }); | |
| 255 | ||
| 256 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 257 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 258 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 259 | expect(midThread?.session?.status).toBe("running"); | |
| 260 | expect(midThread?.session?.activeTurnId).toBe("turn-primary"); | |
| 261 | ||
| 262 | harness.emit({ | |
| 263 | type: "turn.completed", | |
| 264 | eventId: asEventId("evt-turn-completed-primary"), | |
| 265 | provider: "codex", | |
| 266 | sessionId: asSessionId("sess-1"), | |
| 267 | createdAt: new Date().toISOString(), | |
| 268 | threadId: asProviderThreadId("provider-thread-1"), | |
| 269 | turnId: asProviderTurnId("turn-primary"), | |
| 270 | status: "completed", | |
| 271 | }); | |
| 272 | ||
| 273 | await waitForThread( | |
| 274 | harness.engine, | |
| 275 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | |
| 276 | ); | |
| 277 | }); | |
| 278 | ||
| 279 | it("ignores non-active turn completion when runtime omits thread id", async () => { | |
| 280 | const harness = await createHarness(); | |
| 281 | const now = new Date().toISOString(); | |
| 282 | ||
| 283 | harness.emit({ | |
| 284 | type: "turn.started", | |
| 285 | eventId: asEventId("evt-turn-started-guarded"), | |
| 286 | provider: "codex", | |
| 287 | sessionId: asSessionId("sess-1"), | |
| 288 | createdAt: now, | |
| 289 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 290 | }); | |
| 291 | ||
| 292 | await waitForThread( | |
| 293 | harness.engine, | |
| 294 | (thread) => | |
| 295 | thread.session?.status === "running" && | |
| 296 | thread.session?.activeTurnId === "turn-guarded-main", | |
| 297 | ); | |
| 298 | ||
| 299 | harness.emit({ | |
| 300 | type: "turn.completed", | |
| 301 | eventId: asEventId("evt-turn-completed-guarded-other"), | |
| 302 | provider: "codex", | |
| 303 | sessionId: asSessionId("sess-1"), | |
| 304 | createdAt: new Date().toISOString(), | |
| 305 | turnId: asProviderTurnId("turn-guarded-other"), | |
| 306 | status: "completed", | |
| 307 | }); | |
| 308 | ||
| 309 | await Effect.runPromise(Effect.sleep("40 millis")); | |
| 310 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 311 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 312 | expect(midThread?.session?.status).toBe("running"); | |
| 313 | expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main"); | |
| 314 | ||
| 315 | harness.emit({ | |
| 316 | type: "turn.completed", | |
| 317 | eventId: asEventId("evt-turn-completed-guarded-main"), | |
| 318 | provider: "codex", | |
| 319 | sessionId: asSessionId("sess-1"), | |
| 320 | createdAt: new Date().toISOString(), | |
| 321 | turnId: asProviderTurnId("turn-guarded-main"), | |
| 322 | status: "completed", | |
| 323 | }); | |
| 324 | ||
| 325 | await waitForThread( | |
| 326 | harness.engine, | |
| 327 | (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null, | |
| 328 | ); | |
| 329 | }); | |
| 330 | ||
| 331 | it("maps message delta/completed into finalized assistant messages", async () => { | |
| 332 | const harness = await createHarness(); | |
| 333 | const now = new Date().toISOString(); | |
| 334 | ||
| 335 | harness.emit({ | |
| 336 | type: "message.delta", | |
| 337 | eventId: asEventId("evt-message-delta-1"), | |
| 338 | provider: "codex", | |
| 339 | sessionId: asSessionId("sess-1"), | |
| 340 | createdAt: now, | |
| 341 | turnId: asProviderTurnId("turn-2"), | |
| 342 | itemId: asItemId("item-1"), | |
| 343 | delta: "hello", | |
| 344 | }); | |
| 345 | harness.emit({ | |
| 346 | type: "message.delta", | |
| 347 | eventId: asEventId("evt-message-delta-2"), | |
| 348 | provider: "codex", | |
| 349 | sessionId: asSessionId("sess-1"), | |
| 350 | createdAt: now, | |
| 351 | turnId: asProviderTurnId("turn-2"), | |
| 352 | itemId: asItemId("item-1"), | |
| 353 | delta: " world", | |
| 354 | }); | |
| 355 | harness.emit({ | |
| 356 | type: "message.completed", | |
| 357 | eventId: asEventId("evt-message-completed"), | |
| 358 | provider: "codex", | |
| 359 | sessionId: asSessionId("sess-1"), | |
| 360 | createdAt: now, | |
| 361 | turnId: asProviderTurnId("turn-2"), | |
| 362 | itemId: asItemId("item-1"), | |
| 363 | }); | |
| 364 | ||
| 365 | const thread = await waitForThread(harness.engine, (entry) => | |
| 366 | entry.messages.some((message) => message.id === "assistant:item-1" && !message.streaming), | |
| 367 | ); | |
| 368 | const message = thread.messages.find((entry) => entry.id === "assistant:item-1"); | |
| 369 | expect(message?.text).toBe("hello world"); | |
| 370 | expect(message?.streaming).toBe(false); | |
| 371 | }); | |
| 372 | ||
| 373 | it("buffers assistant deltas by default until completion", async () => { | |
| 374 | const harness = await createHarness(); | |
| 375 | const now = new Date().toISOString(); | |
| 376 | ||
| 377 | harness.emit({ | |
| 378 | type: "turn.started", | |
| 379 | eventId: asEventId("evt-turn-started-buffered"), | |
| 380 | provider: "codex", | |
| 381 | sessionId: asSessionId("sess-1"), | |
| 382 | createdAt: now, | |
| 383 | turnId: asProviderTurnId("turn-buffered"), | |
| 384 | }); | |
| 385 | await waitForThread( | |
| 386 | harness.engine, | |
| 387 | (thread) => | |
| 388 | thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered", | |
| 389 | ); | |
| 390 | ||
| 391 | harness.emit({ | |
| 392 | type: "message.delta", | |
| 393 | eventId: asEventId("evt-message-delta-buffered"), | |
| 394 | provider: "codex", | |
| 395 | sessionId: asSessionId("sess-1"), | |
| 396 | createdAt: now, | |
| 397 | turnId: asProviderTurnId("turn-buffered"), | |
| 398 | itemId: asItemId("item-buffered"), | |
| 399 | delta: "buffer me", | |
| 400 | }); | |
| 401 | ||
| 402 | await Effect.runPromise(Effect.sleep("30 millis")); | |
| 403 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | |
| 404 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 405 | expect(midThread?.messages.some((message) => message.id === "assistant:item-buffered")).toBe( | |
| 406 | false, | |
| 407 | ); | |
| 408 | ||
| 409 | harness.emit({ | |
| 410 | type: "message.completed", | |
| 411 | eventId: asEventId("evt-message-completed-buffered"), | |
| 412 | provider: "codex", | |
| 413 | sessionId: asSessionId("sess-1"), | |
| 414 | createdAt: now, | |
| 415 | turnId: asProviderTurnId("turn-buffered"), | |
| 416 | itemId: asItemId("item-buffered"), | |
| 417 | }); | |
| 418 | ||
| 419 | const thread = await waitForThread( | |
| 420 | harness.engine, | |
| 421 | (entry) => | |
| 422 | entry.messages.some((message) => message.id === "assistant:item-buffered" && !message.streaming), | |
| 423 | ); | |
| 424 | const message = thread.messages.find((entry) => entry.id === "assistant:item-buffered"); | |
| 425 | expect(message?.text).toBe("buffer me"); | |
| 426 | expect(message?.streaming).toBe(false); | |
| 427 | }); | |
| 428 | ||
| 429 | it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { | |
| 430 | const harness = await createHarness(); | |
| 431 | const now = new Date().toISOString(); | |
| 432 | ||
| 433 | await Effect.runPromise( | |
| 434 | harness.engine.dispatch({ | |
| 435 | type: "thread.turn.start", | |
| 436 | commandId: CommandId.makeUnsafe("cmd-turn-start-streaming-mode"), | |
| 437 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 438 | message: { | |
| 439 | messageId: asMessageId("message-streaming-mode"), | |
| 440 | role: "user", | |
| 441 | text: "stream please", | |
| 442 | attachments: [], | |
| 443 | }, | |
| 444 | assistantDeliveryMode: "streaming", | |
| 445 | approvalPolicy: "on-request", | |
| 446 | sandboxMode: "workspace-write", | |
| 447 | createdAt: now, | |
| 448 | }), | |
| 449 | ); | |
| 450 | await Effect.runPromise(Effect.sleep("30 millis")); | |
| 451 | ||
| 452 | harness.emit({ | |
| 453 | type: "turn.started", | |
| 454 | eventId: asEventId("evt-turn-started-streaming-mode"), | |
| 455 | provider: "codex", | |
| 456 | sessionId: asSessionId("sess-1"), | |
| 457 | createdAt: now, | |
| 458 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 459 | }); | |
| 460 | await waitForThread( | |
| 461 | harness.engine, | |
| 462 | (thread) => | |
| 463 | thread.session?.status === "running" && | |
| 464 | thread.session?.activeTurnId === "turn-streaming-mode", | |
| 465 | ); | |
| 466 | ||
| 467 | harness.emit({ | |
| 468 | type: "message.delta", | |
| 469 | eventId: asEventId("evt-message-delta-streaming-mode"), | |
| 470 | provider: "codex", | |
| 471 | sessionId: asSessionId("sess-1"), | |
| 472 | createdAt: now, | |
| 473 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 474 | itemId: asItemId("item-streaming-mode"), | |
| 475 | delta: "hello live", | |
| 476 | }); | |
| 477 | ||
| 478 | const liveThread = await waitForThread( | |
| 479 | harness.engine, | |
| 480 | (entry) => | |
| 481 | entry.messages.some( | |
| 482 | (message) => | |
| 483 | message.id === "assistant:item-streaming-mode" && | |
| 484 | message.streaming && | |
| 485 | message.text === "hello live", | |
| 486 | ), | |
| 487 | ); | |
| 488 | const liveMessage = liveThread.messages.find( | |
| 489 | (entry) => entry.id === "assistant:item-streaming-mode", | |
| 490 | ); | |
| 491 | expect(liveMessage?.streaming).toBe(true); | |
| 492 | ||
| 493 | harness.emit({ | |
| 494 | type: "message.completed", | |
| 495 | eventId: asEventId("evt-message-completed-streaming-mode"), | |
| 496 | provider: "codex", | |
| 497 | sessionId: asSessionId("sess-1"), | |
| 498 | createdAt: now, | |
| 499 | turnId: asProviderTurnId("turn-streaming-mode"), | |
| 500 | itemId: asItemId("item-streaming-mode"), | |
| 501 | }); | |
| 502 | ||
| 503 | const finalThread = await waitForThread( | |
| 504 | harness.engine, | |
| 505 | (entry) => | |
| 506 | entry.messages.some( | |
| 507 | (message) => message.id === "assistant:item-streaming-mode" && !message.streaming, | |
| 508 | ), | |
| 509 | ); | |
| 510 | const finalMessage = finalThread.messages.find( | |
| 511 | (entry) => entry.id === "assistant:item-streaming-mode", | |
| 512 | ); | |
| 513 | expect(finalMessage?.text).toBe("hello live"); | |
| 514 | expect(finalMessage?.streaming).toBe(false); | |
| 515 | }); | |
| 516 | ||
| 517 | it("spills oversized buffered deltas and still finalizes full assistant text", async () => { | |
| 518 | const harness = await createHarness(); | |
| 519 | const now = new Date().toISOString(); | |
| 520 | const oversizedText = "x".repeat(40_000); | |
| 521 | ||
| 522 | harness.emit({ | |
| 523 | type: "turn.started", | |
| 524 | eventId: asEventId("evt-turn-started-buffer-spill"), | |
| 525 | provider: "codex", | |
| 526 | sessionId: asSessionId("sess-1"), | |
| 527 | createdAt: now, | |
| 528 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 529 | }); | |
| 530 | await waitForThread( | |
| 531 | harness.engine, | |
| 532 | (thread) => | |
| 533 | thread.session?.status === "running" && | |
| 534 | thread.session?.activeTurnId === "turn-buffer-spill", | |
| 535 | ); | |
| 536 | ||
| 537 | harness.emit({ | |
| 538 | type: "message.delta", | |
| 539 | eventId: asEventId("evt-message-delta-buffer-spill"), | |
| 540 | provider: "codex", | |
| 541 | sessionId: asSessionId("sess-1"), | |
| 542 | createdAt: now, | |
| 543 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 544 | itemId: asItemId("item-buffer-spill"), | |
| 545 | delta: oversizedText, | |
| 546 | }); | |
| 547 | harness.emit({ | |
| 548 | type: "message.completed", | |
| 549 | eventId: asEventId("evt-message-completed-buffer-spill"), | |
| 550 | provider: "codex", | |
| 551 | sessionId: asSessionId("sess-1"), | |
| 552 | createdAt: now, | |
| 553 | turnId: asProviderTurnId("turn-buffer-spill"), | |
| 554 | itemId: asItemId("item-buffer-spill"), | |
| 555 | }); | |
| 556 | ||
| 557 | const thread = await waitForThread( | |
| 558 | harness.engine, | |
| 559 | (entry) => | |
| 560 | entry.messages.some( | |
| 561 | (message) => message.id === "assistant:item-buffer-spill" && !message.streaming, | |
| 562 | ), | |
| 563 | ); | |
| 564 | const message = thread.messages.find((entry) => entry.id === "assistant:item-buffer-spill"); | |
| 565 | expect(message?.text.length).toBe(oversizedText.length); | |
| 566 | expect(message?.text).toBe(oversizedText); | |
| 567 | expect(message?.streaming).toBe(false); | |
| 568 | }); | |
| 569 | ||
| 570 | it("does not duplicate assistant completion when message.completed is followed by turn.completed", async () => { | |
| 571 | const harness = await createHarness(); | |
| 572 | const now = new Date().toISOString(); | |
| 573 | ||
| 574 | harness.emit({ | |
| 575 | type: "turn.started", | |
| 576 | eventId: asEventId("evt-turn-started-for-complete-dedup"), | |
| 577 | provider: "codex", | |
| 578 | sessionId: asSessionId("sess-1"), | |
| 579 | createdAt: now, | |
| 580 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 581 | }); | |
| 582 | ||
| 583 | await waitForThread( | |
| 584 | harness.engine, | |
| 585 | (thread) => | |
| 586 | thread.session?.status === "running" && | |
| 587 | thread.session?.activeTurnId === "turn-complete-dedup", | |
| 588 | ); | |
| 589 | ||
| 590 | harness.emit({ | |
| 591 | type: "message.delta", | |
| 592 | eventId: asEventId("evt-message-delta-for-complete-dedup"), | |
| 593 | provider: "codex", | |
| 594 | sessionId: asSessionId("sess-1"), | |
| 595 | createdAt: now, | |
| 596 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 597 | itemId: asItemId("item-complete-dedup"), | |
| 598 | delta: "done", | |
| 599 | }); | |
| 600 | harness.emit({ | |
| 601 | type: "message.completed", | |
| 602 | eventId: asEventId("evt-message-completed-for-complete-dedup"), | |
| 603 | provider: "codex", | |
| 604 | sessionId: asSessionId("sess-1"), | |
| 605 | createdAt: now, | |
| 606 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 607 | itemId: asItemId("item-complete-dedup"), | |
| 608 | }); | |
| 609 | harness.emit({ | |
| 610 | type: "turn.completed", | |
| 611 | eventId: asEventId("evt-turn-completed-for-complete-dedup"), | |
| 612 | provider: "codex", | |
| 613 | sessionId: asSessionId("sess-1"), | |
| 614 | createdAt: now, | |
| 615 | turnId: asProviderTurnId("turn-complete-dedup"), | |
| 616 | status: "completed", | |
| 617 | }); | |
| 618 | ||
| 619 | await waitForThread( | |
| 620 | harness.engine, | |
| 621 | (thread) => | |
| 622 | thread.session?.status === "ready" && | |
| 623 | thread.session?.activeTurnId === null && | |
| 624 | thread.messages.some( | |
| 625 | (message) => message.id === "assistant:item-complete-dedup" && !message.streaming, | |
| 626 | ), | |
| 627 | ); | |
| 628 | ||
| 629 | const events = await Effect.runPromise( | |
| 630 | Stream.runCollect(harness.engine.readEvents(0)).pipe( | |
| 631 | Effect.map((chunk) => Array.from(chunk)), | |
| 632 | ), | |
| 633 | ); | |
| 634 | const completionEvents = events.filter((event) => { | |
| 635 | if (event.type !== "thread.message-sent") { | |
| 636 | return false; | |
| 637 | } | |
| 638 | return ( | |
| 639 | event.payload.messageId === "assistant:item-complete-dedup" && | |
| 640 | event.payload.streaming === false | |
| 641 | ); | |
| 642 | }); | |
| 643 | expect(completionEvents).toHaveLength(1); | |
| 644 | }); | |
| 645 | ||
| 646 | it("maps runtime.error into errored session state", async () => { | |
| 647 | const harness = await createHarness(); | |
| 648 | const now = new Date().toISOString(); | |
| 649 | ||
| 650 | harness.emit({ | |
| 651 | type: "runtime.error", | |
| 652 | eventId: asEventId("evt-runtime-error"), | |
| 653 | provider: "codex", | |
| 654 | sessionId: asSessionId("sess-1"), | |
| 655 | createdAt: now, | |
| 656 | turnId: asProviderTurnId("turn-3"), | |
| 657 | message: "runtime exploded", | |
| 658 | }); | |
| 659 | ||
| 660 | const thread = await waitForThread( | |
| 661 | harness.engine, | |
| 662 | (entry) => | |
| 663 | entry.session?.status === "error" && | |
| 664 | entry.session?.activeTurnId === "turn-3" && | |
| 665 | entry.session?.lastError === "runtime exploded", | |
| 666 | ); | |
| 667 | expect(thread.session?.status).toBe("error"); | |
| 668 | expect(thread.session?.lastError).toBe("runtime exploded"); | |
| 669 | }); | |
| 670 | ||
| 671 | it("maps session/thread lifecycle and tool.started into session/activity projections", async () => { | |
| 672 | const harness = await createHarness(); | |
| 673 | const now = new Date().toISOString(); | |
| 674 | ||
| 675 | harness.emit({ | |
| 676 | type: "session.started", | |
| 677 | eventId: asEventId("evt-session-started"), | |
| 678 | provider: "codex", | |
| 679 | sessionId: asSessionId("sess-1"), | |
| 680 | createdAt: now, | |
| 681 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 682 | message: "session started", | |
| 683 | }); | |
| 684 | harness.emit({ | |
| 685 | type: "thread.started", | |
| 686 | eventId: asEventId("evt-thread-started"), | |
| 687 | provider: "codex", | |
| 688 | sessionId: asSessionId("sess-1"), | |
| 689 | createdAt: now, | |
| 690 | threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), | |
| 691 | }); | |
| 692 | harness.emit({ | |
| 693 | type: "tool.started", | |
| 694 | eventId: asEventId("evt-tool-started"), | |
| 695 | provider: "codex", | |
| 696 | sessionId: asSessionId("sess-1"), | |
| 697 | createdAt: now, | |
| 698 | turnId: asProviderTurnId("turn-9"), | |
| 699 | toolKind: "other", | |
| 700 | title: "Read file", | |
| 701 | detail: "/tmp/file.ts", | |
| 702 | }); | |
| 703 | ||
| 704 | const thread = await waitForThread( | |
| 705 | harness.engine, | |
| 706 | (entry) => | |
| 707 | entry.session?.status === "ready" && | |
| 708 | entry.session?.activeTurnId === null && | |
| 709 | entry.activities.some((activity) => activity.kind === "tool.started"), | |
| 710 | ); | |
| 711 | ||
| 712 | expect(thread.session?.status).toBe("ready"); | |
| 713 | expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true); | |
| 714 | }); | |
| 715 | ||
| 716 | it("continues processing runtime events after a single event handler failure", async () => { | |
| 717 | const harness = await createHarness(); | |
| 718 | const now = new Date().toISOString(); | |
| 719 | ||
| 720 | harness.emit({ | |
| 721 | type: "message.delta", | |
| 722 | eventId: asEventId("evt-invalid-delta"), | |
| 723 | provider: "codex", | |
| 724 | sessionId: asSessionId("sess-1"), | |
| 725 | createdAt: now, | |
| 726 | turnId: asProviderTurnId("turn-invalid"), | |
| 727 | itemId: asItemId("item-invalid"), | |
| 728 | delta: undefined, | |
| 729 | } as unknown as ProviderRuntimeEvent); | |
| 730 | ||
| 731 | harness.emit({ | |
| 732 | type: "runtime.error", | |
| 733 | eventId: asEventId("evt-runtime-error-after-failure"), | |
| 734 | provider: "codex", | |
| 735 | sessionId: asSessionId("sess-1"), | |
| 736 | createdAt: new Date().toISOString(), | |
| 737 | turnId: asProviderTurnId("turn-after-failure"), | |
| 738 | message: "runtime still processed", | |
| 739 | }); | |
| 740 | ||
| 741 | const thread = await waitForThread( | |
| 742 | harness.engine, | |
| 743 | (entry) => | |
| 744 | entry.session?.status === "error" && | |
| 745 | entry.session?.activeTurnId === "turn-after-failure" && | |
| 746 | entry.session?.lastError === "runtime still processed", | |
| 747 | ); | |
| 748 | expect(thread.session?.status).toBe("error"); | |
| 749 | expect(thread.session?.lastError).toBe("runtime still processed"); | |
| 750 | }); | |
| 751 | }); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts created+639| ... | ... | @@ -0,0 +1,639 @@ |
| 1 | import { | |
| 2 | type AssistantDeliveryMode, | |
| 3 | CommandId, | |
| 4 | MessageId, | |
| 5 | type OrchestrationEvent, | |
| 6 | type ProviderApprovalPolicy, | |
| 7 | type ProviderSandboxMode, | |
| 8 | ProviderThreadId, | |
| 9 | type ThreadId, | |
| 10 | TurnId, | |
| 11 | type OrchestrationThreadActivity, | |
| 12 | type ProviderRuntimeEvent, | |
| 13 | type ProviderSessionId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; | |
| 16 | ||
| 17 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 18 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 19 | import { | |
| 20 | ProviderRuntimeIngestionService, | |
| 21 | type ProviderRuntimeIngestionShape, | |
| 22 | } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 23 | ||
| 24 | const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; | |
| 25 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => | |
| 26 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); | |
| 27 | ||
| 28 | const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; | |
| 29 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "on-request"; | |
| 30 | const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; | |
| 31 | const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; | |
| 32 | const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); | |
| 33 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; | |
| 34 | const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120); | |
| 35 | const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; | |
| 36 | const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; | |
| 37 | ||
| 38 | type TurnStartRequestedDomainEvent = Extract< | |
| 39 | OrchestrationEvent, | |
| 40 | { type: "thread.turn-start-requested" } | |
| 41 | >; | |
| 42 | ||
| 43 | type RuntimeIngestionInput = | |
| 44 | | { | |
| 45 | source: "runtime"; | |
| 46 | event: ProviderRuntimeEvent; | |
| 47 | } | |
| 48 | | { | |
| 49 | source: "domain"; | |
| 50 | event: TurnStartRequestedDomainEvent; | |
| 51 | }; | |
| 52 | ||
| 53 | function toTurnId(value: string | undefined): TurnId | undefined { | |
| 54 | return value === undefined ? undefined : TurnId.makeUnsafe(value); | |
| 55 | } | |
| 56 | ||
| 57 | function toProviderThreadId(value: string | undefined): ProviderThreadId | null { | |
| 58 | return value === undefined ? null : ProviderThreadId.makeUnsafe(value); | |
| 59 | } | |
| 60 | ||
| 61 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { | |
| 62 | if (left === null || left === undefined || right === null || right === undefined) { | |
| 63 | return false; | |
| 64 | } | |
| 65 | return left === right; | |
| 66 | } | |
| 67 | ||
| 68 | function truncateDetail(value: string, limit = 180): string { | |
| 69 | return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; | |
| 70 | } | |
| 71 | ||
| 72 | function runtimeEventToActivities( | |
| 73 | event: ProviderRuntimeEvent, | |
| 74 | ): ReadonlyArray<OrchestrationThreadActivity> { | |
| 75 | switch (event.type) { | |
| 76 | case "approval.requested": { | |
| 77 | return [ | |
| 78 | { | |
| 79 | id: event.eventId, | |
| 80 | createdAt: event.createdAt, | |
| 81 | tone: "approval", | |
| 82 | kind: "approval.requested", | |
| 83 | summary: | |
| 84 | event.requestKind === "command" | |
| 85 | ? "Command approval requested" | |
| 86 | : "File-change approval requested", | |
| 87 | payload: { | |
| 88 | requestId: event.requestId, | |
| 89 | requestKind: event.requestKind, | |
| 90 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 91 | }, | |
| 92 | turnId: toTurnId(event.turnId) ?? null, | |
| 93 | }, | |
| 94 | ]; | |
| 95 | } | |
| 96 | ||
| 97 | case "approval.resolved": { | |
| 98 | return [ | |
| 99 | { | |
| 100 | id: event.eventId, | |
| 101 | createdAt: event.createdAt, | |
| 102 | tone: "approval", | |
| 103 | kind: "approval.resolved", | |
| 104 | summary: "Approval resolved", | |
| 105 | payload: { | |
| 106 | requestId: event.requestId, | |
| 107 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | |
| 108 | ...(event.decision ? { decision: event.decision } : {}), | |
| 109 | }, | |
| 110 | turnId: toTurnId(event.turnId) ?? null, | |
| 111 | }, | |
| 112 | ]; | |
| 113 | } | |
| 114 | ||
| 115 | case "runtime.error": { | |
| 116 | return [ | |
| 117 | { | |
| 118 | id: event.eventId, | |
| 119 | createdAt: event.createdAt, | |
| 120 | tone: "error", | |
| 121 | kind: "runtime.error", | |
| 122 | summary: "Runtime error", | |
| 123 | payload: { | |
| 124 | message: truncateDetail(event.message), | |
| 125 | }, | |
| 126 | turnId: toTurnId(event.turnId) ?? null, | |
| 127 | }, | |
| 128 | ]; | |
| 129 | } | |
| 130 | ||
| 131 | case "tool.completed": { | |
| 132 | return [ | |
| 133 | { | |
| 134 | id: event.eventId, | |
| 135 | createdAt: event.createdAt, | |
| 136 | tone: "tool", | |
| 137 | kind: "tool.completed", | |
| 138 | summary: `${event.title} complete`, | |
| 139 | payload: { | |
| 140 | toolKind: event.toolKind, | |
| 141 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 142 | }, | |
| 143 | turnId: toTurnId(event.turnId) ?? null, | |
| 144 | }, | |
| 145 | ]; | |
| 146 | } | |
| 147 | ||
| 148 | case "tool.started": { | |
| 149 | return [ | |
| 150 | { | |
| 151 | id: event.eventId, | |
| 152 | createdAt: event.createdAt, | |
| 153 | tone: "tool", | |
| 154 | kind: "tool.started", | |
| 155 | summary: `${event.title} started`, | |
| 156 | payload: { | |
| 157 | toolKind: event.toolKind, | |
| 158 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 159 | }, | |
| 160 | turnId: toTurnId(event.turnId) ?? null, | |
| 161 | }, | |
| 162 | ]; | |
| 163 | } | |
| 164 | ||
| 165 | default: | |
| 166 | return []; | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | const make = Effect.gen(function* () { | |
| 171 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 172 | const providerService = yield* ProviderService; | |
| 173 | ||
| 174 | const assistantDeliveryModeRef = yield* Ref.make<AssistantDeliveryMode>( | |
| 175 | DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 176 | ); | |
| 177 | ||
| 178 | const turnMessageIdsByTurnKey = yield* Cache.make<string, Set<MessageId>>({ | |
| 179 | capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, | |
| 180 | timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL, | |
| 181 | lookup: () => Effect.succeed(new Set<MessageId>()), | |
| 182 | }); | |
| 183 | ||
| 184 | const bufferedAssistantTextByMessageId = yield* Cache.make<MessageId, string>({ | |
| 185 | capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, | |
| 186 | timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, | |
| 187 | lookup: () => Effect.succeed(""), | |
| 188 | }); | |
| 189 | ||
| 190 | const rememberAssistantMessageId = ( | |
| 191 | sessionId: ProviderSessionId, | |
| 192 | turnId: TurnId, | |
| 193 | messageId: MessageId, | |
| 194 | ) => | |
| 195 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( | |
| 196 | Effect.flatMap((existingIds) => | |
| 197 | Cache.set( | |
| 198 | turnMessageIdsByTurnKey, | |
| 199 | providerTurnKey(sessionId, turnId), | |
| 200 | Option.match(existingIds, { | |
| 201 | onNone: () => new Set([messageId]), | |
| 202 | onSome: (ids) => { | |
| 203 | const nextIds = new Set(ids); | |
| 204 | nextIds.add(messageId); | |
| 205 | return nextIds; | |
| 206 | }, | |
| 207 | }), | |
| 208 | ), | |
| 209 | ), | |
| 210 | ); | |
| 211 | ||
| 212 | const forgetAssistantMessageId = ( | |
| 213 | sessionId: ProviderSessionId, | |
| 214 | turnId: TurnId, | |
| 215 | messageId: MessageId, | |
| 216 | ) => | |
| 217 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( | |
| 218 | Effect.flatMap((existingIds) => | |
| 219 | Option.match(existingIds, { | |
| 220 | onNone: () => Effect.void, | |
| 221 | onSome: (ids) => { | |
| 222 | const nextIds = new Set(ids); | |
| 223 | nextIds.delete(messageId); | |
| 224 | if (nextIds.size === 0) { | |
| 225 | return Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); | |
| 226 | } | |
| 227 | return Cache.set(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId), nextIds); | |
| 228 | }, | |
| 229 | }), | |
| 230 | ), | |
| 231 | ); | |
| 232 | ||
| 233 | const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => | |
| 234 | Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)).pipe( | |
| 235 | Effect.map((existingIds) => | |
| 236 | Option.getOrElse(existingIds, (): Set<MessageId> => new Set<MessageId>()), | |
| 237 | ), | |
| 238 | ); | |
| 239 | ||
| 240 | const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => | |
| 241 | Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(sessionId, turnId)); | |
| 242 | ||
| 243 | const appendBufferedAssistantText = (messageId: MessageId, delta: string) => | |
| 244 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( | |
| 245 | Effect.flatMap((existingText) => | |
| 246 | Effect.gen(function* () { | |
| 247 | const nextText = Option.match(existingText, { | |
| 248 | onNone: () => delta, | |
| 249 | onSome: (text) => `${text}${delta}`, | |
| 250 | }); | |
| 251 | if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { | |
| 252 | yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); | |
| 253 | return ""; | |
| 254 | } | |
| 255 | ||
| 256 | // Safety valve: flush full buffered text as an assistant delta to cap memory. | |
| 257 | yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); | |
| 258 | return nextText; | |
| 259 | }), | |
| 260 | ), | |
| 261 | ); | |
| 262 | ||
| 263 | const takeBufferedAssistantText = (messageId: MessageId) => | |
| 264 | Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( | |
| 265 | Effect.flatMap((existingText) => | |
| 266 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( | |
| 267 | Effect.as(Option.getOrElse(existingText, () => "")), | |
| 268 | ), | |
| 269 | ), | |
| 270 | ); | |
| 271 | ||
| 272 | const clearBufferedAssistantText = (messageId: MessageId) => | |
| 273 | Cache.invalidate(bufferedAssistantTextByMessageId, messageId); | |
| 274 | ||
| 275 | const clearAssistantMessageState = (messageId: MessageId) => | |
| 276 | clearBufferedAssistantText(messageId); | |
| 277 | ||
| 278 | const finalizeAssistantMessage = (input: { | |
| 279 | event: ProviderRuntimeEvent; | |
| 280 | threadId: ThreadId; | |
| 281 | messageId: MessageId; | |
| 282 | turnId?: TurnId; | |
| 283 | createdAt: string; | |
| 284 | commandTag: string; | |
| 285 | finalDeltaCommandTag: string; | |
| 286 | }) => | |
| 287 | Effect.gen(function* () { | |
| 288 | const text = yield* takeBufferedAssistantText(input.messageId); | |
| 289 | ||
| 290 | if (text.length > 0) { | |
| 291 | yield* orchestrationEngine.dispatch({ | |
| 292 | type: "thread.message.assistant.delta", | |
| 293 | commandId: providerCommandId(input.event, input.finalDeltaCommandTag), | |
| 294 | threadId: input.threadId, | |
| 295 | messageId: input.messageId, | |
| 296 | delta: text, | |
| 297 | ...(input.turnId ? { turnId: input.turnId } : {}), | |
| 298 | createdAt: input.createdAt, | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | yield* orchestrationEngine.dispatch({ | |
| 303 | type: "thread.message.assistant.complete", | |
| 304 | commandId: providerCommandId(input.event, input.commandTag), | |
| 305 | threadId: input.threadId, | |
| 306 | messageId: input.messageId, | |
| 307 | ...(input.turnId ? { turnId: input.turnId } : {}), | |
| 308 | createdAt: input.createdAt, | |
| 309 | }); | |
| 310 | yield* clearAssistantMessageState(input.messageId); | |
| 311 | }); | |
| 312 | ||
| 313 | const clearTurnStateForSession = (sessionId: ProviderSessionId) => | |
| 314 | Effect.gen(function* () { | |
| 315 | const prefix = `${sessionId}:`; | |
| 316 | const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); | |
| 317 | yield* Effect.forEach( | |
| 318 | turnKeys, | |
| 319 | (key) => | |
| 320 | Effect.gen(function* () { | |
| 321 | if (!key.startsWith(prefix)) { | |
| 322 | return; | |
| 323 | } | |
| 324 | ||
| 325 | const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); | |
| 326 | if (Option.isSome(messageIds)) { | |
| 327 | yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { | |
| 328 | concurrency: 1, | |
| 329 | }).pipe(Effect.asVoid); | |
| 330 | } | |
| 331 | ||
| 332 | yield* Cache.invalidate(turnMessageIdsByTurnKey, key); | |
| 333 | }), | |
| 334 | { concurrency: 1 }, | |
| 335 | ).pipe(Effect.asVoid); | |
| 336 | }); | |
| 337 | ||
| 338 | const processRuntimeEvent = (event: ProviderRuntimeEvent) => | |
| 339 | Effect.gen(function* () { | |
| 340 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 341 | const thread = readModel.threads.find( | |
| 342 | (entry) => entry.session?.providerSessionId === event.sessionId, | |
| 343 | ); | |
| 344 | if (!thread) return; | |
| 345 | ||
| 346 | const now = event.createdAt; | |
| 347 | const sessionProviderThreadId = thread.session?.providerThreadId ?? null; | |
| 348 | const eventProviderThreadId = toProviderThreadId(event.threadId); | |
| 349 | const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); | |
| 350 | const activeTurnId = thread.session?.activeTurnId ?? null; | |
| 351 | ||
| 352 | const matchesThreadScope = | |
| 353 | eventProviderThreadId === null || | |
| 354 | sessionProviderThreadId === null || | |
| 355 | sameId(eventProviderThreadId, sessionProviderThreadId); | |
| 356 | const conflictsWithActiveTurn = | |
| 357 | activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); | |
| 358 | const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; | |
| 359 | ||
| 360 | const shouldApplyThreadLifecycle = (() => { | |
| 361 | if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { | |
| 362 | return true; | |
| 363 | } | |
| 364 | switch (event.type) { | |
| 365 | case "session.exited": | |
| 366 | return true; | |
| 367 | case "session.started": | |
| 368 | case "thread.started": | |
| 369 | if (!matchesThreadScope) { | |
| 370 | return false; | |
| 371 | } | |
| 372 | // Never let auxiliary/provider-side spawned threads replace the primary thread binding. | |
| 373 | if ( | |
| 374 | eventProviderThreadId !== null && | |
| 375 | sessionProviderThreadId !== null && | |
| 376 | !sameId(eventProviderThreadId, sessionProviderThreadId) | |
| 377 | ) { | |
| 378 | return false; | |
| 379 | } | |
| 380 | return true; | |
| 381 | case "turn.started": | |
| 382 | if (!matchesThreadScope) { | |
| 383 | return false; | |
| 384 | } | |
| 385 | return !conflictsWithActiveTurn; | |
| 386 | case "turn.completed": | |
| 387 | if (!matchesThreadScope) { | |
| 388 | return false; | |
| 389 | } | |
| 390 | if (conflictsWithActiveTurn || missingTurnForActiveTurn) { | |
| 391 | return false; | |
| 392 | } | |
| 393 | // Only the active turn may close the lifecycle state. | |
| 394 | if (activeTurnId !== null && eventTurnId !== undefined) { | |
| 395 | return sameId(activeTurnId, eventTurnId); | |
| 396 | } | |
| 397 | // Without an active turn, only accept completion when no thread mismatch signal exists. | |
| 398 | return eventProviderThreadId === null || sessionProviderThreadId === null; | |
| 399 | default: | |
| 400 | return true; | |
| 401 | } | |
| 402 | })(); | |
| 403 | ||
| 404 | if ( | |
| 405 | event.type === "session.started" || | |
| 406 | event.type === "session.exited" || | |
| 407 | event.type === "thread.started" || | |
| 408 | event.type === "turn.started" || | |
| 409 | event.type === "turn.completed" | |
| 410 | ) { | |
| 411 | const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) : null; | |
| 412 | const providerThreadIdFromEvent = | |
| 413 | event.type === "thread.started" | |
| 414 | ? ProviderThreadId.makeUnsafe(event.threadId) | |
| 415 | : event.threadId !== undefined | |
| 416 | ? ProviderThreadId.makeUnsafe(event.threadId) | |
| 417 | : null; | |
| 418 | const providerThreadId = | |
| 419 | providerThreadIdFromEvent ?? sessionProviderThreadId ?? null; | |
| 420 | const status = | |
| 421 | event.type === "turn.started" | |
| 422 | ? "running" | |
| 423 | : event.type === "session.exited" | |
| 424 | ? "stopped" | |
| 425 | : event.type === "turn.completed" && event.status === "failed" | |
| 426 | ? "error" | |
| 427 | : "ready"; | |
| 428 | const lastError = | |
| 429 | event.type === "turn.completed" && event.status === "failed" | |
| 430 | ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") | |
| 431 | : status === "ready" | |
| 432 | ? null | |
| 433 | : (thread.session?.lastError ?? null); | |
| 434 | ||
| 435 | if (shouldApplyThreadLifecycle) { | |
| 436 | yield* orchestrationEngine.dispatch({ | |
| 437 | type: "thread.session.set", | |
| 438 | commandId: providerCommandId(event, "thread-session-set"), | |
| 439 | threadId: thread.id, | |
| 440 | session: { | |
| 441 | threadId: thread.id, | |
| 442 | status, | |
| 443 | providerName: event.provider, | |
| 444 | providerSessionId: event.sessionId, | |
| 445 | providerThreadId, | |
| 446 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 447 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 448 | activeTurnId: nextActiveTurnId, | |
| 449 | lastError, | |
| 450 | updatedAt: now, | |
| 451 | }, | |
| 452 | createdAt: now, | |
| 453 | }); | |
| 454 | } | |
| 455 | } | |
| 456 | ||
| 457 | if (event.type === "message.delta" && event.delta.length > 0) { | |
| 458 | const assistantMessageId = MessageId.makeUnsafe( | |
| 459 | `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, | |
| 460 | ); | |
| 461 | const turnId = toTurnId(event.turnId); | |
| 462 | if (turnId) { | |
| 463 | yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | |
| 464 | } | |
| 465 | ||
| 466 | const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); | |
| 467 | if (assistantDeliveryMode === "buffered") { | |
| 468 | const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta); | |
| 469 | if (spillChunk.length > 0) { | |
| 470 | yield* orchestrationEngine.dispatch({ | |
| 471 | type: "thread.message.assistant.delta", | |
| 472 | commandId: providerCommandId(event, "assistant-delta-buffer-spill"), | |
| 473 | threadId: thread.id, | |
| 474 | messageId: assistantMessageId, | |
| 475 | delta: spillChunk, | |
| 476 | ...(turnId ? { turnId } : {}), | |
| 477 | createdAt: now, | |
| 478 | }); | |
| 479 | } | |
| 480 | } else { | |
| 481 | yield* orchestrationEngine.dispatch({ | |
| 482 | type: "thread.message.assistant.delta", | |
| 483 | commandId: providerCommandId(event, "assistant-delta"), | |
| 484 | threadId: thread.id, | |
| 485 | messageId: assistantMessageId, | |
| 486 | delta: event.delta, | |
| 487 | ...(turnId ? { turnId } : {}), | |
| 488 | createdAt: now, | |
| 489 | }); | |
| 490 | } | |
| 491 | } | |
| 492 | ||
| 493 | if (event.type === "message.completed") { | |
| 494 | const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); | |
| 495 | const turnId = toTurnId(event.turnId); | |
| 496 | if (turnId) { | |
| 497 | yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | |
| 498 | } | |
| 499 | ||
| 500 | yield* finalizeAssistantMessage({ | |
| 501 | event, | |
| 502 | threadId: thread.id, | |
| 503 | messageId: assistantMessageId, | |
| 504 | ...(turnId ? { turnId } : {}), | |
| 505 | createdAt: now, | |
| 506 | commandTag: "assistant-complete", | |
| 507 | finalDeltaCommandTag: "assistant-delta-finalize", | |
| 508 | }); | |
| 509 | ||
| 510 | if (turnId) { | |
| 511 | yield* forgetAssistantMessageId(event.sessionId, turnId, assistantMessageId); | |
| 512 | } | |
| 513 | } | |
| 514 | ||
| 515 | if (event.type === "turn.completed") { | |
| 516 | const turnId = toTurnId(event.turnId); | |
| 517 | if (turnId) { | |
| 518 | const assistantMessageIds = yield* getAssistantMessageIdsForTurn(event.sessionId, turnId); | |
| 519 | yield* Effect.forEach( | |
| 520 | assistantMessageIds, | |
| 521 | (assistantMessageId) => | |
| 522 | finalizeAssistantMessage({ | |
| 523 | event, | |
| 524 | threadId: thread.id, | |
| 525 | messageId: assistantMessageId, | |
| 526 | turnId, | |
| 527 | createdAt: now, | |
| 528 | commandTag: "assistant-complete-finalize", | |
| 529 | finalDeltaCommandTag: "assistant-delta-finalize-fallback", | |
| 530 | }), | |
| 531 | { concurrency: 1 }, | |
| 532 | ).pipe(Effect.asVoid); | |
| 533 | yield* clearAssistantMessageIdsForTurn(event.sessionId, turnId); | |
| 534 | } | |
| 535 | } | |
| 536 | ||
| 537 | if (event.type === "session.exited") { | |
| 538 | yield* clearTurnStateForSession(event.sessionId); | |
| 539 | } | |
| 540 | ||
| 541 | if (event.type === "runtime.error") { | |
| 542 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD | |
| 543 | ? true | |
| 544 | : matchesThreadScope && | |
| 545 | (activeTurnId === null || | |
| 546 | eventTurnId === undefined || | |
| 547 | sameId(activeTurnId, eventTurnId)); | |
| 548 | ||
| 549 | const providerThreadId = | |
| 550 | event.threadId !== undefined | |
| 551 | ? ProviderThreadId.makeUnsafe(event.threadId) | |
| 552 | : (thread.session?.providerThreadId ?? null); | |
| 553 | ||
| 554 | if (shouldApplyRuntimeError) { | |
| 555 | yield* orchestrationEngine.dispatch({ | |
| 556 | type: "thread.session.set", | |
| 557 | commandId: providerCommandId(event, "runtime-error-session-set"), | |
| 558 | threadId: thread.id, | |
| 559 | session: { | |
| 560 | threadId: thread.id, | |
| 561 | status: "error", | |
| 562 | providerName: event.provider, | |
| 563 | providerSessionId: event.sessionId, | |
| 564 | providerThreadId, | |
| 565 | approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, | |
| 566 | sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, | |
| 567 | activeTurnId: eventTurnId ?? null, | |
| 568 | lastError: event.message, | |
| 569 | updatedAt: now, | |
| 570 | }, | |
| 571 | createdAt: now, | |
| 572 | }); | |
| 573 | } | |
| 574 | } | |
| 575 | ||
| 576 | const activities = runtimeEventToActivities(event); | |
| 577 | yield* Effect.forEach(activities, (activity) => | |
| 578 | orchestrationEngine.dispatch({ | |
| 579 | type: "thread.activity.append", | |
| 580 | commandId: providerCommandId(event, "thread-activity-append"), | |
| 581 | threadId: thread.id, | |
| 582 | activity, | |
| 583 | createdAt: activity.createdAt, | |
| 584 | }), | |
| 585 | ).pipe(Effect.asVoid); | |
| 586 | }); | |
| 587 | ||
| 588 | const processDomainEvent = (event: TurnStartRequestedDomainEvent) => | |
| 589 | Ref.set( | |
| 590 | assistantDeliveryModeRef, | |
| 591 | event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 592 | ); | |
| 593 | ||
| 594 | const processInput = (input: RuntimeIngestionInput) => | |
| 595 | input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); | |
| 596 | ||
| 597 | const processInputSafely = (input: RuntimeIngestionInput) => | |
| 598 | processInput(input).pipe( | |
| 599 | Effect.catchCause((cause) => { | |
| 600 | if (Cause.hasInterruptsOnly(cause)) { | |
| 601 | return Effect.failCause(cause); | |
| 602 | } | |
| 603 | return Effect.logWarning("provider runtime ingestion failed to process event", { | |
| 604 | source: input.source, | |
| 605 | eventId: input.event.eventId, | |
| 606 | eventType: input.event.type, | |
| 607 | cause: Cause.pretty(cause), | |
| 608 | }); | |
| 609 | }), | |
| 610 | ); | |
| 611 | ||
| 612 | const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () { | |
| 613 | const inputQueue = yield* Queue.unbounded<RuntimeIngestionInput>(); | |
| 614 | yield* Effect.addFinalizer(() => Queue.shutdown(inputQueue).pipe(Effect.asVoid)); | |
| 615 | ||
| 616 | yield* Effect.forkScoped( | |
| 617 | Effect.forever(Queue.take(inputQueue).pipe(Effect.flatMap(processInputSafely))), | |
| 618 | ); | |
| 619 | yield* Effect.forkScoped( | |
| 620 | Stream.runForEach(providerService.streamEvents, (event) => | |
| 621 | Queue.offer(inputQueue, { source: "runtime", event }).pipe(Effect.asVoid), | |
| 622 | ), | |
| 623 | ); | |
| 624 | yield* Effect.forkScoped( | |
| 625 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 626 | if (event.type !== "thread.turn-start-requested") { | |
| 627 | return Effect.void; | |
| 628 | } | |
| 629 | return Queue.offer(inputQueue, { source: "domain", event }).pipe(Effect.asVoid); | |
| 630 | }), | |
| 631 | ); | |
| 632 | }); | |
| 633 | ||
| 634 | return { | |
| 635 | start, | |
| 636 | } satisfies ProviderRuntimeIngestionShape; | |
| 637 | }); | |
| 638 | ||
| 639 | export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make); |
apps/server/src/orchestration/Schemas.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { | |
| 2 | ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, | |
| 3 | ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, | |
| 4 | ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, | |
| 5 | ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, | |
| 6 | ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, | |
| 7 | ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, | |
| 8 | ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, | |
| 9 | ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, | |
| 10 | ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, | |
| 11 | ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, | |
| 12 | ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema, | |
| 13 | ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema, | |
| 14 | ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema, | |
| 15 | ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, | |
| 16 | ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema, | |
| 17 | ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | ||
| 20 | // Server-internal alias surface, backed by contract schemas as the source of truth. | |
| 21 | export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; | |
| 22 | export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; | |
| 23 | export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; | |
| 24 | ||
| 25 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; | |
| 26 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; | |
| 27 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; | |
| 28 | ||
| 29 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | |
| 30 | export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; | |
| 31 | export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; | |
| 32 | export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; | |
| 33 | export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; | |
| 34 | ||
| 35 | export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; | |
| 36 | export const ThreadTurnInterruptRequestedPayload = | |
| 37 | ContractsThreadTurnInterruptRequestedPayloadSchema; | |
| 38 | export const ThreadApprovalResponseRequestedPayload = | |
| 39 | ContractsThreadApprovalResponseRequestedPayloadSchema; | |
| 40 | export const ThreadCheckpointRevertRequestedPayload = | |
| 41 | ContractsThreadCheckpointRevertRequestedPayloadSchema; | |
| 42 | export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopRequestedPayloadSchema; |
apps/server/src/orchestration/Services/CheckpointReactor.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | /** | |
| 2 | * CheckpointReactor - Checkpoint reaction service interface. | |
| 3 | * | |
| 4 | * Owns background workers that react to orchestration checkpoint lifecycle | |
| 5 | * events and apply checkpoint side effects. | |
| 6 | * | |
| 7 | * @module CheckpointReactor | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect, Scope } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * CheckpointReactorShape - Service API for checkpoint reactor lifecycle. | |
| 14 | */ | |
| 15 | export interface CheckpointReactorShape { | |
| 16 | /** | |
| 17 | * Start the checkpoint reactor. | |
| 18 | * | |
| 19 | * The returned effect must be run in a scope so all worker fibers can be | |
| 20 | * finalized on shutdown. | |
| 21 | * | |
| 22 | * Consumes both orchestration-domain and provider-runtime events via an | |
| 23 | * internal queue. | |
| 24 | */ | |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * CheckpointReactor - Service tag for checkpoint reactor workers. | |
| 30 | */ | |
| 31 | export class CheckpointReactor extends ServiceMap.Service< | |
| 32 | CheckpointReactor, | |
| 33 | CheckpointReactorShape | |
| 34 | >()("t3/orchestration/Services/CheckpointReactor") {} |
apps/server/src/orchestration/Services/OrchestrationEngine.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | /** | |
| 2 | * OrchestrationEngineService - Service interface for orchestration command handling. | |
| 3 | * | |
| 4 | * Owns command validation/dispatch and in-memory read-model updates backed by | |
| 5 | * `OrchestrationEventStore` persistence. It does not own provider process | |
| 6 | * management or transport concerns (e.g. websocket request parsing). | |
| 7 | * | |
| 8 | * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, | |
| 9 | * replay, and unknown-input decoding all return typed domain errors. | |
| 10 | * | |
| 11 | * @module OrchestrationEngineService | |
| 12 | */ | |
| 13 | import type { | |
| 14 | OrchestrationCommand, | |
| 15 | OrchestrationEvent, | |
| 16 | OrchestrationReadModel, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { ServiceMap } from "effect"; | |
| 19 | import type { Effect, Stream } from "effect"; | |
| 20 | ||
| 21 | import type { OrchestrationDispatchError } from "../Errors.ts"; | |
| 22 | import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; | |
| 23 | ||
| 24 | /** | |
| 25 | * OrchestrationEngineShape - Service API for orchestration command and event flow. | |
| 26 | */ | |
| 27 | export interface OrchestrationEngineShape { | |
| 28 | /** | |
| 29 | * Read the current in-memory orchestration read model. | |
| 30 | * | |
| 31 | * @returns Effect containing the latest read model. | |
| 32 | */ | |
| 33 | readonly getReadModel: () => Effect.Effect<OrchestrationReadModel, never, never>; | |
| 34 | ||
| 35 | /** | |
| 36 | * Replay persisted orchestration events from an exclusive sequence cursor. | |
| 37 | * | |
| 38 | * @param fromSequenceExclusive - Sequence cursor (exclusive). | |
| 39 | * @returns Stream containing ordered events. | |
| 40 | */ | |
| 41 | readonly readEvents: ( | |
| 42 | fromSequenceExclusive: number, | |
| 43 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>; | |
| 44 | ||
| 45 | /** | |
| 46 | * Dispatch a validated orchestration command. | |
| 47 | * | |
| 48 | * @param command - Valid orchestration command. | |
| 49 | * @returns Effect containing the sequence of the persisted event. | |
| 50 | * | |
| 51 | * Dispatch is serialized through an internal queue and deduplicated via | |
| 52 | * command receipts. | |
| 53 | */ | |
| 54 | readonly dispatch: ( | |
| 55 | command: OrchestrationCommand, | |
| 56 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; | |
| 57 | ||
| 58 | /** | |
| 59 | * Stream persisted domain events in dispatch order. | |
| 60 | * | |
| 61 | * This is a hot runtime stream (new events only), not a historical replay. | |
| 62 | */ | |
| 63 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * OrchestrationEngineService - Service tag for orchestration engine access. | |
| 68 | * | |
| 69 | * @example | |
| 70 | * ```ts | |
| 71 | * const program = Effect.gen(function* () { | |
| 72 | * const engine = yield* OrchestrationEngineService | |
| 73 | * return yield* engine.getReadModel() | |
| 74 | * }) | |
| 75 | * ``` | |
| 76 | */ | |
| 77 | export class OrchestrationEngineService extends ServiceMap.Service< | |
| 78 | OrchestrationEngineService, | |
| 79 | OrchestrationEngineShape | |
| 80 | >()("t3/orchestration/Services/OrchestrationEngine/OrchestrationEngineService") {} |
apps/server/src/orchestration/Services/OrchestrationReactor.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | /** | |
| 2 | * OrchestrationReactor - Composite orchestration reactor service interface. | |
| 3 | * | |
| 4 | * Coordinates startup of orchestration runtime reactors that translate domain | |
| 5 | * events into downstream side effects. | |
| 6 | * | |
| 7 | * @module OrchestrationReactor | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect, Scope } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * OrchestrationReactorShape - Service API for orchestration reactor lifecycle. | |
| 14 | */ | |
| 15 | export interface OrchestrationReactorShape { | |
| 16 | /** | |
| 17 | * Start orchestration-side reactors for provider/runtime/checkpoint flows. | |
| 18 | * | |
| 19 | * The returned effect must be run in a scope so all worker fibers can be | |
| 20 | * finalized on shutdown. | |
| 21 | */ | |
| 22 | readonly start: Effect.Effect<void, never, Scope.Scope>; | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * OrchestrationReactor - Service tag for orchestration reactor coordination. | |
| 27 | */ | |
| 28 | export class OrchestrationReactor extends ServiceMap.Service< | |
| 29 | OrchestrationReactor, | |
| 30 | OrchestrationReactorShape | |
| 31 | >()("t3/orchestration/Services/OrchestrationReactor") {} |
apps/server/src/orchestration/Services/ProjectionPipeline.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | /** | |
| 2 | * OrchestrationProjectionPipeline - Event projection pipeline service interface. | |
| 3 | * | |
| 4 | * Coordinates projection bootstrap/replay and per-event projection updates for | |
| 5 | * orchestration read models. | |
| 6 | * | |
| 7 | * @module OrchestrationProjectionPipeline | |
| 8 | */ | |
| 9 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 10 | import { ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 14 | ||
| 15 | /** | |
| 16 | * OrchestrationProjectionPipelineShape - Service API for projection execution. | |
| 17 | */ | |
| 18 | export interface OrchestrationProjectionPipelineShape { | |
| 19 | /** | |
| 20 | * Bootstrap projections by replaying persisted events. | |
| 21 | * | |
| 22 | * Resumes each projector from its stored projection-state cursor. | |
| 23 | */ | |
| 24 | readonly bootstrap: Effect.Effect<void, ProjectionRepositoryError>; | |
| 25 | ||
| 26 | /** | |
| 27 | * Project a single orchestration event into projection repositories. | |
| 28 | * | |
| 29 | * Projectors are executed sequentially to preserve deterministic ordering. | |
| 30 | */ | |
| 31 | readonly projectEvent: ( | |
| 32 | event: OrchestrationEvent, | |
| 33 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * OrchestrationProjectionPipeline - Service tag for orchestration projections. | |
| 38 | */ | |
| 39 | export class OrchestrationProjectionPipeline extends ServiceMap.Service< | |
| 40 | OrchestrationProjectionPipeline, | |
| 41 | OrchestrationProjectionPipelineShape | |
| 42 | >()("t3/orchestration/Services/ProjectionPipeline/OrchestrationProjectionPipeline") {} |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | /** | |
| 2 | * ProjectionSnapshotQuery - Read-model snapshot query service interface. | |
| 3 | * | |
| 4 | * Exposes the current orchestration projection snapshot for read-only API | |
| 5 | * access. | |
| 6 | * | |
| 7 | * @module ProjectionSnapshotQuery | |
| 8 | */ | |
| 9 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | |
| 10 | import { ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 14 | ||
| 15 | /** | |
| 16 | * ProjectionSnapshotQueryShape - Service API for read-model snapshots. | |
| 17 | */ | |
| 18 | export interface ProjectionSnapshotQueryShape { | |
| 19 | /** | |
| 20 | * Read the latest orchestration projection snapshot. | |
| 21 | * | |
| 22 | * Rehydrates from projection tables and derives snapshot sequence from | |
| 23 | * projector cursor state. | |
| 24 | */ | |
| 25 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * ProjectionSnapshotQuery - Service tag for projection snapshot queries. | |
| 30 | */ | |
| 31 | export class ProjectionSnapshotQuery extends ServiceMap.Service< | |
| 32 | ProjectionSnapshotQuery, | |
| 33 | ProjectionSnapshotQueryShape | |
| 34 | >()("t3/orchestration/Services/ProjectionSnapshotQuery") {} |
apps/server/src/orchestration/Services/ProviderCommandReactor.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | /** | |
| 2 | * ProviderCommandReactor - Provider command reaction service interface. | |
| 3 | * | |
| 4 | * Owns background workers that react to orchestration intent events and | |
| 5 | * dispatch provider-side command execution. | |
| 6 | * | |
| 7 | * @module ProviderCommandReactor | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect, Scope } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * ProviderCommandReactorShape - Service API for provider command reactors. | |
| 14 | */ | |
| 15 | export interface ProviderCommandReactorShape { | |
| 16 | /** | |
| 17 | * Start reacting to provider-intent orchestration domain events. | |
| 18 | * | |
| 19 | * The returned effect must be run in a scope so all worker fibers can be | |
| 20 | * finalized on shutdown. | |
| 21 | * | |
| 22 | * Filters orchestration domain events to provider-intent types before | |
| 23 | * processing. | |
| 24 | */ | |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * ProviderCommandReactor - Service tag for provider command reaction workers. | |
| 30 | */ | |
| 31 | export class ProviderCommandReactor extends ServiceMap.Service< | |
| 32 | ProviderCommandReactor, | |
| 33 | ProviderCommandReactorShape | |
| 34 | >()("t3/orchestration/Services/ProviderCommandReactor") {} |
apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | /** | |
| 2 | * ProviderRuntimeIngestionService - Provider runtime ingestion service interface. | |
| 3 | * | |
| 4 | * Owns background workers that consume provider runtime streams and emit | |
| 5 | * orchestration commands/events. | |
| 6 | * | |
| 7 | * @module ProviderRuntimeIngestionService | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect, Scope } from "effect"; | |
| 11 | ||
| 12 | /** | |
| 13 | * ProviderRuntimeIngestionShape - Service API for runtime ingestion lifecycle. | |
| 14 | */ | |
| 15 | export interface ProviderRuntimeIngestionShape { | |
| 16 | /** | |
| 17 | * Start ingesting provider runtime events into orchestration commands. | |
| 18 | * | |
| 19 | * The returned effect must be run in a scope so all worker fibers can be | |
| 20 | * finalized on shutdown. | |
| 21 | * | |
| 22 | * Uses an internal queue and continues after non-interrupt failures by | |
| 23 | * logging warnings. | |
| 24 | */ | |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * ProviderRuntimeIngestionService - Service tag for runtime ingestion workers. | |
| 30 | */ | |
| 31 | export class ProviderRuntimeIngestionService extends ServiceMap.Service< | |
| 32 | ProviderRuntimeIngestionService, | |
| 33 | ProviderRuntimeIngestionShape | |
| 34 | >()("t3/orchestration/Services/ProviderRuntimeIngestion/ProviderRuntimeIngestionService") {} |
apps/server/src/orchestration/commandInvariants.test.ts created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { | |
| 3 | MessageId, | |
| 4 | CommandId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | type OrchestrationCommand, | |
| 8 | type OrchestrationReadModel, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { Effect } from "effect"; | |
| 11 | ||
| 12 | import { | |
| 13 | findThreadById, | |
| 14 | listThreadsByProjectId, | |
| 15 | requireNonNegativeInteger, | |
| 16 | requireThread, | |
| 17 | requireThreadAbsent, | |
| 18 | } from "./commandInvariants.ts"; | |
| 19 | ||
| 20 | const now = new Date().toISOString(); | |
| 21 | ||
| 22 | const readModel: OrchestrationReadModel = { | |
| 23 | snapshotSequence: 2, | |
| 24 | updatedAt: now, | |
| 25 | projects: [ | |
| 26 | { | |
| 27 | id: ProjectId.makeUnsafe("project-a"), | |
| 28 | title: "Project A", | |
| 29 | workspaceRoot: "/tmp/project-a", | |
| 30 | defaultModel: "gpt-5-codex", | |
| 31 | scripts: [], | |
| 32 | createdAt: now, | |
| 33 | updatedAt: now, | |
| 34 | deletedAt: null, | |
| 35 | }, | |
| 36 | { | |
| 37 | id: ProjectId.makeUnsafe("project-b"), | |
| 38 | title: "Project B", | |
| 39 | workspaceRoot: "/tmp/project-b", | |
| 40 | defaultModel: "gpt-5-codex", | |
| 41 | scripts: [], | |
| 42 | createdAt: now, | |
| 43 | updatedAt: now, | |
| 44 | deletedAt: null, | |
| 45 | }, | |
| 46 | ], | |
| 47 | threads: [ | |
| 48 | { | |
| 49 | id: ThreadId.makeUnsafe("thread-1"), | |
| 50 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 51 | title: "Thread A", | |
| 52 | model: "gpt-5-codex", | |
| 53 | branch: null, | |
| 54 | worktreePath: null, | |
| 55 | createdAt: now, | |
| 56 | updatedAt: now, | |
| 57 | latestTurn: null, | |
| 58 | messages: [], | |
| 59 | session: null, | |
| 60 | activities: [], | |
| 61 | checkpoints: [], | |
| 62 | deletedAt: null, | |
| 63 | }, | |
| 64 | { | |
| 65 | id: ThreadId.makeUnsafe("thread-2"), | |
| 66 | projectId: ProjectId.makeUnsafe("project-b"), | |
| 67 | title: "Thread B", | |
| 68 | model: "gpt-5-codex", | |
| 69 | branch: null, | |
| 70 | worktreePath: null, | |
| 71 | createdAt: now, | |
| 72 | updatedAt: now, | |
| 73 | latestTurn: null, | |
| 74 | messages: [], | |
| 75 | session: null, | |
| 76 | activities: [], | |
| 77 | checkpoints: [], | |
| 78 | deletedAt: null, | |
| 79 | }, | |
| 80 | ], | |
| 81 | }; | |
| 82 | ||
| 83 | const messageSendCommand: OrchestrationCommand = { | |
| 84 | type: "thread.turn.start", | |
| 85 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 86 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 87 | message: { | |
| 88 | messageId: MessageId.makeUnsafe("msg-1"), | |
| 89 | role: "user", | |
| 90 | text: "hello", | |
| 91 | attachments: [], | |
| 92 | }, | |
| 93 | approvalPolicy: "on-request", | |
| 94 | sandboxMode: "workspace-write", | |
| 95 | createdAt: now, | |
| 96 | }; | |
| 97 | ||
| 98 | describe("commandInvariants", () => { | |
| 99 | it("finds threads by id and project", () => { | |
| 100 | expect(findThreadById(readModel, ThreadId.makeUnsafe("thread-1"))?.projectId).toBe("project-a"); | |
| 101 | expect(findThreadById(readModel, ThreadId.makeUnsafe("missing"))).toBeUndefined(); | |
| 102 | expect( | |
| 103 | listThreadsByProjectId(readModel, ProjectId.makeUnsafe("project-b")).map( | |
| 104 | (thread) => thread.id, | |
| 105 | ), | |
| 106 | ).toEqual([ThreadId.makeUnsafe("thread-2")]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("requires existing thread", async () => { | |
| 110 | const thread = await Effect.runPromise( | |
| 111 | requireThread({ | |
| 112 | readModel, | |
| 113 | command: messageSendCommand, | |
| 114 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 115 | }), | |
| 116 | ); | |
| 117 | expect(thread.id).toBe(ThreadId.makeUnsafe("thread-1")); | |
| 118 | ||
| 119 | await expect( | |
| 120 | Effect.runPromise( | |
| 121 | requireThread({ | |
| 122 | readModel, | |
| 123 | command: messageSendCommand, | |
| 124 | threadId: ThreadId.makeUnsafe("missing"), | |
| 125 | }), | |
| 126 | ), | |
| 127 | ).rejects.toThrow("does not exist"); | |
| 128 | }); | |
| 129 | ||
| 130 | it("requires missing thread for create flows", async () => { | |
| 131 | await Effect.runPromise( | |
| 132 | requireThreadAbsent({ | |
| 133 | readModel, | |
| 134 | command: { | |
| 135 | type: "thread.create", | |
| 136 | commandId: CommandId.makeUnsafe("cmd-2"), | |
| 137 | threadId: ThreadId.makeUnsafe("thread-3"), | |
| 138 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 139 | title: "new", | |
| 140 | model: "gpt-5-codex", | |
| 141 | branch: null, | |
| 142 | worktreePath: null, | |
| 143 | createdAt: now, | |
| 144 | }, | |
| 145 | threadId: ThreadId.makeUnsafe("thread-3"), | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | await expect( | |
| 150 | Effect.runPromise( | |
| 151 | requireThreadAbsent({ | |
| 152 | readModel, | |
| 153 | command: { | |
| 154 | type: "thread.create", | |
| 155 | commandId: CommandId.makeUnsafe("cmd-3"), | |
| 156 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 157 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 158 | title: "dup", | |
| 159 | model: "gpt-5-codex", | |
| 160 | branch: null, | |
| 161 | worktreePath: null, | |
| 162 | createdAt: now, | |
| 163 | }, | |
| 164 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 165 | }), | |
| 166 | ), | |
| 167 | ).rejects.toThrow("already exists"); | |
| 168 | }); | |
| 169 | ||
| 170 | it("requires non-negative integers", async () => { | |
| 171 | await Effect.runPromise( | |
| 172 | requireNonNegativeInteger({ | |
| 173 | commandType: "thread.checkpoint.revert", | |
| 174 | field: "turnCount", | |
| 175 | value: 0, | |
| 176 | }), | |
| 177 | ); | |
| 178 | ||
| 179 | await expect( | |
| 180 | Effect.runPromise( | |
| 181 | requireNonNegativeInteger({ | |
| 182 | commandType: "thread.checkpoint.revert", | |
| 183 | field: "turnCount", | |
| 184 | value: -1, | |
| 185 | }), | |
| 186 | ), | |
| 187 | ).rejects.toThrow("greater than or equal to 0"); | |
| 188 | }); | |
| 189 | }); |
apps/server/src/orchestration/commandInvariants.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationProject, | |
| 4 | OrchestrationReadModel, | |
| 5 | OrchestrationThread, | |
| 6 | ProjectId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { Effect } from "effect"; | |
| 10 | ||
| 11 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | |
| 12 | ||
| 13 | function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError { | |
| 14 | return new OrchestrationCommandInvariantError({ | |
| 15 | commandType, | |
| 16 | detail, | |
| 17 | }); | |
| 18 | } | |
| 19 | ||
| 20 | export function findThreadById( | |
| 21 | readModel: OrchestrationReadModel, | |
| 22 | threadId: ThreadId, | |
| 23 | ): OrchestrationThread | undefined { | |
| 24 | return readModel.threads.find((thread) => thread.id === threadId); | |
| 25 | } | |
| 26 | ||
| 27 | export function findProjectById( | |
| 28 | readModel: OrchestrationReadModel, | |
| 29 | projectId: ProjectId, | |
| 30 | ): OrchestrationProject | undefined { | |
| 31 | return readModel.projects.find((project) => project.id === projectId); | |
| 32 | } | |
| 33 | ||
| 34 | export function listThreadsByProjectId( | |
| 35 | readModel: OrchestrationReadModel, | |
| 36 | projectId: ProjectId, | |
| 37 | ): ReadonlyArray<OrchestrationThread> { | |
| 38 | return readModel.threads.filter((thread) => thread.projectId === projectId); | |
| 39 | } | |
| 40 | ||
| 41 | export function requireProject(input: { | |
| 42 | readonly readModel: OrchestrationReadModel; | |
| 43 | readonly command: OrchestrationCommand; | |
| 44 | readonly projectId: ProjectId; | |
| 45 | }): Effect.Effect<OrchestrationProject, OrchestrationCommandInvariantError> { | |
| 46 | const project = findProjectById(input.readModel, input.projectId); | |
| 47 | if (project) { | |
| 48 | return Effect.succeed(project); | |
| 49 | } | |
| 50 | return Effect.fail( | |
| 51 | invariantError( | |
| 52 | input.command.type, | |
| 53 | `Project '${input.projectId}' does not exist for command '${input.command.type}'.`, | |
| 54 | ), | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | export function requireProjectAbsent(input: { | |
| 59 | readonly readModel: OrchestrationReadModel; | |
| 60 | readonly command: OrchestrationCommand; | |
| 61 | readonly projectId: ProjectId; | |
| 62 | }): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 63 | if (!findProjectById(input.readModel, input.projectId)) { | |
| 64 | return Effect.void; | |
| 65 | } | |
| 66 | return Effect.fail( | |
| 67 | invariantError( | |
| 68 | input.command.type, | |
| 69 | `Project '${input.projectId}' already exists and cannot be created twice.`, | |
| 70 | ), | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 74 | export function requireThread(input: { | |
| 75 | readonly readModel: OrchestrationReadModel; | |
| 76 | readonly command: OrchestrationCommand; | |
| 77 | readonly threadId: ThreadId; | |
| 78 | }): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> { | |
| 79 | const thread = findThreadById(input.readModel, input.threadId); | |
| 80 | if (thread) { | |
| 81 | return Effect.succeed(thread); | |
| 82 | } | |
| 83 | return Effect.fail( | |
| 84 | invariantError( | |
| 85 | input.command.type, | |
| 86 | `Thread '${input.threadId}' does not exist for command '${input.command.type}'.`, | |
| 87 | ), | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | export function requireThreadAbsent(input: { | |
| 92 | readonly readModel: OrchestrationReadModel; | |
| 93 | readonly command: OrchestrationCommand; | |
| 94 | readonly threadId: ThreadId; | |
| 95 | }): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 96 | if (!findThreadById(input.readModel, input.threadId)) { | |
| 97 | return Effect.void; | |
| 98 | } | |
| 99 | return Effect.fail( | |
| 100 | invariantError( | |
| 101 | input.command.type, | |
| 102 | `Thread '${input.threadId}' already exists and cannot be created twice.`, | |
| 103 | ), | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | export function requireNonNegativeInteger(input: { | |
| 108 | readonly commandType: OrchestrationCommand["type"]; | |
| 109 | readonly field: string; | |
| 110 | readonly value: number; | |
| 111 | }): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 112 | if (Number.isInteger(input.value) && input.value >= 0) { | |
| 113 | return Effect.void; | |
| 114 | } | |
| 115 | return Effect.fail( | |
| 116 | invariantError( | |
| 117 | input.commandType, | |
| 118 | `${input.field} must be an integer greater than or equal to 0.`, | |
| 119 | ), | |
| 120 | ); | |
| 121 | } |
apps/server/src/orchestration/decider.projectScripts.test.ts created+183| ... | ... | @@ -0,0 +1,183 @@ |
| 1 | import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | import { Effect } from "effect"; | |
| 4 | ||
| 5 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 6 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 7 | ||
| 8 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 9 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 10 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 11 | ||
| 12 | describe("decider project scripts", () => { | |
| 13 | it("emits empty scripts on project.create", async () => { | |
| 14 | const now = new Date().toISOString(); | |
| 15 | const readModel = createEmptyReadModel(now); | |
| 16 | ||
| 17 | const result = await Effect.runPromise( | |
| 18 | decideOrchestrationCommand({ | |
| 19 | command: { | |
| 20 | type: "project.create", | |
| 21 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 22 | projectId: asProjectId("project-scripts"), | |
| 23 | title: "Scripts", | |
| 24 | workspaceRoot: "/tmp/scripts", | |
| 25 | createdAt: now, | |
| 26 | }, | |
| 27 | readModel, | |
| 28 | }), | |
| 29 | ); | |
| 30 | ||
| 31 | const event = Array.isArray(result) ? result[0] : result; | |
| 32 | expect(event.type).toBe("project.created"); | |
| 33 | expect((event.payload as { scripts: unknown[] }).scripts).toEqual([]); | |
| 34 | }); | |
| 35 | ||
| 36 | it("propagates scripts in project.meta.update payload", async () => { | |
| 37 | const now = new Date().toISOString(); | |
| 38 | const initial = createEmptyReadModel(now); | |
| 39 | const readModel = await Effect.runPromise( | |
| 40 | projectEvent(initial, { | |
| 41 | sequence: 1, | |
| 42 | eventId: asEventId("evt-project-create-scripts"), | |
| 43 | aggregateKind: "project", | |
| 44 | aggregateId: asProjectId("project-scripts"), | |
| 45 | type: "project.created", | |
| 46 | occurredAt: now, | |
| 47 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 48 | causationEventId: null, | |
| 49 | correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"), | |
| 50 | metadata: {}, | |
| 51 | payload: { | |
| 52 | projectId: asProjectId("project-scripts"), | |
| 53 | title: "Scripts", | |
| 54 | workspaceRoot: "/tmp/scripts", | |
| 55 | defaultModel: null, | |
| 56 | scripts: [], | |
| 57 | createdAt: now, | |
| 58 | updatedAt: now, | |
| 59 | }, | |
| 60 | }), | |
| 61 | ); | |
| 62 | ||
| 63 | const scripts = [ | |
| 64 | { | |
| 65 | id: "lint", | |
| 66 | name: "Lint", | |
| 67 | command: "bun run lint", | |
| 68 | icon: "lint", | |
| 69 | runOnWorktreeCreate: false, | |
| 70 | }, | |
| 71 | ] as const; | |
| 72 | ||
| 73 | const result = await Effect.runPromise( | |
| 74 | decideOrchestrationCommand({ | |
| 75 | command: { | |
| 76 | type: "project.meta.update", | |
| 77 | commandId: CommandId.makeUnsafe("cmd-project-update-scripts"), | |
| 78 | projectId: asProjectId("project-scripts"), | |
| 79 | scripts: Array.from(scripts), | |
| 80 | }, | |
| 81 | readModel, | |
| 82 | }), | |
| 83 | ); | |
| 84 | ||
| 85 | const event = Array.isArray(result) ? result[0] : result; | |
| 86 | expect(event.type).toBe("project.meta-updated"); | |
| 87 | expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts); | |
| 88 | }); | |
| 89 | ||
| 90 | it("emits user message and turn-start-requested events for thread.turn.start", async () => { | |
| 91 | const now = new Date().toISOString(); | |
| 92 | const initial = createEmptyReadModel(now); | |
| 93 | const withProject = await Effect.runPromise( | |
| 94 | projectEvent(initial, { | |
| 95 | sequence: 1, | |
| 96 | eventId: asEventId("evt-project-create"), | |
| 97 | aggregateKind: "project", | |
| 98 | aggregateId: asProjectId("project-1"), | |
| 99 | type: "project.created", | |
| 100 | occurredAt: now, | |
| 101 | commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 102 | causationEventId: null, | |
| 103 | correlationId: CommandId.makeUnsafe("cmd-project-create"), | |
| 104 | metadata: {}, | |
| 105 | payload: { | |
| 106 | projectId: asProjectId("project-1"), | |
| 107 | title: "Project", | |
| 108 | workspaceRoot: "/tmp/project", | |
| 109 | defaultModel: null, | |
| 110 | scripts: [], | |
| 111 | createdAt: now, | |
| 112 | updatedAt: now, | |
| 113 | }, | |
| 114 | }), | |
| 115 | ); | |
| 116 | const readModel = await Effect.runPromise( | |
| 117 | projectEvent(withProject, { | |
| 118 | sequence: 2, | |
| 119 | eventId: asEventId("evt-thread-create"), | |
| 120 | aggregateKind: "thread", | |
| 121 | aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 122 | type: "thread.created", | |
| 123 | occurredAt: now, | |
| 124 | commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 125 | causationEventId: null, | |
| 126 | correlationId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 127 | metadata: {}, | |
| 128 | payload: { | |
| 129 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 130 | projectId: asProjectId("project-1"), | |
| 131 | title: "Thread", | |
| 132 | model: "gpt-5-codex", | |
| 133 | branch: null, | |
| 134 | worktreePath: null, | |
| 135 | createdAt: now, | |
| 136 | updatedAt: now, | |
| 137 | }, | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | const result = await Effect.runPromise( | |
| 142 | decideOrchestrationCommand({ | |
| 143 | command: { | |
| 144 | type: "thread.turn.start", | |
| 145 | commandId: CommandId.makeUnsafe("cmd-turn-start"), | |
| 146 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 147 | message: { | |
| 148 | messageId: asMessageId("message-user-1"), | |
| 149 | role: "user", | |
| 150 | text: "hello", | |
| 151 | attachments: [], | |
| 152 | }, | |
| 153 | model: "gpt-5", | |
| 154 | effort: "high", | |
| 155 | approvalPolicy: "on-request", | |
| 156 | sandboxMode: "workspace-write", | |
| 157 | createdAt: now, | |
| 158 | }, | |
| 159 | readModel, | |
| 160 | }), | |
| 161 | ); | |
| 162 | ||
| 163 | expect(Array.isArray(result)).toBe(true); | |
| 164 | const events = Array.isArray(result) ? result : [result]; | |
| 165 | expect(events).toHaveLength(2); | |
| 166 | expect(events[0]?.type).toBe("thread.message-sent"); | |
| 167 | const turnStartEvent = events[1]; | |
| 168 | expect(turnStartEvent?.type).toBe("thread.turn-start-requested"); | |
| 169 | expect(turnStartEvent?.causationEventId).toBe(events[0]?.eventId ?? null); | |
| 170 | if (turnStartEvent?.type !== "thread.turn-start-requested") { | |
| 171 | return; | |
| 172 | } | |
| 173 | expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered"); | |
| 174 | expect(turnStartEvent.payload).toMatchObject({ | |
| 175 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 176 | messageId: asMessageId("message-user-1"), | |
| 177 | model: "gpt-5", | |
| 178 | effort: "high", | |
| 179 | approvalPolicy: "on-request", | |
| 180 | sandboxMode: "workspace-write", | |
| 181 | }); | |
| 182 | }); | |
| 183 | }); |
apps/server/src/orchestration/decider.ts created+526| ... | ... | @@ -0,0 +1,526 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { Effect } from "effect"; | |
| 7 | ||
| 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | |
| 9 | import { | |
| 10 | requireProject, | |
| 11 | requireProjectAbsent, | |
| 12 | requireThread, | |
| 13 | requireThreadAbsent, | |
| 14 | } from "./commandInvariants.ts"; | |
| 15 | ||
| 16 | const nowIso = () => new Date().toISOString(); | |
| 17 | const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; | |
| 18 | ||
| 19 | const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = { | |
| 20 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], | |
| 21 | aggregateKind: "thread", | |
| 22 | aggregateId: "" as OrchestrationEvent["aggregateId"], | |
| 23 | occurredAt: nowIso(), | |
| 24 | commandId: null, | |
| 25 | causationEventId: null, | |
| 26 | correlationId: null, | |
| 27 | metadata: {}, | |
| 28 | }; | |
| 29 | ||
| 30 | function withEventBase( | |
| 31 | input: Pick<OrchestrationCommand, "commandId"> & { | |
| 32 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; | |
| 33 | readonly aggregateId: OrchestrationEvent["aggregateId"]; | |
| 34 | readonly occurredAt: string; | |
| 35 | readonly metadata?: OrchestrationEvent["metadata"]; | |
| 36 | }, | |
| 37 | ): Omit<OrchestrationEvent, "sequence" | "type" | "payload"> { | |
| 38 | return { | |
| 39 | ...defaultMetadata, | |
| 40 | eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], | |
| 41 | aggregateKind: input.aggregateKind, | |
| 42 | aggregateId: input.aggregateId, | |
| 43 | occurredAt: input.occurredAt, | |
| 44 | commandId: input.commandId, | |
| 45 | correlationId: input.commandId, | |
| 46 | metadata: input.metadata ?? {}, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ | |
| 51 | command, | |
| 52 | readModel, | |
| 53 | }: { | |
| 54 | readonly command: OrchestrationCommand; | |
| 55 | readonly readModel: OrchestrationReadModel; | |
| 56 | }): Effect.fn.Return< | |
| 57 | Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>, | |
| 58 | OrchestrationCommandInvariantError | |
| 59 | > { | |
| 60 | switch (command.type) { | |
| 61 | case "project.create": { | |
| 62 | yield* requireProjectAbsent({ | |
| 63 | readModel, | |
| 64 | command, | |
| 65 | projectId: command.projectId, | |
| 66 | }); | |
| 67 | ||
| 68 | return { | |
| 69 | ...withEventBase({ | |
| 70 | aggregateKind: "project", | |
| 71 | aggregateId: command.projectId, | |
| 72 | occurredAt: command.createdAt, | |
| 73 | commandId: command.commandId, | |
| 74 | }), | |
| 75 | type: "project.created", | |
| 76 | payload: { | |
| 77 | projectId: command.projectId, | |
| 78 | title: command.title, | |
| 79 | workspaceRoot: command.workspaceRoot, | |
| 80 | defaultModel: command.defaultModel ?? null, | |
| 81 | scripts: [], | |
| 82 | createdAt: command.createdAt, | |
| 83 | updatedAt: command.createdAt, | |
| 84 | }, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | case "project.meta.update": { | |
| 89 | yield* requireProject({ | |
| 90 | readModel, | |
| 91 | command, | |
| 92 | projectId: command.projectId, | |
| 93 | }); | |
| 94 | const occurredAt = nowIso(); | |
| 95 | return { | |
| 96 | ...withEventBase({ | |
| 97 | aggregateKind: "project", | |
| 98 | aggregateId: command.projectId, | |
| 99 | occurredAt, | |
| 100 | commandId: command.commandId, | |
| 101 | }), | |
| 102 | type: "project.meta-updated", | |
| 103 | payload: { | |
| 104 | projectId: command.projectId, | |
| 105 | ...(command.title !== undefined ? { title: command.title } : {}), | |
| 106 | ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), | |
| 107 | ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), | |
| 108 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), | |
| 109 | updatedAt: occurredAt, | |
| 110 | }, | |
| 111 | }; | |
| 112 | } | |
| 113 | ||
| 114 | case "project.delete": { | |
| 115 | yield* requireProject({ | |
| 116 | readModel, | |
| 117 | command, | |
| 118 | projectId: command.projectId, | |
| 119 | }); | |
| 120 | const occurredAt = nowIso(); | |
| 121 | return { | |
| 122 | ...withEventBase({ | |
| 123 | aggregateKind: "project", | |
| 124 | aggregateId: command.projectId, | |
| 125 | occurredAt, | |
| 126 | commandId: command.commandId, | |
| 127 | }), | |
| 128 | type: "project.deleted", | |
| 129 | payload: { | |
| 130 | projectId: command.projectId, | |
| 131 | deletedAt: occurredAt, | |
| 132 | }, | |
| 133 | }; | |
| 134 | } | |
| 135 | ||
| 136 | case "thread.create": { | |
| 137 | yield* requireProject({ | |
| 138 | readModel, | |
| 139 | command, | |
| 140 | projectId: command.projectId, | |
| 141 | }); | |
| 142 | yield* requireThreadAbsent({ | |
| 143 | readModel, | |
| 144 | command, | |
| 145 | threadId: command.threadId, | |
| 146 | }); | |
| 147 | return { | |
| 148 | ...withEventBase({ | |
| 149 | aggregateKind: "thread", | |
| 150 | aggregateId: command.threadId, | |
| 151 | occurredAt: command.createdAt, | |
| 152 | commandId: command.commandId, | |
| 153 | }), | |
| 154 | type: "thread.created", | |
| 155 | payload: { | |
| 156 | threadId: command.threadId, | |
| 157 | projectId: command.projectId, | |
| 158 | title: command.title, | |
| 159 | model: command.model, | |
| 160 | branch: command.branch, | |
| 161 | worktreePath: command.worktreePath, | |
| 162 | createdAt: command.createdAt, | |
| 163 | updatedAt: command.createdAt, | |
| 164 | }, | |
| 165 | }; | |
| 166 | } | |
| 167 | ||
| 168 | case "thread.delete": { | |
| 169 | yield* requireThread({ | |
| 170 | readModel, | |
| 171 | command, | |
| 172 | threadId: command.threadId, | |
| 173 | }); | |
| 174 | const occurredAt = nowIso(); | |
| 175 | return { | |
| 176 | ...withEventBase({ | |
| 177 | aggregateKind: "thread", | |
| 178 | aggregateId: command.threadId, | |
| 179 | occurredAt, | |
| 180 | commandId: command.commandId, | |
| 181 | }), | |
| 182 | type: "thread.deleted", | |
| 183 | payload: { | |
| 184 | threadId: command.threadId, | |
| 185 | deletedAt: occurredAt, | |
| 186 | }, | |
| 187 | }; | |
| 188 | } | |
| 189 | ||
| 190 | case "thread.meta.update": { | |
| 191 | yield* requireThread({ | |
| 192 | readModel, | |
| 193 | command, | |
| 194 | threadId: command.threadId, | |
| 195 | }); | |
| 196 | const occurredAt = nowIso(); | |
| 197 | return { | |
| 198 | ...withEventBase({ | |
| 199 | aggregateKind: "thread", | |
| 200 | aggregateId: command.threadId, | |
| 201 | occurredAt, | |
| 202 | commandId: command.commandId, | |
| 203 | }), | |
| 204 | type: "thread.meta-updated", | |
| 205 | payload: { | |
| 206 | threadId: command.threadId, | |
| 207 | ...(command.title !== undefined ? { title: command.title } : {}), | |
| 208 | ...(command.model !== undefined ? { model: command.model } : {}), | |
| 209 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | |
| 210 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | |
| 211 | updatedAt: occurredAt, | |
| 212 | }, | |
| 213 | }; | |
| 214 | } | |
| 215 | ||
| 216 | case "thread.turn.start": { | |
| 217 | yield* requireThread({ | |
| 218 | readModel, | |
| 219 | command, | |
| 220 | threadId: command.threadId, | |
| 221 | }); | |
| 222 | const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = { | |
| 223 | ...withEventBase({ | |
| 224 | aggregateKind: "thread", | |
| 225 | aggregateId: command.threadId, | |
| 226 | occurredAt: command.createdAt, | |
| 227 | commandId: command.commandId, | |
| 228 | }), | |
| 229 | type: "thread.message-sent", | |
| 230 | payload: { | |
| 231 | threadId: command.threadId, | |
| 232 | messageId: command.message.messageId, | |
| 233 | role: "user", | |
| 234 | text: command.message.text, | |
| 235 | attachments: command.message.attachments, | |
| 236 | turnId: null, | |
| 237 | streaming: false, | |
| 238 | createdAt: command.createdAt, | |
| 239 | updatedAt: command.createdAt, | |
| 240 | }, | |
| 241 | }; | |
| 242 | const turnStartRequestedEvent: Omit<OrchestrationEvent, "sequence"> = { | |
| 243 | ...withEventBase({ | |
| 244 | aggregateKind: "thread", | |
| 245 | aggregateId: command.threadId, | |
| 246 | occurredAt: command.createdAt, | |
| 247 | commandId: command.commandId, | |
| 248 | }), | |
| 249 | causationEventId: userMessageEvent.eventId, | |
| 250 | type: "thread.turn-start-requested", | |
| 251 | payload: { | |
| 252 | threadId: command.threadId, | |
| 253 | messageId: command.message.messageId, | |
| 254 | ...(command.model !== undefined ? { model: command.model } : {}), | |
| 255 | ...(command.effort !== undefined ? { effort: command.effort } : {}), | |
| 256 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| 257 | approvalPolicy: command.approvalPolicy, | |
| 258 | sandboxMode: command.sandboxMode, | |
| 259 | createdAt: command.createdAt, | |
| 260 | }, | |
| 261 | }; | |
| 262 | return [userMessageEvent, turnStartRequestedEvent]; | |
| 263 | } | |
| 264 | ||
| 265 | case "thread.turn.interrupt": { | |
| 266 | yield* requireThread({ | |
| 267 | readModel, | |
| 268 | command, | |
| 269 | threadId: command.threadId, | |
| 270 | }); | |
| 271 | return { | |
| 272 | ...withEventBase({ | |
| 273 | aggregateKind: "thread", | |
| 274 | aggregateId: command.threadId, | |
| 275 | occurredAt: command.createdAt, | |
| 276 | commandId: command.commandId, | |
| 277 | }), | |
| 278 | type: "thread.turn-interrupt-requested", | |
| 279 | payload: { | |
| 280 | threadId: command.threadId, | |
| 281 | ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), | |
| 282 | createdAt: command.createdAt, | |
| 283 | }, | |
| 284 | }; | |
| 285 | } | |
| 286 | ||
| 287 | case "thread.approval.respond": { | |
| 288 | yield* requireThread({ | |
| 289 | readModel, | |
| 290 | command, | |
| 291 | threadId: command.threadId, | |
| 292 | }); | |
| 293 | return { | |
| 294 | ...withEventBase({ | |
| 295 | aggregateKind: "thread", | |
| 296 | aggregateId: command.threadId, | |
| 297 | occurredAt: command.createdAt, | |
| 298 | commandId: command.commandId, | |
| 299 | metadata: { | |
| 300 | requestId: command.requestId, | |
| 301 | }, | |
| 302 | }), | |
| 303 | type: "thread.approval-response-requested", | |
| 304 | payload: { | |
| 305 | threadId: command.threadId, | |
| 306 | requestId: command.requestId, | |
| 307 | decision: command.decision, | |
| 308 | createdAt: command.createdAt, | |
| 309 | }, | |
| 310 | }; | |
| 311 | } | |
| 312 | ||
| 313 | case "thread.checkpoint.revert": { | |
| 314 | yield* requireThread({ | |
| 315 | readModel, | |
| 316 | command, | |
| 317 | threadId: command.threadId, | |
| 318 | }); | |
| 319 | return { | |
| 320 | ...withEventBase({ | |
| 321 | aggregateKind: "thread", | |
| 322 | aggregateId: command.threadId, | |
| 323 | occurredAt: command.createdAt, | |
| 324 | commandId: command.commandId, | |
| 325 | }), | |
| 326 | type: "thread.checkpoint-revert-requested", | |
| 327 | payload: { | |
| 328 | threadId: command.threadId, | |
| 329 | turnCount: command.turnCount, | |
| 330 | createdAt: command.createdAt, | |
| 331 | }, | |
| 332 | }; | |
| 333 | } | |
| 334 | ||
| 335 | case "thread.session.stop": { | |
| 336 | yield* requireThread({ | |
| 337 | readModel, | |
| 338 | command, | |
| 339 | threadId: command.threadId, | |
| 340 | }); | |
| 341 | return { | |
| 342 | ...withEventBase({ | |
| 343 | aggregateKind: "thread", | |
| 344 | aggregateId: command.threadId, | |
| 345 | occurredAt: command.createdAt, | |
| 346 | commandId: command.commandId, | |
| 347 | }), | |
| 348 | type: "thread.session-stop-requested", | |
| 349 | payload: { | |
| 350 | threadId: command.threadId, | |
| 351 | createdAt: command.createdAt, | |
| 352 | }, | |
| 353 | }; | |
| 354 | } | |
| 355 | ||
| 356 | case "thread.session.set": { | |
| 357 | yield* requireThread({ | |
| 358 | readModel, | |
| 359 | command, | |
| 360 | threadId: command.threadId, | |
| 361 | }); | |
| 362 | return { | |
| 363 | ...withEventBase({ | |
| 364 | aggregateKind: "thread", | |
| 365 | aggregateId: command.threadId, | |
| 366 | occurredAt: command.createdAt, | |
| 367 | commandId: command.commandId, | |
| 368 | metadata: { | |
| 369 | ...(command.session.providerSessionId !== null | |
| 370 | ? { providerSessionId: command.session.providerSessionId } | |
| 371 | : {}), | |
| 372 | ...(command.session.providerThreadId !== null | |
| 373 | ? { providerThreadId: command.session.providerThreadId } | |
| 374 | : {}), | |
| 375 | }, | |
| 376 | }), | |
| 377 | type: "thread.session-set", | |
| 378 | payload: { | |
| 379 | threadId: command.threadId, | |
| 380 | session: command.session, | |
| 381 | }, | |
| 382 | }; | |
| 383 | } | |
| 384 | ||
| 385 | case "thread.message.assistant.delta": { | |
| 386 | yield* requireThread({ | |
| 387 | readModel, | |
| 388 | command, | |
| 389 | threadId: command.threadId, | |
| 390 | }); | |
| 391 | return { | |
| 392 | ...withEventBase({ | |
| 393 | aggregateKind: "thread", | |
| 394 | aggregateId: command.threadId, | |
| 395 | occurredAt: command.createdAt, | |
| 396 | commandId: command.commandId, | |
| 397 | }), | |
| 398 | type: "thread.message-sent", | |
| 399 | payload: { | |
| 400 | threadId: command.threadId, | |
| 401 | messageId: command.messageId, | |
| 402 | role: "assistant", | |
| 403 | text: command.delta, | |
| 404 | turnId: command.turnId ?? null, | |
| 405 | streaming: true, | |
| 406 | createdAt: command.createdAt, | |
| 407 | updatedAt: command.createdAt, | |
| 408 | }, | |
| 409 | }; | |
| 410 | } | |
| 411 | ||
| 412 | case "thread.message.assistant.complete": { | |
| 413 | yield* requireThread({ | |
| 414 | readModel, | |
| 415 | command, | |
| 416 | threadId: command.threadId, | |
| 417 | }); | |
| 418 | return { | |
| 419 | ...withEventBase({ | |
| 420 | aggregateKind: "thread", | |
| 421 | aggregateId: command.threadId, | |
| 422 | occurredAt: command.createdAt, | |
| 423 | commandId: command.commandId, | |
| 424 | }), | |
| 425 | type: "thread.message-sent", | |
| 426 | payload: { | |
| 427 | threadId: command.threadId, | |
| 428 | messageId: command.messageId, | |
| 429 | role: "assistant", | |
| 430 | text: "", | |
| 431 | turnId: command.turnId ?? null, | |
| 432 | streaming: false, | |
| 433 | createdAt: command.createdAt, | |
| 434 | updatedAt: command.createdAt, | |
| 435 | }, | |
| 436 | }; | |
| 437 | } | |
| 438 | ||
| 439 | case "thread.turn.diff.complete": { | |
| 440 | yield* requireThread({ | |
| 441 | readModel, | |
| 442 | command, | |
| 443 | threadId: command.threadId, | |
| 444 | }); | |
| 445 | return { | |
| 446 | ...withEventBase({ | |
| 447 | aggregateKind: "thread", | |
| 448 | aggregateId: command.threadId, | |
| 449 | occurredAt: command.createdAt, | |
| 450 | commandId: command.commandId, | |
| 451 | }), | |
| 452 | type: "thread.turn-diff-completed", | |
| 453 | payload: { | |
| 454 | threadId: command.threadId, | |
| 455 | turnId: command.turnId, | |
| 456 | checkpointTurnCount: command.checkpointTurnCount, | |
| 457 | checkpointRef: command.checkpointRef, | |
| 458 | status: command.status, | |
| 459 | files: command.files, | |
| 460 | assistantMessageId: command.assistantMessageId ?? null, | |
| 461 | completedAt: command.completedAt, | |
| 462 | }, | |
| 463 | }; | |
| 464 | } | |
| 465 | ||
| 466 | case "thread.revert.complete": { | |
| 467 | yield* requireThread({ | |
| 468 | readModel, | |
| 469 | command, | |
| 470 | threadId: command.threadId, | |
| 471 | }); | |
| 472 | return { | |
| 473 | ...withEventBase({ | |
| 474 | aggregateKind: "thread", | |
| 475 | aggregateId: command.threadId, | |
| 476 | occurredAt: command.createdAt, | |
| 477 | commandId: command.commandId, | |
| 478 | }), | |
| 479 | type: "thread.reverted", | |
| 480 | payload: { | |
| 481 | threadId: command.threadId, | |
| 482 | turnCount: command.turnCount, | |
| 483 | }, | |
| 484 | }; | |
| 485 | } | |
| 486 | ||
| 487 | case "thread.activity.append": { | |
| 488 | yield* requireThread({ | |
| 489 | readModel, | |
| 490 | command, | |
| 491 | threadId: command.threadId, | |
| 492 | }); | |
| 493 | const requestId = | |
| 494 | typeof command.activity.payload === "object" && | |
| 495 | command.activity.payload !== null && | |
| 496 | "requestId" in command.activity.payload && | |
| 497 | typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" | |
| 498 | ? ((command.activity.payload as { requestId: string }) | |
| 499 | .requestId as OrchestrationEvent["metadata"]["requestId"]) | |
| 500 | : undefined; | |
| 501 | return { | |
| 502 | ...withEventBase({ | |
| 503 | aggregateKind: "thread", | |
| 504 | aggregateId: command.threadId, | |
| 505 | occurredAt: command.createdAt, | |
| 506 | commandId: command.commandId, | |
| 507 | ...(requestId !== undefined ? { metadata: { requestId } } : {}), | |
| 508 | }), | |
| 509 | type: "thread.activity-appended", | |
| 510 | payload: { | |
| 511 | threadId: command.threadId, | |
| 512 | activity: command.activity, | |
| 513 | }, | |
| 514 | }; | |
| 515 | } | |
| 516 | ||
| 517 | default: { | |
| 518 | command satisfies never; | |
| 519 | const fallback = command as never as { type: string }; | |
| 520 | return yield* new OrchestrationCommandInvariantError({ | |
| 521 | commandType: fallback.type, | |
| 522 | detail: `Unknown command type: ${fallback.type}`, | |
| 523 | }); | |
| 524 | } | |
| 525 | } | |
| 526 | }); |
apps/server/src/orchestration/projector.test.ts created+755| ... | ... | @@ -0,0 +1,755 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | ProjectId, | |
| 5 | ThreadId, | |
| 6 | type OrchestrationEvent, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Effect } from "effect"; | |
| 9 | import { describe, expect, it } from "vitest"; | |
| 10 | ||
| 11 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 12 | ||
| 13 | function makeEvent(input: { | |
| 14 | sequence: number; | |
| 15 | type: OrchestrationEvent["type"]; | |
| 16 | occurredAt: string; | |
| 17 | aggregateKind: OrchestrationEvent["aggregateKind"]; | |
| 18 | aggregateId: string; | |
| 19 | commandId: string | null; | |
| 20 | payload: unknown; | |
| 21 | }): OrchestrationEvent { | |
| 22 | return { | |
| 23 | sequence: input.sequence, | |
| 24 | eventId: EventId.makeUnsafe(`event-${input.sequence}`), | |
| 25 | type: input.type, | |
| 26 | aggregateKind: input.aggregateKind, | |
| 27 | aggregateId: | |
| 28 | input.aggregateKind === "project" | |
| 29 | ? ProjectId.makeUnsafe(input.aggregateId) | |
| 30 | : ThreadId.makeUnsafe(input.aggregateId), | |
| 31 | occurredAt: input.occurredAt, | |
| 32 | commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), | |
| 33 | causationEventId: null, | |
| 34 | correlationId: null, | |
| 35 | metadata: {}, | |
| 36 | payload: input.payload as never, | |
| 37 | } as OrchestrationEvent; | |
| 38 | } | |
| 39 | ||
| 40 | describe("orchestration projector", () => { | |
| 41 | it("applies thread.created events", async () => { | |
| 42 | const now = new Date().toISOString(); | |
| 43 | const model = createEmptyReadModel(now); | |
| 44 | ||
| 45 | const next = await Effect.runPromise( | |
| 46 | projectEvent( | |
| 47 | model, | |
| 48 | makeEvent({ | |
| 49 | sequence: 1, | |
| 50 | type: "thread.created", | |
| 51 | aggregateKind: "thread", | |
| 52 | aggregateId: "thread-1", | |
| 53 | occurredAt: now, | |
| 54 | commandId: "cmd-thread-create", | |
| 55 | payload: { | |
| 56 | threadId: "thread-1", | |
| 57 | projectId: "project-1", | |
| 58 | title: "demo", | |
| 59 | model: "gpt-5-codex", | |
| 60 | branch: null, | |
| 61 | worktreePath: null, | |
| 62 | createdAt: now, | |
| 63 | updatedAt: now, | |
| 64 | }, | |
| 65 | }), | |
| 66 | ), | |
| 67 | ); | |
| 68 | ||
| 69 | expect(next.snapshotSequence).toBe(1); | |
| 70 | expect(next.threads).toEqual([ | |
| 71 | { | |
| 72 | id: "thread-1", | |
| 73 | projectId: "project-1", | |
| 74 | title: "demo", | |
| 75 | model: "gpt-5-codex", | |
| 76 | branch: null, | |
| 77 | worktreePath: null, | |
| 78 | latestTurn: null, | |
| 79 | createdAt: now, | |
| 80 | updatedAt: now, | |
| 81 | deletedAt: null, | |
| 82 | messages: [], | |
| 83 | activities: [], | |
| 84 | checkpoints: [], | |
| 85 | session: null, | |
| 86 | }, | |
| 87 | ]); | |
| 88 | }); | |
| 89 | ||
| 90 | it("fails when event payload cannot be decoded by runtime schema", async () => { | |
| 91 | const now = new Date().toISOString(); | |
| 92 | const model = createEmptyReadModel(now); | |
| 93 | ||
| 94 | await expect( | |
| 95 | Effect.runPromise( | |
| 96 | projectEvent( | |
| 97 | model, | |
| 98 | makeEvent({ | |
| 99 | sequence: 1, | |
| 100 | type: "thread.created", | |
| 101 | aggregateKind: "thread", | |
| 102 | aggregateId: "thread-1", | |
| 103 | occurredAt: now, | |
| 104 | commandId: "cmd-invalid", | |
| 105 | payload: { | |
| 106 | // missing required threadId | |
| 107 | projectId: "project-1", | |
| 108 | title: "demo", | |
| 109 | model: "gpt-5-codex", | |
| 110 | branch: null, | |
| 111 | worktreePath: null, | |
| 112 | createdAt: now, | |
| 113 | updatedAt: now, | |
| 114 | }, | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | ).rejects.toBeDefined(); | |
| 119 | }); | |
| 120 | ||
| 121 | it("keeps projector forward-compatible for unhandled event types", async () => { | |
| 122 | const now = new Date().toISOString(); | |
| 123 | const model = createEmptyReadModel(now); | |
| 124 | ||
| 125 | const next = await Effect.runPromise( | |
| 126 | projectEvent( | |
| 127 | model, | |
| 128 | makeEvent({ | |
| 129 | sequence: 7, | |
| 130 | type: "thread.turn-start-requested", | |
| 131 | aggregateKind: "thread", | |
| 132 | aggregateId: "thread-1", | |
| 133 | occurredAt: "2026-01-01T00:00:00.000Z", | |
| 134 | commandId: "cmd-unhandled", | |
| 135 | payload: { | |
| 136 | threadId: "thread-1", | |
| 137 | messageId: "message-1", | |
| 138 | approvalPolicy: "on-request", | |
| 139 | sandboxMode: "workspace-write", | |
| 140 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 141 | }, | |
| 142 | }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | ||
| 146 | expect(next.snapshotSequence).toBe(7); | |
| 147 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); | |
| 148 | expect(next.threads).toEqual([]); | |
| 149 | }); | |
| 150 | ||
| 151 | it("tracks latest turn id from session lifecycle events", async () => { | |
| 152 | const createdAt = "2026-02-23T08:00:00.000Z"; | |
| 153 | const startedAt = "2026-02-23T08:00:05.000Z"; | |
| 154 | const model = createEmptyReadModel(createdAt); | |
| 155 | ||
| 156 | const afterCreate = await Effect.runPromise( | |
| 157 | projectEvent( | |
| 158 | model, | |
| 159 | makeEvent({ | |
| 160 | sequence: 1, | |
| 161 | type: "thread.created", | |
| 162 | aggregateKind: "thread", | |
| 163 | aggregateId: "thread-1", | |
| 164 | occurredAt: createdAt, | |
| 165 | commandId: "cmd-create", | |
| 166 | payload: { | |
| 167 | threadId: "thread-1", | |
| 168 | projectId: "project-1", | |
| 169 | title: "demo", | |
| 170 | model: "gpt-5.3-codex", | |
| 171 | branch: null, | |
| 172 | worktreePath: null, | |
| 173 | createdAt, | |
| 174 | updatedAt: createdAt, | |
| 175 | }, | |
| 176 | }), | |
| 177 | ), | |
| 178 | ); | |
| 179 | ||
| 180 | const afterRunning = await Effect.runPromise( | |
| 181 | projectEvent( | |
| 182 | afterCreate, | |
| 183 | makeEvent({ | |
| 184 | sequence: 2, | |
| 185 | type: "thread.session-set", | |
| 186 | aggregateKind: "thread", | |
| 187 | aggregateId: "thread-1", | |
| 188 | occurredAt: startedAt, | |
| 189 | commandId: "cmd-running", | |
| 190 | payload: { | |
| 191 | threadId: "thread-1", | |
| 192 | session: { | |
| 193 | threadId: "thread-1", | |
| 194 | status: "running", | |
| 195 | providerName: "codex", | |
| 196 | providerSessionId: "session-1", | |
| 197 | providerThreadId: "provider-thread-1", | |
| 198 | approvalPolicy: "on-request", | |
| 199 | sandboxMode: "workspace-write", | |
| 200 | activeTurnId: "turn-1", | |
| 201 | lastError: null, | |
| 202 | updatedAt: startedAt, | |
| 203 | }, | |
| 204 | }, | |
| 205 | }), | |
| 206 | ), | |
| 207 | ); | |
| 208 | ||
| 209 | const thread = afterRunning.threads[0]; | |
| 210 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); | |
| 211 | expect(thread?.session?.status).toBe("running"); | |
| 212 | }); | |
| 213 | ||
| 214 | it("marks assistant messages completed with non-streaming updates", async () => { | |
| 215 | const createdAt = "2026-02-23T09:00:00.000Z"; | |
| 216 | const deltaAt = "2026-02-23T09:00:01.000Z"; | |
| 217 | const completeAt = "2026-02-23T09:00:03.500Z"; | |
| 218 | const model = createEmptyReadModel(createdAt); | |
| 219 | ||
| 220 | const afterCreate = await Effect.runPromise( | |
| 221 | projectEvent( | |
| 222 | model, | |
| 223 | makeEvent({ | |
| 224 | sequence: 1, | |
| 225 | type: "thread.created", | |
| 226 | aggregateKind: "thread", | |
| 227 | aggregateId: "thread-1", | |
| 228 | occurredAt: createdAt, | |
| 229 | commandId: "cmd-create", | |
| 230 | payload: { | |
| 231 | threadId: "thread-1", | |
| 232 | projectId: "project-1", | |
| 233 | title: "demo", | |
| 234 | model: "gpt-5.3-codex", | |
| 235 | branch: null, | |
| 236 | worktreePath: null, | |
| 237 | createdAt, | |
| 238 | updatedAt: createdAt, | |
| 239 | }, | |
| 240 | }), | |
| 241 | ), | |
| 242 | ); | |
| 243 | ||
| 244 | const afterDelta = await Effect.runPromise( | |
| 245 | projectEvent( | |
| 246 | afterCreate, | |
| 247 | makeEvent({ | |
| 248 | sequence: 2, | |
| 249 | type: "thread.message-sent", | |
| 250 | aggregateKind: "thread", | |
| 251 | aggregateId: "thread-1", | |
| 252 | occurredAt: deltaAt, | |
| 253 | commandId: "cmd-delta", | |
| 254 | payload: { | |
| 255 | threadId: "thread-1", | |
| 256 | messageId: "assistant:msg-1", | |
| 257 | role: "assistant", | |
| 258 | text: "hello", | |
| 259 | turnId: "turn-1", | |
| 260 | streaming: true, | |
| 261 | createdAt: deltaAt, | |
| 262 | updatedAt: deltaAt, | |
| 263 | }, | |
| 264 | }), | |
| 265 | ), | |
| 266 | ); | |
| 267 | ||
| 268 | const afterComplete = await Effect.runPromise( | |
| 269 | projectEvent( | |
| 270 | afterDelta, | |
| 271 | makeEvent({ | |
| 272 | sequence: 3, | |
| 273 | type: "thread.message-sent", | |
| 274 | aggregateKind: "thread", | |
| 275 | aggregateId: "thread-1", | |
| 276 | occurredAt: completeAt, | |
| 277 | commandId: "cmd-complete", | |
| 278 | payload: { | |
| 279 | threadId: "thread-1", | |
| 280 | messageId: "assistant:msg-1", | |
| 281 | role: "assistant", | |
| 282 | text: "", | |
| 283 | turnId: "turn-1", | |
| 284 | streaming: false, | |
| 285 | createdAt: completeAt, | |
| 286 | updatedAt: completeAt, | |
| 287 | }, | |
| 288 | }), | |
| 289 | ), | |
| 290 | ); | |
| 291 | ||
| 292 | const message = afterComplete.threads[0]?.messages[0]; | |
| 293 | expect(message?.id).toBe("assistant:msg-1"); | |
| 294 | expect(message?.text).toBe("hello"); | |
| 295 | expect(message?.streaming).toBe(false); | |
| 296 | expect(message?.updatedAt).toBe(completeAt); | |
| 297 | }); | |
| 298 | ||
| 299 | it("prunes reverted turn messages from in-memory thread snapshot", async () => { | |
| 300 | const createdAt = "2026-02-23T10:00:00.000Z"; | |
| 301 | const model = createEmptyReadModel(createdAt); | |
| 302 | ||
| 303 | const afterCreate = await Effect.runPromise( | |
| 304 | projectEvent( | |
| 305 | model, | |
| 306 | makeEvent({ | |
| 307 | sequence: 1, | |
| 308 | type: "thread.created", | |
| 309 | aggregateKind: "thread", | |
| 310 | aggregateId: "thread-1", | |
| 311 | occurredAt: createdAt, | |
| 312 | commandId: "cmd-create", | |
| 313 | payload: { | |
| 314 | threadId: "thread-1", | |
| 315 | projectId: "project-1", | |
| 316 | title: "demo", | |
| 317 | model: "gpt-5.3-codex", | |
| 318 | branch: null, | |
| 319 | worktreePath: null, | |
| 320 | createdAt, | |
| 321 | updatedAt: createdAt, | |
| 322 | }, | |
| 323 | }), | |
| 324 | ), | |
| 325 | ); | |
| 326 | ||
| 327 | const events: ReadonlyArray<OrchestrationEvent> = [ | |
| 328 | makeEvent({ | |
| 329 | sequence: 2, | |
| 330 | type: "thread.message-sent", | |
| 331 | aggregateKind: "thread", | |
| 332 | aggregateId: "thread-1", | |
| 333 | occurredAt: "2026-02-23T10:00:01.000Z", | |
| 334 | commandId: "cmd-user-1", | |
| 335 | payload: { | |
| 336 | threadId: "thread-1", | |
| 337 | messageId: "user-msg-1", | |
| 338 | role: "user", | |
| 339 | text: "First edit", | |
| 340 | turnId: null, | |
| 341 | streaming: false, | |
| 342 | createdAt: "2026-02-23T10:00:01.000Z", | |
| 343 | updatedAt: "2026-02-23T10:00:01.000Z", | |
| 344 | }, | |
| 345 | }), | |
| 346 | makeEvent({ | |
| 347 | sequence: 3, | |
| 348 | type: "thread.message-sent", | |
| 349 | aggregateKind: "thread", | |
| 350 | aggregateId: "thread-1", | |
| 351 | occurredAt: "2026-02-23T10:00:02.000Z", | |
| 352 | commandId: "cmd-assistant-1", | |
| 353 | payload: { | |
| 354 | threadId: "thread-1", | |
| 355 | messageId: "assistant-msg-1", | |
| 356 | role: "assistant", | |
| 357 | text: "Updated README to v2.\n", | |
| 358 | turnId: "turn-1", | |
| 359 | streaming: false, | |
| 360 | createdAt: "2026-02-23T10:00:02.000Z", | |
| 361 | updatedAt: "2026-02-23T10:00:02.000Z", | |
| 362 | }, | |
| 363 | }), | |
| 364 | makeEvent({ | |
| 365 | sequence: 4, | |
| 366 | type: "thread.turn-diff-completed", | |
| 367 | aggregateKind: "thread", | |
| 368 | aggregateId: "thread-1", | |
| 369 | occurredAt: "2026-02-23T10:00:02.500Z", | |
| 370 | commandId: "cmd-turn-1-complete", | |
| 371 | payload: { | |
| 372 | threadId: "thread-1", | |
| 373 | turnId: "turn-1", | |
| 374 | checkpointTurnCount: 1, | |
| 375 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", | |
| 376 | status: "ready", | |
| 377 | files: [], | |
| 378 | assistantMessageId: "assistant-msg-1", | |
| 379 | completedAt: "2026-02-23T10:00:02.500Z", | |
| 380 | }, | |
| 381 | }), | |
| 382 | makeEvent({ | |
| 383 | sequence: 5, | |
| 384 | type: "thread.activity-appended", | |
| 385 | aggregateKind: "thread", | |
| 386 | aggregateId: "thread-1", | |
| 387 | occurredAt: "2026-02-23T10:00:02.750Z", | |
| 388 | commandId: "cmd-activity-1", | |
| 389 | payload: { | |
| 390 | threadId: "thread-1", | |
| 391 | activity: { | |
| 392 | id: "activity-1", | |
| 393 | tone: "tool", | |
| 394 | kind: "tool.started", | |
| 395 | summary: "Edit file started", | |
| 396 | payload: { toolKind: "command" }, | |
| 397 | turnId: "turn-1", | |
| 398 | createdAt: "2026-02-23T10:00:02.750Z", | |
| 399 | }, | |
| 400 | }, | |
| 401 | }), | |
| 402 | makeEvent({ | |
| 403 | sequence: 6, | |
| 404 | type: "thread.message-sent", | |
| 405 | aggregateKind: "thread", | |
| 406 | aggregateId: "thread-1", | |
| 407 | occurredAt: "2026-02-23T10:00:03.000Z", | |
| 408 | commandId: "cmd-user-2", | |
| 409 | payload: { | |
| 410 | threadId: "thread-1", | |
| 411 | messageId: "user-msg-2", | |
| 412 | role: "user", | |
| 413 | text: "Second edit", | |
| 414 | turnId: null, | |
| 415 | streaming: false, | |
| 416 | createdAt: "2026-02-23T10:00:03.000Z", | |
| 417 | updatedAt: "2026-02-23T10:00:03.000Z", | |
| 418 | }, | |
| 419 | }), | |
| 420 | makeEvent({ | |
| 421 | sequence: 7, | |
| 422 | type: "thread.message-sent", | |
| 423 | aggregateKind: "thread", | |
| 424 | aggregateId: "thread-1", | |
| 425 | occurredAt: "2026-02-23T10:00:04.000Z", | |
| 426 | commandId: "cmd-assistant-2", | |
| 427 | payload: { | |
| 428 | threadId: "thread-1", | |
| 429 | messageId: "assistant-msg-2", | |
| 430 | role: "assistant", | |
| 431 | text: "Updated README to v3.\n", | |
| 432 | turnId: "turn-2", | |
| 433 | streaming: false, | |
| 434 | createdAt: "2026-02-23T10:00:04.000Z", | |
| 435 | updatedAt: "2026-02-23T10:00:04.000Z", | |
| 436 | }, | |
| 437 | }), | |
| 438 | makeEvent({ | |
| 439 | sequence: 8, | |
| 440 | type: "thread.turn-diff-completed", | |
| 441 | aggregateKind: "thread", | |
| 442 | aggregateId: "thread-1", | |
| 443 | occurredAt: "2026-02-23T10:00:04.500Z", | |
| 444 | commandId: "cmd-turn-2-complete", | |
| 445 | payload: { | |
| 446 | threadId: "thread-1", | |
| 447 | turnId: "turn-2", | |
| 448 | checkpointTurnCount: 2, | |
| 449 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", | |
| 450 | status: "ready", | |
| 451 | files: [], | |
| 452 | assistantMessageId: "assistant-msg-2", | |
| 453 | completedAt: "2026-02-23T10:00:04.500Z", | |
| 454 | }, | |
| 455 | }), | |
| 456 | makeEvent({ | |
| 457 | sequence: 9, | |
| 458 | type: "thread.activity-appended", | |
| 459 | aggregateKind: "thread", | |
| 460 | aggregateId: "thread-1", | |
| 461 | occurredAt: "2026-02-23T10:00:04.750Z", | |
| 462 | commandId: "cmd-activity-2", | |
| 463 | payload: { | |
| 464 | threadId: "thread-1", | |
| 465 | activity: { | |
| 466 | id: "activity-2", | |
| 467 | tone: "tool", | |
| 468 | kind: "tool.completed", | |
| 469 | summary: "Edit file complete", | |
| 470 | payload: { toolKind: "command" }, | |
| 471 | turnId: "turn-2", | |
| 472 | createdAt: "2026-02-23T10:00:04.750Z", | |
| 473 | }, | |
| 474 | }, | |
| 475 | }), | |
| 476 | makeEvent({ | |
| 477 | sequence: 10, | |
| 478 | type: "thread.reverted", | |
| 479 | aggregateKind: "thread", | |
| 480 | aggregateId: "thread-1", | |
| 481 | occurredAt: "2026-02-23T10:00:05.000Z", | |
| 482 | commandId: "cmd-revert", | |
| 483 | payload: { | |
| 484 | threadId: "thread-1", | |
| 485 | turnCount: 1, | |
| 486 | }, | |
| 487 | }), | |
| 488 | ]; | |
| 489 | ||
| 490 | const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>( | |
| 491 | (statePromise, event) => | |
| 492 | statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), | |
| 493 | Promise.resolve(afterCreate), | |
| 494 | ); | |
| 495 | ||
| 496 | const thread = afterRevert.threads[0]; | |
| 497 | expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( | |
| 498 | [ | |
| 499 | { role: "user", text: "First edit" }, | |
| 500 | { role: "assistant", text: "Updated README to v2.\n" }, | |
| 501 | ], | |
| 502 | ); | |
| 503 | expect( | |
| 504 | thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), | |
| 505 | ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); | |
| 506 | expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); | |
| 507 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); | |
| 508 | }); | |
| 509 | ||
| 510 | it("does not fallback-retain messages tied to removed turn IDs", async () => { | |
| 511 | const createdAt = "2026-02-26T12:00:00.000Z"; | |
| 512 | const model = createEmptyReadModel(createdAt); | |
| 513 | ||
| 514 | const afterCreate = await Effect.runPromise( | |
| 515 | projectEvent( | |
| 516 | model, | |
| 517 | makeEvent({ | |
| 518 | sequence: 1, | |
| 519 | type: "thread.created", | |
| 520 | aggregateKind: "thread", | |
| 521 | aggregateId: "thread-revert", | |
| 522 | occurredAt: createdAt, | |
| 523 | commandId: "cmd-create-revert", | |
| 524 | payload: { | |
| 525 | threadId: "thread-revert", | |
| 526 | projectId: "project-1", | |
| 527 | title: "demo", | |
| 528 | model: "gpt-5.3-codex", | |
| 529 | branch: null, | |
| 530 | worktreePath: null, | |
| 531 | createdAt, | |
| 532 | updatedAt: createdAt, | |
| 533 | }, | |
| 534 | }), | |
| 535 | ), | |
| 536 | ); | |
| 537 | ||
| 538 | const events: ReadonlyArray<OrchestrationEvent> = [ | |
| 539 | makeEvent({ | |
| 540 | sequence: 2, | |
| 541 | type: "thread.turn-diff-completed", | |
| 542 | aggregateKind: "thread", | |
| 543 | aggregateId: "thread-revert", | |
| 544 | occurredAt: "2026-02-26T12:00:01.000Z", | |
| 545 | commandId: "cmd-turn-1", | |
| 546 | payload: { | |
| 547 | threadId: "thread-revert", | |
| 548 | turnId: "turn-1", | |
| 549 | checkpointTurnCount: 1, | |
| 550 | checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", | |
| 551 | status: "ready", | |
| 552 | files: [], | |
| 553 | assistantMessageId: "assistant-keep", | |
| 554 | completedAt: "2026-02-26T12:00:01.000Z", | |
| 555 | }, | |
| 556 | }), | |
| 557 | makeEvent({ | |
| 558 | sequence: 3, | |
| 559 | type: "thread.message-sent", | |
| 560 | aggregateKind: "thread", | |
| 561 | aggregateId: "thread-revert", | |
| 562 | occurredAt: "2026-02-26T12:00:01.100Z", | |
| 563 | commandId: "cmd-assistant-keep", | |
| 564 | payload: { | |
| 565 | threadId: "thread-revert", | |
| 566 | messageId: "assistant-keep", | |
| 567 | role: "assistant", | |
| 568 | text: "kept", | |
| 569 | turnId: "turn-1", | |
| 570 | streaming: false, | |
| 571 | createdAt: "2026-02-26T12:00:01.100Z", | |
| 572 | updatedAt: "2026-02-26T12:00:01.100Z", | |
| 573 | }, | |
| 574 | }), | |
| 575 | makeEvent({ | |
| 576 | sequence: 4, | |
| 577 | type: "thread.turn-diff-completed", | |
| 578 | aggregateKind: "thread", | |
| 579 | aggregateId: "thread-revert", | |
| 580 | occurredAt: "2026-02-26T12:00:02.000Z", | |
| 581 | commandId: "cmd-turn-2", | |
| 582 | payload: { | |
| 583 | threadId: "thread-revert", | |
| 584 | turnId: "turn-2", | |
| 585 | checkpointTurnCount: 2, | |
| 586 | checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", | |
| 587 | status: "ready", | |
| 588 | files: [], | |
| 589 | assistantMessageId: "assistant-remove", | |
| 590 | completedAt: "2026-02-26T12:00:02.000Z", | |
| 591 | }, | |
| 592 | }), | |
| 593 | makeEvent({ | |
| 594 | sequence: 5, | |
| 595 | type: "thread.message-sent", | |
| 596 | aggregateKind: "thread", | |
| 597 | aggregateId: "thread-revert", | |
| 598 | occurredAt: "2026-02-26T12:00:02.050Z", | |
| 599 | commandId: "cmd-user-remove", | |
| 600 | payload: { | |
| 601 | threadId: "thread-revert", | |
| 602 | messageId: "user-remove", | |
| 603 | role: "user", | |
| 604 | text: "removed", | |
| 605 | turnId: "turn-2", | |
| 606 | streaming: false, | |
| 607 | createdAt: "2026-02-26T12:00:02.050Z", | |
| 608 | updatedAt: "2026-02-26T12:00:02.050Z", | |
| 609 | }, | |
| 610 | }), | |
| 611 | makeEvent({ | |
| 612 | sequence: 6, | |
| 613 | type: "thread.message-sent", | |
| 614 | aggregateKind: "thread", | |
| 615 | aggregateId: "thread-revert", | |
| 616 | occurredAt: "2026-02-26T12:00:02.100Z", | |
| 617 | commandId: "cmd-assistant-remove", | |
| 618 | payload: { | |
| 619 | threadId: "thread-revert", | |
| 620 | messageId: "assistant-remove", | |
| 621 | role: "assistant", | |
| 622 | text: "removed", | |
| 623 | turnId: "turn-2", | |
| 624 | streaming: false, | |
| 625 | createdAt: "2026-02-26T12:00:02.100Z", | |
| 626 | updatedAt: "2026-02-26T12:00:02.100Z", | |
| 627 | }, | |
| 628 | }), | |
| 629 | makeEvent({ | |
| 630 | sequence: 7, | |
| 631 | type: "thread.reverted", | |
| 632 | aggregateKind: "thread", | |
| 633 | aggregateId: "thread-revert", | |
| 634 | occurredAt: "2026-02-26T12:00:03.000Z", | |
| 635 | commandId: "cmd-revert", | |
| 636 | payload: { | |
| 637 | threadId: "thread-revert", | |
| 638 | turnCount: 1, | |
| 639 | }, | |
| 640 | }), | |
| 641 | ]; | |
| 642 | ||
| 643 | const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>( | |
| 644 | (statePromise, event) => | |
| 645 | statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), | |
| 646 | Promise.resolve(afterCreate), | |
| 647 | ); | |
| 648 | ||
| 649 | const thread = afterRevert.threads[0]; | |
| 650 | expect( | |
| 651 | thread?.messages.map((message) => ({ | |
| 652 | id: message.id, | |
| 653 | role: message.role, | |
| 654 | turnId: message.turnId, | |
| 655 | })), | |
| 656 | ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); | |
| 657 | }); | |
| 658 | ||
| 659 | it("caps message and checkpoint retention for long-lived threads", async () => { | |
| 660 | const createdAt = "2026-03-01T10:00:00.000Z"; | |
| 661 | const model = createEmptyReadModel(createdAt); | |
| 662 | ||
| 663 | const afterCreate = await Effect.runPromise( | |
| 664 | projectEvent( | |
| 665 | model, | |
| 666 | makeEvent({ | |
| 667 | sequence: 1, | |
| 668 | type: "thread.created", | |
| 669 | aggregateKind: "thread", | |
| 670 | aggregateId: "thread-capped", | |
| 671 | occurredAt: createdAt, | |
| 672 | commandId: "cmd-create-capped", | |
| 673 | payload: { | |
| 674 | threadId: "thread-capped", | |
| 675 | projectId: "project-1", | |
| 676 | title: "capped", | |
| 677 | model: "gpt-5-codex", | |
| 678 | branch: null, | |
| 679 | worktreePath: null, | |
| 680 | createdAt, | |
| 681 | updatedAt: createdAt, | |
| 682 | }, | |
| 683 | }), | |
| 684 | ), | |
| 685 | ); | |
| 686 | ||
| 687 | const messageEvents: ReadonlyArray<OrchestrationEvent> = Array.from( | |
| 688 | { length: 2_100 }, | |
| 689 | (_, index) => | |
| 690 | makeEvent({ | |
| 691 | sequence: index + 2, | |
| 692 | type: "thread.message-sent", | |
| 693 | aggregateKind: "thread", | |
| 694 | aggregateId: "thread-capped", | |
| 695 | occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, | |
| 696 | commandId: `cmd-message-${index}`, | |
| 697 | payload: { | |
| 698 | threadId: "thread-capped", | |
| 699 | messageId: `msg-${index}`, | |
| 700 | role: "assistant", | |
| 701 | text: `message-${index}`, | |
| 702 | turnId: `turn-${index}`, | |
| 703 | streaming: false, | |
| 704 | createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, | |
| 705 | updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, | |
| 706 | }, | |
| 707 | }), | |
| 708 | ); | |
| 709 | const afterMessages = await messageEvents.reduce< | |
| 710 | Promise<ReturnType<typeof createEmptyReadModel>> | |
| 711 | >( | |
| 712 | (statePromise, event) => | |
| 713 | statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), | |
| 714 | Promise.resolve(afterCreate), | |
| 715 | ); | |
| 716 | ||
| 717 | const checkpointEvents: ReadonlyArray<OrchestrationEvent> = Array.from( | |
| 718 | { length: 600 }, | |
| 719 | (_, index) => | |
| 720 | makeEvent({ | |
| 721 | sequence: index + 2_102, | |
| 722 | type: "thread.turn-diff-completed", | |
| 723 | aggregateKind: "thread", | |
| 724 | aggregateId: "thread-capped", | |
| 725 | occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, | |
| 726 | commandId: `cmd-checkpoint-${index}`, | |
| 727 | payload: { | |
| 728 | threadId: "thread-capped", | |
| 729 | turnId: `turn-${index}`, | |
| 730 | checkpointTurnCount: index + 1, | |
| 731 | checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, | |
| 732 | status: "ready", | |
| 733 | files: [], | |
| 734 | assistantMessageId: `msg-${index}`, | |
| 735 | completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, | |
| 736 | }, | |
| 737 | }), | |
| 738 | ); | |
| 739 | const finalState = await checkpointEvents.reduce< | |
| 740 | Promise<ReturnType<typeof createEmptyReadModel>> | |
| 741 | >( | |
| 742 | (statePromise, event) => | |
| 743 | statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), | |
| 744 | Promise.resolve(afterMessages), | |
| 745 | ); | |
| 746 | ||
| 747 | const thread = finalState.threads[0]; | |
| 748 | expect(thread?.messages).toHaveLength(2_000); | |
| 749 | expect(thread?.messages[0]?.id).toBe("msg-100"); | |
| 750 | expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); | |
| 751 | expect(thread?.checkpoints).toHaveLength(500); | |
| 752 | expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); | |
| 753 | expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); | |
| 754 | }); | |
| 755 | }); |
apps/server/src/orchestration/projector.ts created+522| ... | ... | @@ -0,0 +1,522 @@ |
| 1 | import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | OrchestrationCheckpointSummary, | |
| 4 | OrchestrationMessage, | |
| 5 | OrchestrationSession, | |
| 6 | OrchestrationThread, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Effect, Schema } from "effect"; | |
| 9 | ||
| 10 | import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; | |
| 11 | import { | |
| 12 | MessageSentPayloadSchema, | |
| 13 | ProjectCreatedPayload, | |
| 14 | ProjectDeletedPayload, | |
| 15 | ProjectMetaUpdatedPayload, | |
| 16 | ThreadActivityAppendedPayload, | |
| 17 | ThreadCreatedPayload, | |
| 18 | ThreadDeletedPayload, | |
| 19 | ThreadMetaUpdatedPayload, | |
| 20 | ThreadRevertedPayload, | |
| 21 | ThreadSessionSetPayload, | |
| 22 | ThreadTurnDiffCompletedPayload, | |
| 23 | } from "./Schemas.ts"; | |
| 24 | ||
| 25 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | |
| 26 | const MAX_THREAD_MESSAGES = 2_000; | |
| 27 | const MAX_THREAD_CHECKPOINTS = 500; | |
| 28 | ||
| 29 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { | |
| 30 | if (status === "error") return "error" as const; | |
| 31 | if (status === "missing") return "interrupted" as const; | |
| 32 | return "completed" as const; | |
| 33 | } | |
| 34 | ||
| 35 | function updateThread( | |
| 36 | threads: ReadonlyArray<OrchestrationThread>, | |
| 37 | threadId: ThreadId, | |
| 38 | patch: ThreadPatch, | |
| 39 | ): OrchestrationThread[] { | |
| 40 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); | |
| 41 | } | |
| 42 | ||
| 43 | function decodeForEvent<A>( | |
| 44 | schema: Schema.Schema<A>, | |
| 45 | value: unknown, | |
| 46 | eventType: OrchestrationEvent["type"], | |
| 47 | field: string, | |
| 48 | ): Effect.Effect<A, OrchestrationProjectorDecodeError> { | |
| 49 | return Effect.try({ | |
| 50 | try: () => Schema.decodeUnknownSync(schema as any)(value), | |
| 51 | catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | function retainThreadMessagesAfterRevert( | |
| 56 | messages: ReadonlyArray<OrchestrationMessage>, | |
| 57 | retainedTurnIds: ReadonlySet<string>, | |
| 58 | turnCount: number, | |
| 59 | ): ReadonlyArray<OrchestrationMessage> { | |
| 60 | const retainedMessageIds = new Set<string>(); | |
| 61 | for (const message of messages) { | |
| 62 | if (message.role === "system") { | |
| 63 | retainedMessageIds.add(message.id); | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { | |
| 67 | retainedMessageIds.add(message.id); | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | const retainedUserCount = messages.filter( | |
| 72 | (message) => message.role === "user" && retainedMessageIds.has(message.id), | |
| 73 | ).length; | |
| 74 | const missingUserCount = Math.max(0, turnCount - retainedUserCount); | |
| 75 | if (missingUserCount > 0) { | |
| 76 | const fallbackUserMessages = messages | |
| 77 | .filter( | |
| 78 | (message) => | |
| 79 | message.role === "user" && | |
| 80 | !retainedMessageIds.has(message.id) && | |
| 81 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 82 | ) | |
| 83 | .toSorted( | |
| 84 | (left, right) => | |
| 85 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | |
| 86 | ) | |
| 87 | .slice(0, missingUserCount); | |
| 88 | for (const message of fallbackUserMessages) { | |
| 89 | retainedMessageIds.add(message.id); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | const retainedAssistantCount = messages.filter( | |
| 94 | (message) => message.role === "assistant" && retainedMessageIds.has(message.id), | |
| 95 | ).length; | |
| 96 | const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); | |
| 97 | if (missingAssistantCount > 0) { | |
| 98 | const fallbackAssistantMessages = messages | |
| 99 | .filter( | |
| 100 | (message) => | |
| 101 | message.role === "assistant" && | |
| 102 | !retainedMessageIds.has(message.id) && | |
| 103 | (message.turnId === null || retainedTurnIds.has(message.turnId)), | |
| 104 | ) | |
| 105 | .toSorted( | |
| 106 | (left, right) => | |
| 107 | left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), | |
| 108 | ) | |
| 109 | .slice(0, missingAssistantCount); | |
| 110 | for (const message of fallbackAssistantMessages) { | |
| 111 | retainedMessageIds.add(message.id); | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | return messages.filter((message) => retainedMessageIds.has(message.id)); | |
| 116 | } | |
| 117 | ||
| 118 | function retainThreadActivitiesAfterRevert( | |
| 119 | activities: ReadonlyArray<OrchestrationThread["activities"][number]>, | |
| 120 | retainedTurnIds: ReadonlySet<string>, | |
| 121 | ): ReadonlyArray<OrchestrationThread["activities"][number]> { | |
| 122 | return activities.filter( | |
| 123 | (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), | |
| 124 | ); | |
| 125 | } | |
| 126 | ||
| 127 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | |
| 128 | return { | |
| 129 | snapshotSequence: 0, | |
| 130 | projects: [], | |
| 131 | threads: [], | |
| 132 | updatedAt: nowIso, | |
| 133 | }; | |
| 134 | } | |
| 135 | ||
| 136 | export function projectEvent( | |
| 137 | model: OrchestrationReadModel, | |
| 138 | event: OrchestrationEvent, | |
| 139 | ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> { | |
| 140 | const nextBase: OrchestrationReadModel = { | |
| 141 | ...model, | |
| 142 | snapshotSequence: event.sequence, | |
| 143 | updatedAt: event.occurredAt, | |
| 144 | }; | |
| 145 | ||
| 146 | switch (event.type) { | |
| 147 | case "project.created": | |
| 148 | return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( | |
| 149 | Effect.map((payload) => { | |
| 150 | const existing = nextBase.projects.find((entry) => entry.id === payload.projectId); | |
| 151 | const nextProject = { | |
| 152 | id: payload.projectId, | |
| 153 | title: payload.title, | |
| 154 | workspaceRoot: payload.workspaceRoot, | |
| 155 | defaultModel: payload.defaultModel, | |
| 156 | scripts: payload.scripts, | |
| 157 | createdAt: payload.createdAt, | |
| 158 | updatedAt: payload.updatedAt, | |
| 159 | deletedAt: null, | |
| 160 | }; | |
| 161 | ||
| 162 | return { | |
| 163 | ...nextBase, | |
| 164 | projects: existing | |
| 165 | ? nextBase.projects.map((entry) => | |
| 166 | entry.id === payload.projectId ? nextProject : entry, | |
| 167 | ) | |
| 168 | : [...nextBase.projects, nextProject], | |
| 169 | }; | |
| 170 | }), | |
| 171 | ); | |
| 172 | ||
| 173 | case "project.meta-updated": | |
| 174 | return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe( | |
| 175 | Effect.map((payload) => ({ | |
| 176 | ...nextBase, | |
| 177 | projects: nextBase.projects.map((project) => | |
| 178 | project.id === payload.projectId | |
| 179 | ? { | |
| 180 | ...project, | |
| 181 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 182 | ...(payload.workspaceRoot !== undefined | |
| 183 | ? { workspaceRoot: payload.workspaceRoot } | |
| 184 | : {}), | |
| 185 | ...(payload.defaultModel !== undefined | |
| 186 | ? { defaultModel: payload.defaultModel } | |
| 187 | : {}), | |
| 188 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), | |
| 189 | updatedAt: payload.updatedAt, | |
| 190 | } | |
| 191 | : project, | |
| 192 | ), | |
| 193 | })), | |
| 194 | ); | |
| 195 | ||
| 196 | case "project.deleted": | |
| 197 | return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe( | |
| 198 | Effect.map((payload) => ({ | |
| 199 | ...nextBase, | |
| 200 | projects: nextBase.projects.map((project) => | |
| 201 | project.id === payload.projectId | |
| 202 | ? { | |
| 203 | ...project, | |
| 204 | deletedAt: payload.deletedAt, | |
| 205 | updatedAt: payload.deletedAt, | |
| 206 | } | |
| 207 | : project, | |
| 208 | ), | |
| 209 | })), | |
| 210 | ); | |
| 211 | ||
| 212 | case "thread.created": | |
| 213 | return Effect.gen(function* () { | |
| 214 | const payload = yield* decodeForEvent( | |
| 215 | ThreadCreatedPayload, | |
| 216 | event.payload, | |
| 217 | event.type, | |
| 218 | "payload", | |
| 219 | ); | |
| 220 | const thread: OrchestrationThread = yield* decodeForEvent( | |
| 221 | OrchestrationThread, | |
| 222 | { | |
| 223 | id: payload.threadId, | |
| 224 | projectId: payload.projectId, | |
| 225 | title: payload.title, | |
| 226 | model: payload.model, | |
| 227 | branch: payload.branch, | |
| 228 | worktreePath: payload.worktreePath, | |
| 229 | latestTurn: null, | |
| 230 | createdAt: payload.createdAt, | |
| 231 | updatedAt: payload.updatedAt, | |
| 232 | deletedAt: null, | |
| 233 | messages: [], | |
| 234 | activities: [], | |
| 235 | checkpoints: [], | |
| 236 | session: null, | |
| 237 | }, | |
| 238 | event.type, | |
| 239 | "thread", | |
| 240 | ); | |
| 241 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); | |
| 242 | return { | |
| 243 | ...nextBase, | |
| 244 | threads: existing | |
| 245 | ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) | |
| 246 | : [...nextBase.threads, thread], | |
| 247 | }; | |
| 248 | }); | |
| 249 | ||
| 250 | case "thread.deleted": | |
| 251 | return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( | |
| 252 | Effect.map((payload) => ({ | |
| 253 | ...nextBase, | |
| 254 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 255 | deletedAt: payload.deletedAt, | |
| 256 | updatedAt: payload.deletedAt, | |
| 257 | }), | |
| 258 | })), | |
| 259 | ); | |
| 260 | ||
| 261 | case "thread.meta-updated": | |
| 262 | return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( | |
| 263 | Effect.map((payload) => ({ | |
| 264 | ...nextBase, | |
| 265 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 266 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 267 | ...(payload.model !== undefined ? { model: payload.model } : {}), | |
| 268 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | |
| 269 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | |
| 270 | updatedAt: payload.updatedAt, | |
| 271 | }), | |
| 272 | })), | |
| 273 | ); | |
| 274 | ||
| 275 | case "thread.message-sent": | |
| 276 | return Effect.gen(function* () { | |
| 277 | const payload = yield* decodeForEvent( | |
| 278 | MessageSentPayloadSchema, | |
| 279 | event.payload, | |
| 280 | event.type, | |
| 281 | "payload", | |
| 282 | ); | |
| 283 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 284 | if (!thread) { | |
| 285 | return nextBase; | |
| 286 | } | |
| 287 | ||
| 288 | const message: OrchestrationMessage = yield* decodeForEvent( | |
| 289 | OrchestrationMessage, | |
| 290 | { | |
| 291 | id: payload.messageId, | |
| 292 | role: payload.role, | |
| 293 | text: payload.text, | |
| 294 | ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), | |
| 295 | turnId: payload.turnId, | |
| 296 | streaming: payload.streaming, | |
| 297 | createdAt: payload.createdAt, | |
| 298 | updatedAt: payload.updatedAt, | |
| 299 | }, | |
| 300 | event.type, | |
| 301 | "message", | |
| 302 | ); | |
| 303 | ||
| 304 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | |
| 305 | const messages = existingMessage | |
| 306 | ? thread.messages.map((entry) => | |
| 307 | entry.id === message.id | |
| 308 | ? { | |
| 309 | ...entry, | |
| 310 | text: message.streaming | |
| 311 | ? `${entry.text}${message.text}` | |
| 312 | : message.text.length > 0 | |
| 313 | ? message.text | |
| 314 | : entry.text, | |
| 315 | streaming: message.streaming, | |
| 316 | updatedAt: message.updatedAt, | |
| 317 | turnId: message.turnId, | |
| 318 | ...(message.attachments !== undefined | |
| 319 | ? { attachments: message.attachments } | |
| 320 | : {}), | |
| 321 | } | |
| 322 | : entry, | |
| 323 | ) | |
| 324 | : [...thread.messages, message]; | |
| 325 | const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); | |
| 326 | ||
| 327 | return { | |
| 328 | ...nextBase, | |
| 329 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 330 | messages: cappedMessages, | |
| 331 | updatedAt: event.occurredAt, | |
| 332 | }), | |
| 333 | }; | |
| 334 | }); | |
| 335 | ||
| 336 | case "thread.session-set": | |
| 337 | return Effect.gen(function* () { | |
| 338 | const payload = yield* decodeForEvent( | |
| 339 | ThreadSessionSetPayload, | |
| 340 | event.payload, | |
| 341 | event.type, | |
| 342 | "payload", | |
| 343 | ); | |
| 344 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 345 | if (!thread) { | |
| 346 | return nextBase; | |
| 347 | } | |
| 348 | ||
| 349 | const session: OrchestrationSession = yield* decodeForEvent( | |
| 350 | OrchestrationSession, | |
| 351 | payload.session, | |
| 352 | event.type, | |
| 353 | "session", | |
| 354 | ); | |
| 355 | ||
| 356 | return { | |
| 357 | ...nextBase, | |
| 358 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 359 | session, | |
| 360 | latestTurn: | |
| 361 | session.status === "running" && session.activeTurnId !== null | |
| 362 | ? { | |
| 363 | turnId: session.activeTurnId, | |
| 364 | state: "running", | |
| 365 | requestedAt: | |
| 366 | thread.latestTurn?.turnId === session.activeTurnId | |
| 367 | ? thread.latestTurn.requestedAt | |
| 368 | : session.updatedAt, | |
| 369 | startedAt: | |
| 370 | thread.latestTurn?.turnId === session.activeTurnId | |
| 371 | ? (thread.latestTurn.startedAt ?? session.updatedAt) | |
| 372 | : session.updatedAt, | |
| 373 | completedAt: null, | |
| 374 | assistantMessageId: | |
| 375 | thread.latestTurn?.turnId === session.activeTurnId | |
| 376 | ? thread.latestTurn.assistantMessageId | |
| 377 | : null, | |
| 378 | } | |
| 379 | : thread.latestTurn, | |
| 380 | updatedAt: event.occurredAt, | |
| 381 | }), | |
| 382 | }; | |
| 383 | }); | |
| 384 | ||
| 385 | case "thread.turn-diff-completed": | |
| 386 | return Effect.gen(function* () { | |
| 387 | const payload = yield* decodeForEvent( | |
| 388 | ThreadTurnDiffCompletedPayload, | |
| 389 | event.payload, | |
| 390 | event.type, | |
| 391 | "payload", | |
| 392 | ); | |
| 393 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 394 | if (!thread) { | |
| 395 | return nextBase; | |
| 396 | } | |
| 397 | ||
| 398 | const checkpoint = yield* decodeForEvent( | |
| 399 | OrchestrationCheckpointSummary, | |
| 400 | { | |
| 401 | turnId: payload.turnId, | |
| 402 | checkpointTurnCount: payload.checkpointTurnCount, | |
| 403 | checkpointRef: payload.checkpointRef, | |
| 404 | status: payload.status, | |
| 405 | files: payload.files, | |
| 406 | assistantMessageId: payload.assistantMessageId, | |
| 407 | completedAt: payload.completedAt, | |
| 408 | }, | |
| 409 | event.type, | |
| 410 | "checkpoint", | |
| 411 | ); | |
| 412 | ||
| 413 | const checkpoints = [ | |
| 414 | ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId), | |
| 415 | checkpoint, | |
| 416 | ] | |
| 417 | .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount) | |
| 418 | .slice(-MAX_THREAD_CHECKPOINTS); | |
| 419 | ||
| 420 | return { | |
| 421 | ...nextBase, | |
| 422 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 423 | checkpoints, | |
| 424 | latestTurn: { | |
| 425 | turnId: payload.turnId, | |
| 426 | state: checkpointStatusToLatestTurnState(payload.status), | |
| 427 | requestedAt: | |
| 428 | thread.latestTurn?.turnId === payload.turnId | |
| 429 | ? thread.latestTurn.requestedAt | |
| 430 | : payload.completedAt, | |
| 431 | startedAt: | |
| 432 | thread.latestTurn?.turnId === payload.turnId | |
| 433 | ? (thread.latestTurn.startedAt ?? payload.completedAt) | |
| 434 | : payload.completedAt, | |
| 435 | completedAt: payload.completedAt, | |
| 436 | assistantMessageId: payload.assistantMessageId, | |
| 437 | }, | |
| 438 | updatedAt: event.occurredAt, | |
| 439 | }), | |
| 440 | }; | |
| 441 | }); | |
| 442 | ||
| 443 | case "thread.reverted": | |
| 444 | return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe( | |
| 445 | Effect.map((payload) => { | |
| 446 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 447 | if (!thread) { | |
| 448 | return nextBase; | |
| 449 | } | |
| 450 | ||
| 451 | const checkpoints = thread.checkpoints | |
| 452 | .filter((entry) => entry.checkpointTurnCount <= payload.turnCount) | |
| 453 | .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount) | |
| 454 | .slice(-MAX_THREAD_CHECKPOINTS); | |
| 455 | const retainedTurnIds = new Set(checkpoints.map((checkpoint) => checkpoint.turnId)); | |
| 456 | const messages = retainThreadMessagesAfterRevert( | |
| 457 | thread.messages, | |
| 458 | retainedTurnIds, | |
| 459 | payload.turnCount, | |
| 460 | ).slice(-MAX_THREAD_MESSAGES); | |
| 461 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); | |
| 462 | ||
| 463 | const latestCheckpoint = checkpoints.at(-1) ?? null; | |
| 464 | const latestTurn = | |
| 465 | latestCheckpoint === null | |
| 466 | ? null | |
| 467 | : { | |
| 468 | turnId: latestCheckpoint.turnId, | |
| 469 | state: checkpointStatusToLatestTurnState(latestCheckpoint.status), | |
| 470 | requestedAt: latestCheckpoint.completedAt, | |
| 471 | startedAt: latestCheckpoint.completedAt, | |
| 472 | completedAt: latestCheckpoint.completedAt, | |
| 473 | assistantMessageId: latestCheckpoint.assistantMessageId, | |
| 474 | }; | |
| 475 | ||
| 476 | return { | |
| 477 | ...nextBase, | |
| 478 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 479 | checkpoints, | |
| 480 | messages, | |
| 481 | activities, | |
| 482 | latestTurn, | |
| 483 | updatedAt: event.occurredAt, | |
| 484 | }), | |
| 485 | }; | |
| 486 | }), | |
| 487 | ); | |
| 488 | ||
| 489 | case "thread.activity-appended": | |
| 490 | return decodeForEvent( | |
| 491 | ThreadActivityAppendedPayload, | |
| 492 | event.payload, | |
| 493 | event.type, | |
| 494 | "payload", | |
| 495 | ).pipe( | |
| 496 | Effect.map((payload) => { | |
| 497 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 498 | if (!thread) { | |
| 499 | return nextBase; | |
| 500 | } | |
| 501 | ||
| 502 | const activities = [ | |
| 503 | ...thread.activities.filter((entry) => entry.id !== payload.activity.id), | |
| 504 | payload.activity, | |
| 505 | ] | |
| 506 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) | |
| 507 | .slice(-500); | |
| 508 | ||
| 509 | return { | |
| 510 | ...nextBase, | |
| 511 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 512 | activities, | |
| 513 | updatedAt: event.occurredAt, | |
| 514 | }), | |
| 515 | }; | |
| 516 | }), | |
| 517 | ); | |
| 518 | ||
| 519 | default: | |
| 520 | return Effect.succeed(nextBase); | |
| 521 | } | |
| 522 | } |
apps/server/src/os-jank.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | import { Effect, Path } from "effect"; | |
| 3 | import { execFileSync } from "node:child_process"; | |
| 4 | ||
| 5 | export function fixPath(): void { | |
| 6 | if (process.platform !== "darwin") return; | |
| 7 | ||
| 8 | try { | |
| 9 | const shell = process.env.SHELL ?? "/bin/zsh"; | |
| 10 | const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { | |
| 11 | encoding: "utf8", | |
| 12 | timeout: 5000, | |
| 13 | }); | |
| 14 | if (result) { | |
| 15 | process.env.PATH = result; | |
| 16 | } | |
| 17 | } catch { | |
| 18 | // Silently ignore — keep default PATH | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | export const expandHomePath = Effect.fn(function* (input: string) { | |
| 23 | const { join, sep } = yield* Path.Path; | |
| 24 | if (input === "~") { | |
| 25 | return OS.homedir(); | |
| 26 | } | |
| 27 | if (input.startsWith(`~${sep}`)) { | |
| 28 | return join(OS.homedir(), input.slice(sep.length)); | |
| 29 | } | |
| 30 | return input; | |
| 31 | }); | |
| 32 | ||
| 33 | export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { | |
| 34 | const { join, resolve } = yield* Path.Path; | |
| 35 | if (!raw || raw.trim().length === 0) { | |
| 36 | return join(OS.homedir(), ".t3", "userdata"); | |
| 37 | } | |
| 38 | return resolve(yield* expandHomePath(raw.trim())); | |
| 39 | }); |
apps/server/src/persistence/Errors.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import { Schema, SchemaIssue } from "effect"; | |
| 2 | ||
| 3 | // =============================== | |
| 4 | // Core Persistence Errors | |
| 5 | // =============================== | |
| 6 | ||
| 7 | export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlError>()( | |
| 8 | "PersistenceSqlError", | |
| 9 | { | |
| 10 | operation: Schema.String, | |
| 11 | detail: Schema.String, | |
| 12 | cause: Schema.optional(Schema.Defect), | |
| 13 | }, | |
| 14 | ) { | |
| 15 | override get message(): string { | |
| 16 | return `SQL error in ${this.operation}: ${this.detail}`; | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceDecodeError>()( | |
| 21 | "PersistenceDecodeError", | |
| 22 | { | |
| 23 | operation: Schema.String, | |
| 24 | issue: Schema.String, | |
| 25 | cause: Schema.optional(Schema.Defect), | |
| 26 | }, | |
| 27 | ) { | |
| 28 | override get message(): string { | |
| 29 | return `Decode error in ${this.operation}: ${this.issue}`; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export function toPersistenceSqlError(operation: string) { | |
| 34 | return (cause: unknown): PersistenceSqlError => | |
| 35 | new PersistenceSqlError({ | |
| 36 | operation, | |
| 37 | detail: `Failed to execute ${operation}`, | |
| 38 | cause, | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | export function toPersistenceDecodeError(operation: string) { | |
| 43 | return (error: Schema.SchemaError): PersistenceDecodeError => | |
| 44 | new PersistenceDecodeError({ | |
| 45 | operation, | |
| 46 | issue: SchemaIssue.makeFormatterDefault()(error.issue), | |
| 47 | cause: error, | |
| 48 | }); | |
| 49 | } | |
| 50 | ||
| 51 | export function toPersistenceDecodeCauseError(operation: string) { | |
| 52 | return (cause: unknown): PersistenceDecodeError => | |
| 53 | new PersistenceDecodeError({ | |
| 54 | operation, | |
| 55 | issue: `Failed to execute ${operation}`, | |
| 56 | cause, | |
| 57 | }); | |
| 58 | } | |
| 59 | ||
| 60 | export const isPersistenceError = (u: unknown) => | |
| 61 | Schema.is(PersistenceSqlError)(u) || Schema.is(PersistenceDecodeError)(u); | |
| 62 | ||
| 63 | // =============================== | |
| 64 | // Provider Session Repository Errors | |
| 65 | // =============================== | |
| 66 | ||
| 67 | export class ProviderSessionRepositoryValidationError extends Schema.TaggedErrorClass<ProviderSessionRepositoryValidationError>()( | |
| 68 | "ProviderSessionRepositoryValidationError", | |
| 69 | { | |
| 70 | operation: Schema.String, | |
| 71 | issue: Schema.String, | |
| 72 | cause: Schema.optional(Schema.Defect), | |
| 73 | }, | |
| 74 | ) { | |
| 75 | override get message(): string { | |
| 76 | return `Provider session repository validation failed in ${this.operation}: ${this.issue}`; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedErrorClass<ProviderSessionRepositoryPersistenceError>()( | |
| 81 | "ProviderSessionRepositoryPersistenceError", | |
| 82 | { | |
| 83 | operation: Schema.String, | |
| 84 | detail: Schema.String, | |
| 85 | cause: Schema.optional(Schema.Defect), | |
| 86 | }, | |
| 87 | ) { | |
| 88 | override get message(): string { | |
| 89 | return `Provider session repository persistence error in ${this.operation}: ${this.detail}`; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError; | |
| 94 | ||
| 95 | export type ProviderSessionRepositoryError = | |
| 96 | | ProviderSessionRepositoryValidationError | |
| 97 | | ProviderSessionRepositoryPersistenceError; | |
| 98 | ||
| 99 | export type OrchestrationCommandReceiptRepositoryError = | |
| 100 | | PersistenceSqlError | |
| 101 | | PersistenceDecodeError; | |
| 102 | ||
| 103 | export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; | |
| 104 | ||
| 105 | export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | GetByCommandIdInput, | |
| 9 | OrchestrationCommandReceipt, | |
| 10 | OrchestrationCommandReceiptRepository, | |
| 11 | type OrchestrationCommandReceiptRepositoryShape, | |
| 12 | } from "../Services/OrchestrationCommandReceipts.ts"; | |
| 13 | ||
| 14 | const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { | |
| 15 | const sql = yield* SqlClient.SqlClient; | |
| 16 | ||
| 17 | const upsertReceiptRow = SqlSchema.void({ | |
| 18 | Request: OrchestrationCommandReceipt, | |
| 19 | execute: (receipt) => | |
| 20 | sql` | |
| 21 | INSERT INTO orchestration_command_receipts ( | |
| 22 | command_id, | |
| 23 | aggregate_kind, | |
| 24 | aggregate_id, | |
| 25 | accepted_at, | |
| 26 | result_sequence, | |
| 27 | status, | |
| 28 | error | |
| 29 | ) | |
| 30 | VALUES ( | |
| 31 | ${receipt.commandId}, | |
| 32 | ${receipt.aggregateKind}, | |
| 33 | ${receipt.aggregateId}, | |
| 34 | ${receipt.acceptedAt}, | |
| 35 | ${receipt.resultSequence}, | |
| 36 | ${receipt.status}, | |
| 37 | ${receipt.error} | |
| 38 | ) | |
| 39 | ON CONFLICT (command_id) | |
| 40 | DO UPDATE SET | |
| 41 | aggregate_kind = excluded.aggregate_kind, | |
| 42 | aggregate_id = excluded.aggregate_id, | |
| 43 | accepted_at = excluded.accepted_at, | |
| 44 | result_sequence = excluded.result_sequence, | |
| 45 | status = excluded.status, | |
| 46 | error = excluded.error | |
| 47 | `, | |
| 48 | }); | |
| 49 | ||
| 50 | const findReceiptByCommandId = SqlSchema.findOneOption({ | |
| 51 | Request: GetByCommandIdInput, | |
| 52 | Result: OrchestrationCommandReceipt, | |
| 53 | execute: ({ commandId }) => | |
| 54 | sql` | |
| 55 | SELECT | |
| 56 | command_id AS "commandId", | |
| 57 | aggregate_kind AS "aggregateKind", | |
| 58 | aggregate_id AS "aggregateId", | |
| 59 | accepted_at AS "acceptedAt", | |
| 60 | result_sequence AS "resultSequence", | |
| 61 | status, | |
| 62 | error | |
| 63 | FROM orchestration_command_receipts | |
| 64 | WHERE command_id = ${commandId} | |
| 65 | `, | |
| 66 | }); | |
| 67 | ||
| 68 | const upsert: OrchestrationCommandReceiptRepositoryShape["upsert"] = (receipt) => | |
| 69 | upsertReceiptRow(receipt).pipe( | |
| 70 | Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), | |
| 71 | ); | |
| 72 | ||
| 73 | const getByCommandId: OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = (input) => | |
| 74 | findReceiptByCommandId(input).pipe( | |
| 75 | Effect.mapError( | |
| 76 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), | |
| 77 | ), | |
| 78 | ); | |
| 79 | ||
| 80 | return { | |
| 81 | upsert, | |
| 82 | getByCommandId, | |
| 83 | } satisfies OrchestrationCommandReceiptRepositoryShape; | |
| 84 | }); | |
| 85 | ||
| 86 | export const OrchestrationCommandReceiptRepositoryLive = Layer.effect( | |
| 87 | OrchestrationCommandReceiptRepository, | |
| 88 | makeOrchestrationCommandReceiptRepository, | |
| 89 | ); |
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { Effect, Layer, Schema, Stream } from "effect"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 5 | ||
| 6 | import { PersistenceDecodeError } from "../Errors.ts"; | |
| 7 | import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; | |
| 8 | import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; | |
| 9 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 10 | ||
| 11 | const layer = it.layer( | |
| 12 | OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 13 | ); | |
| 14 | ||
| 15 | layer("OrchestrationEventStore", (it) => { | |
| 16 | it.effect("stores json columns as strings and replays decoded events", () => | |
| 17 | Effect.gen(function* () { | |
| 18 | const eventStore = yield* OrchestrationEventStore; | |
| 19 | const sql = yield* SqlClient.SqlClient; | |
| 20 | const now = new Date().toISOString(); | |
| 21 | ||
| 22 | const appended = yield* eventStore.append({ | |
| 23 | type: "project.created", | |
| 24 | eventId: EventId.makeUnsafe("evt-store-roundtrip"), | |
| 25 | aggregateKind: "project", | |
| 26 | aggregateId: ProjectId.makeUnsafe("project-roundtrip"), | |
| 27 | occurredAt: now, | |
| 28 | commandId: CommandId.makeUnsafe("cmd-store-roundtrip"), | |
| 29 | causationEventId: null, | |
| 30 | correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"), | |
| 31 | metadata: { | |
| 32 | adapterKey: "codex", | |
| 33 | }, | |
| 34 | payload: { | |
| 35 | projectId: ProjectId.makeUnsafe("project-roundtrip"), | |
| 36 | title: "Roundtrip Project", | |
| 37 | workspaceRoot: "/tmp/project-roundtrip", | |
| 38 | defaultModel: null, | |
| 39 | scripts: [], | |
| 40 | createdAt: now, | |
| 41 | updatedAt: now, | |
| 42 | }, | |
| 43 | }); | |
| 44 | ||
| 45 | const storedRows = yield* sql<{ | |
| 46 | readonly payloadJson: string; | |
| 47 | readonly metadataJson: string; | |
| 48 | }>` | |
| 49 | SELECT | |
| 50 | payload_json AS "payloadJson", | |
| 51 | metadata_json AS "metadataJson" | |
| 52 | FROM orchestration_events | |
| 53 | WHERE event_id = ${appended.eventId} | |
| 54 | `; | |
| 55 | assert.equal(storedRows.length, 1); | |
| 56 | assert.equal(typeof storedRows[0]?.payloadJson, "string"); | |
| 57 | assert.equal(typeof storedRows[0]?.metadataJson, "string"); | |
| 58 | ||
| 59 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe( | |
| 60 | Effect.map((chunk) => Array.from(chunk)), | |
| 61 | ); | |
| 62 | assert.equal(replayed.length, 1); | |
| 63 | assert.equal(replayed[0]?.type, "project.created"); | |
| 64 | assert.equal(replayed[0]?.metadata.adapterKey, "codex"); | |
| 65 | }), | |
| 66 | ); | |
| 67 | ||
| 68 | it.effect("fails with PersistenceDecodeError when stored json is invalid", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | const eventStore = yield* OrchestrationEventStore; | |
| 71 | const sql = yield* SqlClient.SqlClient; | |
| 72 | const now = new Date().toISOString(); | |
| 73 | ||
| 74 | yield* sql` | |
| 75 | INSERT INTO orchestration_events ( | |
| 76 | event_id, | |
| 77 | aggregate_kind, | |
| 78 | stream_id, | |
| 79 | stream_version, | |
| 80 | event_type, | |
| 81 | occurred_at, | |
| 82 | command_id, | |
| 83 | causation_event_id, | |
| 84 | correlation_id, | |
| 85 | actor_kind, | |
| 86 | payload_json, | |
| 87 | metadata_json | |
| 88 | ) | |
| 89 | VALUES ( | |
| 90 | ${EventId.makeUnsafe("evt-store-invalid-json")}, | |
| 91 | ${"project"}, | |
| 92 | ${ProjectId.makeUnsafe("project-invalid-json")}, | |
| 93 | ${0}, | |
| 94 | ${"project.created"}, | |
| 95 | ${now}, | |
| 96 | ${CommandId.makeUnsafe("cmd-store-invalid-json")}, | |
| 97 | ${null}, | |
| 98 | ${null}, | |
| 99 | ${"server"}, | |
| 100 | ${"{"}, | |
| 101 | ${"{}"} | |
| 102 | ) | |
| 103 | `; | |
| 104 | ||
| 105 | const replayResult = yield* Effect.result( | |
| 106 | Stream.runCollect(eventStore.readFromSequence(0, 10)), | |
| 107 | ); | |
| 108 | assert.equal(replayResult._tag, "Failure"); | |
| 109 | if (replayResult._tag === "Failure") { | |
| 110 | assert.ok(Schema.is(PersistenceDecodeError)(replayResult.failure)); | |
| 111 | assert.ok( | |
| 112 | replayResult.failure.operation.includes( | |
| 113 | "OrchestrationEventStore.readFromSequence:decodeRows", | |
| 114 | ), | |
| 115 | ); | |
| 116 | } | |
| 117 | }), | |
| 118 | ); | |
| 119 | }); |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts created+269| ... | ... | @@ -0,0 +1,269 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | EventId, | |
| 4 | IsoDateTime, | |
| 5 | NonNegativeInt, | |
| 6 | OrchestrationActorKind, | |
| 7 | OrchestrationAggregateKind, | |
| 8 | OrchestrationEvent, | |
| 9 | OrchestrationEventMetadata, | |
| 10 | OrchestrationEventType, | |
| 11 | ProjectId, | |
| 12 | ThreadId, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 16 | import { Effect, Layer, Schema, Stream } from "effect"; | |
| 17 | ||
| 18 | import { | |
| 19 | toPersistenceDecodeError, | |
| 20 | toPersistenceSqlError, | |
| 21 | type OrchestrationEventStoreError, | |
| 22 | } from "../Errors.ts"; | |
| 23 | import { | |
| 24 | OrchestrationEventStore, | |
| 25 | type OrchestrationEventStoreShape, | |
| 26 | } from "../Services/OrchestrationEventStore.ts"; | |
| 27 | ||
| 28 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | |
| 29 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | |
| 30 | const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); | |
| 31 | ||
| 32 | const AppendEventRequestSchema = Schema.Struct({ | |
| 33 | eventId: EventId, | |
| 34 | aggregateKind: OrchestrationAggregateKind, | |
| 35 | streamId: Schema.Union([ProjectId, ThreadId]), | |
| 36 | type: OrchestrationEventType, | |
| 37 | causationEventId: Schema.NullOr(EventId), | |
| 38 | correlationId: Schema.NullOr(CommandId), | |
| 39 | actorKind: OrchestrationActorKind, | |
| 40 | occurredAt: IsoDateTime, | |
| 41 | commandId: Schema.NullOr(CommandId), | |
| 42 | payloadJson: UnknownFromJsonString, | |
| 43 | metadataJson: EventMetadataFromJsonString, | |
| 44 | }); | |
| 45 | ||
| 46 | const OrchestrationEventPersistedRowSchema = Schema.Struct({ | |
| 47 | sequence: NonNegativeInt, | |
| 48 | eventId: EventId, | |
| 49 | type: OrchestrationEventType, | |
| 50 | aggregateKind: OrchestrationAggregateKind, | |
| 51 | aggregateId: Schema.Union([ProjectId, ThreadId]), | |
| 52 | occurredAt: IsoDateTime, | |
| 53 | commandId: Schema.NullOr(CommandId), | |
| 54 | causationEventId: Schema.NullOr(EventId), | |
| 55 | correlationId: Schema.NullOr(CommandId), | |
| 56 | payload: UnknownFromJsonString, | |
| 57 | metadata: EventMetadataFromJsonString, | |
| 58 | }); | |
| 59 | ||
| 60 | const ReadFromSequenceRequestSchema = Schema.Struct({ | |
| 61 | sequenceExclusive: NonNegativeInt, | |
| 62 | limit: Schema.Number, | |
| 63 | }); | |
| 64 | const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; | |
| 65 | const READ_PAGE_SIZE = 500; | |
| 66 | ||
| 67 | function inferActorKind( | |
| 68 | event: Omit<OrchestrationEvent, "sequence">, | |
| 69 | ): Schema.Schema.Type<typeof OrchestrationActorKind> { | |
| 70 | if (event.commandId !== null && event.commandId.startsWith("provider:")) { | |
| 71 | return "provider"; | |
| 72 | } | |
| 73 | if (event.commandId !== null && event.commandId.startsWith("server:")) { | |
| 74 | return "server"; | |
| 75 | } | |
| 76 | if ( | |
| 77 | event.metadata.providerSessionId !== undefined || | |
| 78 | event.metadata.providerThreadId !== undefined || | |
| 79 | event.metadata.providerTurnId !== undefined || | |
| 80 | event.metadata.providerItemId !== undefined || | |
| 81 | event.metadata.adapterKey !== undefined | |
| 82 | ) { | |
| 83 | return "provider"; | |
| 84 | } | |
| 85 | if (event.commandId === null) { | |
| 86 | return "server"; | |
| 87 | } | |
| 88 | return "client"; | |
| 89 | } | |
| 90 | ||
| 91 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 92 | return (cause: unknown): OrchestrationEventStoreError => | |
| 93 | Schema.isSchemaError(cause) | |
| 94 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 95 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 96 | } | |
| 97 | ||
| 98 | const makeEventStore = Effect.gen(function* () { | |
| 99 | const sql = yield* SqlClient.SqlClient; | |
| 100 | ||
| 101 | const appendEventRow = SqlSchema.findOne({ | |
| 102 | Request: AppendEventRequestSchema, | |
| 103 | Result: OrchestrationEventPersistedRowSchema, | |
| 104 | execute: (request) => | |
| 105 | sql` | |
| 106 | INSERT INTO orchestration_events ( | |
| 107 | event_id, | |
| 108 | aggregate_kind, | |
| 109 | stream_id, | |
| 110 | stream_version, | |
| 111 | event_type, | |
| 112 | occurred_at, | |
| 113 | command_id, | |
| 114 | causation_event_id, | |
| 115 | correlation_id, | |
| 116 | actor_kind, | |
| 117 | payload_json, | |
| 118 | metadata_json | |
| 119 | ) | |
| 120 | VALUES ( | |
| 121 | ${request.eventId}, | |
| 122 | ${request.aggregateKind}, | |
| 123 | ${request.streamId}, | |
| 124 | COALESCE( | |
| 125 | ( | |
| 126 | SELECT stream_version + 1 | |
| 127 | FROM orchestration_events | |
| 128 | WHERE aggregate_kind = ${request.aggregateKind} | |
| 129 | AND stream_id = ${request.streamId} | |
| 130 | ORDER BY stream_version DESC | |
| 131 | LIMIT 1 | |
| 132 | ), | |
| 133 | 0 | |
| 134 | ), | |
| 135 | ${request.type}, | |
| 136 | ${request.occurredAt}, | |
| 137 | ${request.commandId}, | |
| 138 | ${request.causationEventId}, | |
| 139 | ${request.correlationId}, | |
| 140 | ${request.actorKind}, | |
| 141 | ${request.payloadJson}, | |
| 142 | ${request.metadataJson} | |
| 143 | ) | |
| 144 | RETURNING | |
| 145 | sequence, | |
| 146 | event_id AS "eventId", | |
| 147 | event_type AS "type", | |
| 148 | aggregate_kind AS "aggregateKind", | |
| 149 | stream_id AS "aggregateId", | |
| 150 | occurred_at AS "occurredAt", | |
| 151 | command_id AS "commandId", | |
| 152 | causation_event_id AS "causationEventId", | |
| 153 | correlation_id AS "correlationId", | |
| 154 | payload_json AS "payload", | |
| 155 | metadata_json AS "metadata" | |
| 156 | `, | |
| 157 | }); | |
| 158 | ||
| 159 | const readEventRowsFromSequence = SqlSchema.findAll({ | |
| 160 | Request: ReadFromSequenceRequestSchema, | |
| 161 | Result: OrchestrationEventPersistedRowSchema, | |
| 162 | execute: (request) => | |
| 163 | sql` | |
| 164 | SELECT | |
| 165 | sequence, | |
| 166 | event_id AS "eventId", | |
| 167 | event_type AS "type", | |
| 168 | aggregate_kind AS "aggregateKind", | |
| 169 | stream_id AS "aggregateId", | |
| 170 | occurred_at AS "occurredAt", | |
| 171 | command_id AS "commandId", | |
| 172 | causation_event_id AS "causationEventId", | |
| 173 | correlation_id AS "correlationId", | |
| 174 | payload_json AS "payload", | |
| 175 | metadata_json AS "metadata" | |
| 176 | FROM orchestration_events | |
| 177 | WHERE sequence > ${request.sequenceExclusive} | |
| 178 | ORDER BY sequence ASC | |
| 179 | LIMIT ${request.limit} | |
| 180 | `, | |
| 181 | }); | |
| 182 | ||
| 183 | const append: OrchestrationEventStoreShape["append"] = (event) => | |
| 184 | appendEventRow({ | |
| 185 | eventId: event.eventId, | |
| 186 | aggregateKind: event.aggregateKind, | |
| 187 | streamId: event.aggregateId, | |
| 188 | type: event.type, | |
| 189 | causationEventId: event.causationEventId, | |
| 190 | correlationId: event.correlationId, | |
| 191 | actorKind: inferActorKind(event), | |
| 192 | occurredAt: event.occurredAt, | |
| 193 | commandId: event.commandId, | |
| 194 | payloadJson: event.payload, | |
| 195 | metadataJson: event.metadata, | |
| 196 | }).pipe( | |
| 197 | Effect.mapError( | |
| 198 | toPersistenceSqlOrDecodeError( | |
| 199 | "OrchestrationEventStore.append:insert", | |
| 200 | "OrchestrationEventStore.append:decodeRow", | |
| 201 | ), | |
| 202 | ), | |
| 203 | Effect.flatMap((row) => | |
| 204 | decodeEvent(row).pipe( | |
| 205 | Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), | |
| 206 | ), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( | |
| 211 | sequenceExclusive, | |
| 212 | limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, | |
| 213 | ) => { | |
| 214 | const normalizedLimit = Math.max(0, Math.floor(limit)); | |
| 215 | if (normalizedLimit === 0) { | |
| 216 | return Stream.empty; | |
| 217 | } | |
| 218 | const readPage = ( | |
| 219 | cursor: number, | |
| 220 | remaining: number, | |
| 221 | ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> => | |
| 222 | Stream.fromEffect( | |
| 223 | readEventRowsFromSequence({ | |
| 224 | sequenceExclusive: cursor, | |
| 225 | limit: Math.min(remaining, READ_PAGE_SIZE), | |
| 226 | }).pipe( | |
| 227 | Effect.mapError( | |
| 228 | toPersistenceSqlOrDecodeError( | |
| 229 | "OrchestrationEventStore.readFromSequence:query", | |
| 230 | "OrchestrationEventStore.readFromSequence:decodeRows", | |
| 231 | ), | |
| 232 | ), | |
| 233 | Effect.flatMap((rows) => | |
| 234 | Effect.forEach(rows, (row) => | |
| 235 | decodeEvent(row).pipe( | |
| 236 | Effect.mapError( | |
| 237 | toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), | |
| 238 | ), | |
| 239 | ), | |
| 240 | ), | |
| 241 | ), | |
| 242 | ), | |
| 243 | ).pipe( | |
| 244 | Stream.flatMap((events) => { | |
| 245 | if (events.length === 0) { | |
| 246 | return Stream.empty; | |
| 247 | } | |
| 248 | const nextRemaining = remaining - events.length; | |
| 249 | if (nextRemaining <= 0) { | |
| 250 | return Stream.fromIterable(events); | |
| 251 | } | |
| 252 | return Stream.concat( | |
| 253 | Stream.fromIterable(events), | |
| 254 | readPage(events[events.length - 1]!.sequence, nextRemaining), | |
| 255 | ); | |
| 256 | }), | |
| 257 | ); | |
| 258 | ||
| 259 | return readPage(sequenceExclusive, normalizedLimit); | |
| 260 | }; | |
| 261 | ||
| 262 | return { | |
| 263 | append, | |
| 264 | readFromSequence, | |
| 265 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), | |
| 266 | } satisfies OrchestrationEventStoreShape; | |
| 267 | }); | |
| 268 | ||
| 269 | export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); |
apps/server/src/persistence/Layers/ProjectionCheckpoints.ts created+213| ... | ... | @@ -0,0 +1,213 @@ |
| 1 | import { OrchestrationCheckpointFile } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 5 | ||
| 6 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 7 | import { | |
| 8 | DeleteByThreadIdInput, | |
| 9 | GetByThreadAndTurnCountInput, | |
| 10 | ListByThreadIdInput, | |
| 11 | ProjectionCheckpoint, | |
| 12 | ProjectionCheckpointRepository, | |
| 13 | type ProjectionCheckpointRepositoryShape, | |
| 14 | } from "../Services/ProjectionCheckpoints.ts"; | |
| 15 | ||
| 16 | const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( | |
| 17 | Struct.assign({ | |
| 18 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 19 | }), | |
| 20 | ); | |
| 21 | ||
| 22 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 23 | return (cause: unknown) => | |
| 24 | Schema.isSchemaError(cause) | |
| 25 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 26 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 27 | } | |
| 28 | ||
| 29 | const makeProjectionCheckpointRepository = Effect.gen(function* () { | |
| 30 | const sql = yield* SqlClient.SqlClient; | |
| 31 | ||
| 32 | const clearCheckpointConflict = SqlSchema.void({ | |
| 33 | Request: GetByThreadAndTurnCountInput, | |
| 34 | execute: ({ threadId, checkpointTurnCount }) => | |
| 35 | sql` | |
| 36 | UPDATE projection_turns | |
| 37 | SET | |
| 38 | checkpoint_turn_count = NULL, | |
| 39 | checkpoint_ref = NULL, | |
| 40 | checkpoint_status = NULL, | |
| 41 | checkpoint_files_json = '[]' | |
| 42 | WHERE thread_id = ${threadId} | |
| 43 | AND checkpoint_turn_count = ${checkpointTurnCount} | |
| 44 | `, | |
| 45 | }); | |
| 46 | ||
| 47 | const upsertProjectionCheckpointRow = SqlSchema.void({ | |
| 48 | Request: ProjectionCheckpointDbRowSchema, | |
| 49 | execute: (row) => | |
| 50 | sql` | |
| 51 | INSERT INTO projection_turns ( | |
| 52 | thread_id, | |
| 53 | turn_id, | |
| 54 | pending_message_id, | |
| 55 | assistant_message_id, | |
| 56 | state, | |
| 57 | requested_at, | |
| 58 | started_at, | |
| 59 | completed_at, | |
| 60 | checkpoint_turn_count, | |
| 61 | checkpoint_ref, | |
| 62 | checkpoint_status, | |
| 63 | checkpoint_files_json | |
| 64 | ) | |
| 65 | VALUES ( | |
| 66 | ${row.threadId}, | |
| 67 | ${row.turnId}, | |
| 68 | NULL, | |
| 69 | ${row.assistantMessageId}, | |
| 70 | ${row.status === "error" ? "error" : "completed"}, | |
| 71 | ${row.completedAt}, | |
| 72 | ${row.completedAt}, | |
| 73 | ${row.completedAt}, | |
| 74 | ${row.checkpointTurnCount}, | |
| 75 | ${row.checkpointRef}, | |
| 76 | ${row.status}, | |
| 77 | ${row.files} | |
| 78 | ) | |
| 79 | ON CONFLICT (thread_id, turn_id) | |
| 80 | DO UPDATE SET | |
| 81 | assistant_message_id = excluded.assistant_message_id, | |
| 82 | state = excluded.state, | |
| 83 | completed_at = excluded.completed_at, | |
| 84 | checkpoint_turn_count = excluded.checkpoint_turn_count, | |
| 85 | checkpoint_ref = excluded.checkpoint_ref, | |
| 86 | checkpoint_status = excluded.checkpoint_status, | |
| 87 | checkpoint_files_json = excluded.checkpoint_files_json | |
| 88 | `, | |
| 89 | }); | |
| 90 | ||
| 91 | const listProjectionCheckpointRows = SqlSchema.findAll({ | |
| 92 | Request: ListByThreadIdInput, | |
| 93 | Result: ProjectionCheckpointDbRowSchema, | |
| 94 | execute: ({ threadId }) => | |
| 95 | sql` | |
| 96 | SELECT | |
| 97 | thread_id AS "threadId", | |
| 98 | turn_id AS "turnId", | |
| 99 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 100 | checkpoint_ref AS "checkpointRef", | |
| 101 | checkpoint_status AS "status", | |
| 102 | checkpoint_files_json AS "files", | |
| 103 | assistant_message_id AS "assistantMessageId", | |
| 104 | completed_at AS "completedAt" | |
| 105 | FROM projection_turns | |
| 106 | WHERE thread_id = ${threadId} | |
| 107 | AND checkpoint_turn_count IS NOT NULL | |
| 108 | ORDER BY checkpoint_turn_count ASC | |
| 109 | `, | |
| 110 | }); | |
| 111 | ||
| 112 | const getProjectionCheckpointRow = SqlSchema.findOneOption({ | |
| 113 | Request: GetByThreadAndTurnCountInput, | |
| 114 | Result: ProjectionCheckpointDbRowSchema, | |
| 115 | execute: ({ threadId, checkpointTurnCount }) => | |
| 116 | sql` | |
| 117 | SELECT | |
| 118 | thread_id AS "threadId", | |
| 119 | turn_id AS "turnId", | |
| 120 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 121 | checkpoint_ref AS "checkpointRef", | |
| 122 | checkpoint_status AS "status", | |
| 123 | checkpoint_files_json AS "files", | |
| 124 | assistant_message_id AS "assistantMessageId", | |
| 125 | completed_at AS "completedAt" | |
| 126 | FROM projection_turns | |
| 127 | WHERE thread_id = ${threadId} | |
| 128 | AND checkpoint_turn_count = ${checkpointTurnCount} | |
| 129 | `, | |
| 130 | }); | |
| 131 | ||
| 132 | const deleteProjectionCheckpointRows = SqlSchema.void({ | |
| 133 | Request: DeleteByThreadIdInput, | |
| 134 | execute: ({ threadId }) => | |
| 135 | sql` | |
| 136 | UPDATE projection_turns | |
| 137 | SET | |
| 138 | checkpoint_turn_count = NULL, | |
| 139 | checkpoint_ref = NULL, | |
| 140 | checkpoint_status = NULL, | |
| 141 | checkpoint_files_json = '[]' | |
| 142 | WHERE thread_id = ${threadId} | |
| 143 | AND checkpoint_turn_count IS NOT NULL | |
| 144 | `, | |
| 145 | }); | |
| 146 | ||
| 147 | const upsertCheckpointRow = (row: Schema.Schema.Type<typeof ProjectionCheckpointDbRowSchema>) => | |
| 148 | sql.withTransaction( | |
| 149 | clearCheckpointConflict({ | |
| 150 | threadId: row.threadId, | |
| 151 | checkpointTurnCount: row.checkpointTurnCount, | |
| 152 | }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))), | |
| 153 | ); | |
| 154 | ||
| 155 | const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) => | |
| 156 | upsertCheckpointRow(row).pipe( | |
| 157 | Effect.mapError( | |
| 158 | toPersistenceSqlOrDecodeError( | |
| 159 | "ProjectionCheckpointRepository.upsert:query", | |
| 160 | "ProjectionCheckpointRepository.upsert:encodeRequest", | |
| 161 | ), | |
| 162 | ), | |
| 163 | ); | |
| 164 | ||
| 165 | const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) => | |
| 166 | listProjectionCheckpointRows(input).pipe( | |
| 167 | Effect.mapError( | |
| 168 | toPersistenceSqlOrDecodeError( | |
| 169 | "ProjectionCheckpointRepository.listByThreadId:query", | |
| 170 | "ProjectionCheckpointRepository.listByThreadId:decodeRows", | |
| 171 | ), | |
| 172 | ), | |
| 173 | Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionCheckpoint>>), | |
| 174 | ); | |
| 175 | ||
| 176 | const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = ( | |
| 177 | input, | |
| 178 | ) => | |
| 179 | getProjectionCheckpointRow(input).pipe( | |
| 180 | Effect.mapError( | |
| 181 | toPersistenceSqlOrDecodeError( | |
| 182 | "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", | |
| 183 | "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", | |
| 184 | ), | |
| 185 | ), | |
| 186 | Effect.flatMap((rowOption) => | |
| 187 | Option.match(rowOption, { | |
| 188 | onNone: () => Effect.succeed(Option.none()), | |
| 189 | onSome: (row) => | |
| 190 | Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionCheckpoint>)), | |
| 191 | }), | |
| 192 | ), | |
| 193 | ); | |
| 194 | ||
| 195 | const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) => | |
| 196 | deleteProjectionCheckpointRows(input).pipe( | |
| 197 | Effect.mapError( | |
| 198 | toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), | |
| 199 | ), | |
| 200 | ); | |
| 201 | ||
| 202 | return { | |
| 203 | upsert, | |
| 204 | listByThreadId, | |
| 205 | getByThreadAndTurnCount, | |
| 206 | deleteByThreadId, | |
| 207 | } satisfies ProjectionCheckpointRepositoryShape; | |
| 208 | }); | |
| 209 | ||
| 210 | export const ProjectionCheckpointRepositoryLive = Layer.effect( | |
| 211 | ProjectionCheckpointRepository, | |
| 212 | makeProjectionCheckpointRepository, | |
| 213 | ); |
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | GetProjectionPendingApprovalInput, | |
| 8 | DeleteProjectionPendingApprovalInput, | |
| 9 | ListProjectionPendingApprovalsInput, | |
| 10 | ProjectionPendingApproval, | |
| 11 | ProjectionPendingApprovalRepository, | |
| 12 | type ProjectionPendingApprovalRepositoryShape, | |
| 13 | } from "../Services/ProjectionPendingApprovals.ts"; | |
| 14 | ||
| 15 | const makeProjectionPendingApprovalRepository = Effect.gen(function* () { | |
| 16 | const sql = yield* SqlClient.SqlClient; | |
| 17 | ||
| 18 | const upsertProjectionPendingApprovalRow = SqlSchema.void({ | |
| 19 | Request: ProjectionPendingApproval, | |
| 20 | execute: (row) => | |
| 21 | sql` | |
| 22 | INSERT INTO projection_pending_approvals ( | |
| 23 | request_id, | |
| 24 | thread_id, | |
| 25 | turn_id, | |
| 26 | status, | |
| 27 | decision, | |
| 28 | created_at, | |
| 29 | resolved_at | |
| 30 | ) | |
| 31 | VALUES ( | |
| 32 | ${row.requestId}, | |
| 33 | ${row.threadId}, | |
| 34 | ${row.turnId}, | |
| 35 | ${row.status}, | |
| 36 | ${row.decision}, | |
| 37 | ${row.createdAt}, | |
| 38 | ${row.resolvedAt} | |
| 39 | ) | |
| 40 | ON CONFLICT (request_id) | |
| 41 | DO UPDATE SET | |
| 42 | thread_id = excluded.thread_id, | |
| 43 | turn_id = excluded.turn_id, | |
| 44 | status = excluded.status, | |
| 45 | decision = excluded.decision, | |
| 46 | created_at = excluded.created_at, | |
| 47 | resolved_at = excluded.resolved_at | |
| 48 | `, | |
| 49 | }); | |
| 50 | ||
| 51 | const listProjectionPendingApprovalRows = SqlSchema.findAll({ | |
| 52 | Request: ListProjectionPendingApprovalsInput, | |
| 53 | Result: ProjectionPendingApproval, | |
| 54 | execute: ({ threadId }) => | |
| 55 | sql` | |
| 56 | SELECT | |
| 57 | request_id AS "requestId", | |
| 58 | thread_id AS "threadId", | |
| 59 | turn_id AS "turnId", | |
| 60 | status, | |
| 61 | decision, | |
| 62 | created_at AS "createdAt", | |
| 63 | resolved_at AS "resolvedAt" | |
| 64 | FROM projection_pending_approvals | |
| 65 | WHERE thread_id = ${threadId} | |
| 66 | ORDER BY created_at ASC, request_id ASC | |
| 67 | `, | |
| 68 | }); | |
| 69 | ||
| 70 | const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ | |
| 71 | Request: GetProjectionPendingApprovalInput, | |
| 72 | Result: ProjectionPendingApproval, | |
| 73 | execute: ({ requestId }) => | |
| 74 | sql` | |
| 75 | SELECT | |
| 76 | request_id AS "requestId", | |
| 77 | thread_id AS "threadId", | |
| 78 | turn_id AS "turnId", | |
| 79 | status, | |
| 80 | decision, | |
| 81 | created_at AS "createdAt", | |
| 82 | resolved_at AS "resolvedAt" | |
| 83 | FROM projection_pending_approvals | |
| 84 | WHERE request_id = ${requestId} | |
| 85 | `, | |
| 86 | }); | |
| 87 | ||
| 88 | const deleteProjectionPendingApprovalRow = SqlSchema.void({ | |
| 89 | Request: DeleteProjectionPendingApprovalInput, | |
| 90 | execute: ({ requestId }) => | |
| 91 | sql` | |
| 92 | DELETE FROM projection_pending_approvals | |
| 93 | WHERE request_id = ${requestId} | |
| 94 | `, | |
| 95 | }); | |
| 96 | ||
| 97 | const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => | |
| 98 | upsertProjectionPendingApprovalRow(row).pipe( | |
| 99 | Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), | |
| 100 | ); | |
| 101 | ||
| 102 | const listByThreadId: ProjectionPendingApprovalRepositoryShape["listByThreadId"] = (input) => | |
| 103 | listProjectionPendingApprovalRows(input).pipe( | |
| 104 | Effect.mapError( | |
| 105 | toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), | |
| 106 | ), | |
| 107 | ); | |
| 108 | ||
| 109 | const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => | |
| 110 | getProjectionPendingApprovalRow(input).pipe( | |
| 111 | Effect.mapError( | |
| 112 | toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), | |
| 113 | ), | |
| 114 | ); | |
| 115 | ||
| 116 | const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = ( | |
| 117 | input, | |
| 118 | ) => | |
| 119 | deleteProjectionPendingApprovalRow(input).pipe( | |
| 120 | Effect.mapError( | |
| 121 | toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), | |
| 122 | ), | |
| 123 | ); | |
| 124 | ||
| 125 | return { | |
| 126 | upsert, | |
| 127 | listByThreadId, | |
| 128 | getByRequestId, | |
| 129 | deleteByRequestId, | |
| 130 | } satisfies ProjectionPendingApprovalRepositoryShape; | |
| 131 | }); | |
| 132 | ||
| 133 | export const ProjectionPendingApprovalRepositoryLive = Layer.effect( | |
| 134 | ProjectionPendingApprovalRepository, | |
| 135 | makeProjectionPendingApprovalRepository, | |
| 136 | ); |
apps/server/src/persistence/Layers/ProjectionProjects.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | DeleteProjectionProjectInput, | |
| 9 | GetProjectionProjectInput, | |
| 10 | ProjectionProject, | |
| 11 | ProjectionProjectRepository, | |
| 12 | type ProjectionProjectRepositoryShape, | |
| 13 | } from "../Services/ProjectionProjects.ts"; | |
| 14 | import { ProjectScript } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | // Makes sure that the scripts are parsed from the JSON string the DB returns | |
| 17 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | |
| 18 | Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), | |
| 19 | ); | |
| 20 | ||
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 22 | return (cause: unknown) => | |
| 23 | Schema.isSchemaError(cause) | |
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 26 | } | |
| 27 | ||
| 28 | const makeProjectionProjectRepository = Effect.gen(function* () { | |
| 29 | const sql = yield* SqlClient.SqlClient; | |
| 30 | ||
| 31 | const upsertProjectionProjectRow = SqlSchema.void({ | |
| 32 | Request: ProjectionProjectDbRowSchema, | |
| 33 | execute: (row) => | |
| 34 | sql` | |
| 35 | INSERT INTO projection_projects ( | |
| 36 | project_id, | |
| 37 | title, | |
| 38 | workspace_root, | |
| 39 | default_model, | |
| 40 | scripts_json, | |
| 41 | created_at, | |
| 42 | updated_at, | |
| 43 | deleted_at | |
| 44 | ) | |
| 45 | VALUES ( | |
| 46 | ${row.projectId}, | |
| 47 | ${row.title}, | |
| 48 | ${row.workspaceRoot}, | |
| 49 | ${row.defaultModel}, | |
| 50 | ${row.scripts}, | |
| 51 | ${row.createdAt}, | |
| 52 | ${row.updatedAt}, | |
| 53 | ${row.deletedAt} | |
| 54 | ) | |
| 55 | ON CONFLICT (project_id) | |
| 56 | DO UPDATE SET | |
| 57 | title = excluded.title, | |
| 58 | workspace_root = excluded.workspace_root, | |
| 59 | default_model = excluded.default_model, | |
| 60 | scripts_json = excluded.scripts_json, | |
| 61 | created_at = excluded.created_at, | |
| 62 | updated_at = excluded.updated_at, | |
| 63 | deleted_at = excluded.deleted_at | |
| 64 | `, | |
| 65 | }); | |
| 66 | ||
| 67 | const getProjectionProjectRow = SqlSchema.findOneOption({ | |
| 68 | Request: GetProjectionProjectInput, | |
| 69 | Result: ProjectionProjectDbRowSchema, | |
| 70 | execute: ({ projectId }) => | |
| 71 | sql` | |
| 72 | SELECT | |
| 73 | project_id AS "projectId", | |
| 74 | title, | |
| 75 | workspace_root AS "workspaceRoot", | |
| 76 | default_model AS "defaultModel", | |
| 77 | scripts_json AS "scripts", | |
| 78 | created_at AS "createdAt", | |
| 79 | updated_at AS "updatedAt", | |
| 80 | deleted_at AS "deletedAt" | |
| 81 | FROM projection_projects | |
| 82 | WHERE project_id = ${projectId} | |
| 83 | `, | |
| 84 | }); | |
| 85 | ||
| 86 | const listProjectionProjectRows = SqlSchema.findAll({ | |
| 87 | Request: Schema.Void, | |
| 88 | Result: ProjectionProjectDbRowSchema, | |
| 89 | execute: () => | |
| 90 | sql` | |
| 91 | SELECT | |
| 92 | project_id AS "projectId", | |
| 93 | title, | |
| 94 | workspace_root AS "workspaceRoot", | |
| 95 | default_model AS "defaultModel", | |
| 96 | scripts_json AS "scripts", | |
| 97 | created_at AS "createdAt", | |
| 98 | updated_at AS "updatedAt", | |
| 99 | deleted_at AS "deletedAt" | |
| 100 | FROM projection_projects | |
| 101 | ORDER BY created_at ASC, project_id ASC | |
| 102 | `, | |
| 103 | }); | |
| 104 | ||
| 105 | const deleteProjectionProjectRow = SqlSchema.void({ | |
| 106 | Request: DeleteProjectionProjectInput, | |
| 107 | execute: ({ projectId }) => | |
| 108 | sql` | |
| 109 | DELETE FROM projection_projects | |
| 110 | WHERE project_id = ${projectId} | |
| 111 | `, | |
| 112 | }); | |
| 113 | ||
| 114 | const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => | |
| 115 | upsertProjectionProjectRow(row).pipe( | |
| 116 | Effect.mapError( | |
| 117 | toPersistenceSqlOrDecodeError( | |
| 118 | "ProjectionProjectRepository.upsert:query", | |
| 119 | "ProjectionProjectRepository.upsert:encodeRequest", | |
| 120 | ), | |
| 121 | ), | |
| 122 | ); | |
| 123 | ||
| 124 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => | |
| 125 | getProjectionProjectRow(input).pipe( | |
| 126 | Effect.mapError( | |
| 127 | toPersistenceSqlOrDecodeError( | |
| 128 | "ProjectionProjectRepository.getById:query", | |
| 129 | "ProjectionProjectRepository.getById:decodeRow", | |
| 130 | ), | |
| 131 | ), | |
| 132 | Effect.flatMap((rowOption) => | |
| 133 | Option.match(rowOption, { | |
| 134 | onNone: () => Effect.succeed(Option.none()), | |
| 135 | onSome: (row) => | |
| 136 | Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionProject>)), | |
| 137 | }), | |
| 138 | ), | |
| 139 | ); | |
| 140 | ||
| 141 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => | |
| 142 | listProjectionProjectRows().pipe( | |
| 143 | Effect.mapError( | |
| 144 | toPersistenceSqlOrDecodeError( | |
| 145 | "ProjectionProjectRepository.listAll:query", | |
| 146 | "ProjectionProjectRepository.listAll:decodeRows", | |
| 147 | ), | |
| 148 | ), | |
| 149 | Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionProject>>), | |
| 150 | ); | |
| 151 | ||
| 152 | const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => | |
| 153 | deleteProjectionProjectRow(input).pipe( | |
| 154 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), | |
| 155 | ); | |
| 156 | ||
| 157 | return { | |
| 158 | upsert, | |
| 159 | getById, | |
| 160 | listAll, | |
| 161 | deleteById, | |
| 162 | } satisfies ProjectionProjectRepositoryShape; | |
| 163 | }); | |
| 164 | ||
| 165 | export const ProjectionProjectRepositoryLive = Layer.effect( | |
| 166 | ProjectionProjectRepository, | |
| 167 | makeProjectionProjectRepository, | |
| 168 | ); |
apps/server/src/persistence/Layers/ProjectionState.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { NonNegativeInt } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Schema } from "effect"; | |
| 5 | ||
| 6 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 7 | ||
| 8 | import { | |
| 9 | ProjectionStateRepository, | |
| 10 | type ProjectionStateRepositoryShape, | |
| 11 | GetProjectionStateInput, | |
| 12 | ProjectionState, | |
| 13 | } from "../Services/ProjectionState.ts"; | |
| 14 | ||
| 15 | const MinLastAppliedSequenceRowSchema = Schema.Struct({ | |
| 16 | minLastAppliedSequence: Schema.NullOr(NonNegativeInt), | |
| 17 | }); | |
| 18 | ||
| 19 | const makeProjectionStateRepository = Effect.gen(function* () { | |
| 20 | const sql = yield* SqlClient.SqlClient; | |
| 21 | ||
| 22 | const upsertProjectionStateRow = SqlSchema.void({ | |
| 23 | Request: ProjectionState, | |
| 24 | execute: (row) => | |
| 25 | sql` | |
| 26 | INSERT INTO projection_state ( | |
| 27 | projector, | |
| 28 | last_applied_sequence, | |
| 29 | updated_at | |
| 30 | ) | |
| 31 | VALUES ( | |
| 32 | ${row.projector}, | |
| 33 | ${row.lastAppliedSequence}, | |
| 34 | ${row.updatedAt} | |
| 35 | ) | |
| 36 | ON CONFLICT (projector) | |
| 37 | DO UPDATE SET | |
| 38 | last_applied_sequence = excluded.last_applied_sequence, | |
| 39 | updated_at = excluded.updated_at | |
| 40 | `, | |
| 41 | }); | |
| 42 | ||
| 43 | const getProjectionStateRow = SqlSchema.findOneOption({ | |
| 44 | Request: GetProjectionStateInput, | |
| 45 | Result: ProjectionState, | |
| 46 | execute: ({ projector }) => | |
| 47 | sql` | |
| 48 | SELECT | |
| 49 | projector, | |
| 50 | last_applied_sequence AS "lastAppliedSequence", | |
| 51 | updated_at AS "updatedAt" | |
| 52 | FROM projection_state | |
| 53 | WHERE projector = ${projector} | |
| 54 | `, | |
| 55 | }); | |
| 56 | ||
| 57 | const listProjectionStateRows = SqlSchema.findAll({ | |
| 58 | Request: Schema.Void, | |
| 59 | Result: ProjectionState, | |
| 60 | execute: () => | |
| 61 | sql` | |
| 62 | SELECT | |
| 63 | projector, | |
| 64 | last_applied_sequence AS "lastAppliedSequence", | |
| 65 | updated_at AS "updatedAt" | |
| 66 | FROM projection_state | |
| 67 | ORDER BY projector ASC | |
| 68 | `, | |
| 69 | }); | |
| 70 | ||
| 71 | const readMinLastAppliedSequence = SqlSchema.findOne({ | |
| 72 | Request: Schema.Void, | |
| 73 | Result: MinLastAppliedSequenceRowSchema, | |
| 74 | execute: () => | |
| 75 | sql` | |
| 76 | SELECT | |
| 77 | MIN(last_applied_sequence) AS "minLastAppliedSequence" | |
| 78 | FROM projection_state | |
| 79 | `, | |
| 80 | }); | |
| 81 | ||
| 82 | const upsert: ProjectionStateRepositoryShape["upsert"] = (row) => | |
| 83 | upsertProjectionStateRow(row).pipe( | |
| 84 | Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), | |
| 85 | ); | |
| 86 | ||
| 87 | const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) => | |
| 88 | getProjectionStateRow(input).pipe( | |
| 89 | Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), | |
| 90 | ); | |
| 91 | ||
| 92 | const listAll: ProjectionStateRepositoryShape["listAll"] = () => | |
| 93 | listProjectionStateRows(undefined).pipe( | |
| 94 | Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), | |
| 95 | ); | |
| 96 | ||
| 97 | const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () => | |
| 98 | readMinLastAppliedSequence(undefined).pipe( | |
| 99 | Effect.mapError( | |
| 100 | toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), | |
| 101 | ), | |
| 102 | Effect.map((row) => row.minLastAppliedSequence), | |
| 103 | ); | |
| 104 | ||
| 105 | return { | |
| 106 | upsert, | |
| 107 | getByProjector, | |
| 108 | listAll, | |
| 109 | minLastAppliedSequence, | |
| 110 | } satisfies ProjectionStateRepositoryShape; | |
| 111 | }); | |
| 112 | ||
| 113 | export const ProjectionStateRepositoryLive = Layer.effect( | |
| 114 | ProjectionStateRepository, | |
| 115 | makeProjectionStateRepository, | |
| 116 | ); |
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | DeleteProjectionThreadActivitiesInput, | |
| 9 | ListProjectionThreadActivitiesInput, | |
| 10 | ProjectionThreadActivity, | |
| 11 | ProjectionThreadActivityRepository, | |
| 12 | type ProjectionThreadActivityRepositoryShape, | |
| 13 | } from "../Services/ProjectionThreadActivities.ts"; | |
| 14 | ||
| 15 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | |
| 16 | Struct.assign({ | |
| 17 | payload: Schema.fromJsonString(Schema.Unknown), | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 22 | return (cause: unknown) => | |
| 23 | Schema.isSchemaError(cause) | |
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 26 | } | |
| 27 | ||
| 28 | const makeProjectionThreadActivityRepository = Effect.gen(function* () { | |
| 29 | const sql = yield* SqlClient.SqlClient; | |
| 30 | ||
| 31 | const upsertProjectionThreadActivityRow = SqlSchema.void({ | |
| 32 | Request: ProjectionThreadActivityDbRowSchema, | |
| 33 | execute: (row) => | |
| 34 | sql` | |
| 35 | INSERT INTO projection_thread_activities ( | |
| 36 | activity_id, | |
| 37 | thread_id, | |
| 38 | turn_id, | |
| 39 | tone, | |
| 40 | kind, | |
| 41 | summary, | |
| 42 | payload_json, | |
| 43 | created_at | |
| 44 | ) | |
| 45 | VALUES ( | |
| 46 | ${row.activityId}, | |
| 47 | ${row.threadId}, | |
| 48 | ${row.turnId}, | |
| 49 | ${row.tone}, | |
| 50 | ${row.kind}, | |
| 51 | ${row.summary}, | |
| 52 | ${row.payload}, | |
| 53 | ${row.createdAt} | |
| 54 | ) | |
| 55 | ON CONFLICT (activity_id) | |
| 56 | DO UPDATE SET | |
| 57 | thread_id = excluded.thread_id, | |
| 58 | turn_id = excluded.turn_id, | |
| 59 | tone = excluded.tone, | |
| 60 | kind = excluded.kind, | |
| 61 | summary = excluded.summary, | |
| 62 | payload_json = excluded.payload_json, | |
| 63 | created_at = excluded.created_at | |
| 64 | `, | |
| 65 | }); | |
| 66 | ||
| 67 | const listProjectionThreadActivityRows = SqlSchema.findAll({ | |
| 68 | Request: ListProjectionThreadActivitiesInput, | |
| 69 | Result: ProjectionThreadActivityDbRowSchema, | |
| 70 | execute: ({ threadId }) => | |
| 71 | sql` | |
| 72 | SELECT | |
| 73 | activity_id AS "activityId", | |
| 74 | thread_id AS "threadId", | |
| 75 | turn_id AS "turnId", | |
| 76 | tone, | |
| 77 | kind, | |
| 78 | summary, | |
| 79 | payload_json AS "payload", | |
| 80 | created_at AS "createdAt" | |
| 81 | FROM projection_thread_activities | |
| 82 | WHERE thread_id = ${threadId} | |
| 83 | ORDER BY created_at ASC, activity_id ASC | |
| 84 | `, | |
| 85 | }); | |
| 86 | ||
| 87 | const deleteProjectionThreadActivityRows = SqlSchema.void({ | |
| 88 | Request: DeleteProjectionThreadActivitiesInput, | |
| 89 | execute: ({ threadId }) => | |
| 90 | sql` | |
| 91 | DELETE FROM projection_thread_activities | |
| 92 | WHERE thread_id = ${threadId} | |
| 93 | `, | |
| 94 | }); | |
| 95 | ||
| 96 | const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => | |
| 97 | upsertProjectionThreadActivityRow(row).pipe( | |
| 98 | Effect.mapError( | |
| 99 | toPersistenceSqlOrDecodeError( | |
| 100 | "ProjectionThreadActivityRepository.upsert:query", | |
| 101 | "ProjectionThreadActivityRepository.upsert:encodeRequest", | |
| 102 | ), | |
| 103 | ), | |
| 104 | ); | |
| 105 | ||
| 106 | const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) => | |
| 107 | listProjectionThreadActivityRows(input).pipe( | |
| 108 | Effect.mapError( | |
| 109 | toPersistenceSqlOrDecodeError( | |
| 110 | "ProjectionThreadActivityRepository.listByThreadId:query", | |
| 111 | "ProjectionThreadActivityRepository.listByThreadId:decodeRows", | |
| 112 | ), | |
| 113 | ), | |
| 114 | Effect.map( | |
| 115 | (rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivity>>, | |
| 116 | ), | |
| 117 | ); | |
| 118 | ||
| 119 | const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => | |
| 120 | deleteProjectionThreadActivityRows(input).pipe( | |
| 121 | Effect.mapError( | |
| 122 | toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), | |
| 123 | ), | |
| 124 | ); | |
| 125 | ||
| 126 | return { | |
| 127 | upsert, | |
| 128 | listByThreadId, | |
| 129 | deleteByThreadId, | |
| 130 | } satisfies ProjectionThreadActivityRepositoryShape; | |
| 131 | }); | |
| 132 | ||
| 133 | export const ProjectionThreadActivityRepositoryLive = Layer.effect( | |
| 134 | ProjectionThreadActivityRepository, | |
| 135 | makeProjectionThreadActivityRepository, | |
| 136 | ); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | ProjectionThreadMessageRepository, | |
| 8 | type ProjectionThreadMessageRepositoryShape, | |
| 9 | DeleteProjectionThreadMessagesInput, | |
| 10 | ListProjectionThreadMessagesInput, | |
| 11 | ProjectionThreadMessage, | |
| 12 | } from "../Services/ProjectionThreadMessages.ts"; | |
| 13 | ||
| 14 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 15 | Struct.assign({ | |
| 16 | isStreaming: Schema.Number, | |
| 17 | }), | |
| 18 | ); | |
| 19 | ||
| 20 | const makeProjectionThreadMessageRepository = Effect.gen(function* () { | |
| 21 | const sql = yield* SqlClient.SqlClient; | |
| 22 | ||
| 23 | const upsertProjectionThreadMessageRow = SqlSchema.void({ | |
| 24 | Request: ProjectionThreadMessage, | |
| 25 | execute: (row) => | |
| 26 | sql` | |
| 27 | INSERT INTO projection_thread_messages ( | |
| 28 | message_id, | |
| 29 | thread_id, | |
| 30 | turn_id, | |
| 31 | role, | |
| 32 | text, | |
| 33 | is_streaming, | |
| 34 | created_at, | |
| 35 | updated_at | |
| 36 | ) | |
| 37 | VALUES ( | |
| 38 | ${row.messageId}, | |
| 39 | ${row.threadId}, | |
| 40 | ${row.turnId}, | |
| 41 | ${row.role}, | |
| 42 | ${row.text}, | |
| 43 | ${row.isStreaming ? 1 : 0}, | |
| 44 | ${row.createdAt}, | |
| 45 | ${row.updatedAt} | |
| 46 | ) | |
| 47 | ON CONFLICT (message_id) | |
| 48 | DO UPDATE SET | |
| 49 | thread_id = excluded.thread_id, | |
| 50 | turn_id = excluded.turn_id, | |
| 51 | role = excluded.role, | |
| 52 | text = excluded.text, | |
| 53 | is_streaming = excluded.is_streaming, | |
| 54 | created_at = excluded.created_at, | |
| 55 | updated_at = excluded.updated_at | |
| 56 | `, | |
| 57 | }); | |
| 58 | ||
| 59 | const listProjectionThreadMessageRows = SqlSchema.findAll({ | |
| 60 | Request: ListProjectionThreadMessagesInput, | |
| 61 | Result: ProjectionThreadMessageDbRowSchema, | |
| 62 | execute: ({ threadId }) => | |
| 63 | sql` | |
| 64 | SELECT | |
| 65 | message_id AS "messageId", | |
| 66 | thread_id AS "threadId", | |
| 67 | turn_id AS "turnId", | |
| 68 | role, | |
| 69 | text, | |
| 70 | is_streaming AS "isStreaming", | |
| 71 | created_at AS "createdAt", | |
| 72 | updated_at AS "updatedAt" | |
| 73 | FROM projection_thread_messages | |
| 74 | WHERE thread_id = ${threadId} | |
| 75 | ORDER BY created_at ASC, message_id ASC | |
| 76 | `, | |
| 77 | }); | |
| 78 | ||
| 79 | const deleteProjectionThreadMessageRows = SqlSchema.void({ | |
| 80 | Request: DeleteProjectionThreadMessagesInput, | |
| 81 | execute: ({ threadId }) => | |
| 82 | sql` | |
| 83 | DELETE FROM projection_thread_messages | |
| 84 | WHERE thread_id = ${threadId} | |
| 85 | `, | |
| 86 | }); | |
| 87 | ||
| 88 | const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => | |
| 89 | upsertProjectionThreadMessageRow(row).pipe( | |
| 90 | Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), | |
| 91 | ); | |
| 92 | ||
| 93 | const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => | |
| 94 | listProjectionThreadMessageRows(input).pipe( | |
| 95 | Effect.mapError( | |
| 96 | toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), | |
| 97 | ), | |
| 98 | Effect.map((rows) => | |
| 99 | rows.map((row) => ({ | |
| 100 | ...row, | |
| 101 | isStreaming: row.isStreaming === 1, | |
| 102 | })), | |
| 103 | ), | |
| 104 | ); | |
| 105 | ||
| 106 | const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => | |
| 107 | deleteProjectionThreadMessageRows(input).pipe( | |
| 108 | Effect.mapError( | |
| 109 | toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), | |
| 110 | ), | |
| 111 | ); | |
| 112 | ||
| 113 | return { | |
| 114 | upsert, | |
| 115 | listByThreadId, | |
| 116 | deleteByThreadId, | |
| 117 | } satisfies ProjectionThreadMessageRepositoryShape; | |
| 118 | }); | |
| 119 | ||
| 120 | export const ProjectionThreadMessageRepositoryLive = Layer.effect( | |
| 121 | ProjectionThreadMessageRepository, | |
| 122 | makeProjectionThreadMessageRepository, | |
| 123 | ); |
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | ProjectionThreadSession, | |
| 9 | ProjectionThreadSessionRepository, | |
| 10 | type ProjectionThreadSessionRepositoryShape, | |
| 11 | DeleteProjectionThreadSessionInput, | |
| 12 | GetProjectionThreadSessionInput, | |
| 13 | } from "../Services/ProjectionThreadSessions.ts"; | |
| 14 | ||
| 15 | const makeProjectionThreadSessionRepository = Effect.gen(function* () { | |
| 16 | const sql = yield* SqlClient.SqlClient; | |
| 17 | ||
| 18 | const upsertProjectionThreadSessionRow = SqlSchema.void({ | |
| 19 | Request: ProjectionThreadSession, | |
| 20 | execute: (row) => | |
| 21 | sql` | |
| 22 | INSERT INTO projection_thread_sessions ( | |
| 23 | thread_id, | |
| 24 | status, | |
| 25 | provider_name, | |
| 26 | provider_session_id, | |
| 27 | provider_thread_id, | |
| 28 | approval_policy, | |
| 29 | sandbox_mode, | |
| 30 | active_turn_id, | |
| 31 | last_error, | |
| 32 | updated_at | |
| 33 | ) | |
| 34 | VALUES ( | |
| 35 | ${row.threadId}, | |
| 36 | ${row.status}, | |
| 37 | ${row.providerName}, | |
| 38 | ${row.providerSessionId}, | |
| 39 | ${row.providerThreadId}, | |
| 40 | ${row.approvalPolicy}, | |
| 41 | ${row.sandboxMode}, | |
| 42 | ${row.activeTurnId}, | |
| 43 | ${row.lastError}, | |
| 44 | ${row.updatedAt} | |
| 45 | ) | |
| 46 | ON CONFLICT (thread_id) | |
| 47 | DO UPDATE SET | |
| 48 | status = excluded.status, | |
| 49 | provider_name = excluded.provider_name, | |
| 50 | provider_session_id = excluded.provider_session_id, | |
| 51 | provider_thread_id = excluded.provider_thread_id, | |
| 52 | approval_policy = excluded.approval_policy, | |
| 53 | sandbox_mode = excluded.sandbox_mode, | |
| 54 | active_turn_id = excluded.active_turn_id, | |
| 55 | last_error = excluded.last_error, | |
| 56 | updated_at = excluded.updated_at | |
| 57 | `, | |
| 58 | }); | |
| 59 | ||
| 60 | const getProjectionThreadSessionRow = SqlSchema.findOneOption({ | |
| 61 | Request: GetProjectionThreadSessionInput, | |
| 62 | Result: ProjectionThreadSession, | |
| 63 | execute: ({ threadId }) => | |
| 64 | sql` | |
| 65 | SELECT | |
| 66 | thread_id AS "threadId", | |
| 67 | status, | |
| 68 | provider_name AS "providerName", | |
| 69 | provider_session_id AS "providerSessionId", | |
| 70 | provider_thread_id AS "providerThreadId", | |
| 71 | approval_policy AS "approvalPolicy", | |
| 72 | sandbox_mode AS "sandboxMode", | |
| 73 | active_turn_id AS "activeTurnId", | |
| 74 | last_error AS "lastError", | |
| 75 | updated_at AS "updatedAt" | |
| 76 | FROM projection_thread_sessions | |
| 77 | WHERE thread_id = ${threadId} | |
| 78 | `, | |
| 79 | }); | |
| 80 | ||
| 81 | const deleteProjectionThreadSessionRow = SqlSchema.void({ | |
| 82 | Request: DeleteProjectionThreadSessionInput, | |
| 83 | execute: ({ threadId }) => | |
| 84 | sql` | |
| 85 | DELETE FROM projection_thread_sessions | |
| 86 | WHERE thread_id = ${threadId} | |
| 87 | `, | |
| 88 | }); | |
| 89 | ||
| 90 | const upsert: ProjectionThreadSessionRepositoryShape["upsert"] = (row) => | |
| 91 | upsertProjectionThreadSessionRow(row).pipe( | |
| 92 | Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), | |
| 93 | ); | |
| 94 | ||
| 95 | const getByThreadId: ProjectionThreadSessionRepositoryShape["getByThreadId"] = (input) => | |
| 96 | getProjectionThreadSessionRow(input).pipe( | |
| 97 | Effect.mapError( | |
| 98 | toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), | |
| 99 | ), | |
| 100 | ); | |
| 101 | ||
| 102 | const deleteByThreadId: ProjectionThreadSessionRepositoryShape["deleteByThreadId"] = (input) => | |
| 103 | deleteProjectionThreadSessionRow(input).pipe( | |
| 104 | Effect.mapError( | |
| 105 | toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), | |
| 106 | ), | |
| 107 | ); | |
| 108 | ||
| 109 | return { | |
| 110 | upsert, | |
| 111 | getByThreadId, | |
| 112 | deleteByThreadId, | |
| 113 | } satisfies ProjectionThreadSessionRepositoryShape; | |
| 114 | }); | |
| 115 | ||
| 116 | export const ProjectionThreadSessionRepositoryLive = Layer.effect( | |
| 117 | ProjectionThreadSessionRepository, | |
| 118 | makeProjectionThreadSessionRepository, | |
| 119 | ); |
apps/server/src/persistence/Layers/ProjectionThreads.ts created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | DeleteProjectionThreadInput, | |
| 8 | GetProjectionThreadInput, | |
| 9 | ListProjectionThreadsByProjectInput, | |
| 10 | ProjectionThread, | |
| 11 | ProjectionThreadRepository, | |
| 12 | type ProjectionThreadRepositoryShape, | |
| 13 | } from "../Services/ProjectionThreads.ts"; | |
| 14 | ||
| 15 | const makeProjectionThreadRepository = Effect.gen(function* () { | |
| 16 | const sql = yield* SqlClient.SqlClient; | |
| 17 | ||
| 18 | const upsertProjectionThreadRow = SqlSchema.void({ | |
| 19 | Request: ProjectionThread, | |
| 20 | execute: (row) => | |
| 21 | sql` | |
| 22 | INSERT INTO projection_threads ( | |
| 23 | thread_id, | |
| 24 | project_id, | |
| 25 | title, | |
| 26 | model, | |
| 27 | branch, | |
| 28 | worktree_path, | |
| 29 | latest_turn_id, | |
| 30 | created_at, | |
| 31 | updated_at, | |
| 32 | deleted_at | |
| 33 | ) | |
| 34 | VALUES ( | |
| 35 | ${row.threadId}, | |
| 36 | ${row.projectId}, | |
| 37 | ${row.title}, | |
| 38 | ${row.model}, | |
| 39 | ${row.branch}, | |
| 40 | ${row.worktreePath}, | |
| 41 | ${row.latestTurnId}, | |
| 42 | ${row.createdAt}, | |
| 43 | ${row.updatedAt}, | |
| 44 | ${row.deletedAt} | |
| 45 | ) | |
| 46 | ON CONFLICT (thread_id) | |
| 47 | DO UPDATE SET | |
| 48 | project_id = excluded.project_id, | |
| 49 | title = excluded.title, | |
| 50 | model = excluded.model, | |
| 51 | branch = excluded.branch, | |
| 52 | worktree_path = excluded.worktree_path, | |
| 53 | latest_turn_id = excluded.latest_turn_id, | |
| 54 | created_at = excluded.created_at, | |
| 55 | updated_at = excluded.updated_at, | |
| 56 | deleted_at = excluded.deleted_at | |
| 57 | `, | |
| 58 | }); | |
| 59 | ||
| 60 | const getProjectionThreadRow = SqlSchema.findOneOption({ | |
| 61 | Request: GetProjectionThreadInput, | |
| 62 | Result: ProjectionThread, | |
| 63 | execute: ({ threadId }) => | |
| 64 | sql` | |
| 65 | SELECT | |
| 66 | thread_id AS "threadId", | |
| 67 | project_id AS "projectId", | |
| 68 | title, | |
| 69 | model, | |
| 70 | branch, | |
| 71 | worktree_path AS "worktreePath", | |
| 72 | latest_turn_id AS "latestTurnId", | |
| 73 | created_at AS "createdAt", | |
| 74 | updated_at AS "updatedAt", | |
| 75 | deleted_at AS "deletedAt" | |
| 76 | FROM projection_threads | |
| 77 | WHERE thread_id = ${threadId} | |
| 78 | `, | |
| 79 | }); | |
| 80 | ||
| 81 | const listProjectionThreadRows = SqlSchema.findAll({ | |
| 82 | Request: ListProjectionThreadsByProjectInput, | |
| 83 | Result: ProjectionThread, | |
| 84 | execute: ({ projectId }) => | |
| 85 | sql` | |
| 86 | SELECT | |
| 87 | thread_id AS "threadId", | |
| 88 | project_id AS "projectId", | |
| 89 | title, | |
| 90 | model, | |
| 91 | branch, | |
| 92 | worktree_path AS "worktreePath", | |
| 93 | latest_turn_id AS "latestTurnId", | |
| 94 | created_at AS "createdAt", | |
| 95 | updated_at AS "updatedAt", | |
| 96 | deleted_at AS "deletedAt" | |
| 97 | FROM projection_threads | |
| 98 | WHERE project_id = ${projectId} | |
| 99 | ORDER BY created_at ASC, thread_id ASC | |
| 100 | `, | |
| 101 | }); | |
| 102 | ||
| 103 | const deleteProjectionThreadRow = SqlSchema.void({ | |
| 104 | Request: DeleteProjectionThreadInput, | |
| 105 | execute: ({ threadId }) => | |
| 106 | sql` | |
| 107 | DELETE FROM projection_threads | |
| 108 | WHERE thread_id = ${threadId} | |
| 109 | `, | |
| 110 | }); | |
| 111 | ||
| 112 | const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => | |
| 113 | upsertProjectionThreadRow(row).pipe( | |
| 114 | Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), | |
| 115 | ); | |
| 116 | ||
| 117 | const getById: ProjectionThreadRepositoryShape["getById"] = (input) => | |
| 118 | getProjectionThreadRow(input).pipe( | |
| 119 | Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), | |
| 120 | ); | |
| 121 | ||
| 122 | const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => | |
| 123 | listProjectionThreadRows(input).pipe( | |
| 124 | Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), | |
| 125 | ); | |
| 126 | ||
| 127 | const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => | |
| 128 | deleteProjectionThreadRow(input).pipe( | |
| 129 | Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), | |
| 130 | ); | |
| 131 | ||
| 132 | return { | |
| 133 | upsert, | |
| 134 | getById, | |
| 135 | listByProjectId, | |
| 136 | deleteById, | |
| 137 | } satisfies ProjectionThreadRepositoryShape; | |
| 138 | }); | |
| 139 | ||
| 140 | export const ProjectionThreadRepositoryLive = Layer.effect( | |
| 141 | ProjectionThreadRepository, | |
| 142 | makeProjectionThreadRepository, | |
| 143 | ); |
apps/server/src/persistence/Layers/ProjectionTurns.ts created+335| ... | ... | @@ -0,0 +1,335 @@ |
| 1 | import { OrchestrationCheckpointFile } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 5 | ||
| 6 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 7 | import { | |
| 8 | ClearCheckpointTurnConflictInput, | |
| 9 | DeleteProjectionTurnsByThreadInput, | |
| 10 | GetProjectionPendingTurnStartInput, | |
| 11 | GetProjectionTurnByTurnIdInput, | |
| 12 | ListProjectionTurnsByThreadInput, | |
| 13 | ProjectionPendingTurnStart, | |
| 14 | ProjectionTurn, | |
| 15 | ProjectionTurnById, | |
| 16 | ProjectionTurnRepository, | |
| 17 | type ProjectionTurnRepositoryShape, | |
| 18 | } from "../Services/ProjectionTurns.ts"; | |
| 19 | ||
| 20 | const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( | |
| 21 | Struct.assign({ | |
| 22 | checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( | |
| 27 | Struct.assign({ | |
| 28 | checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 29 | }), | |
| 30 | ); | |
| 31 | ||
| 32 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 33 | return (cause: unknown) => | |
| 34 | Schema.isSchemaError(cause) | |
| 35 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 36 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 37 | } | |
| 38 | ||
| 39 | const makeProjectionTurnRepository = Effect.gen(function* () { | |
| 40 | const sql = yield* SqlClient.SqlClient; | |
| 41 | ||
| 42 | const upsertProjectionTurnById = SqlSchema.void({ | |
| 43 | Request: ProjectionTurnByIdDbRowSchema, | |
| 44 | execute: (row) => | |
| 45 | sql` | |
| 46 | INSERT INTO projection_turns ( | |
| 47 | thread_id, | |
| 48 | turn_id, | |
| 49 | pending_message_id, | |
| 50 | assistant_message_id, | |
| 51 | state, | |
| 52 | requested_at, | |
| 53 | started_at, | |
| 54 | completed_at, | |
| 55 | checkpoint_turn_count, | |
| 56 | checkpoint_ref, | |
| 57 | checkpoint_status, | |
| 58 | checkpoint_files_json | |
| 59 | ) | |
| 60 | VALUES ( | |
| 61 | ${row.threadId}, | |
| 62 | ${row.turnId}, | |
| 63 | ${row.pendingMessageId}, | |
| 64 | ${row.assistantMessageId}, | |
| 65 | ${row.state}, | |
| 66 | ${row.requestedAt}, | |
| 67 | ${row.startedAt}, | |
| 68 | ${row.completedAt}, | |
| 69 | ${row.checkpointTurnCount}, | |
| 70 | ${row.checkpointRef}, | |
| 71 | ${row.checkpointStatus}, | |
| 72 | ${row.checkpointFiles} | |
| 73 | ) | |
| 74 | ON CONFLICT (thread_id, turn_id) | |
| 75 | DO UPDATE SET | |
| 76 | pending_message_id = excluded.pending_message_id, | |
| 77 | assistant_message_id = excluded.assistant_message_id, | |
| 78 | state = excluded.state, | |
| 79 | requested_at = excluded.requested_at, | |
| 80 | started_at = excluded.started_at, | |
| 81 | completed_at = excluded.completed_at, | |
| 82 | checkpoint_turn_count = excluded.checkpoint_turn_count, | |
| 83 | checkpoint_ref = excluded.checkpoint_ref, | |
| 84 | checkpoint_status = excluded.checkpoint_status, | |
| 85 | checkpoint_files_json = excluded.checkpoint_files_json | |
| 86 | `, | |
| 87 | }); | |
| 88 | ||
| 89 | const clearPendingProjectionTurnsByThread = SqlSchema.void({ | |
| 90 | Request: DeleteProjectionTurnsByThreadInput, | |
| 91 | execute: ({ threadId }) => | |
| 92 | sql` | |
| 93 | DELETE FROM projection_turns | |
| 94 | WHERE thread_id = ${threadId} | |
| 95 | AND turn_id IS NULL | |
| 96 | AND state = 'pending' | |
| 97 | AND checkpoint_turn_count IS NULL | |
| 98 | `, | |
| 99 | }); | |
| 100 | ||
| 101 | const insertPendingProjectionTurn = SqlSchema.void({ | |
| 102 | Request: ProjectionPendingTurnStart, | |
| 103 | execute: (row) => | |
| 104 | sql` | |
| 105 | INSERT INTO projection_turns ( | |
| 106 | thread_id, | |
| 107 | turn_id, | |
| 108 | pending_message_id, | |
| 109 | assistant_message_id, | |
| 110 | state, | |
| 111 | requested_at, | |
| 112 | started_at, | |
| 113 | completed_at, | |
| 114 | checkpoint_turn_count, | |
| 115 | checkpoint_ref, | |
| 116 | checkpoint_status, | |
| 117 | checkpoint_files_json | |
| 118 | ) | |
| 119 | VALUES ( | |
| 120 | ${row.threadId}, | |
| 121 | NULL, | |
| 122 | ${row.messageId}, | |
| 123 | NULL, | |
| 124 | 'pending', | |
| 125 | ${row.requestedAt}, | |
| 126 | NULL, | |
| 127 | NULL, | |
| 128 | NULL, | |
| 129 | NULL, | |
| 130 | NULL, | |
| 131 | '[]' | |
| 132 | ) | |
| 133 | `, | |
| 134 | }); | |
| 135 | ||
| 136 | const getPendingProjectionTurn = SqlSchema.findOneOption({ | |
| 137 | Request: GetProjectionPendingTurnStartInput, | |
| 138 | Result: ProjectionPendingTurnStart, | |
| 139 | execute: ({ threadId }) => | |
| 140 | sql` | |
| 141 | SELECT | |
| 142 | thread_id AS "threadId", | |
| 143 | pending_message_id AS "messageId", | |
| 144 | requested_at AS "requestedAt" | |
| 145 | FROM projection_turns | |
| 146 | WHERE thread_id = ${threadId} | |
| 147 | AND turn_id IS NULL | |
| 148 | AND state = 'pending' | |
| 149 | AND pending_message_id IS NOT NULL | |
| 150 | AND checkpoint_turn_count IS NULL | |
| 151 | ORDER BY requested_at DESC | |
| 152 | LIMIT 1 | |
| 153 | `, | |
| 154 | }); | |
| 155 | ||
| 156 | const listProjectionTurnsByThread = SqlSchema.findAll({ | |
| 157 | Request: ListProjectionTurnsByThreadInput, | |
| 158 | Result: ProjectionTurnDbRowSchema, | |
| 159 | execute: ({ threadId }) => | |
| 160 | sql` | |
| 161 | SELECT | |
| 162 | thread_id AS "threadId", | |
| 163 | turn_id AS "turnId", | |
| 164 | pending_message_id AS "pendingMessageId", | |
| 165 | assistant_message_id AS "assistantMessageId", | |
| 166 | state, | |
| 167 | requested_at AS "requestedAt", | |
| 168 | started_at AS "startedAt", | |
| 169 | completed_at AS "completedAt", | |
| 170 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 171 | checkpoint_ref AS "checkpointRef", | |
| 172 | checkpoint_status AS "checkpointStatus", | |
| 173 | checkpoint_files_json AS "checkpointFiles" | |
| 174 | FROM projection_turns | |
| 175 | WHERE thread_id = ${threadId} | |
| 176 | ORDER BY | |
| 177 | CASE | |
| 178 | WHEN checkpoint_turn_count IS NULL THEN 1 | |
| 179 | ELSE 0 | |
| 180 | END ASC, | |
| 181 | checkpoint_turn_count ASC, | |
| 182 | requested_at ASC, | |
| 183 | turn_id ASC | |
| 184 | `, | |
| 185 | }); | |
| 186 | ||
| 187 | const getProjectionTurnByTurnId = SqlSchema.findOneOption({ | |
| 188 | Request: GetProjectionTurnByTurnIdInput, | |
| 189 | Result: ProjectionTurnByIdDbRowSchema, | |
| 190 | execute: ({ threadId, turnId }) => | |
| 191 | sql` | |
| 192 | SELECT | |
| 193 | thread_id AS "threadId", | |
| 194 | turn_id AS "turnId", | |
| 195 | pending_message_id AS "pendingMessageId", | |
| 196 | assistant_message_id AS "assistantMessageId", | |
| 197 | state, | |
| 198 | requested_at AS "requestedAt", | |
| 199 | started_at AS "startedAt", | |
| 200 | completed_at AS "completedAt", | |
| 201 | checkpoint_turn_count AS "checkpointTurnCount", | |
| 202 | checkpoint_ref AS "checkpointRef", | |
| 203 | checkpoint_status AS "checkpointStatus", | |
| 204 | checkpoint_files_json AS "checkpointFiles" | |
| 205 | FROM projection_turns | |
| 206 | WHERE thread_id = ${threadId} | |
| 207 | AND turn_id = ${turnId} | |
| 208 | LIMIT 1 | |
| 209 | `, | |
| 210 | }); | |
| 211 | ||
| 212 | const clearCheckpointTurnConflictRow = SqlSchema.void({ | |
| 213 | Request: ClearCheckpointTurnConflictInput, | |
| 214 | execute: ({ threadId, turnId, checkpointTurnCount }) => | |
| 215 | sql` | |
| 216 | UPDATE projection_turns | |
| 217 | SET | |
| 218 | checkpoint_turn_count = NULL, | |
| 219 | checkpoint_ref = NULL, | |
| 220 | checkpoint_status = NULL, | |
| 221 | checkpoint_files_json = '[]' | |
| 222 | WHERE thread_id = ${threadId} | |
| 223 | AND checkpoint_turn_count = ${checkpointTurnCount} | |
| 224 | AND (turn_id IS NULL OR turn_id <> ${turnId}) | |
| 225 | `, | |
| 226 | }); | |
| 227 | ||
| 228 | const deleteProjectionTurnsByThread = SqlSchema.void({ | |
| 229 | Request: DeleteProjectionTurnsByThreadInput, | |
| 230 | execute: ({ threadId }) => | |
| 231 | sql` | |
| 232 | DELETE FROM projection_turns | |
| 233 | WHERE thread_id = ${threadId} | |
| 234 | `, | |
| 235 | }); | |
| 236 | ||
| 237 | const upsertByTurnId: ProjectionTurnRepositoryShape["upsertByTurnId"] = (row) => | |
| 238 | upsertProjectionTurnById(row).pipe( | |
| 239 | Effect.mapError( | |
| 240 | toPersistenceSqlOrDecodeError( | |
| 241 | "ProjectionTurnRepository.upsertByTurnId:query", | |
| 242 | "ProjectionTurnRepository.upsertByTurnId:encodeRequest", | |
| 243 | ), | |
| 244 | ), | |
| 245 | ); | |
| 246 | ||
| 247 | const replacePendingTurnStart: ProjectionTurnRepositoryShape["replacePendingTurnStart"] = (row) => | |
| 248 | sql | |
| 249 | .withTransaction( | |
| 250 | clearPendingProjectionTurnsByThread({ threadId: row.threadId }).pipe( | |
| 251 | Effect.flatMap(() => insertPendingProjectionTurn(row)), | |
| 252 | ), | |
| 253 | ) | |
| 254 | .pipe( | |
| 255 | Effect.mapError( | |
| 256 | toPersistenceSqlOrDecodeError( | |
| 257 | "ProjectionTurnRepository.replacePendingTurnStart:query", | |
| 258 | "ProjectionTurnRepository.replacePendingTurnStart:encodeRequest", | |
| 259 | ), | |
| 260 | ), | |
| 261 | ); | |
| 262 | ||
| 263 | const getPendingTurnStartByThreadId: ProjectionTurnRepositoryShape["getPendingTurnStartByThreadId"] = | |
| 264 | (input) => | |
| 265 | getPendingProjectionTurn(input).pipe( | |
| 266 | Effect.mapError( | |
| 267 | toPersistenceSqlError("ProjectionTurnRepository.getPendingTurnStartByThreadId:query"), | |
| 268 | ), | |
| 269 | ); | |
| 270 | ||
| 271 | const deletePendingTurnStartByThreadId: ProjectionTurnRepositoryShape["deletePendingTurnStartByThreadId"] = | |
| 272 | (input) => | |
| 273 | clearPendingProjectionTurnsByThread(input).pipe( | |
| 274 | Effect.mapError( | |
| 275 | toPersistenceSqlError("ProjectionTurnRepository.deletePendingTurnStartByThreadId:query"), | |
| 276 | ), | |
| 277 | ); | |
| 278 | ||
| 279 | const listByThreadId: ProjectionTurnRepositoryShape["listByThreadId"] = (input) => | |
| 280 | listProjectionTurnsByThread(input).pipe( | |
| 281 | Effect.mapError( | |
| 282 | toPersistenceSqlOrDecodeError( | |
| 283 | "ProjectionTurnRepository.listByThreadId:query", | |
| 284 | "ProjectionTurnRepository.listByThreadId:decodeRows", | |
| 285 | ), | |
| 286 | ), | |
| 287 | Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionTurn>>), | |
| 288 | ); | |
| 289 | ||
| 290 | const getByTurnId: ProjectionTurnRepositoryShape["getByTurnId"] = (input) => | |
| 291 | getProjectionTurnByTurnId(input).pipe( | |
| 292 | Effect.mapError( | |
| 293 | toPersistenceSqlOrDecodeError( | |
| 294 | "ProjectionTurnRepository.getByTurnId:query", | |
| 295 | "ProjectionTurnRepository.getByTurnId:decodeRow", | |
| 296 | ), | |
| 297 | ), | |
| 298 | Effect.flatMap((rowOption) => | |
| 299 | Option.match(rowOption, { | |
| 300 | onNone: () => Effect.succeed(Option.none()), | |
| 301 | onSome: (row) => | |
| 302 | Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionTurnById>)), | |
| 303 | }), | |
| 304 | ), | |
| 305 | ); | |
| 306 | ||
| 307 | const clearCheckpointTurnConflict: ProjectionTurnRepositoryShape["clearCheckpointTurnConflict"] = | |
| 308 | (input) => | |
| 309 | clearCheckpointTurnConflictRow(input).pipe( | |
| 310 | Effect.mapError( | |
| 311 | toPersistenceSqlError("ProjectionTurnRepository.clearCheckpointTurnConflict:query"), | |
| 312 | ), | |
| 313 | ); | |
| 314 | ||
| 315 | const deleteByThreadId: ProjectionTurnRepositoryShape["deleteByThreadId"] = (input) => | |
| 316 | deleteProjectionTurnsByThread(input).pipe( | |
| 317 | Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), | |
| 318 | ); | |
| 319 | ||
| 320 | return { | |
| 321 | upsertByTurnId, | |
| 322 | replacePendingTurnStart, | |
| 323 | getPendingTurnStartByThreadId, | |
| 324 | deletePendingTurnStartByThreadId, | |
| 325 | listByThreadId, | |
| 326 | getByTurnId, | |
| 327 | clearCheckpointTurnConflict, | |
| 328 | deleteByThreadId, | |
| 329 | } satisfies ProjectionTurnRepositoryShape; | |
| 330 | }); | |
| 331 | ||
| 332 | export const ProjectionTurnRepositoryLive = Layer.effect( | |
| 333 | ProjectionTurnRepository, | |
| 334 | makeProjectionTurnRepository, | |
| 335 | ); |
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts created+204| ... | ... | @@ -0,0 +1,204 @@ |
| 1 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | toPersistenceDecodeError, | |
| 8 | toPersistenceSqlError, | |
| 9 | type ProviderSessionRuntimeRepositoryError, | |
| 10 | } from "../Errors.ts"; | |
| 11 | import { | |
| 12 | ProviderSessionRuntime, | |
| 13 | ProviderSessionRuntimeRepository, | |
| 14 | type ProviderSessionRuntimeRepositoryShape, | |
| 15 | } from "../Services/ProviderSessionRuntime.ts"; | |
| 16 | ||
| 17 | const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( | |
| 18 | Struct.assign({ | |
| 19 | resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), | |
| 20 | runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), | |
| 21 | }), | |
| 22 | ); | |
| 23 | ||
| 24 | const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); | |
| 25 | ||
| 26 | const GetRuntimeRequestSchema = Schema.Struct({ | |
| 27 | providerSessionId: ProviderSessionId, | |
| 28 | }); | |
| 29 | ||
| 30 | const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; | |
| 31 | ||
| 32 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 33 | return (cause: unknown): ProviderSessionRuntimeRepositoryError => | |
| 34 | Schema.isSchemaError(cause) | |
| 35 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 36 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 37 | } | |
| 38 | ||
| 39 | const makeProviderSessionRuntimeRepository = Effect.gen(function* () { | |
| 40 | const sql = yield* SqlClient.SqlClient; | |
| 41 | ||
| 42 | const upsertRuntimeRow = SqlSchema.void({ | |
| 43 | Request: ProviderSessionRuntimeDbRowSchema, | |
| 44 | execute: (runtime) => | |
| 45 | sql` | |
| 46 | INSERT INTO provider_session_runtime ( | |
| 47 | provider_session_id, | |
| 48 | thread_id, | |
| 49 | provider_name, | |
| 50 | adapter_key, | |
| 51 | provider_thread_id, | |
| 52 | status, | |
| 53 | last_seen_at, | |
| 54 | resume_cursor_json, | |
| 55 | runtime_payload_json | |
| 56 | ) | |
| 57 | VALUES ( | |
| 58 | ${runtime.providerSessionId}, | |
| 59 | ${runtime.threadId}, | |
| 60 | ${runtime.providerName}, | |
| 61 | ${runtime.adapterKey}, | |
| 62 | ${runtime.providerThreadId}, | |
| 63 | ${runtime.status}, | |
| 64 | ${runtime.lastSeenAt}, | |
| 65 | ${runtime.resumeCursor}, | |
| 66 | ${runtime.runtimePayload} | |
| 67 | ) | |
| 68 | ON CONFLICT (provider_session_id) | |
| 69 | DO UPDATE SET | |
| 70 | thread_id = excluded.thread_id, | |
| 71 | provider_name = excluded.provider_name, | |
| 72 | adapter_key = excluded.adapter_key, | |
| 73 | provider_thread_id = excluded.provider_thread_id, | |
| 74 | status = excluded.status, | |
| 75 | last_seen_at = excluded.last_seen_at, | |
| 76 | resume_cursor_json = excluded.resume_cursor_json, | |
| 77 | runtime_payload_json = excluded.runtime_payload_json | |
| 78 | `, | |
| 79 | }); | |
| 80 | ||
| 81 | const getRuntimeRowBySessionId = SqlSchema.findOneOption({ | |
| 82 | Request: GetRuntimeRequestSchema, | |
| 83 | Result: ProviderSessionRuntimeDbRowSchema, | |
| 84 | execute: ({ providerSessionId }) => | |
| 85 | sql` | |
| 86 | SELECT | |
| 87 | provider_session_id AS "providerSessionId", | |
| 88 | thread_id AS "threadId", | |
| 89 | provider_name AS "providerName", | |
| 90 | adapter_key AS "adapterKey", | |
| 91 | provider_thread_id AS "providerThreadId", | |
| 92 | status, | |
| 93 | last_seen_at AS "lastSeenAt", | |
| 94 | resume_cursor_json AS "resumeCursor", | |
| 95 | runtime_payload_json AS "runtimePayload" | |
| 96 | FROM provider_session_runtime | |
| 97 | WHERE provider_session_id = ${providerSessionId} | |
| 98 | `, | |
| 99 | }); | |
| 100 | ||
| 101 | const listRuntimeRows = SqlSchema.findAll({ | |
| 102 | Request: Schema.Void, | |
| 103 | Result: ProviderSessionRuntimeDbRowSchema, | |
| 104 | execute: () => | |
| 105 | sql` | |
| 106 | SELECT | |
| 107 | provider_session_id AS "providerSessionId", | |
| 108 | thread_id AS "threadId", | |
| 109 | provider_name AS "providerName", | |
| 110 | adapter_key AS "adapterKey", | |
| 111 | provider_thread_id AS "providerThreadId", | |
| 112 | status, | |
| 113 | last_seen_at AS "lastSeenAt", | |
| 114 | resume_cursor_json AS "resumeCursor", | |
| 115 | runtime_payload_json AS "runtimePayload" | |
| 116 | FROM provider_session_runtime | |
| 117 | ORDER BY last_seen_at ASC, provider_session_id ASC | |
| 118 | `, | |
| 119 | }); | |
| 120 | ||
| 121 | const deleteRuntimeBySessionId = SqlSchema.void({ | |
| 122 | Request: DeleteRuntimeRequestSchema, | |
| 123 | execute: ({ providerSessionId }) => | |
| 124 | sql` | |
| 125 | DELETE FROM provider_session_runtime | |
| 126 | WHERE provider_session_id = ${providerSessionId} | |
| 127 | `, | |
| 128 | }); | |
| 129 | ||
| 130 | const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) => | |
| 131 | upsertRuntimeRow(runtime).pipe( | |
| 132 | Effect.mapError( | |
| 133 | toPersistenceSqlOrDecodeError( | |
| 134 | "ProviderSessionRuntimeRepository.upsert:query", | |
| 135 | "ProviderSessionRuntimeRepository.upsert:encodeRequest", | |
| 136 | ), | |
| 137 | ), | |
| 138 | ); | |
| 139 | ||
| 140 | const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) => | |
| 141 | getRuntimeRowBySessionId(input).pipe( | |
| 142 | Effect.mapError( | |
| 143 | toPersistenceSqlOrDecodeError( | |
| 144 | "ProviderSessionRuntimeRepository.getBySessionId:query", | |
| 145 | "ProviderSessionRuntimeRepository.getBySessionId:decodeRow", | |
| 146 | ), | |
| 147 | ), | |
| 148 | Effect.flatMap((runtimeRowOption) => | |
| 149 | Option.match(runtimeRowOption, { | |
| 150 | onNone: () => Effect.succeed(Option.none()), | |
| 151 | onSome: (row) => | |
| 152 | decodeRuntime(row).pipe( | |
| 153 | Effect.mapError( | |
| 154 | toPersistenceDecodeError( | |
| 155 | "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime", | |
| 156 | ), | |
| 157 | ), | |
| 158 | Effect.map((runtime) => Option.some(runtime)), | |
| 159 | ), | |
| 160 | }), | |
| 161 | ), | |
| 162 | ); | |
| 163 | ||
| 164 | const list: ProviderSessionRuntimeRepositoryShape["list"] = () => | |
| 165 | listRuntimeRows(undefined).pipe( | |
| 166 | Effect.mapError( | |
| 167 | toPersistenceSqlOrDecodeError( | |
| 168 | "ProviderSessionRuntimeRepository.list:query", | |
| 169 | "ProviderSessionRuntimeRepository.list:decodeRows", | |
| 170 | ), | |
| 171 | ), | |
| 172 | Effect.flatMap((rows) => | |
| 173 | Effect.forEach( | |
| 174 | rows, | |
| 175 | (row) => | |
| 176 | decodeRuntime(row).pipe( | |
| 177 | Effect.mapError( | |
| 178 | toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), | |
| 179 | ), | |
| 180 | ), | |
| 181 | { concurrency: "unbounded" }, | |
| 182 | ), | |
| 183 | ), | |
| 184 | ); | |
| 185 | ||
| 186 | const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) => | |
| 187 | deleteRuntimeBySessionId(input).pipe( | |
| 188 | Effect.mapError( | |
| 189 | toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"), | |
| 190 | ), | |
| 191 | ); | |
| 192 | ||
| 193 | return { | |
| 194 | upsert, | |
| 195 | getBySessionId, | |
| 196 | list, | |
| 197 | deleteBySessionId, | |
| 198 | } satisfies ProviderSessionRuntimeRepositoryShape; | |
| 199 | }); | |
| 200 | ||
| 201 | export const ProviderSessionRuntimeRepositoryLive = Layer.effect( | |
| 202 | ProviderSessionRuntimeRepository, | |
| 203 | makeProviderSessionRuntimeRepository, | |
| 204 | ); |
apps/server/src/persistence/Layers/ProviderSessions.ts created+225| ... | ... | @@ -0,0 +1,225 @@ |
| 1 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | ProviderSessionRepositoryPersistenceError, | |
| 8 | ProviderSessionRepositoryValidationError, | |
| 9 | } from "../Errors.ts"; | |
| 10 | import { | |
| 11 | ProviderSessionRepository, | |
| 12 | type ProviderSessionEntry, | |
| 13 | type ProviderSessionRepositoryShape, | |
| 14 | } from "../Services/ProviderSessions.ts"; | |
| 15 | ||
| 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 17 | ||
| 18 | const ProviderSessionRowSchema = Schema.Struct({ | |
| 19 | sessionId: ProviderSessionId, | |
| 20 | provider: ProviderKind, | |
| 21 | threadId: Schema.NullOr(ThreadId), | |
| 22 | createdAt: Schema.String, | |
| 23 | updatedAt: Schema.String, | |
| 24 | }); | |
| 25 | ||
| 26 | const SessionIdRequestSchema = Schema.Struct({ | |
| 27 | sessionId: ProviderSessionId, | |
| 28 | }); | |
| 29 | ||
| 30 | const UpsertSessionRequestSchema = Schema.Struct({ | |
| 31 | sessionId: ProviderSessionId, | |
| 32 | provider: ProviderKind, | |
| 33 | threadId: Schema.NullOr(ThreadId), | |
| 34 | }); | |
| 35 | ||
| 36 | function errorMessage(cause: unknown, fallback: string): string { | |
| 37 | if (cause instanceof Error && cause.message.length > 0) { | |
| 38 | return cause.message; | |
| 39 | } | |
| 40 | return fallback; | |
| 41 | } | |
| 42 | ||
| 43 | function toValidationError( | |
| 44 | operation: string, | |
| 45 | cause: unknown, | |
| 46 | ): ProviderSessionRepositoryValidationError { | |
| 47 | return new ProviderSessionRepositoryValidationError({ | |
| 48 | operation, | |
| 49 | issue: errorMessage(cause, "Invalid provider session repository input."), | |
| 50 | cause, | |
| 51 | }); | |
| 52 | } | |
| 53 | ||
| 54 | function decodeInput<S extends Schema.Top>(schema: S, input: unknown, operation: string) { | |
| 55 | return Schema.decodeUnknownEffect(schema)(input).pipe( | |
| 56 | Effect.mapError((cause) => toValidationError(operation, cause)), | |
| 57 | ); | |
| 58 | } | |
| 59 | ||
| 60 | function toPersistenceError( | |
| 61 | operation: string, | |
| 62 | cause: unknown, | |
| 63 | ): ProviderSessionRepositoryPersistenceError { | |
| 64 | return new ProviderSessionRepositoryPersistenceError({ | |
| 65 | operation, | |
| 66 | detail: `Failed to execute ${operation}.`, | |
| 67 | cause, | |
| 68 | }); | |
| 69 | } | |
| 70 | ||
| 71 | function toEntry(row: Schema.Schema.Type<typeof ProviderSessionRowSchema>): ProviderSessionEntry { | |
| 72 | return { | |
| 73 | sessionId: row.sessionId, | |
| 74 | provider: row.provider, | |
| 75 | ...(row.threadId !== null ? { threadId: row.threadId } : {}), | |
| 76 | createdAt: row.createdAt, | |
| 77 | updatedAt: row.updatedAt, | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | const makeProviderSessionRepository = Effect.gen(function* () { | |
| 82 | const sql = yield* SqlClient.SqlClient; | |
| 83 | ||
| 84 | const upsertSessionRow = SqlSchema.findOne({ | |
| 85 | Request: UpsertSessionRequestSchema, | |
| 86 | Result: ProviderSessionRowSchema, | |
| 87 | execute: (request) => | |
| 88 | sql` | |
| 89 | INSERT INTO provider_sessions ( | |
| 90 | session_id, | |
| 91 | provider, | |
| 92 | thread_id, | |
| 93 | created_at, | |
| 94 | updated_at | |
| 95 | ) | |
| 96 | VALUES ( | |
| 97 | ${request.sessionId}, | |
| 98 | ${request.provider}, | |
| 99 | ${request.threadId}, | |
| 100 | CURRENT_TIMESTAMP, | |
| 101 | CURRENT_TIMESTAMP | |
| 102 | ) | |
| 103 | ON CONFLICT (session_id) | |
| 104 | DO UPDATE SET | |
| 105 | provider = excluded.provider, | |
| 106 | thread_id = excluded.thread_id, | |
| 107 | updated_at = CURRENT_TIMESTAMP | |
| 108 | RETURNING | |
| 109 | session_id AS "sessionId", | |
| 110 | provider, | |
| 111 | thread_id AS "threadId", | |
| 112 | created_at AS "createdAt", | |
| 113 | updated_at AS "updatedAt" | |
| 114 | `, | |
| 115 | }); | |
| 116 | ||
| 117 | const findSessionRow = SqlSchema.findOneOption({ | |
| 118 | Request: SessionIdRequestSchema, | |
| 119 | Result: ProviderSessionRowSchema, | |
| 120 | execute: ({ sessionId }) => | |
| 121 | sql` | |
| 122 | SELECT | |
| 123 | session_id AS "sessionId", | |
| 124 | provider, | |
| 125 | thread_id AS "threadId", | |
| 126 | created_at AS "createdAt", | |
| 127 | updated_at AS "updatedAt" | |
| 128 | FROM provider_sessions | |
| 129 | WHERE session_id = ${sessionId} | |
| 130 | `, | |
| 131 | }); | |
| 132 | ||
| 133 | const listSessionRows = SqlSchema.findAll({ | |
| 134 | Request: Schema.Void, | |
| 135 | Result: ProviderSessionRowSchema, | |
| 136 | execute: () => | |
| 137 | sql` | |
| 138 | SELECT | |
| 139 | session_id AS "sessionId", | |
| 140 | provider, | |
| 141 | thread_id AS "threadId", | |
| 142 | created_at AS "createdAt", | |
| 143 | updated_at AS "updatedAt" | |
| 144 | FROM provider_sessions | |
| 145 | ORDER BY created_at ASC, session_id ASC | |
| 146 | `, | |
| 147 | }); | |
| 148 | ||
| 149 | const deleteSessionRow = SqlSchema.void({ | |
| 150 | Request: SessionIdRequestSchema, | |
| 151 | execute: ({ sessionId }) => sql`DELETE FROM provider_sessions WHERE session_id = ${sessionId}`, | |
| 152 | }); | |
| 153 | ||
| 154 | const upsertSession: ProviderSessionRepositoryShape["upsertSession"] = (input) => | |
| 155 | Effect.gen(function* () { | |
| 156 | const parsed = yield* decodeInput( | |
| 157 | UpsertSessionRequestSchema, | |
| 158 | { | |
| 159 | sessionId: input.sessionId, | |
| 160 | provider: input.provider, | |
| 161 | threadId: input.threadId ?? null, | |
| 162 | }, | |
| 163 | "ProviderSessionRepository.upsertSession", | |
| 164 | ); | |
| 165 | ||
| 166 | yield* upsertSessionRow(parsed).pipe( | |
| 167 | Effect.mapError((cause) => | |
| 168 | toPersistenceError("ProviderSessionRepository.upsertSession:query", cause), | |
| 169 | ), | |
| 170 | Effect.asVoid, | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | const getSession: ProviderSessionRepositoryShape["getSession"] = (input) => | |
| 175 | Effect.gen(function* () { | |
| 176 | const parsed = yield* decodeInput( | |
| 177 | SessionIdRequestSchema, | |
| 178 | { sessionId: input.sessionId }, | |
| 179 | "ProviderSessionRepository.getSession", | |
| 180 | ); | |
| 181 | ||
| 182 | const row = yield* findSessionRow(parsed).pipe( | |
| 183 | Effect.mapError((cause) => | |
| 184 | toPersistenceError("ProviderSessionRepository.getSession:query", cause), | |
| 185 | ), | |
| 186 | ); | |
| 187 | ||
| 188 | return Option.map(row, toEntry); | |
| 189 | }); | |
| 190 | ||
| 191 | const listSessions: ProviderSessionRepositoryShape["listSessions"] = () => | |
| 192 | listSessionRows(undefined).pipe( | |
| 193 | Effect.mapError((cause) => | |
| 194 | toPersistenceError("ProviderSessionRepository.listSessions:query", cause), | |
| 195 | ), | |
| 196 | Effect.map((rows) => rows.map(toEntry)), | |
| 197 | ); | |
| 198 | ||
| 199 | const deleteSession: ProviderSessionRepositoryShape["deleteSession"] = (input) => | |
| 200 | Effect.gen(function* () { | |
| 201 | const parsed = yield* decodeInput( | |
| 202 | SessionIdRequestSchema, | |
| 203 | { sessionId: input.sessionId }, | |
| 204 | "ProviderSessionRepository.deleteSession", | |
| 205 | ); | |
| 206 | ||
| 207 | yield* deleteSessionRow(parsed).pipe( | |
| 208 | Effect.mapError((cause) => | |
| 209 | toPersistenceError("ProviderSessionRepository.deleteSession:query", cause), | |
| 210 | ), | |
| 211 | ); | |
| 212 | }); | |
| 213 | ||
| 214 | return { | |
| 215 | upsertSession, | |
| 216 | getSession, | |
| 217 | listSessions, | |
| 218 | deleteSession, | |
| 219 | } satisfies ProviderSessionRepositoryShape; | |
| 220 | }); | |
| 221 | ||
| 222 | export const ProviderSessionRepositoryLive = Layer.effect( | |
| 223 | ProviderSessionRepository, | |
| 224 | makeProviderSessionRepository, | |
| 225 | ); |
apps/server/src/persistence/Layers/Sqlite.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import { Effect, Layer, FileSystem, Path } from "effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | import { runMigrations } from "../Migrations.ts"; | |
| 5 | import { ServerConfig } from "../../config.ts"; | |
| 6 | ||
| 7 | type RuntimeSqliteLayerConfig = { | |
| 8 | readonly filename: string; | |
| 9 | }; | |
| 10 | ||
| 11 | type Loader = { | |
| 12 | layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>; | |
| 13 | }; | |
| 14 | const defaultSqliteClientLoaders = { | |
| 15 | bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), | |
| 16 | node: () => import("../NodeSqliteClient.ts"), | |
| 17 | } satisfies Record<string, () => Promise<Loader>>; | |
| 18 | ||
| 19 | const makeRuntimeSqliteLayer = ( | |
| 20 | config: RuntimeSqliteLayerConfig, | |
| 21 | ): Layer.Layer<SqlClient.SqlClient> => | |
| 22 | Effect.gen(function* () { | |
| 23 | const runtime = process.versions.bun !== undefined ? "bun" : "node"; | |
| 24 | const loader = defaultSqliteClientLoaders[runtime]; | |
| 25 | const clientModule = yield* Effect.promise<Loader>(loader); | |
| 26 | return clientModule.layer(config); | |
| 27 | }).pipe(Layer.unwrap); | |
| 28 | ||
| 29 | const setup = Layer.effectDiscard( | |
| 30 | Effect.gen(function* () { | |
| 31 | const sql = yield* SqlClient.SqlClient; | |
| 32 | yield* sql`PRAGMA journal_mode = WAL;`; | |
| 33 | yield* sql`PRAGMA foreign_keys = ON;`; | |
| 34 | yield* runMigrations; | |
| 35 | }), | |
| 36 | ); | |
| 37 | ||
| 38 | export const makeSqlitePersistenceLive = (dbPath: string) => | |
| 39 | Effect.gen(function* () { | |
| 40 | const fs = yield* FileSystem.FileSystem; | |
| 41 | const path = yield* Path.Path; | |
| 42 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); | |
| 43 | ||
| 44 | return Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: dbPath })); | |
| 45 | }).pipe(Layer.unwrap); | |
| 46 | ||
| 47 | export const SqlitePersistenceMemory = Layer.provideMerge( | |
| 48 | setup, | |
| 49 | makeRuntimeSqliteLayer({ filename: ":memory:" }), | |
| 50 | ); | |
| 51 | ||
| 52 | export const layerConfig = Effect.gen(function* () { | |
| 53 | const { stateDir } = yield* ServerConfig; | |
| 54 | const { join } = yield* Path.Path; | |
| 55 | const dbPath = join(stateDir, "state.sqlite"); | |
| 56 | return makeSqlitePersistenceLive(dbPath); | |
| 57 | }).pipe(Layer.unwrap); |
apps/server/src/persistence/Migrations.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | /** | |
| 2 | * MigrationsLive - Migration runner with inline loader | |
| 3 | * | |
| 4 | * Uses Migrator.make with fromRecord to define migrations inline. | |
| 5 | * All migrations are statically imported - no dynamic file system loading. | |
| 6 | * | |
| 7 | * Migrations run automatically when the MigrationLayer is provided, | |
| 8 | * ensuring the database schema is always up-to-date before the application starts. | |
| 9 | */ | |
| 10 | ||
| 11 | import * as Migrator from "effect/unstable/sql/Migrator"; | |
| 12 | import * as Layer from "effect/Layer"; | |
| 13 | ||
| 14 | // Import all migrations statically | |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | |
| 16 | import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; | |
| 17 | import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; | |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; | |
| 19 | import Migration0005 from "./Migrations/005_Projections.ts"; | |
| 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; | |
| 21 | ||
| 22 | /** | |
| 23 | * Migration loader with all migrations defined inline. | |
| 24 | * | |
| 25 | * Key format: "{id}_{name}" where: | |
| 26 | * - id: numeric migration ID (determines execution order) | |
| 27 | * - name: descriptive name for the migration | |
| 28 | * | |
| 29 | * Uses Migrator.fromRecord which parses the key format and | |
| 30 | * returns migrations sorted by ID. | |
| 31 | */ | |
| 32 | const loader = Migrator.fromRecord({ | |
| 33 | "1_OrchestrationEvents": Migration0001, | |
| 34 | "2_OrchestrationCommandReceipts": Migration0002, | |
| 35 | "3_CheckpointDiffBlobs": Migration0003, | |
| 36 | "4_ProviderSessionRuntime": Migration0004, | |
| 37 | "5_Projections": Migration0005, | |
| 38 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, | |
| 39 | }); | |
| 40 | ||
| 41 | /** | |
| 42 | * Migrator run function - no schema dumping needed | |
| 43 | * Uses the base Migrator.make without platform dependencies | |
| 44 | */ | |
| 45 | const run = Migrator.make({}); | |
| 46 | ||
| 47 | /** | |
| 48 | * Run all pending migrations. | |
| 49 | * | |
| 50 | * Creates the migrations tracking table (effect_sql_migrations) if it doesn't exist, | |
| 51 | * then runs any migrations with ID greater than the latest recorded migration. | |
| 52 | * | |
| 53 | * Returns array of [id, name] tuples for migrations that were run. | |
| 54 | * | |
| 55 | * @returns Effect containing array of executed migrations | |
| 56 | */ | |
| 57 | export const runMigrations = run({ loader }); | |
| 58 | ||
| 59 | /** | |
| 60 | * Layer that runs migrations when the layer is built. | |
| 61 | * | |
| 62 | * Use this to ensure migrations run before your application starts. | |
| 63 | * Migrations are run automatically - no separate script is needed. | |
| 64 | * | |
| 65 | * @example | |
| 66 | * ```typescript | |
| 67 | * import { MigrationsLive } from "@acme/db/Migrations" | |
| 68 | * import * as SqliteClient from "@acme/db/SqliteClient" | |
| 69 | * | |
| 70 | * // Migrations run automatically when SqliteClient is provided | |
| 71 | * const AppLayer = MigrationsLive.pipe( | |
| 72 | * Layer.provideMerge(SqliteClient.layer({ filename: "database.sqlite" })) | |
| 73 | * ) | |
| 74 | * ``` | |
| 75 | */ | |
| 76 | export const MigrationsLive = Layer.effectDiscard(runMigrations); |
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS orchestration_events ( | |
| 9 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 10 | event_id TEXT NOT NULL UNIQUE, | |
| 11 | aggregate_kind TEXT NOT NULL, | |
| 12 | stream_id TEXT NOT NULL, | |
| 13 | stream_version INTEGER NOT NULL, | |
| 14 | event_type TEXT NOT NULL, | |
| 15 | occurred_at TEXT NOT NULL, | |
| 16 | command_id TEXT, | |
| 17 | causation_event_id TEXT, | |
| 18 | correlation_id TEXT, | |
| 19 | actor_kind TEXT NOT NULL, | |
| 20 | payload_json TEXT NOT NULL, | |
| 21 | metadata_json TEXT NOT NULL | |
| 22 | ) | |
| 23 | `; | |
| 24 | ||
| 25 | yield* sql` | |
| 26 | CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version | |
| 27 | ON orchestration_events(aggregate_kind, stream_id, stream_version) | |
| 28 | `; | |
| 29 | ||
| 30 | yield* sql` | |
| 31 | CREATE INDEX IF NOT EXISTS idx_orch_events_stream_sequence | |
| 32 | ON orchestration_events(aggregate_kind, stream_id, sequence) | |
| 33 | `; | |
| 34 | ||
| 35 | yield* sql` | |
| 36 | CREATE INDEX IF NOT EXISTS idx_orch_events_command_id | |
| 37 | ON orchestration_events(command_id) | |
| 38 | `; | |
| 39 | ||
| 40 | yield* sql` | |
| 41 | CREATE INDEX IF NOT EXISTS idx_orch_events_correlation_id | |
| 42 | ON orchestration_events(correlation_id) | |
| 43 | `; | |
| 44 | }); |
apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS orchestration_command_receipts ( | |
| 9 | command_id TEXT PRIMARY KEY, | |
| 10 | aggregate_kind TEXT NOT NULL, | |
| 11 | aggregate_id TEXT NOT NULL, | |
| 12 | accepted_at TEXT NOT NULL, | |
| 13 | result_sequence INTEGER NOT NULL, | |
| 14 | status TEXT NOT NULL, | |
| 15 | error TEXT | |
| 16 | ) | |
| 17 | `; | |
| 18 | ||
| 19 | yield* sql` | |
| 20 | CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_aggregate | |
| 21 | ON orchestration_command_receipts(aggregate_kind, aggregate_id) | |
| 22 | `; | |
| 23 | ||
| 24 | yield* sql` | |
| 25 | CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_sequence | |
| 26 | ON orchestration_command_receipts(result_sequence) | |
| 27 | `; | |
| 28 | }); |
apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS checkpoint_diff_blobs ( | |
| 9 | thread_id TEXT NOT NULL, | |
| 10 | from_turn_count INTEGER NOT NULL, | |
| 11 | to_turn_count INTEGER NOT NULL, | |
| 12 | diff TEXT NOT NULL, | |
| 13 | created_at TEXT NOT NULL, | |
| 14 | UNIQUE (thread_id, from_turn_count, to_turn_count) | |
| 15 | ) | |
| 16 | `; | |
| 17 | ||
| 18 | yield* sql` | |
| 19 | CREATE INDEX IF NOT EXISTS idx_checkpoint_diff_blobs_thread_to_turn | |
| 20 | ON checkpoint_diff_blobs(thread_id, to_turn_count) | |
| 21 | `; | |
| 22 | }); |
apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS provider_session_runtime ( | |
| 9 | provider_session_id TEXT PRIMARY KEY, | |
| 10 | thread_id TEXT NOT NULL, | |
| 11 | provider_name TEXT NOT NULL, | |
| 12 | adapter_key TEXT NOT NULL, | |
| 13 | provider_thread_id TEXT, | |
| 14 | status TEXT NOT NULL, | |
| 15 | last_seen_at TEXT NOT NULL, | |
| 16 | resume_cursor_json TEXT, | |
| 17 | runtime_payload_json TEXT | |
| 18 | ) | |
| 19 | `; | |
| 20 | ||
| 21 | yield* sql` | |
| 22 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread | |
| 23 | ON provider_session_runtime(thread_id) | |
| 24 | `; | |
| 25 | ||
| 26 | yield* sql` | |
| 27 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status | |
| 28 | ON provider_session_runtime(status) | |
| 29 | `; | |
| 30 | ||
| 31 | yield* sql` | |
| 32 | CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_provider | |
| 33 | ON provider_session_runtime(provider_name) | |
| 34 | `; | |
| 35 | }); |
apps/server/src/persistence/Migrations/005_Projections.ts created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS projection_projects ( | |
| 9 | project_id TEXT PRIMARY KEY, | |
| 10 | title TEXT NOT NULL, | |
| 11 | workspace_root TEXT NOT NULL, | |
| 12 | default_model TEXT, | |
| 13 | scripts_json TEXT NOT NULL, | |
| 14 | created_at TEXT NOT NULL, | |
| 15 | updated_at TEXT NOT NULL, | |
| 16 | deleted_at TEXT | |
| 17 | ) | |
| 18 | `; | |
| 19 | ||
| 20 | yield* sql` | |
| 21 | CREATE TABLE IF NOT EXISTS projection_threads ( | |
| 22 | thread_id TEXT PRIMARY KEY, | |
| 23 | project_id TEXT NOT NULL, | |
| 24 | title TEXT NOT NULL, | |
| 25 | model TEXT NOT NULL, | |
| 26 | branch TEXT, | |
| 27 | worktree_path TEXT, | |
| 28 | latest_turn_id TEXT, | |
| 29 | created_at TEXT NOT NULL, | |
| 30 | updated_at TEXT NOT NULL, | |
| 31 | deleted_at TEXT | |
| 32 | ) | |
| 33 | `; | |
| 34 | ||
| 35 | yield* sql` | |
| 36 | CREATE TABLE IF NOT EXISTS projection_thread_messages ( | |
| 37 | message_id TEXT PRIMARY KEY, | |
| 38 | thread_id TEXT NOT NULL, | |
| 39 | turn_id TEXT, | |
| 40 | role TEXT NOT NULL, | |
| 41 | text TEXT NOT NULL, | |
| 42 | is_streaming INTEGER NOT NULL, | |
| 43 | created_at TEXT NOT NULL, | |
| 44 | updated_at TEXT NOT NULL | |
| 45 | ) | |
| 46 | `; | |
| 47 | ||
| 48 | yield* sql` | |
| 49 | CREATE TABLE IF NOT EXISTS projection_thread_activities ( | |
| 50 | activity_id TEXT PRIMARY KEY, | |
| 51 | thread_id TEXT NOT NULL, | |
| 52 | turn_id TEXT, | |
| 53 | tone TEXT NOT NULL, | |
| 54 | kind TEXT NOT NULL, | |
| 55 | summary TEXT NOT NULL, | |
| 56 | payload_json TEXT NOT NULL, | |
| 57 | created_at TEXT NOT NULL | |
| 58 | ) | |
| 59 | `; | |
| 60 | ||
| 61 | yield* sql` | |
| 62 | CREATE TABLE IF NOT EXISTS projection_thread_sessions ( | |
| 63 | thread_id TEXT PRIMARY KEY, | |
| 64 | status TEXT NOT NULL, | |
| 65 | provider_name TEXT, | |
| 66 | provider_session_id TEXT, | |
| 67 | provider_thread_id TEXT, | |
| 68 | active_turn_id TEXT, | |
| 69 | last_error TEXT, | |
| 70 | updated_at TEXT NOT NULL | |
| 71 | ) | |
| 72 | `; | |
| 73 | ||
| 74 | yield* sql` | |
| 75 | CREATE TABLE IF NOT EXISTS projection_turns ( | |
| 76 | row_id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 77 | thread_id TEXT NOT NULL, | |
| 78 | turn_id TEXT, | |
| 79 | pending_message_id TEXT, | |
| 80 | assistant_message_id TEXT, | |
| 81 | state TEXT NOT NULL, | |
| 82 | requested_at TEXT NOT NULL, | |
| 83 | started_at TEXT, | |
| 84 | completed_at TEXT, | |
| 85 | checkpoint_turn_count INTEGER, | |
| 86 | checkpoint_ref TEXT, | |
| 87 | checkpoint_status TEXT, | |
| 88 | checkpoint_files_json TEXT NOT NULL, | |
| 89 | UNIQUE (thread_id, turn_id), | |
| 90 | UNIQUE (thread_id, checkpoint_turn_count) | |
| 91 | ) | |
| 92 | `; | |
| 93 | ||
| 94 | yield* sql` | |
| 95 | CREATE TABLE IF NOT EXISTS projection_pending_approvals ( | |
| 96 | request_id TEXT PRIMARY KEY, | |
| 97 | thread_id TEXT NOT NULL, | |
| 98 | turn_id TEXT, | |
| 99 | status TEXT NOT NULL, | |
| 100 | decision TEXT, | |
| 101 | created_at TEXT NOT NULL, | |
| 102 | resolved_at TEXT | |
| 103 | ) | |
| 104 | `; | |
| 105 | ||
| 106 | yield* sql` | |
| 107 | CREATE TABLE IF NOT EXISTS projection_state ( | |
| 108 | projector TEXT PRIMARY KEY, | |
| 109 | last_applied_sequence INTEGER NOT NULL, | |
| 110 | updated_at TEXT NOT NULL | |
| 111 | ) | |
| 112 | `; | |
| 113 | ||
| 114 | yield* sql` | |
| 115 | CREATE INDEX IF NOT EXISTS idx_projection_projects_updated_at | |
| 116 | ON projection_projects(updated_at) | |
| 117 | `; | |
| 118 | ||
| 119 | yield* sql` | |
| 120 | CREATE INDEX IF NOT EXISTS idx_projection_threads_project_id | |
| 121 | ON projection_threads(project_id) | |
| 122 | `; | |
| 123 | ||
| 124 | yield* sql` | |
| 125 | CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created | |
| 126 | ON projection_thread_messages(thread_id, created_at) | |
| 127 | `; | |
| 128 | ||
| 129 | yield* sql` | |
| 130 | CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_created | |
| 131 | ON projection_thread_activities(thread_id, created_at) | |
| 132 | `; | |
| 133 | ||
| 134 | yield* sql` | |
| 135 | CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_provider_session | |
| 136 | ON projection_thread_sessions(provider_session_id) | |
| 137 | `; | |
| 138 | ||
| 139 | yield* sql` | |
| 140 | CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_requested | |
| 141 | ON projection_turns(thread_id, requested_at) | |
| 142 | `; | |
| 143 | ||
| 144 | yield* sql` | |
| 145 | CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_checkpoint_completed | |
| 146 | ON projection_turns(thread_id, checkpoint_turn_count, completed_at) | |
| 147 | `; | |
| 148 | ||
| 149 | yield* sql` | |
| 150 | CREATE INDEX IF NOT EXISTS idx_projection_pending_approvals_thread_status | |
| 151 | ON projection_pending_approvals(thread_id, status) | |
| 152 | `; | |
| 153 | }); |
apps/server/src/persistence/Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | const DEFAULT_APPROVAL_POLICY = "never"; | |
| 5 | const DEFAULT_SANDBOX_MODE = "workspace-write"; | |
| 6 | ||
| 7 | export default Effect.gen(function* () { | |
| 8 | const sql = yield* SqlClient.SqlClient; | |
| 9 | ||
| 10 | yield* sql` | |
| 11 | ALTER TABLE projection_thread_sessions | |
| 12 | ADD COLUMN approval_policy TEXT NOT NULL DEFAULT 'never' | |
| 13 | `; | |
| 14 | ||
| 15 | yield* sql` | |
| 16 | ALTER TABLE projection_thread_sessions | |
| 17 | ADD COLUMN sandbox_mode TEXT NOT NULL DEFAULT 'workspace-write' | |
| 18 | `; | |
| 19 | ||
| 20 | yield* sql` | |
| 21 | UPDATE projection_thread_sessions | |
| 22 | SET approval_policy = ${DEFAULT_APPROVAL_POLICY} | |
| 23 | WHERE approval_policy IS NULL | |
| 24 | `; | |
| 25 | yield* sql` | |
| 26 | UPDATE projection_thread_sessions | |
| 27 | SET sandbox_mode = ${DEFAULT_SANDBOX_MODE} | |
| 28 | WHERE sandbox_mode IS NULL | |
| 29 | `; | |
| 30 | }); |
apps/server/src/persistence/NodeSqliteClient.test.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import * as SqliteClient from "./NodeSqliteClient.ts"; | |
| 6 | ||
| 7 | const layer = it.layer(SqliteClient.layerMemory()); | |
| 8 | ||
| 9 | layer("NodeSqliteClient", (it) => { | |
| 10 | it.effect("runs prepared queries and returns positional values", () => | |
| 11 | Effect.gen(function* () { | |
| 12 | const sql = yield* SqlClient.SqlClient; | |
| 13 | ||
| 14 | yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; | |
| 15 | yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; | |
| 16 | ||
| 17 | const rows = yield* sql<{ readonly id: number; readonly name: string }>` | |
| 18 | SELECT id, name FROM entries ORDER BY id | |
| 19 | `; | |
| 20 | assert.equal(rows.length, 2); | |
| 21 | assert.equal(rows[0]?.name, "alpha"); | |
| 22 | assert.equal(rows[1]?.name, "beta"); | |
| 23 | ||
| 24 | const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; | |
| 25 | assert.equal(values.length, 2); | |
| 26 | assert.equal(values[0]?.[1], "alpha"); | |
| 27 | assert.equal(values[1]?.[1], "beta"); | |
| 28 | }), | |
| 29 | ); | |
| 30 | }); |
apps/server/src/persistence/NodeSqliteClient.ts created+241| ... | ... | @@ -0,0 +1,241 @@ |
| 1 | /** | |
| 2 | * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` | |
| 3 | * bindings instead of `better-sqlite3`. | |
| 4 | * | |
| 5 | * @module SqliteClient | |
| 6 | */ | |
| 7 | import { DatabaseSync, type StatementSync } from "node:sqlite"; | |
| 8 | ||
| 9 | import * as Cache from "effect/Cache"; | |
| 10 | import * as Config from "effect/Config"; | |
| 11 | import * as Duration from "effect/Duration"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Fiber from "effect/Fiber"; | |
| 14 | import { identity } from "effect/Function"; | |
| 15 | import * as Layer from "effect/Layer"; | |
| 16 | import * as Scope from "effect/Scope"; | |
| 17 | import * as Semaphore from "effect/Semaphore"; | |
| 18 | import * as ServiceMap from "effect/ServiceMap"; | |
| 19 | import * as Stream from "effect/Stream"; | |
| 20 | import * as Reactivity from "effect/unstable/reactivity/Reactivity"; | |
| 21 | import * as Client from "effect/unstable/sql/SqlClient"; | |
| 22 | import type { Connection } from "effect/unstable/sql/SqlConnection"; | |
| 23 | import { SqlError } from "effect/unstable/sql/SqlError"; | |
| 24 | import * as Statement from "effect/unstable/sql/Statement"; | |
| 25 | ||
| 26 | const ATTR_DB_SYSTEM_NAME = "db.system.name"; | |
| 27 | ||
| 28 | export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; | |
| 29 | ||
| 30 | export type TypeId = "~local/sqlite-node/SqliteClient"; | |
| 31 | ||
| 32 | /** | |
| 33 | * SqliteClient - Effect service tag for the sqlite SQL client. | |
| 34 | */ | |
| 35 | export const SqliteClient = ServiceMap.Service<Client.SqlClient>("t3/persistence/NodeSqliteClient"); | |
| 36 | ||
| 37 | export interface SqliteClientConfig { | |
| 38 | readonly filename: string; | |
| 39 | readonly readonly?: boolean | undefined; | |
| 40 | readonly allowExtension?: boolean | undefined; | |
| 41 | readonly prepareCacheSize?: number | undefined; | |
| 42 | readonly prepareCacheTTL?: Duration.Input | undefined; | |
| 43 | readonly spanAttributes?: Record<string, unknown> | undefined; | |
| 44 | readonly transformResultNames?: ((str: string) => string) | undefined; | |
| 45 | readonly transformQueryNames?: ((str: string) => string) | undefined; | |
| 46 | } | |
| 47 | ||
| 48 | export interface SqliteMemoryClientConfig extends Omit< | |
| 49 | SqliteClientConfig, | |
| 50 | "filename" | "readonly" | |
| 51 | > {} | |
| 52 | ||
| 53 | const makeWithDatabase = ( | |
| 54 | options: SqliteClientConfig, | |
| 55 | openDatabase: () => DatabaseSync, | |
| 56 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | |
| 57 | Effect.gen(function* () { | |
| 58 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); | |
| 59 | const transformRows = options.transformResultNames | |
| 60 | ? Statement.defaultTransforms(options.transformResultNames).array | |
| 61 | : undefined; | |
| 62 | ||
| 63 | const makeConnection = Effect.gen(function* () { | |
| 64 | const scope = yield* Effect.scope; | |
| 65 | const db = openDatabase(); | |
| 66 | yield* Scope.addFinalizer( | |
| 67 | scope, | |
| 68 | Effect.sync(() => db.close()), | |
| 69 | ); | |
| 70 | ||
| 71 | const statementReaderCache = new WeakMap<StatementSync, boolean>(); | |
| 72 | const hasRows = (statement: StatementSync): boolean => { | |
| 73 | const cached = statementReaderCache.get(statement); | |
| 74 | if (cached !== undefined) { | |
| 75 | return cached; | |
| 76 | } | |
| 77 | const value = statement.columns().length > 0; | |
| 78 | statementReaderCache.set(statement, value); | |
| 79 | return value; | |
| 80 | }; | |
| 81 | ||
| 82 | const prepareCache = yield* Cache.make({ | |
| 83 | capacity: options.prepareCacheSize ?? 200, | |
| 84 | timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), | |
| 85 | lookup: (sql: string) => | |
| 86 | Effect.try({ | |
| 87 | try: () => db.prepare(sql), | |
| 88 | catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), | |
| 89 | }), | |
| 90 | }); | |
| 91 | ||
| 92 | const runStatement = ( | |
| 93 | statement: StatementSync, | |
| 94 | params: ReadonlyArray<unknown>, | |
| 95 | raw: boolean, | |
| 96 | ) => | |
| 97 | Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => { | |
| 98 | statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); | |
| 99 | try { | |
| 100 | if (hasRows(statement)) { | |
| 101 | return Effect.succeed(statement.all(...(params as any))); | |
| 102 | } | |
| 103 | const result = statement.run(...(params as any)); | |
| 104 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); | |
| 105 | } catch (cause) { | |
| 106 | return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); | |
| 107 | } | |
| 108 | }); | |
| 109 | ||
| 110 | const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) => | |
| 111 | Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); | |
| 112 | ||
| 113 | const runValues = (sql: string, params: ReadonlyArray<unknown>) => | |
| 114 | Effect.acquireUseRelease( | |
| 115 | Cache.get(prepareCache, sql), | |
| 116 | (statement) => | |
| 117 | Effect.try({ | |
| 118 | try: () => { | |
| 119 | if (hasRows(statement)) { | |
| 120 | statement.setReturnArrays(true); | |
| 121 | // Safe to cast to array after we've setReturnArrays(true) | |
| 122 | return statement.all(...(params as any)) as unknown as ReadonlyArray< | |
| 123 | ReadonlyArray<unknown> | |
| 124 | >; | |
| 125 | } | |
| 126 | statement.run(...(params as any)); | |
| 127 | return []; | |
| 128 | }, | |
| 129 | catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), | |
| 130 | }), | |
| 131 | (statement) => | |
| 132 | Effect.sync(() => { | |
| 133 | if (hasRows(statement)) { | |
| 134 | statement.setReturnArrays(false); | |
| 135 | } | |
| 136 | }), | |
| 137 | ); | |
| 138 | ||
| 139 | return identity<Connection>({ | |
| 140 | execute(sql, params, rowTransform) { | |
| 141 | return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); | |
| 142 | }, | |
| 143 | executeRaw(sql, params) { | |
| 144 | return run(sql, params, true); | |
| 145 | }, | |
| 146 | executeValues(sql, params) { | |
| 147 | return runValues(sql, params); | |
| 148 | }, | |
| 149 | executeUnprepared(sql, params, rowTransform) { | |
| 150 | const effect = runStatement(db.prepare(sql), params ?? [], false); | |
| 151 | return rowTransform ? Effect.map(effect, rowTransform) : effect; | |
| 152 | }, | |
| 153 | executeStream(_sql, _params) { | |
| 154 | return Stream.die("executeStream not implemented"); | |
| 155 | }, | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | const semaphore = yield* Semaphore.make(1); | |
| 160 | const connection = yield* makeConnection; | |
| 161 | ||
| 162 | const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); | |
| 163 | const transactionAcquirer = Effect.uninterruptibleMask((restore) => { | |
| 164 | const fiber = Fiber.getCurrent()!; | |
| 165 | const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); | |
| 166 | return Effect.as( | |
| 167 | Effect.tap(restore(semaphore.take(1)), () => | |
| 168 | Scope.addFinalizer(scope, semaphore.release(1)), | |
| 169 | ), | |
| 170 | connection, | |
| 171 | ); | |
| 172 | }); | |
| 173 | ||
| 174 | return yield* Client.make({ | |
| 175 | acquirer, | |
| 176 | compiler, | |
| 177 | transactionAcquirer, | |
| 178 | spanAttributes: [ | |
| 179 | ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), | |
| 180 | [ATTR_DB_SYSTEM_NAME, "sqlite"], | |
| 181 | ], | |
| 182 | transformRows, | |
| 183 | }); | |
| 184 | }); | |
| 185 | ||
| 186 | const make = ( | |
| 187 | options: SqliteClientConfig, | |
| 188 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | |
| 189 | makeWithDatabase( | |
| 190 | options, | |
| 191 | () => | |
| 192 | new DatabaseSync(options.filename, { | |
| 193 | readOnly: options.readonly ?? false, | |
| 194 | allowExtension: options.allowExtension ?? false, | |
| 195 | }), | |
| 196 | ); | |
| 197 | ||
| 198 | const makeMemory = ( | |
| 199 | config: SqliteMemoryClientConfig = {}, | |
| 200 | ): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> => | |
| 201 | makeWithDatabase( | |
| 202 | { | |
| 203 | ...config, | |
| 204 | filename: ":memory:", | |
| 205 | readonly: false, | |
| 206 | }, | |
| 207 | () => { | |
| 208 | const database = new DatabaseSync(":memory:", { | |
| 209 | allowExtension: config.allowExtension ?? false, | |
| 210 | }); | |
| 211 | return database; | |
| 212 | }, | |
| 213 | ); | |
| 214 | ||
| 215 | export const layerConfig = ( | |
| 216 | config: Config.Wrap<SqliteClientConfig>, | |
| 217 | ): Layer.Layer<Client.SqlClient, Config.ConfigError> => | |
| 218 | Layer.effectServices( | |
| 219 | Config.unwrap(config) | |
| 220 | .asEffect() | |
| 221 | .pipe( | |
| 222 | Effect.flatMap(make), | |
| 223 | Effect.map((client) => | |
| 224 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | |
| 225 | ), | |
| 226 | ), | |
| 227 | ).pipe(Layer.provide(Reactivity.layer)); | |
| 228 | ||
| 229 | export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> => | |
| 230 | Layer.effectServices( | |
| 231 | Effect.map(make(config), (client) => | |
| 232 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | |
| 233 | ), | |
| 234 | ).pipe(Layer.provide(Reactivity.layer)); | |
| 235 | ||
| 236 | export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> => | |
| 237 | Layer.effectServices( | |
| 238 | Effect.map(makeMemory(config), (client) => | |
| 239 | ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), | |
| 240 | ), | |
| 241 | ).pipe(Layer.provide(Reactivity.layer)); |
apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | /** | |
| 2 | * OrchestrationCommandReceiptRepository - Repository interface for command receipts. | |
| 3 | * | |
| 4 | * Owns persistence operations for deduplication and status tracking of | |
| 5 | * orchestration command handling. | |
| 6 | * | |
| 7 | * @module OrchestrationCommandReceiptRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | CommandId, | |
| 11 | IsoDateTime, | |
| 12 | NonNegativeInt, | |
| 13 | OrchestrationAggregateKind, | |
| 14 | OrchestrationCommandReceiptStatus, | |
| 15 | ProjectId, | |
| 16 | ThreadId, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { Option, Schema, ServiceMap } from "effect"; | |
| 19 | import type { Effect } from "effect"; | |
| 20 | ||
| 21 | import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; | |
| 22 | ||
| 23 | export const OrchestrationCommandReceipt = Schema.Struct({ | |
| 24 | commandId: CommandId, | |
| 25 | aggregateKind: OrchestrationAggregateKind, | |
| 26 | aggregateId: Schema.Union([ProjectId, ThreadId]), | |
| 27 | acceptedAt: IsoDateTime, | |
| 28 | resultSequence: NonNegativeInt, | |
| 29 | status: OrchestrationCommandReceiptStatus, | |
| 30 | error: Schema.NullOr(Schema.String), | |
| 31 | }); | |
| 32 | export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; | |
| 33 | ||
| 34 | export const GetByCommandIdInput = Schema.Struct({ | |
| 35 | commandId: CommandId, | |
| 36 | }); | |
| 37 | export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; | |
| 38 | ||
| 39 | /** | |
| 40 | * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts. | |
| 41 | */ | |
| 42 | export interface OrchestrationCommandReceiptRepositoryShape { | |
| 43 | /** | |
| 44 | * Insert or replace a command receipt row. | |
| 45 | * | |
| 46 | * Upserts by `commandId` for idempotent command-result tracking. | |
| 47 | */ | |
| 48 | readonly upsert: ( | |
| 49 | receipt: OrchestrationCommandReceipt, | |
| 50 | ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>; | |
| 51 | ||
| 52 | /** | |
| 53 | * Read a command receipt by command id. | |
| 54 | */ | |
| 55 | readonly getByCommandId: ( | |
| 56 | input: GetByCommandIdInput, | |
| 57 | ) => Effect.Effect< | |
| 58 | Option.Option<OrchestrationCommandReceipt>, | |
| 59 | OrchestrationCommandReceiptRepositoryError | |
| 60 | >; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. | |
| 65 | */ | |
| 66 | export class OrchestrationCommandReceiptRepository extends ServiceMap.Service< | |
| 67 | OrchestrationCommandReceiptRepository, | |
| 68 | OrchestrationCommandReceiptRepositoryShape | |
| 69 | >()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} |
apps/server/src/persistence/Services/OrchestrationEventStore.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | /** | |
| 2 | * OrchestrationEventStore - Event store interface for orchestration events. | |
| 3 | * | |
| 4 | * Owns durable append/replay access to the orchestration event stream. It does | |
| 5 | * not reduce events into read models or apply command validation rules. | |
| 6 | * | |
| 7 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 8 | * persistence/decode errors for event append and replay operations. | |
| 9 | * | |
| 10 | * @module OrchestrationEventStore | |
| 11 | */ | |
| 12 | import { OrchestrationEvent } from "@t3tools/contracts"; | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | import type { Effect, Stream } from "effect"; | |
| 15 | ||
| 16 | import type { OrchestrationEventStoreError } from "../Errors.ts"; | |
| 17 | ||
| 18 | /** | |
| 19 | * OrchestrationEventStoreShape - Service API for orchestration event persistence. | |
| 20 | */ | |
| 21 | export interface OrchestrationEventStoreShape { | |
| 22 | /** | |
| 23 | * Persist a new orchestration event. | |
| 24 | * | |
| 25 | * @param event - Event payload without sequence (assigned by storage). | |
| 26 | * @returns Effect containing the stored event with assigned sequence. | |
| 27 | * | |
| 28 | * Actor kind is inferred from command/metadata before persistence. | |
| 29 | */ | |
| 30 | readonly append: ( | |
| 31 | event: Omit<OrchestrationEvent, "sequence">, | |
| 32 | ) => Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 33 | ||
| 34 | /** | |
| 35 | * Replay events after the provided sequence. | |
| 36 | * | |
| 37 | * @param sequenceExclusive - Sequence cursor (exclusive). | |
| 38 | * @param limit - Maximum number of events to emit. | |
| 39 | * @returns Stream containing ordered events. | |
| 40 | * | |
| 41 | * Reads in fixed-size pages and normalizes non-integer/negative limits. | |
| 42 | */ | |
| 43 | readonly readFromSequence: ( | |
| 44 | sequenceExclusive: number, | |
| 45 | limit?: number, | |
| 46 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 47 | ||
| 48 | /** | |
| 49 | * Read all events from the beginning of the stream. | |
| 50 | * | |
| 51 | * @returns Stream containing all stored events. | |
| 52 | */ | |
| 53 | readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * OrchestrationEventStore - Service tag for orchestration event persistence. | |
| 58 | * | |
| 59 | * @example | |
| 60 | * ```ts | |
| 61 | * const program = Effect.gen(function* () { | |
| 62 | * const events = yield* OrchestrationEventStore | |
| 63 | * return yield* Stream.runCollect(events.readAll()) | |
| 64 | * }) | |
| 65 | * ``` | |
| 66 | */ | |
| 67 | export class OrchestrationEventStore extends ServiceMap.Service< | |
| 68 | OrchestrationEventStore, | |
| 69 | OrchestrationEventStoreShape | |
| 70 | >()("t3/persistence/Services/OrchestrationEventStore") {} |
apps/server/src/persistence/Services/ProjectionCheckpoints.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | /** | |
| 2 | * ProjectionCheckpointRepository - Projection repository interface for checkpoints. | |
| 3 | * | |
| 4 | * Owns persistence operations for projected checkpoint summaries in thread | |
| 5 | * timelines. | |
| 6 | * | |
| 7 | * @module ProjectionCheckpointRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | CheckpointRef, | |
| 11 | IsoDateTime, | |
| 12 | MessageId, | |
| 13 | NonNegativeInt, | |
| 14 | OrchestrationCheckpointFile, | |
| 15 | OrchestrationCheckpointStatus, | |
| 16 | ThreadId, | |
| 17 | TurnId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { Option, ServiceMap, Schema } from "effect"; | |
| 20 | import type { Effect } from "effect"; | |
| 21 | ||
| 22 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 23 | ||
| 24 | export const ProjectionCheckpoint = Schema.Struct({ | |
| 25 | threadId: ThreadId, | |
| 26 | turnId: TurnId, | |
| 27 | checkpointTurnCount: NonNegativeInt, | |
| 28 | checkpointRef: CheckpointRef, | |
| 29 | status: OrchestrationCheckpointStatus, | |
| 30 | files: Schema.Array(OrchestrationCheckpointFile), | |
| 31 | assistantMessageId: Schema.NullOr(MessageId), | |
| 32 | completedAt: IsoDateTime, | |
| 33 | }); | |
| 34 | export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; | |
| 35 | ||
| 36 | export const ListByThreadIdInput = Schema.Struct({ | |
| 37 | threadId: ThreadId, | |
| 38 | }); | |
| 39 | export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; | |
| 40 | ||
| 41 | export const GetByThreadAndTurnCountInput = Schema.Struct({ | |
| 42 | threadId: ThreadId, | |
| 43 | checkpointTurnCount: NonNegativeInt, | |
| 44 | }); | |
| 45 | export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; | |
| 46 | ||
| 47 | export const DeleteByThreadIdInput = Schema.Struct({ | |
| 48 | threadId: ThreadId, | |
| 49 | }); | |
| 50 | export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; | |
| 51 | ||
| 52 | /** | |
| 53 | * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints. | |
| 54 | */ | |
| 55 | export interface ProjectionCheckpointRepositoryShape { | |
| 56 | /** | |
| 57 | * Insert or replace a projected checkpoint row. | |
| 58 | * | |
| 59 | * Upserts by composite key `(threadId, checkpointTurnCount)`. | |
| 60 | */ | |
| 61 | readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 62 | ||
| 63 | /** | |
| 64 | * List projected checkpoints for a thread. | |
| 65 | * | |
| 66 | * Returned in ascending checkpoint turn-count order. | |
| 67 | */ | |
| 68 | readonly listByThreadId: ( | |
| 69 | input: ListByThreadIdInput, | |
| 70 | ) => Effect.Effect<ReadonlyArray<ProjectionCheckpoint>, ProjectionRepositoryError>; | |
| 71 | ||
| 72 | /** | |
| 73 | * Read a projected checkpoint by thread and turn-count key. | |
| 74 | */ | |
| 75 | readonly getByThreadAndTurnCount: ( | |
| 76 | input: GetByThreadAndTurnCountInput, | |
| 77 | ) => Effect.Effect<Option.Option<ProjectionCheckpoint>, ProjectionRepositoryError>; | |
| 78 | ||
| 79 | /** | |
| 80 | * Delete projected checkpoint rows by thread. | |
| 81 | */ | |
| 82 | readonly deleteByThreadId: ( | |
| 83 | input: DeleteByThreadIdInput, | |
| 84 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence. | |
| 89 | */ | |
| 90 | export class ProjectionCheckpointRepository extends ServiceMap.Service< | |
| 91 | ProjectionCheckpointRepository, | |
| 92 | ProjectionCheckpointRepositoryShape | |
| 93 | >()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {} |
apps/server/src/persistence/Services/ProjectionPendingApprovals.ts created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | /** | |
| 2 | * ProjectionPendingApprovalRepository - Repository interface for pending approvals. | |
| 3 | * | |
| 4 | * Owns persistence operations for projected approval requests awaiting user | |
| 5 | * decisions. | |
| 6 | * | |
| 7 | * @module ProjectionPendingApprovalRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | ApprovalRequestId, | |
| 11 | IsoDateTime, | |
| 12 | ProjectionPendingApprovalDecision, | |
| 13 | ProjectionPendingApprovalStatus, | |
| 14 | ThreadId, | |
| 15 | TurnId, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { Option, Schema, ServiceMap } from "effect"; | |
| 18 | import type { Effect } from "effect"; | |
| 19 | ||
| 20 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 21 | ||
| 22 | export const ProjectionPendingApproval = Schema.Struct({ | |
| 23 | requestId: ApprovalRequestId, | |
| 24 | threadId: ThreadId, | |
| 25 | turnId: Schema.NullOr(TurnId), | |
| 26 | status: ProjectionPendingApprovalStatus, | |
| 27 | decision: ProjectionPendingApprovalDecision, | |
| 28 | createdAt: IsoDateTime, | |
| 29 | resolvedAt: Schema.NullOr(IsoDateTime), | |
| 30 | }); | |
| 31 | export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; | |
| 32 | ||
| 33 | export const ListProjectionPendingApprovalsInput = Schema.Struct({ | |
| 34 | threadId: ThreadId, | |
| 35 | }); | |
| 36 | export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; | |
| 37 | ||
| 38 | export const GetProjectionPendingApprovalInput = Schema.Struct({ | |
| 39 | requestId: ApprovalRequestId, | |
| 40 | }); | |
| 41 | export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; | |
| 42 | ||
| 43 | export const DeleteProjectionPendingApprovalInput = Schema.Struct({ | |
| 44 | requestId: ApprovalRequestId, | |
| 45 | }); | |
| 46 | export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; | |
| 47 | ||
| 48 | /** | |
| 49 | * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals. | |
| 50 | */ | |
| 51 | export interface ProjectionPendingApprovalRepositoryShape { | |
| 52 | /** | |
| 53 | * Insert or replace a projected pending approval row. | |
| 54 | * | |
| 55 | * Upserts by `requestId`. | |
| 56 | */ | |
| 57 | readonly upsert: ( | |
| 58 | row: ProjectionPendingApproval, | |
| 59 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 60 | ||
| 61 | /** | |
| 62 | * List pending approvals for a thread. | |
| 63 | * | |
| 64 | * Returned in ascending creation order. | |
| 65 | */ | |
| 66 | readonly listByThreadId: ( | |
| 67 | input: ListProjectionPendingApprovalsInput, | |
| 68 | ) => Effect.Effect<ReadonlyArray<ProjectionPendingApproval>, ProjectionRepositoryError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Read a pending approval row by request id. | |
| 72 | */ | |
| 73 | readonly getByRequestId: ( | |
| 74 | input: GetProjectionPendingApprovalInput, | |
| 75 | ) => Effect.Effect<Option.Option<ProjectionPendingApproval>, ProjectionRepositoryError>; | |
| 76 | ||
| 77 | /** | |
| 78 | * Delete a pending approval row by request id. | |
| 79 | */ | |
| 80 | readonly deleteByRequestId: ( | |
| 81 | input: DeleteProjectionPendingApprovalInput, | |
| 82 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * ProjectionPendingApprovalRepository - Service tag for pending approval persistence. | |
| 87 | */ | |
| 88 | export class ProjectionPendingApprovalRepository extends ServiceMap.Service< | |
| 89 | ProjectionPendingApprovalRepository, | |
| 90 | ProjectionPendingApprovalRepositoryShape | |
| 91 | >()("t3/persistence/Services/ProjectionPendingApprovals/ProjectionPendingApprovalRepository") {} |
apps/server/src/persistence/Services/ProjectionProjects.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | /** | |
| 2 | * ProjectionProjectRepository - Projection repository interface for projects. | |
| 3 | * | |
| 4 | * Owns persistence operations for project rows in the orchestration projection | |
| 5 | * read model. | |
| 6 | * | |
| 7 | * @module ProjectionProjectRepository | |
| 8 | */ | |
| 9 | import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; | |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export const ProjectionProject = Schema.Struct({ | |
| 16 | projectId: ProjectId, | |
| 17 | title: Schema.String, | |
| 18 | workspaceRoot: Schema.String, | |
| 19 | defaultModel: Schema.NullOr(Schema.String), | |
| 20 | scripts: Schema.Array(ProjectScript), | |
| 21 | createdAt: IsoDateTime, | |
| 22 | updatedAt: IsoDateTime, | |
| 23 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 24 | }); | |
| 25 | export type ProjectionProject = typeof ProjectionProject.Type; | |
| 26 | ||
| 27 | export const GetProjectionProjectInput = Schema.Struct({ | |
| 28 | projectId: ProjectId, | |
| 29 | }); | |
| 30 | export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; | |
| 31 | ||
| 32 | export const DeleteProjectionProjectInput = Schema.Struct({ | |
| 33 | projectId: ProjectId, | |
| 34 | }); | |
| 35 | export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; | |
| 36 | ||
| 37 | /** | |
| 38 | * ProjectionProjectRepositoryShape - Service API for projected project records. | |
| 39 | */ | |
| 40 | export interface ProjectionProjectRepositoryShape { | |
| 41 | /** | |
| 42 | * Insert or replace a projected project row. | |
| 43 | * | |
| 44 | * Upserts by `projectId` and persists scripts through JSON encoding. | |
| 45 | */ | |
| 46 | readonly upsert: (row: ProjectionProject) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 47 | ||
| 48 | /** | |
| 49 | * Read a projected project row by id. | |
| 50 | */ | |
| 51 | readonly getById: ( | |
| 52 | input: GetProjectionProjectInput, | |
| 53 | ) => Effect.Effect<Option.Option<ProjectionProject>, ProjectionRepositoryError>; | |
| 54 | ||
| 55 | /** | |
| 56 | * List all projected project rows. | |
| 57 | * | |
| 58 | * Returned in deterministic creation order. | |
| 59 | */ | |
| 60 | readonly listAll: () => Effect.Effect< | |
| 61 | ReadonlyArray<ProjectionProject>, | |
| 62 | ProjectionRepositoryError | |
| 63 | >; | |
| 64 | ||
| 65 | /** | |
| 66 | * Soft-delete a projected project row by id. | |
| 67 | */ | |
| 68 | readonly deleteById: ( | |
| 69 | input: DeleteProjectionProjectInput, | |
| 70 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * ProjectionProjectRepository - Service tag for project projection persistence. | |
| 75 | */ | |
| 76 | export class ProjectionProjectRepository extends ServiceMap.Service< | |
| 77 | ProjectionProjectRepository, | |
| 78 | ProjectionProjectRepositoryShape | |
| 79 | >()("t3/persistence/Services/ProjectionProjects/ProjectionProjectRepository") {} |
apps/server/src/persistence/Services/ProjectionState.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | /** | |
| 2 | * ProjectionStateRepository - Projection repository interface for projector cursors. | |
| 3 | * | |
| 4 | * Owns persistence operations for projection cursor state used to resume | |
| 5 | * incremental event projection. | |
| 6 | * | |
| 7 | * @module ProjectionStateRepository | |
| 8 | */ | |
| 9 | import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; | |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export const ProjectionState = Schema.Struct({ | |
| 16 | projector: Schema.String, | |
| 17 | lastAppliedSequence: NonNegativeInt, | |
| 18 | updatedAt: IsoDateTime, | |
| 19 | }); | |
| 20 | export type ProjectionState = typeof ProjectionState.Type; | |
| 21 | ||
| 22 | export const GetProjectionStateInput = Schema.Struct({ | |
| 23 | projector: Schema.String, | |
| 24 | }); | |
| 25 | export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; | |
| 26 | ||
| 27 | /** | |
| 28 | * ProjectionStateRepositoryShape - Service API for projector state records. | |
| 29 | */ | |
| 30 | export interface ProjectionStateRepositoryShape { | |
| 31 | /** | |
| 32 | * Insert or replace a projection cursor row. | |
| 33 | * | |
| 34 | * Upserts by projector name. | |
| 35 | */ | |
| 36 | readonly upsert: (row: ProjectionState) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 37 | ||
| 38 | /** | |
| 39 | * Read projection cursor state for a projector key. | |
| 40 | */ | |
| 41 | readonly getByProjector: ( | |
| 42 | input: GetProjectionStateInput, | |
| 43 | ) => Effect.Effect<Option.Option<ProjectionState>, ProjectionRepositoryError>; | |
| 44 | ||
| 45 | /** | |
| 46 | * List all projector cursor rows. | |
| 47 | */ | |
| 48 | readonly listAll: () => Effect.Effect<ReadonlyArray<ProjectionState>, ProjectionRepositoryError>; | |
| 49 | ||
| 50 | /** | |
| 51 | * Read the minimum applied sequence across all projectors. | |
| 52 | * | |
| 53 | * Returns `null` when no projector state rows exist. | |
| 54 | */ | |
| 55 | readonly minLastAppliedSequence: () => Effect.Effect<number | null, ProjectionRepositoryError>; | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * ProjectionStateRepository - Service tag for projection cursor persistence. | |
| 60 | */ | |
| 61 | export class ProjectionStateRepository extends ServiceMap.Service< | |
| 62 | ProjectionStateRepository, | |
| 63 | ProjectionStateRepositoryShape | |
| 64 | >()("t3/persistence/Services/ProjectionState/ProjectionStateRepository") {} |
apps/server/src/persistence/Services/ProjectionThreadActivities.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | /** | |
| 2 | * ProjectionThreadActivityRepository - Projection repository interface for thread activity. | |
| 3 | * | |
| 4 | * Owns persistence operations for activity timeline entries projected from | |
| 5 | * orchestration events. | |
| 6 | * | |
| 7 | * @module ProjectionThreadActivityRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | EventId, | |
| 11 | IsoDateTime, | |
| 12 | OrchestrationThreadActivityTone, | |
| 13 | ThreadId, | |
| 14 | TurnId, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { Schema, ServiceMap } from "effect"; | |
| 17 | import type { Effect } from "effect"; | |
| 18 | ||
| 19 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 20 | ||
| 21 | export const ProjectionThreadActivity = Schema.Struct({ | |
| 22 | activityId: EventId, | |
| 23 | threadId: ThreadId, | |
| 24 | turnId: Schema.NullOr(TurnId), | |
| 25 | tone: OrchestrationThreadActivityTone, | |
| 26 | kind: Schema.String, | |
| 27 | summary: Schema.String, | |
| 28 | payload: Schema.Unknown, | |
| 29 | createdAt: IsoDateTime, | |
| 30 | }); | |
| 31 | export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; | |
| 32 | ||
| 33 | export const ListProjectionThreadActivitiesInput = Schema.Struct({ | |
| 34 | threadId: ThreadId, | |
| 35 | }); | |
| 36 | export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; | |
| 37 | ||
| 38 | export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ | |
| 39 | threadId: ThreadId, | |
| 40 | }); | |
| 41 | export type DeleteProjectionThreadActivitiesInput = | |
| 42 | typeof DeleteProjectionThreadActivitiesInput.Type; | |
| 43 | ||
| 44 | /** | |
| 45 | * ProjectionThreadActivityRepositoryShape - Service API for projected thread activity. | |
| 46 | */ | |
| 47 | export interface ProjectionThreadActivityRepositoryShape { | |
| 48 | /** | |
| 49 | * Insert or replace a projected thread activity row. | |
| 50 | * | |
| 51 | * Upserts by `activityId` and JSON-encodes payload. | |
| 52 | */ | |
| 53 | readonly upsert: ( | |
| 54 | row: ProjectionThreadActivity, | |
| 55 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 56 | ||
| 57 | /** | |
| 58 | * List projected thread activity rows for a thread. | |
| 59 | * | |
| 60 | * Returned in ascending creation order. | |
| 61 | */ | |
| 62 | readonly listByThreadId: ( | |
| 63 | input: ListProjectionThreadActivitiesInput, | |
| 64 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>; | |
| 65 | ||
| 66 | /** | |
| 67 | * Delete projected thread activity rows by thread. | |
| 68 | */ | |
| 69 | readonly deleteByThreadId: ( | |
| 70 | input: DeleteProjectionThreadActivitiesInput, | |
| 71 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * ProjectionThreadActivityRepository - Service tag for thread activity persistence. | |
| 76 | */ | |
| 77 | export class ProjectionThreadActivityRepository extends ServiceMap.Service< | |
| 78 | ProjectionThreadActivityRepository, | |
| 79 | ProjectionThreadActivityRepositoryShape | |
| 80 | >()("t3/persistence/Services/ProjectionThreadActivities/ProjectionThreadActivityRepository") {} |
apps/server/src/persistence/Services/ProjectionThreadMessages.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | /** | |
| 2 | * ProjectionThreadMessageRepository - Projection repository interface for messages. | |
| 3 | * | |
| 4 | * Owns persistence operations for projected thread messages rendered in the | |
| 5 | * orchestration read model. | |
| 6 | * | |
| 7 | * @module ProjectionThreadMessageRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | OrchestrationMessageRole, | |
| 11 | MessageId, | |
| 12 | ThreadId, | |
| 13 | TurnId, | |
| 14 | IsoDateTime, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { Schema, ServiceMap } from "effect"; | |
| 17 | import type { Effect } from "effect"; | |
| 18 | ||
| 19 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 20 | ||
| 21 | export const ProjectionThreadMessage = Schema.Struct({ | |
| 22 | messageId: MessageId, | |
| 23 | threadId: ThreadId, | |
| 24 | turnId: Schema.NullOr(TurnId), | |
| 25 | role: OrchestrationMessageRole, | |
| 26 | text: Schema.String, | |
| 27 | isStreaming: Schema.Boolean, | |
| 28 | createdAt: IsoDateTime, | |
| 29 | updatedAt: IsoDateTime, | |
| 30 | }); | |
| 31 | export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; | |
| 32 | ||
| 33 | export const ListProjectionThreadMessagesInput = Schema.Struct({ | |
| 34 | threadId: ThreadId, | |
| 35 | }); | |
| 36 | export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; | |
| 37 | ||
| 38 | export const DeleteProjectionThreadMessagesInput = Schema.Struct({ | |
| 39 | threadId: ThreadId, | |
| 40 | }); | |
| 41 | export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; | |
| 42 | ||
| 43 | /** | |
| 44 | * ProjectionThreadMessageRepositoryShape - Service API for projected thread messages. | |
| 45 | */ | |
| 46 | export interface ProjectionThreadMessageRepositoryShape { | |
| 47 | /** | |
| 48 | * Insert or replace a projected thread message row. | |
| 49 | * | |
| 50 | * Upserts by `messageId`. | |
| 51 | */ | |
| 52 | readonly upsert: ( | |
| 53 | message: ProjectionThreadMessage, | |
| 54 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 55 | ||
| 56 | /** | |
| 57 | * List projected thread messages for a thread. | |
| 58 | * | |
| 59 | * Returned in ascending creation order. | |
| 60 | */ | |
| 61 | readonly listByThreadId: ( | |
| 62 | input: ListProjectionThreadMessagesInput, | |
| 63 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadMessage>, ProjectionRepositoryError>; | |
| 64 | ||
| 65 | /** | |
| 66 | * Delete projected thread messages by thread. | |
| 67 | */ | |
| 68 | readonly deleteByThreadId: ( | |
| 69 | input: DeleteProjectionThreadMessagesInput, | |
| 70 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * ProjectionThreadMessageRepository - Service tag for message projection persistence. | |
| 75 | */ | |
| 76 | export class ProjectionThreadMessageRepository extends ServiceMap.Service< | |
| 77 | ProjectionThreadMessageRepository, | |
| 78 | ProjectionThreadMessageRepositoryShape | |
| 79 | >()("t3/persistence/Services/ProjectionThreadMessages/ProjectionThreadMessageRepository") {} |
apps/server/src/persistence/Services/ProjectionThreadSessions.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | /** | |
| 2 | * ProjectionThreadSessionRepository - Repository interface for thread sessions. | |
| 3 | * | |
| 4 | * Owns persistence operations for projected provider-session linkage and | |
| 5 | * runtime status for each thread. | |
| 6 | * | |
| 7 | * @module ProjectionThreadSessionRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | IsoDateTime, | |
| 11 | OrchestrationSessionStatus, | |
| 12 | ProviderApprovalPolicy, | |
| 13 | ProviderSandboxMode, | |
| 14 | ProviderSessionId, | |
| 15 | ProviderThreadId, | |
| 16 | ThreadId, | |
| 17 | TurnId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { Option, Schema, ServiceMap } from "effect"; | |
| 20 | import type { Effect } from "effect"; | |
| 21 | ||
| 22 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 23 | ||
| 24 | export const ProjectionThreadSession = Schema.Struct({ | |
| 25 | threadId: ThreadId, | |
| 26 | status: OrchestrationSessionStatus, | |
| 27 | providerName: Schema.NullOr(Schema.String), | |
| 28 | providerSessionId: Schema.NullOr(ProviderSessionId), | |
| 29 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 30 | approvalPolicy: ProviderApprovalPolicy, | |
| 31 | sandboxMode: ProviderSandboxMode, | |
| 32 | activeTurnId: Schema.NullOr(TurnId), | |
| 33 | lastError: Schema.NullOr(Schema.String), | |
| 34 | updatedAt: IsoDateTime, | |
| 35 | }); | |
| 36 | export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; | |
| 37 | ||
| 38 | export const GetProjectionThreadSessionInput = Schema.Struct({ | |
| 39 | threadId: ThreadId, | |
| 40 | }); | |
| 41 | export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; | |
| 42 | ||
| 43 | export const DeleteProjectionThreadSessionInput = Schema.Struct({ | |
| 44 | threadId: ThreadId, | |
| 45 | }); | |
| 46 | export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; | |
| 47 | ||
| 48 | /** | |
| 49 | * ProjectionThreadSessionRepositoryShape - Service API for projected thread sessions. | |
| 50 | */ | |
| 51 | export interface ProjectionThreadSessionRepositoryShape { | |
| 52 | /** | |
| 53 | * Insert or replace a projected thread-session row. | |
| 54 | * | |
| 55 | * Upserts by `threadId`. | |
| 56 | */ | |
| 57 | readonly upsert: (row: ProjectionThreadSession) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 58 | ||
| 59 | /** | |
| 60 | * Read projected thread-session state by thread id. | |
| 61 | */ | |
| 62 | readonly getByThreadId: ( | |
| 63 | input: GetProjectionThreadSessionInput, | |
| 64 | ) => Effect.Effect<Option.Option<ProjectionThreadSession>, ProjectionRepositoryError>; | |
| 65 | ||
| 66 | /** | |
| 67 | * Delete projected thread-session state by thread id. | |
| 68 | */ | |
| 69 | readonly deleteByThreadId: ( | |
| 70 | input: DeleteProjectionThreadSessionInput, | |
| 71 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * ProjectionThreadSessionRepository - Service tag for thread-session persistence. | |
| 76 | */ | |
| 77 | export class ProjectionThreadSessionRepository extends ServiceMap.Service< | |
| 78 | ProjectionThreadSessionRepository, | |
| 79 | ProjectionThreadSessionRepositoryShape | |
| 80 | >()("t3/persistence/Services/ProjectionThreadSessions/ProjectionThreadSessionRepository") {} |
apps/server/src/persistence/Services/ProjectionThreads.ts created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | /** | |
| 2 | * ProjectionThreadRepository - Projection repository interface for threads. | |
| 3 | * | |
| 4 | * Owns persistence operations for projected thread records in the | |
| 5 | * orchestration read model. | |
| 6 | * | |
| 7 | * @module ProjectionThreadRepository | |
| 8 | */ | |
| 9 | import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export const ProjectionThread = Schema.Struct({ | |
| 16 | threadId: ThreadId, | |
| 17 | projectId: ProjectId, | |
| 18 | title: Schema.String, | |
| 19 | model: Schema.String, | |
| 20 | branch: Schema.NullOr(Schema.String), | |
| 21 | worktreePath: Schema.NullOr(Schema.String), | |
| 22 | latestTurnId: Schema.NullOr(TurnId), | |
| 23 | createdAt: IsoDateTime, | |
| 24 | updatedAt: IsoDateTime, | |
| 25 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 26 | }); | |
| 27 | export type ProjectionThread = typeof ProjectionThread.Type; | |
| 28 | ||
| 29 | export const GetProjectionThreadInput = Schema.Struct({ | |
| 30 | threadId: ThreadId, | |
| 31 | }); | |
| 32 | export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; | |
| 33 | ||
| 34 | export const DeleteProjectionThreadInput = Schema.Struct({ | |
| 35 | threadId: ThreadId, | |
| 36 | }); | |
| 37 | export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; | |
| 38 | ||
| 39 | export const ListProjectionThreadsByProjectInput = Schema.Struct({ | |
| 40 | projectId: ProjectId, | |
| 41 | }); | |
| 42 | export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; | |
| 43 | ||
| 44 | /** | |
| 45 | * ProjectionThreadRepositoryShape - Service API for projected thread records. | |
| 46 | */ | |
| 47 | export interface ProjectionThreadRepositoryShape { | |
| 48 | /** | |
| 49 | * Insert or replace a projected thread row. | |
| 50 | * | |
| 51 | * Upserts by `threadId`. | |
| 52 | */ | |
| 53 | readonly upsert: (thread: ProjectionThread) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 54 | ||
| 55 | /** | |
| 56 | * Read a projected thread row by id. | |
| 57 | */ | |
| 58 | readonly getById: ( | |
| 59 | input: GetProjectionThreadInput, | |
| 60 | ) => Effect.Effect<Option.Option<ProjectionThread>, ProjectionRepositoryError>; | |
| 61 | ||
| 62 | /** | |
| 63 | * List projected threads for a project. | |
| 64 | * | |
| 65 | * Returned in deterministic creation order. | |
| 66 | */ | |
| 67 | readonly listByProjectId: ( | |
| 68 | input: ListProjectionThreadsByProjectInput, | |
| 69 | ) => Effect.Effect<ReadonlyArray<ProjectionThread>, ProjectionRepositoryError>; | |
| 70 | ||
| 71 | /** | |
| 72 | * Soft-delete a projected thread row by id. | |
| 73 | */ | |
| 74 | readonly deleteById: ( | |
| 75 | input: DeleteProjectionThreadInput, | |
| 76 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * ProjectionThreadRepository - Service tag for thread projection persistence. | |
| 81 | */ | |
| 82 | export class ProjectionThreadRepository extends ServiceMap.Service< | |
| 83 | ProjectionThreadRepository, | |
| 84 | ProjectionThreadRepositoryShape | |
| 85 | >()("t3/persistence/Services/ProjectionThreads/ProjectionThreadRepository") {} |
apps/server/src/persistence/Services/ProjectionTurns.ts created+161| ... | ... | @@ -0,0 +1,161 @@ |
| 1 | /** | |
| 2 | * ProjectionTurnRepository - Projection repository interface for unified turn state. | |
| 3 | * | |
| 4 | * Owns persistence operations for pending starts, running/completed turn lifecycle, | |
| 5 | * and checkpoint metadata in a single projection table. | |
| 6 | * | |
| 7 | * @module ProjectionTurnRepository | |
| 8 | */ | |
| 9 | import { | |
| 10 | CheckpointRef, | |
| 11 | IsoDateTime, | |
| 12 | MessageId, | |
| 13 | NonNegativeInt, | |
| 14 | OrchestrationCheckpointFile, | |
| 15 | OrchestrationCheckpointStatus, | |
| 16 | ThreadId, | |
| 17 | TurnId, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { Option, Schema, ServiceMap } from "effect"; | |
| 20 | import type { Effect } from "effect"; | |
| 21 | ||
| 22 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 23 | ||
| 24 | export const ProjectionTurnState = Schema.Literals([ | |
| 25 | "pending", | |
| 26 | "running", | |
| 27 | "interrupted", | |
| 28 | "completed", | |
| 29 | "error", | |
| 30 | ]); | |
| 31 | export type ProjectionTurnState = typeof ProjectionTurnState.Type; | |
| 32 | ||
| 33 | export const ProjectionTurn = Schema.Struct({ | |
| 34 | threadId: ThreadId, | |
| 35 | turnId: Schema.NullOr(TurnId), | |
| 36 | pendingMessageId: Schema.NullOr(MessageId), | |
| 37 | assistantMessageId: Schema.NullOr(MessageId), | |
| 38 | state: ProjectionTurnState, | |
| 39 | requestedAt: IsoDateTime, | |
| 40 | startedAt: Schema.NullOr(IsoDateTime), | |
| 41 | completedAt: Schema.NullOr(IsoDateTime), | |
| 42 | checkpointTurnCount: Schema.NullOr(NonNegativeInt), | |
| 43 | checkpointRef: Schema.NullOr(CheckpointRef), | |
| 44 | checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), | |
| 45 | checkpointFiles: Schema.Array(OrchestrationCheckpointFile), | |
| 46 | }); | |
| 47 | export type ProjectionTurn = typeof ProjectionTurn.Type; | |
| 48 | ||
| 49 | export const ProjectionTurnById = Schema.Struct({ | |
| 50 | threadId: ThreadId, | |
| 51 | turnId: TurnId, | |
| 52 | pendingMessageId: Schema.NullOr(MessageId), | |
| 53 | assistantMessageId: Schema.NullOr(MessageId), | |
| 54 | state: ProjectionTurnState, | |
| 55 | requestedAt: IsoDateTime, | |
| 56 | startedAt: Schema.NullOr(IsoDateTime), | |
| 57 | completedAt: Schema.NullOr(IsoDateTime), | |
| 58 | checkpointTurnCount: Schema.NullOr(NonNegativeInt), | |
| 59 | checkpointRef: Schema.NullOr(CheckpointRef), | |
| 60 | checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), | |
| 61 | checkpointFiles: Schema.Array(OrchestrationCheckpointFile), | |
| 62 | }); | |
| 63 | export type ProjectionTurnById = typeof ProjectionTurnById.Type; | |
| 64 | ||
| 65 | export const ProjectionPendingTurnStart = Schema.Struct({ | |
| 66 | threadId: ThreadId, | |
| 67 | messageId: MessageId, | |
| 68 | requestedAt: IsoDateTime, | |
| 69 | }); | |
| 70 | export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; | |
| 71 | ||
| 72 | export const ListProjectionTurnsByThreadInput = Schema.Struct({ | |
| 73 | threadId: ThreadId, | |
| 74 | }); | |
| 75 | export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; | |
| 76 | ||
| 77 | export const GetProjectionTurnByTurnIdInput = Schema.Struct({ | |
| 78 | threadId: ThreadId, | |
| 79 | turnId: TurnId, | |
| 80 | }); | |
| 81 | export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type; | |
| 82 | ||
| 83 | export const GetProjectionPendingTurnStartInput = Schema.Struct({ | |
| 84 | threadId: ThreadId, | |
| 85 | }); | |
| 86 | export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type; | |
| 87 | ||
| 88 | export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ | |
| 89 | threadId: ThreadId, | |
| 90 | }); | |
| 91 | export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; | |
| 92 | ||
| 93 | export const ClearCheckpointTurnConflictInput = Schema.Struct({ | |
| 94 | threadId: ThreadId, | |
| 95 | turnId: TurnId, | |
| 96 | checkpointTurnCount: NonNegativeInt, | |
| 97 | }); | |
| 98 | export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type; | |
| 99 | ||
| 100 | export interface ProjectionTurnRepositoryShape { | |
| 101 | /** | |
| 102 | * Inserts or updates the canonical row for a concrete `{threadId, turnId}` turn lifecycle state. | |
| 103 | */ | |
| 104 | readonly upsertByTurnId: ( | |
| 105 | row: ProjectionTurnById, | |
| 106 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 107 | ||
| 108 | /** | |
| 109 | * Replaces any existing pending-start placeholder rows for a thread with exactly one latest pending-start row. | |
| 110 | */ | |
| 111 | readonly replacePendingTurnStart: ( | |
| 112 | row: ProjectionPendingTurnStart, | |
| 113 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 114 | ||
| 115 | /** | |
| 116 | * Returns the newest pending-start placeholder for a thread; this is expected to be at most one row after replacement writes. | |
| 117 | */ | |
| 118 | readonly getPendingTurnStartByThreadId: ( | |
| 119 | input: GetProjectionPendingTurnStartInput, | |
| 120 | ) => Effect.Effect<Option.Option<ProjectionPendingTurnStart>, ProjectionRepositoryError>; | |
| 121 | ||
| 122 | /** | |
| 123 | * Deletes only pending-start placeholder rows (`turnId = null`) for a thread and leaves concrete turn rows untouched. | |
| 124 | */ | |
| 125 | readonly deletePendingTurnStartByThreadId: ( | |
| 126 | input: GetProjectionPendingTurnStartInput, | |
| 127 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 128 | ||
| 129 | /** | |
| 130 | * Lists all projection rows for a thread, including pending placeholders, with checkpoint rows ordered before non-checkpoint rows. | |
| 131 | */ | |
| 132 | readonly listByThreadId: ( | |
| 133 | input: ListProjectionTurnsByThreadInput, | |
| 134 | ) => Effect.Effect<ReadonlyArray<ProjectionTurn>, ProjectionRepositoryError>; | |
| 135 | ||
| 136 | /** | |
| 137 | * Looks up a concrete turn row by `{threadId, turnId}` and never returns pending placeholder rows. | |
| 138 | */ | |
| 139 | readonly getByTurnId: ( | |
| 140 | input: GetProjectionTurnByTurnIdInput, | |
| 141 | ) => Effect.Effect<Option.Option<ProjectionTurnById>, ProjectionRepositoryError>; | |
| 142 | ||
| 143 | /** | |
| 144 | * Clears checkpoint fields on conflicting rows that reuse the same checkpoint turn count in a thread, excluding the provided turn. | |
| 145 | */ | |
| 146 | readonly clearCheckpointTurnConflict: ( | |
| 147 | input: ClearCheckpointTurnConflictInput, | |
| 148 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 149 | ||
| 150 | /** | |
| 151 | * Hard-deletes all projection rows for a thread, including pending-start placeholders and checkpoint metadata rows. | |
| 152 | */ | |
| 153 | readonly deleteByThreadId: ( | |
| 154 | input: DeleteProjectionTurnsByThreadInput, | |
| 155 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 156 | } | |
| 157 | ||
| 158 | export class ProjectionTurnRepository extends ServiceMap.Service< | |
| 159 | ProjectionTurnRepository, | |
| 160 | ProjectionTurnRepositoryShape | |
| 161 | >()("t3/persistence/Services/ProjectionTurns/ProjectionTurnRepository") {} |
apps/server/src/persistence/Services/ProviderSessionRuntime.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | /** | |
| 2 | * ProviderSessionRuntimeRepository - Repository interface for provider runtime sessions. | |
| 3 | * | |
| 4 | * Owns persistence operations for provider runtime metadata and resume cursors. | |
| 5 | * | |
| 6 | * @module ProviderSessionRuntimeRepository | |
| 7 | */ | |
| 8 | import { | |
| 9 | IsoDateTime, | |
| 10 | ProviderSessionId, | |
| 11 | ProviderSessionRuntimeStatus, | |
| 12 | ProviderThreadId, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Option, Schema, ServiceMap } from "effect"; | |
| 16 | import type { Effect } from "effect"; | |
| 17 | ||
| 18 | import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; | |
| 19 | ||
| 20 | export const ProviderSessionRuntime = Schema.Struct({ | |
| 21 | providerSessionId: ProviderSessionId, | |
| 22 | threadId: ThreadId, | |
| 23 | providerName: Schema.String, | |
| 24 | adapterKey: Schema.String, | |
| 25 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 26 | status: ProviderSessionRuntimeStatus, | |
| 27 | lastSeenAt: IsoDateTime, | |
| 28 | resumeCursor: Schema.NullOr(Schema.Unknown), | |
| 29 | runtimePayload: Schema.NullOr(Schema.Unknown), | |
| 30 | }); | |
| 31 | export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; | |
| 32 | ||
| 33 | export const GetProviderSessionRuntimeInput = Schema.Struct({ | |
| 34 | providerSessionId: ProviderSessionId, | |
| 35 | }); | |
| 36 | export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; | |
| 37 | ||
| 38 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ | |
| 39 | providerSessionId: ProviderSessionId, | |
| 40 | }); | |
| 41 | export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; | |
| 42 | ||
| 43 | /** | |
| 44 | * ProviderSessionRuntimeRepositoryShape - Service API for provider runtime records. | |
| 45 | */ | |
| 46 | export interface ProviderSessionRuntimeRepositoryShape { | |
| 47 | /** | |
| 48 | * Insert or replace a provider runtime row. | |
| 49 | * | |
| 50 | * Upserts by `providerSessionId`, including JSON payload/cursor fields. | |
| 51 | */ | |
| 52 | readonly upsert: ( | |
| 53 | runtime: ProviderSessionRuntime, | |
| 54 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; | |
| 55 | ||
| 56 | /** | |
| 57 | * Read provider runtime state by provider session id. | |
| 58 | */ | |
| 59 | readonly getBySessionId: ( | |
| 60 | input: GetProviderSessionRuntimeInput, | |
| 61 | ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>; | |
| 62 | ||
| 63 | /** | |
| 64 | * List all provider runtime rows. | |
| 65 | * | |
| 66 | * Returned in ascending last-seen order. | |
| 67 | */ | |
| 68 | readonly list: () => Effect.Effect< | |
| 69 | ReadonlyArray<ProviderSessionRuntime>, | |
| 70 | ProviderSessionRuntimeRepositoryError | |
| 71 | >; | |
| 72 | ||
| 73 | /** | |
| 74 | * Delete provider runtime state by provider session id. | |
| 75 | */ | |
| 76 | readonly deleteBySessionId: ( | |
| 77 | input: DeleteProviderSessionRuntimeInput, | |
| 78 | ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. | |
| 83 | */ | |
| 84 | export class ProviderSessionRuntimeRepository extends ServiceMap.Service< | |
| 85 | ProviderSessionRuntimeRepository, | |
| 86 | ProviderSessionRuntimeRepositoryShape | |
| 87 | >()("t3/persistence/Services/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {} |
apps/server/src/persistence/Services/ProviderSessions.ts created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | /** | |
| 2 | * ProviderSessionRepository - Repository interface for provider session lookup. | |
| 3 | * | |
| 4 | * Owns persistence operations that map internal sessions to provider kinds and | |
| 5 | * optional thread ownership. | |
| 6 | * | |
| 7 | * @module ProviderSessionRepository | |
| 8 | */ | |
| 9 | import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 10 | import { Option, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export interface ProviderSessionEntry { | |
| 16 | readonly sessionId: ProviderSessionId; | |
| 17 | readonly provider: ProviderKind; | |
| 18 | readonly threadId?: ThreadId; | |
| 19 | readonly createdAt: string; | |
| 20 | readonly updatedAt: string; | |
| 21 | } | |
| 22 | ||
| 23 | export interface UpsertProviderSessionInput { | |
| 24 | readonly sessionId: ProviderSessionId; | |
| 25 | readonly provider: ProviderKind; | |
| 26 | readonly threadId?: ThreadId; | |
| 27 | } | |
| 28 | ||
| 29 | export interface DeleteProviderSessionInput { | |
| 30 | readonly sessionId: ProviderSessionId; | |
| 31 | } | |
| 32 | ||
| 33 | export interface GetProviderSessionInput { | |
| 34 | readonly sessionId: ProviderSessionId; | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * ProviderSessionRepositoryShape - Service API for provider-session records. | |
| 39 | */ | |
| 40 | export interface ProviderSessionRepositoryShape { | |
| 41 | /** | |
| 42 | * Insert or replace a provider-session row. | |
| 43 | * | |
| 44 | * Upserts by `sessionId`. | |
| 45 | */ | |
| 46 | readonly upsertSession: ( | |
| 47 | input: UpsertProviderSessionInput, | |
| 48 | ) => Effect.Effect<void, ProviderSessionRepositoryError>; | |
| 49 | ||
| 50 | /** | |
| 51 | * Read a provider-session row by session id. | |
| 52 | */ | |
| 53 | readonly getSession: ( | |
| 54 | input: GetProviderSessionInput, | |
| 55 | ) => Effect.Effect<Option.Option<ProviderSessionEntry>, ProviderSessionRepositoryError>; | |
| 56 | ||
| 57 | /** | |
| 58 | * List all provider-session rows. | |
| 59 | * | |
| 60 | * Returned in deterministic creation order. | |
| 61 | */ | |
| 62 | readonly listSessions: () => Effect.Effect< | |
| 63 | ReadonlyArray<ProviderSessionEntry>, | |
| 64 | ProviderSessionRepositoryError | |
| 65 | >; | |
| 66 | ||
| 67 | /** | |
| 68 | * Delete a provider-session row by session id. | |
| 69 | */ | |
| 70 | readonly deleteSession: ( | |
| 71 | input: DeleteProviderSessionInput, | |
| 72 | ) => Effect.Effect<void, ProviderSessionRepositoryError>; | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * ProviderSessionRepository - Service tag for provider-session persistence. | |
| 77 | */ | |
| 78 | export class ProviderSessionRepository extends ServiceMap.Service< | |
| 79 | ProviderSessionRepository, | |
| 80 | ProviderSessionRepositoryShape | |
| 81 | >()("t3/persistence/Services/ProviderSessions/ProviderSessionRepository") {} |
apps/server/src/projectFaviconRoute.test.ts created+155| ... | ... | @@ -0,0 +1,155 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import http from "node:http"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { afterEach, describe, expect, it } from "vitest"; | |
| 7 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 8 | ||
| 9 | interface HttpResponse { | |
| 10 | statusCode: number; | |
| 11 | contentType: string | null; | |
| 12 | body: string; | |
| 13 | } | |
| 14 | ||
| 15 | const tempDirs: string[] = []; | |
| 16 | ||
| 17 | function makeTempDir(prefix: string): string { | |
| 18 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 19 | tempDirs.push(dir); | |
| 20 | return dir; | |
| 21 | } | |
| 22 | ||
| 23 | async function withRouteServer( | |
| 24 | run: (baseUrl: string) => Promise<void>, | |
| 25 | ): Promise<void> { | |
| 26 | const server = http.createServer((req, res) => { | |
| 27 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 28 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 29 | return; | |
| 30 | } | |
| 31 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 32 | res.end("Not Found"); | |
| 33 | }); | |
| 34 | ||
| 35 | await new Promise<void>((resolve, reject) => { | |
| 36 | server.listen(0, "127.0.0.1", (error?: Error) => { | |
| 37 | if (error) { | |
| 38 | reject(error); | |
| 39 | return; | |
| 40 | } | |
| 41 | resolve(); | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | const address = server.address(); | |
| 46 | if (typeof address !== "object" || address === null) { | |
| 47 | throw new Error("Expected server address to be an object"); | |
| 48 | } | |
| 49 | const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 50 | ||
| 51 | try { | |
| 52 | await run(baseUrl); | |
| 53 | } finally { | |
| 54 | await new Promise<void>((resolve, reject) => { | |
| 55 | server.close((error?: Error) => { | |
| 56 | if (error) { | |
| 57 | reject(error); | |
| 58 | return; | |
| 59 | } | |
| 60 | resolve(); | |
| 61 | }); | |
| 62 | }); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | async function request(baseUrl: string, pathname: string): Promise<HttpResponse> { | |
| 67 | const response = await fetch(`${baseUrl}${pathname}`); | |
| 68 | return { | |
| 69 | statusCode: response.status, | |
| 70 | contentType: response.headers.get("content-type"), | |
| 71 | body: await response.text(), | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | describe("tryHandleProjectFaviconRequest", () => { | |
| 76 | afterEach(() => { | |
| 77 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 78 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 79 | } | |
| 80 | }); | |
| 81 | ||
| 82 | it("returns 400 when cwd is missing", async () => { | |
| 83 | await withRouteServer(async (baseUrl) => { | |
| 84 | const response = await request(baseUrl, "/api/project-favicon"); | |
| 85 | expect(response.statusCode).toBe(400); | |
| 86 | expect(response.body).toBe("Missing cwd parameter"); | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | it("serves a well-known favicon file from the project root", async () => { | |
| 91 | const projectDir = makeTempDir("t3code-favicon-route-root-"); | |
| 92 | fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8"); | |
| 93 | ||
| 94 | await withRouteServer(async (baseUrl) => { | |
| 95 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 96 | const response = await request(baseUrl, pathname); | |
| 97 | expect(response.statusCode).toBe(200); | |
| 98 | expect(response.contentType).toContain("image/svg+xml"); | |
| 99 | expect(response.body).toBe("<svg>favicon</svg>"); | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | it("resolves icon href from source files when no well-known favicon exists", async () => { | |
| 104 | const projectDir = makeTempDir("t3code-favicon-route-source-"); | |
| 105 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | |
| 106 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 107 | fs.writeFileSync(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">'); | |
| 108 | fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8"); | |
| 109 | ||
| 110 | await withRouteServer(async (baseUrl) => { | |
| 111 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 112 | const response = await request(baseUrl, pathname); | |
| 113 | expect(response.statusCode).toBe(200); | |
| 114 | expect(response.contentType).toContain("image/svg+xml"); | |
| 115 | expect(response.body).toBe("<svg>brand</svg>"); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | it("resolves icon link when href appears before rel in HTML", async () => { | |
| 120 | const projectDir = makeTempDir("t3code-favicon-route-html-order-"); | |
| 121 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | |
| 122 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 123 | fs.writeFileSync(path.join(projectDir, "index.html"), '<link href="/brand/logo.svg" rel="icon">'); | |
| 124 | fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8"); | |
| 125 | ||
| 126 | await withRouteServer(async (baseUrl) => { | |
| 127 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 128 | const response = await request(baseUrl, pathname); | |
| 129 | expect(response.statusCode).toBe(200); | |
| 130 | expect(response.contentType).toContain("image/svg+xml"); | |
| 131 | expect(response.body).toBe("<svg>brand-html-order</svg>"); | |
| 132 | }); | |
| 133 | }); | |
| 134 | ||
| 135 | it("resolves object-style icon metadata when href appears before rel", async () => { | |
| 136 | const projectDir = makeTempDir("t3code-favicon-route-obj-order-"); | |
| 137 | const iconPath = path.join(projectDir, "public", "brand", "obj.svg"); | |
| 138 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | |
| 139 | fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); | |
| 140 | fs.writeFileSync( | |
| 141 | path.join(projectDir, "src", "root.tsx"), | |
| 142 | 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];', | |
| 143 | "utf8", | |
| 144 | ); | |
| 145 | fs.writeFileSync(iconPath, "<svg>brand-obj-order</svg>", "utf8"); | |
| 146 | ||
| 147 | await withRouteServer(async (baseUrl) => { | |
| 148 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | |
| 149 | const response = await request(baseUrl, pathname); | |
| 150 | expect(response.statusCode).toBe(200); | |
| 151 | expect(response.contentType).toContain("image/svg+xml"); | |
| 152 | expect(response.body).toBe("<svg>brand-obj-order</svg>"); | |
| 153 | }); | |
| 154 | }); | |
| 155 | }); |
apps/server/src/projectFaviconRoute.ts created+162| ... | ... | @@ -0,0 +1,162 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import http from "node:http"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | const FAVICON_MIME_TYPES: Record<string, string> = { | |
| 6 | ".png": "image/png", | |
| 7 | ".jpg": "image/jpeg", | |
| 8 | ".svg": "image/svg+xml", | |
| 9 | ".ico": "image/x-icon", | |
| 10 | }; | |
| 11 | ||
| 12 | // Well-known favicon paths checked in order. | |
| 13 | const FAVICON_CANDIDATES = [ | |
| 14 | "favicon.svg", | |
| 15 | "favicon.ico", | |
| 16 | "favicon.png", | |
| 17 | "public/favicon.svg", | |
| 18 | "public/favicon.ico", | |
| 19 | "public/favicon.png", | |
| 20 | "app/favicon.ico", | |
| 21 | "app/favicon.png", | |
| 22 | "app/icon.svg", | |
| 23 | "app/icon.png", | |
| 24 | "app/icon.ico", | |
| 25 | "src/favicon.ico", | |
| 26 | "src/favicon.svg", | |
| 27 | "src/app/favicon.ico", | |
| 28 | "src/app/icon.svg", | |
| 29 | "src/app/icon.png", | |
| 30 | "assets/icon.svg", | |
| 31 | "assets/icon.png", | |
| 32 | "assets/logo.svg", | |
| 33 | "assets/logo.png", | |
| 34 | ]; | |
| 35 | ||
| 36 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | |
| 37 | const ICON_SOURCE_FILES = [ | |
| 38 | "index.html", | |
| 39 | "public/index.html", | |
| 40 | "app/routes/__root.tsx", | |
| 41 | "src/routes/__root.tsx", | |
| 42 | "app/root.tsx", | |
| 43 | "src/root.tsx", | |
| 44 | "src/index.html", | |
| 45 | ]; | |
| 46 | ||
| 47 | // Matches <link ...> tags or object-like icon metadata where rel/href can appear in any order. | |
| 48 | const LINK_ICON_HTML_RE = | |
| 49 | /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; | |
| 50 | const LINK_ICON_OBJ_RE = | |
| 51 | /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; | |
| 52 | ||
| 53 | function extractIconHref(source: string): string | null { | |
| 54 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | |
| 55 | if (htmlMatch?.[1]) return htmlMatch[1]; | |
| 56 | const objMatch = source.match(LINK_ICON_OBJ_RE); | |
| 57 | if (objMatch?.[1]) return objMatch[1]; | |
| 58 | return null; | |
| 59 | } | |
| 60 | ||
| 61 | function resolveIconHref(projectCwd: string, href: string): string[] { | |
| 62 | const clean = href.replace(/^\//, ""); | |
| 63 | return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | |
| 64 | } | |
| 65 | ||
| 66 | function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { | |
| 67 | const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); | |
| 68 | return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | |
| 69 | } | |
| 70 | ||
| 71 | function serveFaviconFile(filePath: string, res: http.ServerResponse): void { | |
| 72 | const ext = path.extname(filePath).toLowerCase(); | |
| 73 | const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 74 | fs.readFile(filePath, (readErr, data) => { | |
| 75 | if (readErr) { | |
| 76 | res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 77 | res.end("Read error"); | |
| 78 | return; | |
| 79 | } | |
| 80 | res.writeHead(200, { | |
| 81 | "Content-Type": contentType, | |
| 82 | "Cache-Control": "public, max-age=3600", | |
| 83 | }); | |
| 84 | res.end(data); | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { | |
| 89 | if (url.pathname !== "/api/project-favicon") { | |
| 90 | return false; | |
| 91 | } | |
| 92 | ||
| 93 | const projectCwd = url.searchParams.get("cwd"); | |
| 94 | if (!projectCwd) { | |
| 95 | res.writeHead(400, { "Content-Type": "text/plain" }); | |
| 96 | res.end("Missing cwd parameter"); | |
| 97 | return true; | |
| 98 | } | |
| 99 | ||
| 100 | const tryResolvedPaths = (paths: string[], index: number, onExhausted: () => void): void => { | |
| 101 | if (index >= paths.length) { | |
| 102 | onExhausted(); | |
| 103 | return; | |
| 104 | } | |
| 105 | const candidate = paths[index]!; | |
| 106 | if (!isPathWithinProject(projectCwd, candidate)) { | |
| 107 | tryResolvedPaths(paths, index + 1, onExhausted); | |
| 108 | return; | |
| 109 | } | |
| 110 | fs.stat(candidate, (err, stats) => { | |
| 111 | if (err || !stats?.isFile()) { | |
| 112 | tryResolvedPaths(paths, index + 1, onExhausted); | |
| 113 | return; | |
| 114 | } | |
| 115 | serveFaviconFile(candidate, res); | |
| 116 | }); | |
| 117 | }; | |
| 118 | ||
| 119 | const trySourceFiles = (index: number): void => { | |
| 120 | if (index >= ICON_SOURCE_FILES.length) { | |
| 121 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 122 | res.end("No favicon found"); | |
| 123 | return; | |
| 124 | } | |
| 125 | const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | |
| 126 | fs.readFile(sourceFile, "utf8", (err, content) => { | |
| 127 | if (err) { | |
| 128 | trySourceFiles(index + 1); | |
| 129 | return; | |
| 130 | } | |
| 131 | const href = extractIconHref(content); | |
| 132 | if (!href) { | |
| 133 | trySourceFiles(index + 1); | |
| 134 | return; | |
| 135 | } | |
| 136 | const candidates = resolveIconHref(projectCwd, href); | |
| 137 | tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | |
| 138 | }); | |
| 139 | }; | |
| 140 | ||
| 141 | const tryCandidates = (index: number): void => { | |
| 142 | if (index >= FAVICON_CANDIDATES.length) { | |
| 143 | trySourceFiles(0); | |
| 144 | return; | |
| 145 | } | |
| 146 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | |
| 147 | if (!isPathWithinProject(projectCwd, candidate)) { | |
| 148 | tryCandidates(index + 1); | |
| 149 | return; | |
| 150 | } | |
| 151 | fs.stat(candidate, (err, stats) => { | |
| 152 | if (err || !stats?.isFile()) { | |
| 153 | tryCandidates(index + 1); | |
| 154 | return; | |
| 155 | } | |
| 156 | serveFaviconFile(candidate, res); | |
| 157 | }); | |
| 158 | }; | |
| 159 | ||
| 160 | tryCandidates(0); | |
| 161 | return true; | |
| 162 | } |
apps/server/src/projectRegistry.test.ts deleted-113| ... | ... | @@ -1,113 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { ProjectRegistry } from "./projectRegistry"; | |
| 8 | ||
| 9 | const tempDirs: string[] = []; | |
| 10 | ||
| 11 | function makeTempDir(prefix: string): string { | |
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 13 | tempDirs.push(dir); | |
| 14 | return dir; | |
| 15 | } | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 19 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | describe("ProjectRegistry scripts", () => { | |
| 24 | it("stores and reloads project scripts", () => { | |
| 25 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 26 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 27 | const registry = new ProjectRegistry(stateDir); | |
| 28 | ||
| 29 | const created = registry.add({ cwd: projectDir }).project; | |
| 30 | const updated = registry.updateScripts({ | |
| 31 | id: created.id, | |
| 32 | scripts: [ | |
| 33 | { | |
| 34 | id: "run", | |
| 35 | name: "Run", | |
| 36 | command: "bun run dev", | |
| 37 | icon: "play", | |
| 38 | runOnWorktreeCreate: false, | |
| 39 | }, | |
| 40 | { | |
| 41 | id: "setup", | |
| 42 | name: "Setup", | |
| 43 | command: "bun install", | |
| 44 | icon: "configure", | |
| 45 | runOnWorktreeCreate: true, | |
| 46 | }, | |
| 47 | ], | |
| 48 | }); | |
| 49 | ||
| 50 | expect(updated.project.scripts).toHaveLength(2); | |
| 51 | expect(updated.project.scripts[1]?.id).toBe("setup"); | |
| 52 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); | |
| 53 | ||
| 54 | const reloaded = new ProjectRegistry(stateDir); | |
| 55 | const listed = reloaded.list(); | |
| 56 | expect(listed).toHaveLength(1); | |
| 57 | expect(listed[0]?.scripts).toHaveLength(2); | |
| 58 | expect(listed[0]?.scripts[0]?.id).toBe("run"); | |
| 59 | expect(listed[0]?.scripts[1]?.id).toBe("setup"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("deduplicates script ids and allows only one setup script", () => { | |
| 63 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 64 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 65 | const registry = new ProjectRegistry(stateDir); | |
| 66 | const created = registry.add({ cwd: projectDir }).project; | |
| 67 | ||
| 68 | const updated = registry.updateScripts({ | |
| 69 | id: created.id, | |
| 70 | scripts: [ | |
| 71 | { | |
| 72 | id: "setup", | |
| 73 | name: "Setup", | |
| 74 | command: "echo setup", | |
| 75 | icon: "configure", | |
| 76 | runOnWorktreeCreate: true, | |
| 77 | }, | |
| 78 | { | |
| 79 | id: "setup", | |
| 80 | name: "Setup Duplicate", | |
| 81 | command: "echo duplicate", | |
| 82 | icon: "configure", | |
| 83 | runOnWorktreeCreate: true, | |
| 84 | }, | |
| 85 | { | |
| 86 | id: "another", | |
| 87 | name: "Another Setup", | |
| 88 | command: "echo another", | |
| 89 | icon: "play", | |
| 90 | runOnWorktreeCreate: true, | |
| 91 | }, | |
| 92 | ], | |
| 93 | }); | |
| 94 | ||
| 95 | expect(updated.project.scripts).toHaveLength(2); | |
| 96 | expect(updated.project.scripts[0]?.id).toBe("setup"); | |
| 97 | expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); | |
| 98 | expect(updated.project.scripts[1]?.id).toBe("another"); | |
| 99 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); | |
| 100 | }); | |
| 101 | ||
| 102 | it("throws when updating scripts for an unknown project id", () => { | |
| 103 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 104 | const registry = new ProjectRegistry(stateDir); | |
| 105 | ||
| 106 | expect(() => | |
| 107 | registry.updateScripts({ | |
| 108 | id: "missing-project", | |
| 109 | scripts: [], | |
| 110 | }), | |
| 111 | ).toThrowError("Project not found"); | |
| 112 | }); | |
| 113 | }); |
apps/server/src/projectRegistry.ts deleted-235| ... | ... | @@ -1,235 +0,0 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | type ProjectAddInput, | |
| 7 | type ProjectAddResult, | |
| 8 | type ProjectListResult, | |
| 9 | type ProjectRecord, | |
| 10 | type ProjectRemoveInput, | |
| 11 | type ProjectScript, | |
| 12 | type ProjectUpdateScriptsInput, | |
| 13 | type ProjectUpdateScriptsResult, | |
| 14 | normalizeProjectScripts, | |
| 15 | projectAddInputSchema, | |
| 16 | projectRecordSchema, | |
| 17 | projectRemoveInputSchema, | |
| 18 | projectScriptsSchema, | |
| 19 | projectUpdateScriptsInputSchema, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | ||
| 22 | function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 23 | const cloned: ProjectScript[] = []; | |
| 24 | for (const script of scripts) { | |
| 25 | cloned.push({ | |
| 26 | id: script.id, | |
| 27 | name: script.name, | |
| 28 | command: script.command, | |
| 29 | icon: script.icon, | |
| 30 | runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 31 | }); | |
| 32 | } | |
| 33 | return cloned; | |
| 34 | } | |
| 35 | ||
| 36 | function normalizeCwd(rawCwd: string): string { | |
| 37 | const resolved = path.resolve(rawCwd.trim()); | |
| 38 | const normalized = path.normalize(resolved); | |
| 39 | if (process.platform === "win32") { | |
| 40 | return normalized.toLowerCase(); | |
| 41 | } | |
| 42 | return normalized; | |
| 43 | } | |
| 44 | ||
| 45 | function isDirectory(cwd: string): boolean { | |
| 46 | try { | |
| 47 | return fs.statSync(cwd).isDirectory(); | |
| 48 | } catch { | |
| 49 | return false; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | function inferProjectName(cwd: string): string { | |
| 54 | const name = path.basename(cwd); | |
| 55 | return name.length > 0 ? name : "project"; | |
| 56 | } | |
| 57 | ||
| 58 | export class ProjectRegistry { | |
| 59 | private readonly stateDir: string; | |
| 60 | private readonly filePath: string; | |
| 61 | private projects: ProjectRecord[]; | |
| 62 | ||
| 63 | constructor(stateDir: string) { | |
| 64 | this.stateDir = stateDir; | |
| 65 | this.filePath = path.join(this.stateDir, "projects.json"); | |
| 66 | fs.mkdirSync(this.stateDir, { recursive: true }); | |
| 67 | this.projects = this.loadFromDisk(); | |
| 68 | } | |
| 69 | ||
| 70 | list(): ProjectListResult { | |
| 71 | return this.projects.map((project) => ({ | |
| 72 | ...project, | |
| 73 | scripts: cloneScripts(project.scripts), | |
| 74 | })); | |
| 75 | } | |
| 76 | ||
| 77 | add(raw: ProjectAddInput): ProjectAddResult { | |
| 78 | const input = projectAddInputSchema.parse(raw); | |
| 79 | const normalizedCwd = normalizeCwd(input.cwd); | |
| 80 | if (!isDirectory(normalizedCwd)) { | |
| 81 | throw new Error(`Project path does not exist: ${normalizedCwd}`); | |
| 82 | } | |
| 83 | ||
| 84 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); | |
| 85 | if (existing) { | |
| 86 | return { | |
| 87 | project: { | |
| 88 | ...existing, | |
| 89 | scripts: cloneScripts(existing.scripts), | |
| 90 | }, | |
| 91 | created: false, | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | const now = new Date().toISOString(); | |
| 96 | const project: ProjectRecord = { | |
| 97 | id: randomUUID(), | |
| 98 | cwd: normalizedCwd, | |
| 99 | name: inferProjectName(normalizedCwd), | |
| 100 | scripts: [], | |
| 101 | createdAt: now, | |
| 102 | updatedAt: now, | |
| 103 | }; | |
| 104 | ||
| 105 | this.projects = [project, ...this.projects]; | |
| 106 | this.persist(); | |
| 107 | return { | |
| 108 | project: { | |
| 109 | ...project, | |
| 110 | scripts: cloneScripts(project.scripts), | |
| 111 | }, | |
| 112 | created: true, | |
| 113 | }; | |
| 114 | } | |
| 115 | ||
| 116 | remove(raw: ProjectRemoveInput): void { | |
| 117 | const input = projectRemoveInputSchema.parse(raw); | |
| 118 | const next = this.projects.filter((project) => project.id !== input.id); | |
| 119 | if (next.length === this.projects.length) { | |
| 120 | return; | |
| 121 | } | |
| 122 | ||
| 123 | this.projects = next; | |
| 124 | this.persist(); | |
| 125 | } | |
| 126 | ||
| 127 | updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { | |
| 128 | const input = projectUpdateScriptsInputSchema.parse(raw); | |
| 129 | const index = this.projects.findIndex((project) => project.id === input.id); | |
| 130 | if (index < 0) { | |
| 131 | throw new Error(`Project not found: ${input.id}`); | |
| 132 | } | |
| 133 | ||
| 134 | const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | |
| 135 | const nextUpdatedAt = new Date().toISOString(); | |
| 136 | const nextProject: ProjectRecord = { | |
| 137 | ...this.projects[index]!, | |
| 138 | scripts: nextScripts, | |
| 139 | updatedAt: nextUpdatedAt, | |
| 140 | }; | |
| 141 | ||
| 142 | const nextProjects = [...this.projects]; | |
| 143 | nextProjects[index] = nextProject; | |
| 144 | this.projects = nextProjects; | |
| 145 | this.persist(); | |
| 146 | ||
| 147 | return { | |
| 148 | project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, | |
| 149 | }; | |
| 150 | } | |
| 151 | ||
| 152 | private loadFromDisk(): ProjectRecord[] { | |
| 153 | const loaded = this.readPersistedProjects(); | |
| 154 | const deduped = new Map<string, ProjectRecord>(); | |
| 155 | const normalizedProjects: ProjectRecord[] = []; | |
| 156 | ||
| 157 | for (const project of loaded) { | |
| 158 | const normalizedCwd = normalizeCwd(project.cwd); | |
| 159 | if (!isDirectory(normalizedCwd)) { | |
| 160 | continue; | |
| 161 | } | |
| 162 | if (deduped.has(normalizedCwd)) { | |
| 163 | continue; | |
| 164 | } | |
| 165 | ||
| 166 | const normalizedProject: ProjectRecord = { | |
| 167 | ...project, | |
| 168 | cwd: normalizedCwd, | |
| 169 | name: | |
| 170 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), | |
| 171 | scripts: normalizeProjectScripts(project.scripts), | |
| 172 | }; | |
| 173 | deduped.set(normalizedCwd, normalizedProject); | |
| 174 | normalizedProjects.push(normalizedProject); | |
| 175 | } | |
| 176 | ||
| 177 | const changed = | |
| 178 | normalizedProjects.length !== loaded.length || | |
| 179 | normalizedProjects.some((project, index) => { | |
| 180 | const source = loaded[index]; | |
| 181 | if (!source) return true; | |
| 182 | return ( | |
| 183 | source.id !== project.id || | |
| 184 | source.cwd !== project.cwd || | |
| 185 | source.name !== project.name || | |
| 186 | JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || | |
| 187 | source.createdAt !== project.createdAt || | |
| 188 | source.updatedAt !== project.updatedAt | |
| 189 | ); | |
| 190 | }); | |
| 191 | ||
| 192 | if (changed) { | |
| 193 | this.projects = normalizedProjects; | |
| 194 | this.persist(); | |
| 195 | } | |
| 196 | ||
| 197 | return normalizedProjects; | |
| 198 | } | |
| 199 | ||
| 200 | private readPersistedProjects(): ProjectRecord[] { | |
| 201 | try { | |
| 202 | const raw = fs.readFileSync(this.filePath, "utf8"); | |
| 203 | const parsed = JSON.parse(raw) as { projects?: unknown }; | |
| 204 | if (!parsed || !Array.isArray(parsed.projects)) { | |
| 205 | return []; | |
| 206 | } | |
| 207 | ||
| 208 | const projects: ProjectRecord[] = []; | |
| 209 | for (const candidate of parsed.projects) { | |
| 210 | const row = projectRecordSchema.safeParse(candidate); | |
| 211 | if (!row.success) { | |
| 212 | continue; | |
| 213 | } | |
| 214 | projects.push(row.data); | |
| 215 | } | |
| 216 | return projects; | |
| 217 | } catch { | |
| 218 | return []; | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | private persist(): void { | |
| 223 | const payload = JSON.stringify( | |
| 224 | { | |
| 225 | version: 1, | |
| 226 | projects: this.projects, | |
| 227 | }, | |
| 228 | null, | |
| 229 | 2, | |
| 230 | ); | |
| 231 | const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`; | |
| 232 | fs.writeFileSync(tempFile, payload); | |
| 233 | fs.renameSync(tempFile, this.filePath); | |
| 234 | } | |
| 235 | } |
apps/server/src/provider/Errors.ts created+163| ... | ... | @@ -0,0 +1,163 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import type { CheckpointServiceError } from "../checkpointing/Errors.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * ProviderAdapterValidationError - Invalid adapter API input. | |
| 7 | */ | |
| 8 | export class ProviderAdapterValidationError extends Schema.TaggedErrorClass<ProviderAdapterValidationError>()( | |
| 9 | "ProviderAdapterValidationError", | |
| 10 | { | |
| 11 | provider: Schema.String, | |
| 12 | operation: Schema.String, | |
| 13 | issue: Schema.String, | |
| 14 | cause: Schema.optional(Schema.Defect), | |
| 15 | }, | |
| 16 | ) { | |
| 17 | override get message(): string { | |
| 18 | return `Provider adapter validation failed (${this.provider}) in ${this.operation}: ${this.issue}`; | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | /** | |
| 23 | * ProviderAdapterSessionNotFoundError - Adapter-owned session id is unknown. | |
| 24 | */ | |
| 25 | export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass<ProviderAdapterSessionNotFoundError>()( | |
| 26 | "ProviderAdapterSessionNotFoundError", | |
| 27 | { | |
| 28 | provider: Schema.String, | |
| 29 | sessionId: Schema.String, | |
| 30 | cause: Schema.optional(Schema.Defect), | |
| 31 | }, | |
| 32 | ) { | |
| 33 | override get message(): string { | |
| 34 | return `Unknown ${this.provider} adapter session: ${this.sessionId}`; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * ProviderAdapterSessionClosedError - Adapter session exists but is closed. | |
| 40 | */ | |
| 41 | export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<ProviderAdapterSessionClosedError>()( | |
| 42 | "ProviderAdapterSessionClosedError", | |
| 43 | { | |
| 44 | provider: Schema.String, | |
| 45 | sessionId: Schema.String, | |
| 46 | cause: Schema.optional(Schema.Defect), | |
| 47 | }, | |
| 48 | ) { | |
| 49 | override get message(): string { | |
| 50 | return `${this.provider} adapter session is closed: ${this.sessionId}`; | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * ProviderAdapterRequestError - Provider protocol request failed or timed out. | |
| 56 | */ | |
| 57 | export class ProviderAdapterRequestError extends Schema.TaggedErrorClass<ProviderAdapterRequestError>()( | |
| 58 | "ProviderAdapterRequestError", | |
| 59 | { | |
| 60 | provider: Schema.String, | |
| 61 | method: Schema.String, | |
| 62 | detail: Schema.String, | |
| 63 | cause: Schema.optional(Schema.Defect), | |
| 64 | }, | |
| 65 | ) { | |
| 66 | override get message(): string { | |
| 67 | return `Provider adapter request failed (${this.provider}) for ${this.method}: ${this.detail}`; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * ProviderAdapterProcessError - Provider process lifecycle failure. | |
| 73 | */ | |
| 74 | export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<ProviderAdapterProcessError>()( | |
| 75 | "ProviderAdapterProcessError", | |
| 76 | { | |
| 77 | provider: Schema.String, | |
| 78 | sessionId: Schema.String, | |
| 79 | detail: Schema.String, | |
| 80 | cause: Schema.optional(Schema.Defect), | |
| 81 | }, | |
| 82 | ) { | |
| 83 | override get message(): string { | |
| 84 | return `Provider adapter process error (${this.provider}) for session ${this.sessionId}: ${this.detail}`; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * ProviderValidationError - Invalid provider API input. | |
| 90 | */ | |
| 91 | export class ProviderValidationError extends Schema.TaggedErrorClass<ProviderValidationError>()( | |
| 92 | "ProviderValidationError", | |
| 93 | { | |
| 94 | operation: Schema.String, | |
| 95 | issue: Schema.String, | |
| 96 | cause: Schema.optional(Schema.Defect), | |
| 97 | }, | |
| 98 | ) { | |
| 99 | override get message(): string { | |
| 100 | return `Provider validation failed in ${this.operation}: ${this.issue}`; | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * ProviderUnsupportedError - Requested provider is not implemented. | |
| 106 | */ | |
| 107 | export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUnsupportedError>()( | |
| 108 | "ProviderUnsupportedError", | |
| 109 | { | |
| 110 | provider: Schema.String, | |
| 111 | cause: Schema.optional(Schema.Defect), | |
| 112 | }, | |
| 113 | ) { | |
| 114 | override get message(): string { | |
| 115 | return `Provider '${this.provider}' is not implemented`; | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * ProviderSessionNotFoundError - Provider-facing session not found. | |
| 121 | */ | |
| 122 | export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<ProviderSessionNotFoundError>()( | |
| 123 | "ProviderSessionNotFoundError", | |
| 124 | { | |
| 125 | sessionId: Schema.String, | |
| 126 | cause: Schema.optional(Schema.Defect), | |
| 127 | }, | |
| 128 | ) { | |
| 129 | override get message(): string { | |
| 130 | return `Unknown provider session: ${this.sessionId}`; | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * ProviderSessionDirectoryPersistenceError - Session directory persistence failure. | |
| 136 | */ | |
| 137 | export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedErrorClass<ProviderSessionDirectoryPersistenceError>()( | |
| 138 | "ProviderSessionDirectoryPersistenceError", | |
| 139 | { | |
| 140 | operation: Schema.String, | |
| 141 | detail: Schema.String, | |
| 142 | cause: Schema.optional(Schema.Defect), | |
| 143 | }, | |
| 144 | ) { | |
| 145 | override get message(): string { | |
| 146 | return `Provider session directory persistence error in ${this.operation}: ${this.detail}`; | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | export type ProviderAdapterError = | |
| 151 | | ProviderAdapterValidationError | |
| 152 | | ProviderAdapterSessionNotFoundError | |
| 153 | | ProviderAdapterSessionClosedError | |
| 154 | | ProviderAdapterRequestError | |
| 155 | | ProviderAdapterProcessError; | |
| 156 | ||
| 157 | export type ProviderServiceError = | |
| 158 | | ProviderValidationError | |
| 159 | | ProviderUnsupportedError | |
| 160 | | ProviderSessionNotFoundError | |
| 161 | | ProviderSessionDirectoryPersistenceError | |
| 162 | | ProviderAdapterError | |
| 163 | | CheckpointServiceError; |
apps/server/src/provider/Layers/CodexAdapter.test.ts created+257| ... | ... | @@ -0,0 +1,257 @@ |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | EventId, | |
| 4 | ProviderItemId, | |
| 5 | type ProviderApprovalDecision, | |
| 6 | type ProviderEvent, | |
| 7 | type ProviderSendTurnInput, | |
| 8 | ProviderSessionId, | |
| 9 | type ProviderSession, | |
| 10 | type ProviderSessionStartInput, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | type ProviderTurnStartResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; | |
| 16 | import { assertFailure } from "@effect/vitest/utils"; | |
| 17 | ||
| 18 | import { Effect, Fiber, Stream } from "effect"; | |
| 19 | ||
| 20 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | |
| 21 | import { ProviderAdapterValidationError } from "../Errors.ts"; | |
| 22 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | |
| 23 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; | |
| 24 | ||
| 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 26 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 27 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 28 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 29 | ||
| 30 | class FakeCodexManager extends CodexAppServerManager { | |
| 31 | public startSessionImpl = vi.fn( | |
| 32 | async (input: ProviderSessionStartInput): Promise<ProviderSession> => { | |
| 33 | const now = new Date().toISOString(); | |
| 34 | return { | |
| 35 | sessionId: asSessionId("sess-1"), | |
| 36 | provider: "codex", | |
| 37 | status: "ready", | |
| 38 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 39 | cwd: input.cwd, | |
| 40 | createdAt: now, | |
| 41 | updatedAt: now, | |
| 42 | }; | |
| 43 | }, | |
| 44 | ); | |
| 45 | ||
| 46 | public sendTurnImpl = vi.fn( | |
| 47 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ | |
| 48 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 49 | turnId: asTurnId("turn-1"), | |
| 50 | }), | |
| 51 | ); | |
| 52 | ||
| 53 | public interruptTurnImpl = vi.fn( | |
| 54 | async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined, | |
| 55 | ); | |
| 56 | ||
| 57 | public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ | |
| 58 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 59 | turns: [], | |
| 60 | })); | |
| 61 | ||
| 62 | public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ | |
| 63 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 64 | turns: [], | |
| 65 | })); | |
| 66 | ||
| 67 | public respondToRequestImpl = vi.fn( | |
| 68 | async ( | |
| 69 | _sessionId: ProviderSessionId, | |
| 70 | _requestId: ApprovalRequestId, | |
| 71 | _decision: ProviderApprovalDecision, | |
| 72 | ): Promise<void> => undefined, | |
| 73 | ); | |
| 74 | ||
| 75 | public stopAllImpl = vi.fn(() => undefined); | |
| 76 | ||
| 77 | override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 78 | return this.startSessionImpl(input); | |
| 79 | } | |
| 80 | ||
| 81 | override sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 82 | return this.sendTurnImpl(input); | |
| 83 | } | |
| 84 | ||
| 85 | override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> { | |
| 86 | return this.interruptTurnImpl(sessionId, turnId); | |
| 87 | } | |
| 88 | ||
| 89 | override readThread(sessionId: ProviderSessionId) { | |
| 90 | return this.readThreadImpl(sessionId); | |
| 91 | } | |
| 92 | ||
| 93 | override rollbackThread(sessionId: ProviderSessionId, numTurns: number) { | |
| 94 | return this.rollbackThreadImpl(sessionId, numTurns); | |
| 95 | } | |
| 96 | ||
| 97 | override respondToRequest( | |
| 98 | sessionId: ProviderSessionId, | |
| 99 | requestId: ApprovalRequestId, | |
| 100 | decision: ProviderApprovalDecision, | |
| 101 | ): Promise<void> { | |
| 102 | return this.respondToRequestImpl(sessionId, requestId, decision); | |
| 103 | } | |
| 104 | ||
| 105 | override stopSession(_sessionId: ProviderSessionId): void {} | |
| 106 | ||
| 107 | override listSessions(): ProviderSession[] { | |
| 108 | return []; | |
| 109 | } | |
| 110 | ||
| 111 | override hasSession(_sessionId: ProviderSessionId): boolean { | |
| 112 | return false; | |
| 113 | } | |
| 114 | ||
| 115 | override stopAll(): void { | |
| 116 | this.stopAllImpl(); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | const validationManager = new FakeCodexManager(); | |
| 121 | const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager })); | |
| 122 | ||
| 123 | validationLayer("CodexAdapterLive validation", (it) => { | |
| 124 | it.effect("returns validation error for non-codex provider on startSession", () => | |
| 125 | Effect.gen(function* () { | |
| 126 | const adapter = yield* CodexAdapter; | |
| 127 | const result = yield* adapter | |
| 128 | .startSession({ | |
| 129 | provider: "claudeCode", | |
| 130 | }) | |
| 131 | .pipe(Effect.result); | |
| 132 | ||
| 133 | assertFailure( | |
| 134 | result, | |
| 135 | new ProviderAdapterValidationError({ | |
| 136 | provider: "codex", | |
| 137 | operation: "startSession", | |
| 138 | issue: "Expected provider 'codex' but received 'claudeCode'.", | |
| 139 | }), | |
| 140 | ); | |
| 141 | assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); | |
| 142 | }), | |
| 143 | ); | |
| 144 | }); | |
| 145 | ||
| 146 | const sessionErrorManager = new FakeCodexManager(); | |
| 147 | sessionErrorManager.sendTurnImpl.mockImplementation(async () => { | |
| 148 | throw new Error("Unknown session: sess-missing"); | |
| 149 | }); | |
| 150 | const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager })); | |
| 151 | ||
| 152 | sessionErrorLayer("CodexAdapterLive session errors", (it) => { | |
| 153 | it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => | |
| 154 | Effect.gen(function* () { | |
| 155 | const adapter = yield* CodexAdapter; | |
| 156 | const result = yield* adapter | |
| 157 | .sendTurn({ | |
| 158 | sessionId: asSessionId("sess-missing"), | |
| 159 | input: "hello", | |
| 160 | attachments: [], | |
| 161 | }) | |
| 162 | .pipe(Effect.result); | |
| 163 | ||
| 164 | assert.equal(result._tag, "Failure"); | |
| 165 | if (result._tag !== "Failure") { | |
| 166 | return; | |
| 167 | } | |
| 168 | ||
| 169 | assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); | |
| 170 | if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { | |
| 171 | return; | |
| 172 | } | |
| 173 | assert.equal(result.failure.provider, "codex"); | |
| 174 | assert.equal(result.failure.sessionId, "sess-missing"); | |
| 175 | assert.instanceOf(result.failure.cause, Error); | |
| 176 | }), | |
| 177 | ); | |
| 178 | }); | |
| 179 | ||
| 180 | const lifecycleManager = new FakeCodexManager(); | |
| 181 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); | |
| 182 | ||
| 183 | lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | |
| 184 | it.effect("maps completed agent message items to canonical message.completed events", () => | |
| 185 | Effect.gen(function* () { | |
| 186 | const adapter = yield* CodexAdapter; | |
| 187 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 188 | ||
| 189 | const event: ProviderEvent = { | |
| 190 | id: asEventId("evt-msg-complete"), | |
| 191 | kind: "notification", | |
| 192 | provider: "codex", | |
| 193 | sessionId: asSessionId("sess-1"), | |
| 194 | createdAt: new Date().toISOString(), | |
| 195 | method: "item/completed", | |
| 196 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 197 | turnId: asTurnId("turn-1"), | |
| 198 | itemId: asItemId("msg_1"), | |
| 199 | payload: { | |
| 200 | item: { | |
| 201 | type: "agentMessage", | |
| 202 | id: "msg_1", | |
| 203 | }, | |
| 204 | }, | |
| 205 | }; | |
| 206 | ||
| 207 | lifecycleManager.emit("event", event); | |
| 208 | const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 209 | ||
| 210 | assert.equal(firstEvent._tag, "Some"); | |
| 211 | if (firstEvent._tag !== "Some") { | |
| 212 | return; | |
| 213 | } | |
| 214 | assert.equal(firstEvent.value.type, "message.completed"); | |
| 215 | if (firstEvent.value.type !== "message.completed") { | |
| 216 | return; | |
| 217 | } | |
| 218 | assert.equal(firstEvent.value.itemId, "msg_1"); | |
| 219 | assert.equal(firstEvent.value.turnId, "turn-1"); | |
| 220 | }), | |
| 221 | ); | |
| 222 | ||
| 223 | it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => | |
| 224 | Effect.gen(function* () { | |
| 225 | const adapter = yield* CodexAdapter; | |
| 226 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | |
| 227 | ||
| 228 | const event: ProviderEvent = { | |
| 229 | id: asEventId("evt-session-closed"), | |
| 230 | kind: "session", | |
| 231 | provider: "codex", | |
| 232 | sessionId: asSessionId("sess-1"), | |
| 233 | createdAt: new Date().toISOString(), | |
| 234 | method: "session/closed", | |
| 235 | message: "Session stopped", | |
| 236 | }; | |
| 237 | ||
| 238 | lifecycleManager.emit("event", event); | |
| 239 | const firstEvent = yield* Fiber.join(firstEventFiber); | |
| 240 | ||
| 241 | assert.equal(firstEvent._tag, "Some"); | |
| 242 | if (firstEvent._tag !== "Some") { | |
| 243 | return; | |
| 244 | } | |
| 245 | assert.equal(firstEvent.value.type, "session.exited"); | |
| 246 | if (firstEvent.value.type !== "session.exited") { | |
| 247 | return; | |
| 248 | } | |
| 249 | assert.equal(firstEvent.value.sessionId, "sess-1"); | |
| 250 | assert.equal(firstEvent.value.message, "Session stopped"); | |
| 251 | }), | |
| 252 | ); | |
| 253 | }); | |
| 254 | ||
| 255 | afterAll(() => { | |
| 256 | assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1); | |
| 257 | }); |
apps/server/src/provider/Layers/CodexAdapter.ts created+581| ... | ... | @@ -0,0 +1,581 @@ |
| 1 | /** | |
| 2 | * CodexAdapterLive - Scoped live implementation for the Codex provider adapter. | |
| 3 | * | |
| 4 | * Wraps `CodexAppServerManager` behind the `CodexAdapter` service contract and | |
| 5 | * maps manager failures into the shared `ProviderAdapterError` algebra. | |
| 6 | * | |
| 7 | * @module CodexAdapterLive | |
| 8 | */ | |
| 9 | import { | |
| 10 | type ProviderEvent, | |
| 11 | type ProviderRuntimeEvent, | |
| 12 | type ProviderRuntimeTurnStatus, | |
| 13 | type ProviderRuntimeToolKind, | |
| 14 | ProviderItemId, | |
| 15 | ProviderApprovalDecision, | |
| 16 | ProviderSessionId, | |
| 17 | ProviderThreadId, | |
| 18 | ProviderTurnId, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { Effect, Layer, Queue, Schema, Stream } from "effect"; | |
| 21 | ||
| 22 | import { | |
| 23 | ProviderAdapterProcessError, | |
| 24 | ProviderAdapterRequestError, | |
| 25 | ProviderAdapterSessionClosedError, | |
| 26 | ProviderAdapterSessionNotFoundError, | |
| 27 | ProviderAdapterValidationError, | |
| 28 | type ProviderAdapterError, | |
| 29 | } from "../Errors.ts"; | |
| 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; | |
| 31 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | |
| 32 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 33 | ||
| 34 | const PROVIDER = "codex" as const; | |
| 35 | ||
| 36 | export interface CodexAdapterLiveOptions { | |
| 37 | readonly manager?: CodexAppServerManager; | |
| 38 | readonly makeManager?: () => CodexAppServerManager; | |
| 39 | readonly nativeEventLogPath?: string; | |
| 40 | } | |
| 41 | ||
| 42 | function toMessage(cause: unknown, fallback: string): string { | |
| 43 | if (cause instanceof Error && cause.message.length > 0) { | |
| 44 | return cause.message; | |
| 45 | } | |
| 46 | return fallback; | |
| 47 | } | |
| 48 | ||
| 49 | function toSessionError( | |
| 50 | sessionId: ProviderSessionId, | |
| 51 | cause: unknown, | |
| 52 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { | |
| 53 | const normalized = toMessage(cause, "").toLowerCase(); | |
| 54 | if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { | |
| 55 | return new ProviderAdapterSessionNotFoundError({ | |
| 56 | provider: PROVIDER, | |
| 57 | sessionId, | |
| 58 | cause, | |
| 59 | }); | |
| 60 | } | |
| 61 | if (normalized.includes("session is closed")) { | |
| 62 | return new ProviderAdapterSessionClosedError({ | |
| 63 | provider: PROVIDER, | |
| 64 | sessionId, | |
| 65 | cause, | |
| 66 | }); | |
| 67 | } | |
| 68 | return undefined; | |
| 69 | } | |
| 70 | ||
| 71 | function toRequestError( | |
| 72 | sessionId: ProviderSessionId, | |
| 73 | method: string, | |
| 74 | cause: unknown, | |
| 75 | ): ProviderAdapterError { | |
| 76 | const sessionError = toSessionError(sessionId, cause); | |
| 77 | if (sessionError) { | |
| 78 | return sessionError; | |
| 79 | } | |
| 80 | return new ProviderAdapterRequestError({ | |
| 81 | provider: PROVIDER, | |
| 82 | method, | |
| 83 | detail: toMessage(cause, `${method} failed`), | |
| 84 | cause, | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | function asObject(value: unknown): Record<string, unknown> | undefined { | |
| 89 | if (!value || typeof value !== "object") { | |
| 90 | return undefined; | |
| 91 | } | |
| 92 | return value as Record<string, unknown>; | |
| 93 | } | |
| 94 | ||
| 95 | function asString(value: unknown): string | undefined { | |
| 96 | return typeof value === "string" ? value : undefined; | |
| 97 | } | |
| 98 | ||
| 99 | function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { | |
| 100 | switch (value) { | |
| 101 | case "completed": | |
| 102 | case "failed": | |
| 103 | case "cancelled": | |
| 104 | case "interrupted": | |
| 105 | return value; | |
| 106 | default: | |
| 107 | return undefined; | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | function normalizeItemType(raw: unknown): string { | |
| 112 | const type = asString(raw); | |
| 113 | if (!type) return "item"; | |
| 114 | return type | |
| 115 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | |
| 116 | .replace(/[._/-]/g, " ") | |
| 117 | .replace(/\s+/g, " ") | |
| 118 | .trim() | |
| 119 | .toLowerCase(); | |
| 120 | } | |
| 121 | ||
| 122 | function shouldDropItemType(type: string): boolean { | |
| 123 | if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { | |
| 124 | return true; | |
| 125 | } | |
| 126 | return type === "work" || type.startsWith("work "); | |
| 127 | } | |
| 128 | ||
| 129 | function toolMeta(type: string): | |
| 130 | | { | |
| 131 | readonly toolKind: ProviderRuntimeToolKind; | |
| 132 | readonly title: string; | |
| 133 | } | |
| 134 | | undefined { | |
| 135 | if (type.includes("command")) { | |
| 136 | return { toolKind: "command", title: "Command run" }; | |
| 137 | } | |
| 138 | if (type.includes("file change")) { | |
| 139 | return { toolKind: "file-change", title: "File change" }; | |
| 140 | } | |
| 141 | if (type.includes("tool")) { | |
| 142 | return { toolKind: "other", title: "Tool call" }; | |
| 143 | } | |
| 144 | return undefined; | |
| 145 | } | |
| 146 | ||
| 147 | function itemDetail( | |
| 148 | item: Record<string, unknown>, | |
| 149 | payload: Record<string, unknown>, | |
| 150 | ): string | undefined { | |
| 151 | const nestedResult = asObject(item.result); | |
| 152 | const candidates = [ | |
| 153 | asString(item.command), | |
| 154 | asString(item.title), | |
| 155 | asString(item.summary), | |
| 156 | asString(item.text), | |
| 157 | asString(item.path), | |
| 158 | asString(item.prompt), | |
| 159 | asString(nestedResult?.command), | |
| 160 | asString(payload.command), | |
| 161 | asString(payload.message), | |
| 162 | asString(payload.prompt), | |
| 163 | ]; | |
| 164 | for (const candidate of candidates) { | |
| 165 | if (!candidate) continue; | |
| 166 | const trimmed = candidate.trim(); | |
| 167 | if (trimmed.length === 0) continue; | |
| 168 | return trimmed; | |
| 169 | } | |
| 170 | return undefined; | |
| 171 | } | |
| 172 | ||
| 173 | function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { | |
| 174 | if (event.method !== "item/completed") { | |
| 175 | return undefined; | |
| 176 | } | |
| 177 | ||
| 178 | const payload = asObject(event.payload); | |
| 179 | const item = asObject(payload?.item); | |
| 180 | if (!item) { | |
| 181 | return undefined; | |
| 182 | } | |
| 183 | ||
| 184 | const normalizedType = normalizeItemType(item.type ?? item.kind); | |
| 185 | if (!normalizedType.includes("agent message")) { | |
| 186 | return undefined; | |
| 187 | } | |
| 188 | ||
| 189 | const itemId = event.itemId ?? asString(item.id); | |
| 190 | if (!itemId) { | |
| 191 | return undefined; | |
| 192 | } | |
| 193 | ||
| 194 | return { | |
| 195 | type: "message.completed", | |
| 196 | eventId: event.id, | |
| 197 | provider: event.provider, | |
| 198 | sessionId: event.sessionId, | |
| 199 | createdAt: event.createdAt, | |
| 200 | itemId: ProviderItemId.makeUnsafe(itemId), | |
| 201 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 202 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { | |
| 207 | if (event.method !== "item/started" && event.method !== "item/completed") { | |
| 208 | return undefined; | |
| 209 | } | |
| 210 | const payload = asObject(event.payload); | |
| 211 | const item = asObject(payload?.item); | |
| 212 | if (!item) { | |
| 213 | return undefined; | |
| 214 | } | |
| 215 | const normalizedType = normalizeItemType(item.type ?? item.kind); | |
| 216 | if (shouldDropItemType(normalizedType)) { | |
| 217 | return undefined; | |
| 218 | } | |
| 219 | const meta = toolMeta(normalizedType); | |
| 220 | if (!meta) { | |
| 221 | return undefined; | |
| 222 | } | |
| 223 | ||
| 224 | const eventBase = { | |
| 225 | eventId: event.id, | |
| 226 | provider: event.provider, | |
| 227 | sessionId: event.sessionId, | |
| 228 | createdAt: event.createdAt, | |
| 229 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 230 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 231 | ...(event.itemId ? { itemId: event.itemId } : {}), | |
| 232 | toolKind: meta.toolKind, | |
| 233 | title: meta.title, | |
| 234 | ...(payload ? { detail: itemDetail(item, payload) } : {}), | |
| 235 | } as const; | |
| 236 | ||
| 237 | if (event.method === "item/started") { | |
| 238 | return { | |
| 239 | type: "tool.started", | |
| 240 | ...eventBase, | |
| 241 | }; | |
| 242 | } | |
| 243 | ||
| 244 | return { | |
| 245 | type: "tool.completed", | |
| 246 | ...eventBase, | |
| 247 | }; | |
| 248 | } | |
| 249 | ||
| 250 | function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntimeEvent> { | |
| 251 | const payload = asObject(event.payload); | |
| 252 | const turn = asObject(payload?.turn); | |
| 253 | ||
| 254 | if (event.kind === "error") { | |
| 255 | if (!event.message) { | |
| 256 | return []; | |
| 257 | } | |
| 258 | return [ | |
| 259 | { | |
| 260 | type: "runtime.error", | |
| 261 | eventId: event.id, | |
| 262 | provider: event.provider, | |
| 263 | sessionId: event.sessionId, | |
| 264 | createdAt: event.createdAt, | |
| 265 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 266 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 267 | ...(event.itemId ? { itemId: event.itemId } : {}), | |
| 268 | message: event.message, | |
| 269 | }, | |
| 270 | ]; | |
| 271 | } | |
| 272 | ||
| 273 | if (event.kind === "request" && event.requestId && event.requestKind) { | |
| 274 | const detail = | |
| 275 | asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); | |
| 276 | return [ | |
| 277 | { | |
| 278 | type: "approval.requested", | |
| 279 | eventId: event.id, | |
| 280 | provider: event.provider, | |
| 281 | sessionId: event.sessionId, | |
| 282 | createdAt: event.createdAt, | |
| 283 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 284 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 285 | ...(event.itemId ? { itemId: event.itemId } : {}), | |
| 286 | requestId: event.requestId, | |
| 287 | requestKind: event.requestKind, | |
| 288 | ...(detail ? { detail } : {}), | |
| 289 | }, | |
| 290 | ]; | |
| 291 | } | |
| 292 | ||
| 293 | if (event.method === "item/requestApproval/decision" && event.requestId) { | |
| 294 | const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision); | |
| 295 | return [ | |
| 296 | { | |
| 297 | type: "approval.resolved", | |
| 298 | eventId: event.id, | |
| 299 | provider: event.provider, | |
| 300 | sessionId: event.sessionId, | |
| 301 | createdAt: event.createdAt, | |
| 302 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 303 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 304 | ...(event.itemId ? { itemId: event.itemId } : {}), | |
| 305 | requestId: event.requestId, | |
| 306 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | |
| 307 | ...(decision ? { decision } : {}), | |
| 308 | }, | |
| 309 | ]; | |
| 310 | } | |
| 311 | ||
| 312 | const messageCompleted = mapMessageCompletedEvent(event); | |
| 313 | if (messageCompleted) { | |
| 314 | return [messageCompleted]; | |
| 315 | } | |
| 316 | ||
| 317 | const tool = mapToolEvent(event); | |
| 318 | if (tool) { | |
| 319 | return [tool]; | |
| 320 | } | |
| 321 | ||
| 322 | if (event.method === "session/started") { | |
| 323 | return [ | |
| 324 | { | |
| 325 | type: "session.started", | |
| 326 | eventId: event.id, | |
| 327 | provider: event.provider, | |
| 328 | sessionId: event.sessionId, | |
| 329 | createdAt: event.createdAt, | |
| 330 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 331 | ...(event.message ? { message: event.message } : {}), | |
| 332 | }, | |
| 333 | ]; | |
| 334 | } | |
| 335 | ||
| 336 | if (event.method === "session/exited" || event.method === "session/closed") { | |
| 337 | return [ | |
| 338 | { | |
| 339 | type: "session.exited", | |
| 340 | eventId: event.id, | |
| 341 | provider: event.provider, | |
| 342 | sessionId: event.sessionId, | |
| 343 | createdAt: event.createdAt, | |
| 344 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 345 | ...(event.message ? { message: event.message } : {}), | |
| 346 | }, | |
| 347 | ]; | |
| 348 | } | |
| 349 | ||
| 350 | if (event.method === "thread/started") { | |
| 351 | const payloadThreadId = asString(asObject(payload?.thread)?.id); | |
| 352 | const threadId = | |
| 353 | event.threadId ?? | |
| 354 | (payloadThreadId ? ProviderThreadId.makeUnsafe(payloadThreadId) : undefined); | |
| 355 | if (!threadId) { | |
| 356 | return []; | |
| 357 | } | |
| 358 | return [ | |
| 359 | { | |
| 360 | type: "thread.started", | |
| 361 | eventId: event.id, | |
| 362 | provider: event.provider, | |
| 363 | sessionId: event.sessionId, | |
| 364 | createdAt: event.createdAt, | |
| 365 | threadId, | |
| 366 | }, | |
| 367 | ]; | |
| 368 | } | |
| 369 | ||
| 370 | if (event.method === "turn/started") { | |
| 371 | const payloadTurnId = asString(turn?.id); | |
| 372 | const turnId = | |
| 373 | event.turnId ?? (payloadTurnId ? ProviderTurnId.makeUnsafe(payloadTurnId) : undefined); | |
| 374 | if (!turnId) { | |
| 375 | return []; | |
| 376 | } | |
| 377 | return [ | |
| 378 | { | |
| 379 | type: "turn.started", | |
| 380 | eventId: event.id, | |
| 381 | provider: event.provider, | |
| 382 | sessionId: event.sessionId, | |
| 383 | createdAt: event.createdAt, | |
| 384 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 385 | turnId, | |
| 386 | }, | |
| 387 | ]; | |
| 388 | } | |
| 389 | ||
| 390 | if (event.method === "turn/completed") { | |
| 391 | return [ | |
| 392 | { | |
| 393 | type: "turn.completed", | |
| 394 | eventId: event.id, | |
| 395 | provider: event.provider, | |
| 396 | sessionId: event.sessionId, | |
| 397 | createdAt: event.createdAt, | |
| 398 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 399 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 400 | ...(toTurnStatus(turn?.status) ? { status: toTurnStatus(turn?.status) } : {}), | |
| 401 | ...(asString(asObject(turn?.error)?.message) | |
| 402 | ? { errorMessage: asString(asObject(turn?.error)?.message) } | |
| 403 | : {}), | |
| 404 | }, | |
| 405 | ]; | |
| 406 | } | |
| 407 | ||
| 408 | if (event.method === "item/agentMessage/delta" && event.textDelta && event.textDelta.length > 0) { | |
| 409 | return [ | |
| 410 | { | |
| 411 | type: "message.delta", | |
| 412 | eventId: event.id, | |
| 413 | provider: event.provider, | |
| 414 | sessionId: event.sessionId, | |
| 415 | createdAt: event.createdAt, | |
| 416 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 417 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 418 | ...(event.itemId ? { itemId: event.itemId } : {}), | |
| 419 | delta: event.textDelta, | |
| 420 | }, | |
| 421 | ]; | |
| 422 | } | |
| 423 | ||
| 424 | return []; | |
| 425 | } | |
| 426 | ||
| 427 | const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | |
| 428 | Effect.gen(function* () { | |
| 429 | const nativeEventLogger = | |
| 430 | options?.nativeEventLogPath !== undefined | |
| 431 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | |
| 432 | : undefined; | |
| 433 | ||
| 434 | const manager = yield* Effect.acquireRelease( | |
| 435 | Effect.sync(() => { | |
| 436 | if (options?.manager) { | |
| 437 | return options.manager; | |
| 438 | } | |
| 439 | if (options?.makeManager) { | |
| 440 | return options.makeManager(); | |
| 441 | } | |
| 442 | return new CodexAppServerManager(); | |
| 443 | }), | |
| 444 | (manager) => | |
| 445 | Effect.sync(() => { | |
| 446 | try { | |
| 447 | manager.stopAll(); | |
| 448 | } catch { | |
| 449 | // Finalizers should never fail and block shutdown. | |
| 450 | } | |
| 451 | }), | |
| 452 | ); | |
| 453 | ||
| 454 | const startSession: CodexAdapterShape["startSession"] = (input) => { | |
| 455 | if (input.provider !== undefined && input.provider !== PROVIDER) { | |
| 456 | return Effect.fail( | |
| 457 | new ProviderAdapterValidationError({ | |
| 458 | provider: PROVIDER, | |
| 459 | operation: "startSession", | |
| 460 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | |
| 461 | }), | |
| 462 | ); | |
| 463 | } | |
| 464 | ||
| 465 | return Effect.tryPromise({ | |
| 466 | try: () => manager.startSession(input), | |
| 467 | catch: (cause) => | |
| 468 | new ProviderAdapterProcessError({ | |
| 469 | provider: PROVIDER, | |
| 470 | sessionId: "pending", | |
| 471 | detail: toMessage(cause, "Failed to start Codex adapter session."), | |
| 472 | cause, | |
| 473 | }), | |
| 474 | }); | |
| 475 | }; | |
| 476 | ||
| 477 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => | |
| 478 | Effect.tryPromise({ | |
| 479 | try: () => manager.sendTurn(input), | |
| 480 | catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), | |
| 481 | }); | |
| 482 | ||
| 483 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => | |
| 484 | Effect.tryPromise({ | |
| 485 | try: () => manager.interruptTurn(sessionId, turnId), | |
| 486 | catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), | |
| 487 | }); | |
| 488 | ||
| 489 | const readThread: CodexAdapterShape["readThread"] = (sessionId) => | |
| 490 | Effect.tryPromise({ | |
| 491 | try: () => manager.readThread(sessionId), | |
| 492 | catch: (cause) => toRequestError(sessionId, "thread/read", cause), | |
| 493 | }); | |
| 494 | ||
| 495 | const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => { | |
| 496 | if (!Number.isInteger(numTurns) || numTurns < 1) { | |
| 497 | return Effect.fail( | |
| 498 | new ProviderAdapterValidationError({ | |
| 499 | provider: PROVIDER, | |
| 500 | operation: "rollbackThread", | |
| 501 | issue: "numTurns must be an integer >= 1.", | |
| 502 | }), | |
| 503 | ); | |
| 504 | } | |
| 505 | ||
| 506 | return Effect.tryPromise({ | |
| 507 | try: () => manager.rollbackThread(sessionId, numTurns), | |
| 508 | catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), | |
| 509 | }); | |
| 510 | }; | |
| 511 | ||
| 512 | const respondToRequest: CodexAdapterShape["respondToRequest"] = ( | |
| 513 | sessionId, | |
| 514 | requestId, | |
| 515 | decision, | |
| 516 | ) => | |
| 517 | Effect.tryPromise({ | |
| 518 | try: () => manager.respondToRequest(sessionId, requestId, decision), | |
| 519 | catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), | |
| 520 | }); | |
| 521 | ||
| 522 | const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => | |
| 523 | Effect.sync(() => { | |
| 524 | manager.stopSession(sessionId); | |
| 525 | }); | |
| 526 | ||
| 527 | const listSessions: CodexAdapterShape["listSessions"] = () => | |
| 528 | Effect.sync(() => manager.listSessions()); | |
| 529 | ||
| 530 | const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => | |
| 531 | Effect.sync(() => manager.hasSession(sessionId)); | |
| 532 | ||
| 533 | const stopAll: CodexAdapterShape["stopAll"] = () => | |
| 534 | Effect.sync(() => { | |
| 535 | manager.stopAll(); | |
| 536 | }); | |
| 537 | ||
| 538 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | |
| 539 | ||
| 540 | yield* Effect.acquireRelease( | |
| 541 | Effect.sync(() => { | |
| 542 | const listener = (event: ProviderEvent) => { | |
| 543 | nativeEventLogger?.write({ | |
| 544 | observedAt: new Date().toISOString(), | |
| 545 | event, | |
| 546 | }); | |
| 547 | Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event)); | |
| 548 | }; | |
| 549 | manager.on("event", listener); | |
| 550 | return listener; | |
| 551 | }), | |
| 552 | (listener) => | |
| 553 | Effect.gen(function* () { | |
| 554 | yield* Effect.sync(() => { | |
| 555 | manager.off("event", listener); | |
| 556 | }); | |
| 557 | yield* Queue.shutdown(runtimeEventQueue); | |
| 558 | }), | |
| 559 | ); | |
| 560 | ||
| 561 | return { | |
| 562 | provider: PROVIDER, | |
| 563 | startSession, | |
| 564 | sendTurn, | |
| 565 | interruptTurn, | |
| 566 | readThread, | |
| 567 | rollbackThread, | |
| 568 | respondToRequest, | |
| 569 | stopSession, | |
| 570 | listSessions, | |
| 571 | hasSession, | |
| 572 | stopAll, | |
| 573 | streamEvents: Stream.fromQueue(runtimeEventQueue), | |
| 574 | } satisfies CodexAdapterShape; | |
| 575 | }); | |
| 576 | ||
| 577 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); | |
| 578 | ||
| 579 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { | |
| 580 | return Layer.effect(CodexAdapter, makeCodexAdapter(options)); | |
| 581 | } |
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 8 | ||
| 9 | describe("EventNdjsonLogger", () => { | |
| 10 | it("writes NDJSON records to disk", () => { | |
| 11 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | |
| 12 | const filePath = path.join(tempDir, "provider-native.ndjson"); | |
| 13 | ||
| 14 | try { | |
| 15 | const logger = makeEventNdjsonLogger(filePath); | |
| 16 | expect(logger).toBeDefined(); | |
| 17 | if (!logger) { | |
| 18 | return; | |
| 19 | } | |
| 20 | ||
| 21 | logger.write({ type: "native", payload: { id: "evt-1" } }); | |
| 22 | logger.write({ type: "canonical", payload: { id: "evt-2" } }); | |
| 23 | logger.close(); | |
| 24 | ||
| 25 | const raw = fs.readFileSync(filePath, "utf8").trim(); | |
| 26 | const lines = raw.split("\n"); | |
| 27 | expect(lines.length).toBe(2); | |
| 28 | ||
| 29 | const first = JSON.parse(lines[0] ?? "{}"); | |
| 30 | const second = JSON.parse(lines[1] ?? "{}"); | |
| 31 | expect(first).toEqual({ type: "native", payload: { id: "evt-1" } }); | |
| 32 | expect(second).toEqual({ type: "canonical", payload: { id: "evt-2" } }); | |
| 33 | } finally { | |
| 34 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 35 | } | |
| 36 | }); | |
| 37 | }); |
apps/server/src/provider/Layers/EventNdjsonLogger.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | /** | |
| 2 | * Provider event NDJSON logger helper. | |
| 3 | * | |
| 4 | * Best-effort writer for observability logs. Failures are downgraded to warnings | |
| 5 | * so provider runtime behavior is unaffected. | |
| 6 | */ | |
| 7 | import fs from "node:fs"; | |
| 8 | import path from "node:path"; | |
| 9 | ||
| 10 | import { createLogger } from "../../logger.ts"; | |
| 11 | ||
| 12 | const logger = createLogger("provider-observability"); | |
| 13 | ||
| 14 | export interface EventNdjsonLogger { | |
| 15 | readonly filePath: string; | |
| 16 | write: (record: unknown) => void; | |
| 17 | close: () => void; | |
| 18 | } | |
| 19 | ||
| 20 | function stringifyRecord(record: unknown): string | undefined { | |
| 21 | try { | |
| 22 | return JSON.stringify(record); | |
| 23 | } catch (error) { | |
| 24 | logger.warn("failed to serialize provider event log record", { error }); | |
| 25 | return undefined; | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | undefined { | |
| 30 | try { | |
| 31 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); | |
| 32 | fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" }); | |
| 33 | } catch (error) { | |
| 34 | logger.warn("failed to create provider event log directory", { filePath, error }); | |
| 35 | return undefined; | |
| 36 | } | |
| 37 | ||
| 38 | try { | |
| 39 | let broken = false; | |
| 40 | ||
| 41 | return { | |
| 42 | filePath, | |
| 43 | write(record: unknown) { | |
| 44 | if (broken) { | |
| 45 | return; | |
| 46 | } | |
| 47 | const serialized = stringifyRecord(record); | |
| 48 | if (!serialized) { | |
| 49 | return; | |
| 50 | } | |
| 51 | try { | |
| 52 | fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" }); | |
| 53 | } catch (error) { | |
| 54 | broken = true; | |
| 55 | logger.warn("provider event log write failed", { filePath, error }); | |
| 56 | } | |
| 57 | }, | |
| 58 | close() { | |
| 59 | // no-op for sync append logger | |
| 60 | }, | |
| 61 | }; | |
| 62 | } catch (error) { | |
| 63 | logger.warn("failed to create provider event log file", { filePath, error }); | |
| 64 | return undefined; | |
| 65 | } | |
| 66 | } |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { it, assert, vi } from "@effect/vitest"; | |
| 3 | import { assertFailure } from "@effect/vitest/utils"; | |
| 4 | ||
| 5 | import { Effect, Layer, Stream } from "effect"; | |
| 6 | ||
| 7 | import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; | |
| 8 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 9 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | |
| 10 | import { ProviderUnsupportedError } from "../Errors.ts"; | |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 12 | ||
| 13 | const fakeCodexAdapter: CodexAdapterShape = { | |
| 14 | provider: "codex", | |
| 15 | startSession: vi.fn(), | |
| 16 | sendTurn: vi.fn(), | |
| 17 | interruptTurn: vi.fn(), | |
| 18 | respondToRequest: vi.fn(), | |
| 19 | stopSession: vi.fn(), | |
| 20 | listSessions: vi.fn(), | |
| 21 | hasSession: vi.fn(), | |
| 22 | readThread: vi.fn(), | |
| 23 | rollbackThread: vi.fn(), | |
| 24 | stopAll: vi.fn(), | |
| 25 | streamEvents: Stream.empty, | |
| 26 | }; | |
| 27 | ||
| 28 | const layer = it.layer( | |
| 29 | Layer.mergeAll( | |
| 30 | Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), | |
| 31 | NodeServices.layer, | |
| 32 | ), | |
| 33 | ); | |
| 34 | ||
| 35 | layer("ProviderAdapterRegistryLive", (it) => { | |
| 36 | it.effect("resolves a registered provider adapter", () => | |
| 37 | Effect.gen(function* () { | |
| 38 | const registry = yield* ProviderAdapterRegistry; | |
| 39 | const adapter = yield* registry.getByProvider("codex"); | |
| 40 | assert.equal(adapter, fakeCodexAdapter); | |
| 41 | ||
| 42 | const providers = yield* registry.listProviders(); | |
| 43 | assert.deepEqual(providers, ["codex"]); | |
| 44 | }), | |
| 45 | ); | |
| 46 | ||
| 47 | it.effect("fails with ProviderUnsupportedError for unknown providers", () => | |
| 48 | Effect.gen(function* () { | |
| 49 | const registry = yield* ProviderAdapterRegistry; | |
| 50 | const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); | |
| 51 | assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); | |
| 52 | }), | |
| 53 | ); | |
| 54 | }); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | /** | |
| 2 | * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. | |
| 3 | * | |
| 4 | * Binds provider kinds (codex/claudeCode/...) to concrete adapter services. | |
| 5 | * This layer only performs adapter lookup; it does not route session-scoped | |
| 6 | * calls or own provider lifecycle workflows. | |
| 7 | * | |
| 8 | * @module ProviderAdapterRegistryLive | |
| 9 | */ | |
| 10 | import { Effect, Layer } from "effect"; | |
| 11 | ||
| 12 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; | |
| 13 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 14 | import { | |
| 15 | ProviderAdapterRegistry, | |
| 16 | type ProviderAdapterRegistryShape, | |
| 17 | } from "../Services/ProviderAdapterRegistry.ts"; | |
| 18 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | |
| 19 | ||
| 20 | export interface ProviderAdapterRegistryLiveOptions { | |
| 21 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; | |
| 22 | } | |
| 23 | ||
| 24 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => | |
| 25 | Effect.gen(function* () { | |
| 26 | const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; | |
| 27 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); | |
| 28 | ||
| 29 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { | |
| 30 | const adapter = byProvider.get(provider); | |
| 31 | if (!adapter) { | |
| 32 | return Effect.fail(new ProviderUnsupportedError({ provider })); | |
| 33 | } | |
| 34 | return Effect.succeed(adapter); | |
| 35 | }; | |
| 36 | ||
| 37 | const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => | |
| 38 | Effect.sync(() => Array.from(byProvider.keys())); | |
| 39 | ||
| 40 | return { | |
| 41 | getByProvider, | |
| 42 | listProviders, | |
| 43 | } satisfies ProviderAdapterRegistryShape; | |
| 44 | }); | |
| 45 | ||
| 46 | export const ProviderAdapterRegistryLive = Layer.effect( | |
| 47 | ProviderAdapterRegistry, | |
| 48 | makeProviderAdapterRegistry(), | |
| 49 | ); |
apps/server/src/provider/Layers/ProviderService.test.ts created+718| ... | ... | @@ -0,0 +1,718 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import type { | |
| 6 | ProviderApprovalDecision, | |
| 7 | ProviderRuntimeEvent, | |
| 8 | ProviderSendTurnInput, | |
| 9 | ProviderSession, | |
| 10 | ProviderTurnStartResult, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { | |
| 13 | ApprovalRequestId, | |
| 14 | EventId, | |
| 15 | ProviderSessionStartInput, | |
| 16 | ProviderSessionId, | |
| 17 | ProviderThreadId, | |
| 18 | ProviderTurnId, | |
| 19 | ThreadId, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | import { it, assert, vi } from "@effect/vitest"; | |
| 22 | import { assertFailure } from "@effect/vitest/utils"; | |
| 23 | ||
| 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; | |
| 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 26 | ||
| 27 | import { | |
| 28 | ProviderAdapterSessionNotFoundError, | |
| 29 | ProviderSessionNotFoundError, | |
| 30 | ProviderValidationError, | |
| 31 | ProviderUnsupportedError, | |
| 32 | type ProviderAdapterError, | |
| 33 | } from "../Errors.ts"; | |
| 34 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 35 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 36 | import { ProviderService } from "../Services/ProviderService.ts"; | |
| 37 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 38 | import { makeProviderServiceLive } from "./ProviderService.ts"; | |
| 39 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | |
| 40 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 41 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | |
| 42 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 43 | import { | |
| 44 | makeSqlitePersistenceLive, | |
| 45 | SqlitePersistenceMemory, | |
| 46 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 47 | ||
| 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 49 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 50 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 51 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | |
| 52 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 53 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 54 | ||
| 55 | function makeFakeCodexAdapter() { | |
| 56 | const sessions = new Map<ProviderSessionId, ProviderSession>(); | |
| 57 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 58 | let nextSession = 1; | |
| 59 | ||
| 60 | const startSession = vi.fn((input: ProviderSessionStartInput) => | |
| 61 | Effect.sync(() => { | |
| 62 | const now = new Date().toISOString(); | |
| 63 | const next = nextSession; | |
| 64 | const session: ProviderSession = { | |
| 65 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), | |
| 66 | provider: "codex", | |
| 67 | status: "ready", | |
| 68 | threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), | |
| 69 | cwd: input.cwd ?? process.cwd(), | |
| 70 | createdAt: now, | |
| 71 | updatedAt: now, | |
| 72 | }; | |
| 73 | nextSession += 1; | |
| 74 | sessions.set(session.sessionId, session); | |
| 75 | return session; | |
| 76 | }), | |
| 77 | ); | |
| 78 | ||
| 79 | const sendTurn = vi.fn( | |
| 80 | ( | |
| 81 | input: ProviderSendTurnInput, | |
| 82 | ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => { | |
| 83 | if (!sessions.has(input.sessionId)) { | |
| 84 | return Effect.fail( | |
| 85 | new ProviderAdapterSessionNotFoundError({ | |
| 86 | provider: "codex", | |
| 87 | sessionId: input.sessionId, | |
| 88 | }), | |
| 89 | ); | |
| 90 | } | |
| 91 | ||
| 92 | return Effect.succeed({ | |
| 93 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 94 | turnId: ProviderTurnId.makeUnsafe("turn-1"), | |
| 95 | }); | |
| 96 | }, | |
| 97 | ); | |
| 98 | ||
| 99 | const interruptTurn = vi.fn( | |
| 100 | (_sessionId: string, _turnId?: string): Effect.Effect<void, ProviderAdapterError> => | |
| 101 | Effect.void, | |
| 102 | ); | |
| 103 | ||
| 104 | const respondToRequest = vi.fn( | |
| 105 | ( | |
| 106 | _sessionId: string, | |
| 107 | _requestId: string, | |
| 108 | _decision: ProviderApprovalDecision, | |
| 109 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void, | |
| 110 | ); | |
| 111 | ||
| 112 | const stopSession = vi.fn( | |
| 113 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | |
| 114 | Effect.sync(() => { | |
| 115 | sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 119 | const listSessions = vi.fn( | |
| 120 | (): Effect.Effect<ReadonlyArray<ProviderSession>> => | |
| 121 | Effect.sync(() => Array.from(sessions.values())), | |
| 122 | ); | |
| 123 | ||
| 124 | const hasSession = vi.fn( | |
| 125 | (sessionId: string): Effect.Effect<boolean> => | |
| 126 | Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), | |
| 127 | ); | |
| 128 | ||
| 129 | const readThread = vi.fn( | |
| 130 | ( | |
| 131 | _sessionId: ProviderSessionId, | |
| 132 | ): Effect.Effect< | |
| 133 | { | |
| 134 | threadId: ProviderThreadId; | |
| 135 | turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; | |
| 136 | }, | |
| 137 | ProviderAdapterError | |
| 138 | > => | |
| 139 | Effect.succeed({ | |
| 140 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 141 | turns: [{ id: asTurnId("turn-1"), items: [] }], | |
| 142 | }), | |
| 143 | ); | |
| 144 | ||
| 145 | const rollbackThread = vi.fn( | |
| 146 | ( | |
| 147 | _sessionId: ProviderSessionId, | |
| 148 | _numTurns: number, | |
| 149 | ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => | |
| 150 | Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), | |
| 151 | ); | |
| 152 | ||
| 153 | const stopAll = vi.fn( | |
| 154 | (): Effect.Effect<void, ProviderAdapterError> => | |
| 155 | Effect.sync(() => { | |
| 156 | sessions.clear(); | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | const adapter: ProviderAdapterShape<ProviderAdapterError> = { | |
| 161 | provider: "codex", | |
| 162 | startSession, | |
| 163 | sendTurn, | |
| 164 | interruptTurn, | |
| 165 | respondToRequest, | |
| 166 | stopSession, | |
| 167 | listSessions, | |
| 168 | hasSession, | |
| 169 | readThread, | |
| 170 | rollbackThread, | |
| 171 | stopAll, | |
| 172 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 173 | }; | |
| 174 | ||
| 175 | const emit = (event: ProviderRuntimeEvent): void => { | |
| 176 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | |
| 177 | }; | |
| 178 | ||
| 179 | return { | |
| 180 | adapter, | |
| 181 | emit, | |
| 182 | startSession, | |
| 183 | sendTurn, | |
| 184 | interruptTurn, | |
| 185 | respondToRequest, | |
| 186 | stopSession, | |
| 187 | listSessions, | |
| 188 | hasSession, | |
| 189 | readThread, | |
| 190 | rollbackThread, | |
| 191 | stopAll, | |
| 192 | }; | |
| 193 | } | |
| 194 | ||
| 195 | const sleep = (ms: number) => | |
| 196 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | |
| 197 | ||
| 198 | function makeProviderServiceLayer() { | |
| 199 | const codex = makeFakeCodexAdapter(); | |
| 200 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 201 | getByProvider: (provider) => | |
| 202 | provider === "codex" | |
| 203 | ? Effect.succeed(codex.adapter) | |
| 204 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 205 | listProviders: () => Effect.succeed(["codex"]), | |
| 206 | }; | |
| 207 | ||
| 208 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | |
| 209 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 210 | Layer.provide(SqlitePersistenceMemory), | |
| 211 | ); | |
| 212 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 213 | ||
| 214 | const layer = it.layer( | |
| 215 | Layer.mergeAll( | |
| 216 | makeProviderServiceLive().pipe( | |
| 217 | Layer.provide(providerAdapterLayer), | |
| 218 | Layer.provide(directoryLayer), | |
| 219 | ), | |
| 220 | directoryLayer, | |
| 221 | runtimeRepositoryLayer, | |
| 222 | NodeServices.layer, | |
| 223 | ), | |
| 224 | ); | |
| 225 | ||
| 226 | return { | |
| 227 | codex, | |
| 228 | layer, | |
| 229 | }; | |
| 230 | } | |
| 231 | ||
| 232 | const routing = makeProviderServiceLayer(); | |
| 233 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => | |
| 234 | Effect.gen(function* () { | |
| 235 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); | |
| 236 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 237 | ||
| 238 | const codex = makeFakeCodexAdapter(); | |
| 239 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 240 | getByProvider: (provider) => | |
| 241 | provider === "codex" | |
| 242 | ? Effect.succeed(codex.adapter) | |
| 243 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 244 | listProviders: () => Effect.succeed(["codex"]), | |
| 245 | }; | |
| 246 | ||
| 247 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 248 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 249 | Layer.provide(persistenceLayer), | |
| 250 | ); | |
| 251 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 252 | ||
| 253 | yield* Effect.gen(function* () { | |
| 254 | const directory = yield* ProviderSessionDirectory; | |
| 255 | yield* directory.upsert({ | |
| 256 | sessionId: asSessionId("sess-stale"), | |
| 257 | provider: "codex", | |
| 258 | threadId: ThreadId.makeUnsafe("thread-stale"), | |
| 259 | }); | |
| 260 | }).pipe(Effect.provide(directoryLayer)); | |
| 261 | ||
| 262 | const providerLayer = makeProviderServiceLive().pipe( | |
| 263 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | |
| 264 | Layer.provide(directoryLayer), | |
| 265 | ); | |
| 266 | ||
| 267 | yield* Effect.gen(function* () { | |
| 268 | yield* ProviderService; | |
| 269 | }).pipe(Effect.provide(providerLayer)); | |
| 270 | ||
| 271 | const persistedProvider = yield* Effect.gen(function* () { | |
| 272 | const directory = yield* ProviderSessionDirectory; | |
| 273 | return yield* directory.getProvider(asSessionId("sess-stale")); | |
| 274 | }).pipe(Effect.provide(directoryLayer)); | |
| 275 | assert.equal(persistedProvider, "codex"); | |
| 276 | ||
| 277 | const runtime = yield* Effect.gen(function* () { | |
| 278 | const repository = yield* ProviderSessionRuntimeRepository; | |
| 279 | return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") }); | |
| 280 | }).pipe(Effect.provide(runtimeRepositoryLayer)); | |
| 281 | assert.equal(Option.isSome(runtime), true); | |
| 282 | ||
| 283 | const legacyTableRows = yield* Effect.gen(function* () { | |
| 284 | const sql = yield* SqlClient.SqlClient; | |
| 285 | return yield* sql<{ readonly name: string }>` | |
| 286 | SELECT name | |
| 287 | FROM sqlite_master | |
| 288 | WHERE type = 'table' AND name = 'provider_sessions' | |
| 289 | `; | |
| 290 | }).pipe(Effect.provide(persistenceLayer)); | |
| 291 | assert.equal(legacyTableRows.length, 0); | |
| 292 | ||
| 293 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 294 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 295 | ); | |
| 296 | ||
| 297 | it.effect( | |
| 298 | "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", | |
| 299 | () => | |
| 300 | Effect.gen(function* () { | |
| 301 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); | |
| 302 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 303 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 304 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 305 | Layer.provide(persistenceLayer), | |
| 306 | ); | |
| 307 | ||
| 308 | const firstCodex = makeFakeCodexAdapter(); | |
| 309 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | |
| 310 | getByProvider: (provider) => | |
| 311 | provider === "codex" | |
| 312 | ? Effect.succeed(firstCodex.adapter) | |
| 313 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 314 | listProviders: () => Effect.succeed(["codex"]), | |
| 315 | }; | |
| 316 | ||
| 317 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 318 | Layer.provide(runtimeRepositoryLayer), | |
| 319 | ); | |
| 320 | const firstProviderLayer = makeProviderServiceLive().pipe( | |
| 321 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), | |
| 322 | Layer.provide(firstDirectoryLayer), | |
| 323 | ); | |
| 324 | ||
| 325 | const startedSession = yield* Effect.gen(function* () { | |
| 326 | const provider = yield* ProviderService; | |
| 327 | return yield* provider.startSession(asThreadId("thread-1"), { | |
| 328 | provider: "codex", | |
| 329 | cwd: "/tmp/project", | |
| 330 | }); | |
| 331 | }).pipe(Effect.provide(firstProviderLayer)); | |
| 332 | ||
| 333 | yield* Effect.gen(function* () { | |
| 334 | const provider = yield* ProviderService; | |
| 335 | yield* provider.stopAll(); | |
| 336 | }).pipe(Effect.provide(firstProviderLayer)); | |
| 337 | ||
| 338 | const persistedAfterStopAll = yield* Effect.gen(function* () { | |
| 339 | const repository = yield* ProviderSessionRuntimeRepository; | |
| 340 | return yield* repository.getBySessionId({ | |
| 341 | providerSessionId: startedSession.sessionId, | |
| 342 | }); | |
| 343 | }).pipe(Effect.provide(runtimeRepositoryLayer)); | |
| 344 | assert.equal(Option.isSome(persistedAfterStopAll), true); | |
| 345 | if (Option.isSome(persistedAfterStopAll)) { | |
| 346 | assert.equal(persistedAfterStopAll.value.status, "stopped"); | |
| 347 | assert.equal(persistedAfterStopAll.value.resumeCursor, null); | |
| 348 | } | |
| 349 | ||
| 350 | const secondCodex = makeFakeCodexAdapter(); | |
| 351 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | |
| 352 | getByProvider: (provider) => | |
| 353 | provider === "codex" | |
| 354 | ? Effect.succeed(secondCodex.adapter) | |
| 355 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 356 | listProviders: () => Effect.succeed(["codex"]), | |
| 357 | }; | |
| 358 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 359 | Layer.provide(runtimeRepositoryLayer), | |
| 360 | ); | |
| 361 | const secondProviderLayer = makeProviderServiceLive().pipe( | |
| 362 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), | |
| 363 | Layer.provide(secondDirectoryLayer), | |
| 364 | ); | |
| 365 | ||
| 366 | secondCodex.startSession.mockClear(); | |
| 367 | secondCodex.rollbackThread.mockClear(); | |
| 368 | ||
| 369 | yield* Effect.gen(function* () { | |
| 370 | const provider = yield* ProviderService; | |
| 371 | yield* provider.rollbackConversation({ | |
| 372 | sessionId: startedSession.sessionId, | |
| 373 | numTurns: 1, | |
| 374 | }); | |
| 375 | }).pipe(Effect.provide(secondProviderLayer)); | |
| 376 | ||
| 377 | assert.deepEqual(secondCodex.startSession.mock.calls, [ | |
| 378 | [ | |
| 379 | { | |
| 380 | provider: "codex", | |
| 381 | resumeThreadId: startedSession.threadId, | |
| 382 | }, | |
| 383 | ], | |
| 384 | ]); | |
| 385 | assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); | |
| 386 | const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; | |
| 387 | assert.equal(typeof rollbackCall?.[0], "string"); | |
| 388 | assert.equal(rollbackCall?.[1], 1); | |
| 389 | ||
| 390 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 391 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 392 | ); | |
| 393 | ||
| 394 | routing.layer("ProviderServiceLive routing", (it) => { | |
| 395 | it.effect("routes provider operations and rollback conversation", () => | |
| 396 | Effect.gen(function* () { | |
| 397 | const provider = yield* ProviderService; | |
| 398 | ||
| 399 | const session = yield* provider.startSession(asThreadId("thread-1"), { | |
| 400 | provider: "codex", | |
| 401 | cwd: "/tmp/project", | |
| 402 | }); | |
| 403 | assert.equal(session.provider, "codex"); | |
| 404 | ||
| 405 | const sessions = yield* provider.listSessions(); | |
| 406 | assert.equal(sessions.length, 1); | |
| 407 | ||
| 408 | yield* provider.sendTurn({ | |
| 409 | sessionId: session.sessionId, | |
| 410 | input: "hello", | |
| 411 | attachments: [], | |
| 412 | }); | |
| 413 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); | |
| 414 | ||
| 415 | yield* provider.interruptTurn({ sessionId: session.sessionId }); | |
| 416 | assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]); | |
| 417 | ||
| 418 | yield* provider.respondToRequest({ | |
| 419 | sessionId: session.sessionId, | |
| 420 | requestId: asRequestId("req-1"), | |
| 421 | decision: "accept", | |
| 422 | }); | |
| 423 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ | |
| 424 | [session.sessionId, asRequestId("req-1"), "accept"], | |
| 425 | ]); | |
| 426 | ||
| 427 | yield* provider.rollbackConversation({ | |
| 428 | sessionId: session.sessionId, | |
| 429 | numTurns: 0, | |
| 430 | }); | |
| 431 | ||
| 432 | yield* provider.stopSession({ sessionId: session.sessionId }); | |
| 433 | const sendAfterStop = yield* Effect.result( | |
| 434 | provider.sendTurn({ | |
| 435 | sessionId: session.sessionId, | |
| 436 | input: "after-stop", | |
| 437 | attachments: [], | |
| 438 | }), | |
| 439 | ); | |
| 440 | assertFailure( | |
| 441 | sendAfterStop, | |
| 442 | new ProviderSessionNotFoundError({ sessionId: session.sessionId }), | |
| 443 | ); | |
| 444 | }), | |
| 445 | ); | |
| 446 | ||
| 447 | it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => | |
| 448 | Effect.gen(function* () { | |
| 449 | const provider = yield* ProviderService; | |
| 450 | ||
| 451 | const initial = yield* provider.startSession(asThreadId("thread-1"), { | |
| 452 | provider: "codex", | |
| 453 | cwd: "/tmp/project", | |
| 454 | }); | |
| 455 | yield* routing.codex.stopSession(initial.sessionId); | |
| 456 | routing.codex.startSession.mockClear(); | |
| 457 | routing.codex.rollbackThread.mockClear(); | |
| 458 | ||
| 459 | yield* provider.rollbackConversation({ | |
| 460 | sessionId: initial.sessionId, | |
| 461 | numTurns: 1, | |
| 462 | }); | |
| 463 | ||
| 464 | assert.deepEqual(routing.codex.startSession.mock.calls, [ | |
| 465 | [ | |
| 466 | { | |
| 467 | provider: "codex", | |
| 468 | resumeThreadId: initial.threadId, | |
| 469 | }, | |
| 470 | ], | |
| 471 | ]); | |
| 472 | assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); | |
| 473 | const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; | |
| 474 | assert.equal(rollbackCall?.[1], 1); | |
| 475 | }), | |
| 476 | ); | |
| 477 | ||
| 478 | it.effect("stops all sessions and clears adapter runtime", () => | |
| 479 | Effect.gen(function* () { | |
| 480 | const provider = yield* ProviderService; | |
| 481 | ||
| 482 | yield* provider.startSession(asThreadId("thread-1"), { | |
| 483 | provider: "codex", | |
| 484 | }); | |
| 485 | yield* provider.startSession(asThreadId("thread-2"), { | |
| 486 | provider: "codex", | |
| 487 | }); | |
| 488 | ||
| 489 | yield* provider.stopAll(); | |
| 490 | ||
| 491 | const remaining = yield* provider.listSessions(); | |
| 492 | assert.equal(remaining.length, 0); | |
| 493 | }), | |
| 494 | ); | |
| 495 | ||
| 496 | it.effect("persists runtime status transitions in provider_session_runtime", () => | |
| 497 | Effect.gen(function* () { | |
| 498 | const provider = yield* ProviderService; | |
| 499 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 500 | ||
| 501 | const session = yield* provider.startSession(asThreadId("thread-1"), { | |
| 502 | provider: "codex", | |
| 503 | }); | |
| 504 | yield* provider.sendTurn({ | |
| 505 | sessionId: session.sessionId, | |
| 506 | input: "hello", | |
| 507 | attachments: [], | |
| 508 | }); | |
| 509 | ||
| 510 | const runningRuntime = yield* runtimeRepository.getBySessionId({ | |
| 511 | providerSessionId: session.sessionId, | |
| 512 | }); | |
| 513 | assert.equal(Option.isSome(runningRuntime), true); | |
| 514 | if (Option.isSome(runningRuntime)) { | |
| 515 | assert.equal(runningRuntime.value.status, "running"); | |
| 516 | assert.equal(runningRuntime.value.resumeCursor, null); | |
| 517 | const payload = runningRuntime.value.runtimePayload; | |
| 518 | assert.equal(payload !== null && typeof payload === "object", true); | |
| 519 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | |
| 520 | const runtimePayload = payload as { | |
| 521 | cwd: string; | |
| 522 | model: string | null; | |
| 523 | activeTurnId: string | null; | |
| 524 | lastError: string | null; | |
| 525 | lastRuntimeEvent: string | null; | |
| 526 | }; | |
| 527 | assert.equal(runtimePayload.cwd, process.cwd()); | |
| 528 | assert.equal(runtimePayload.model, null); | |
| 529 | assert.equal(runtimePayload.activeTurnId, "turn-1"); | |
| 530 | assert.equal(runtimePayload.lastError, null); | |
| 531 | assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); | |
| 532 | } | |
| 533 | } | |
| 534 | ||
| 535 | yield* provider.stopAll(); | |
| 536 | const stoppedRuntime = yield* runtimeRepository.getBySessionId({ | |
| 537 | providerSessionId: session.sessionId, | |
| 538 | }); | |
| 539 | assert.equal(Option.isSome(stoppedRuntime), true); | |
| 540 | if (Option.isSome(stoppedRuntime)) { | |
| 541 | assert.equal(stoppedRuntime.value.status, "stopped"); | |
| 542 | } | |
| 543 | }), | |
| 544 | ); | |
| 545 | }); | |
| 546 | ||
| 547 | const fanout = makeProviderServiceLayer(); | |
| 548 | fanout.layer("ProviderServiceLive fanout", (it) => { | |
| 549 | it.effect("fans out adapter turn completion events", () => | |
| 550 | Effect.gen(function* () { | |
| 551 | const provider = yield* ProviderService; | |
| 552 | const session = yield* provider.startSession(asThreadId("thread-1"), { | |
| 553 | provider: "codex", | |
| 554 | }); | |
| 555 | ||
| 556 | const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]); | |
| 557 | const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => | |
| 558 | Ref.update(eventsRef, (current) => [...current, event]), | |
| 559 | ).pipe(Effect.forkChild); | |
| 560 | yield* sleep(20); | |
| 561 | ||
| 562 | const completedEvent: ProviderRuntimeEvent = { | |
| 563 | type: "turn.completed", | |
| 564 | eventId: asEventId("evt-1"), | |
| 565 | provider: "codex", | |
| 566 | sessionId: session.sessionId, | |
| 567 | createdAt: new Date().toISOString(), | |
| 568 | threadId: asProviderThreadId("thread-1"), | |
| 569 | turnId: asTurnId("turn-1"), | |
| 570 | status: "completed", | |
| 571 | }; | |
| 572 | ||
| 573 | fanout.codex.emit(completedEvent); | |
| 574 | yield* sleep(20); | |
| 575 | ||
| 576 | const events = yield* Ref.get(eventsRef); | |
| 577 | yield* Fiber.interrupt(consumer); | |
| 578 | ||
| 579 | assert.equal( | |
| 580 | events.some((entry) => entry.type === "turn.completed"), | |
| 581 | true, | |
| 582 | ); | |
| 583 | }), | |
| 584 | ); | |
| 585 | ||
| 586 | it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => | |
| 587 | Effect.gen(function* () { | |
| 588 | const provider = yield* ProviderService; | |
| 589 | const session = yield* provider.startSession(asThreadId("thread-1"), { | |
| 590 | provider: "codex", | |
| 591 | }); | |
| 592 | ||
| 593 | const receivedByHealthy: string[] = []; | |
| 594 | const expectedEventIds = new Set<string>(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]); | |
| 595 | const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( | |
| 596 | Stream.runForEach((event) => | |
| 597 | Effect.sync(() => { | |
| 598 | receivedByHealthy.push(event.eventId); | |
| 599 | }), | |
| 600 | ), | |
| 601 | Effect.forkChild, | |
| 602 | ); | |
| 603 | const failingFiber = yield* Stream.take(provider.streamEvents, 1).pipe( | |
| 604 | Stream.runForEach(() => Effect.fail("listener crash")), | |
| 605 | Effect.forkChild, | |
| 606 | ); | |
| 607 | yield* sleep(20); | |
| 608 | ||
| 609 | const events: ReadonlyArray<ProviderRuntimeEvent> = [ | |
| 610 | { | |
| 611 | type: "tool.completed", | |
| 612 | eventId: asEventId("evt-ordered-1"), | |
| 613 | provider: "codex", | |
| 614 | sessionId: session.sessionId, | |
| 615 | createdAt: new Date().toISOString(), | |
| 616 | threadId: asProviderThreadId("thread-1"), | |
| 617 | turnId: asTurnId("turn-1"), | |
| 618 | toolKind: "command", | |
| 619 | title: "Command run", | |
| 620 | detail: "echo one", | |
| 621 | }, | |
| 622 | { | |
| 623 | type: "message.delta", | |
| 624 | eventId: asEventId("evt-ordered-2"), | |
| 625 | provider: "codex", | |
| 626 | sessionId: session.sessionId, | |
| 627 | createdAt: new Date().toISOString(), | |
| 628 | threadId: asProviderThreadId("thread-1"), | |
| 629 | turnId: asTurnId("turn-1"), | |
| 630 | delta: "hello", | |
| 631 | }, | |
| 632 | { | |
| 633 | type: "turn.completed", | |
| 634 | eventId: asEventId("evt-ordered-3"), | |
| 635 | provider: "codex", | |
| 636 | sessionId: session.sessionId, | |
| 637 | createdAt: new Date().toISOString(), | |
| 638 | threadId: asProviderThreadId("thread-1"), | |
| 639 | turnId: asTurnId("turn-1"), | |
| 640 | status: "completed", | |
| 641 | }, | |
| 642 | ]; | |
| 643 | ||
| 644 | for (const event of events) { | |
| 645 | fanout.codex.emit(event); | |
| 646 | } | |
| 647 | const failingResult = yield* Effect.result(Fiber.join(failingFiber)); | |
| 648 | assert.equal(failingResult._tag, "Failure"); | |
| 649 | yield* Fiber.join(healthyFiber); | |
| 650 | ||
| 651 | assert.deepEqual( | |
| 652 | receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), | |
| 653 | ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], | |
| 654 | ); | |
| 655 | }), | |
| 656 | ); | |
| 657 | }); | |
| 658 | ||
| 659 | const validation = makeProviderServiceLayer(); | |
| 660 | validation.layer("ProviderServiceLive validation", (it) => { | |
| 661 | it.effect("returns ProviderValidationError for invalid input payloads", () => | |
| 662 | Effect.gen(function* () { | |
| 663 | const provider = yield* ProviderService; | |
| 664 | ||
| 665 | const failure = yield* Effect.result( | |
| 666 | provider.startSession(asThreadId("thread-validation"), { | |
| 667 | provider: "invalid-provider", | |
| 668 | } as never), | |
| 669 | ); | |
| 670 | ||
| 671 | assert.equal(failure._tag, "Failure"); | |
| 672 | if (failure._tag !== "Failure") { | |
| 673 | return; | |
| 674 | } | |
| 675 | assert.equal(failure.failure._tag, "ProviderValidationError"); | |
| 676 | if (failure.failure._tag !== "ProviderValidationError") { | |
| 677 | return; | |
| 678 | } | |
| 679 | assert.equal(failure.failure.operation, "ProviderService.startSession"); | |
| 680 | assert.equal(failure.failure.issue.includes("invalid-provider"), true); | |
| 681 | }), | |
| 682 | ); | |
| 683 | ||
| 684 | it.effect("fails startSession when adapter returns no threadId", () => | |
| 685 | Effect.gen(function* () { | |
| 686 | const provider = yield* ProviderService; | |
| 687 | ||
| 688 | validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => | |
| 689 | Effect.sync(() => { | |
| 690 | const now = new Date().toISOString(); | |
| 691 | return { | |
| 692 | sessionId: asSessionId("sess-missing-thread"), | |
| 693 | provider: "codex", | |
| 694 | status: "ready", | |
| 695 | cwd: input.cwd ?? process.cwd(), | |
| 696 | createdAt: now, | |
| 697 | updatedAt: now, | |
| 698 | } satisfies ProviderSession; | |
| 699 | }), | |
| 700 | ); | |
| 701 | ||
| 702 | const failure = yield* Effect.result( | |
| 703 | provider.startSession(asThreadId("thread-missing"), { | |
| 704 | provider: "codex", | |
| 705 | cwd: "/tmp/project", | |
| 706 | }), | |
| 707 | ); | |
| 708 | ||
| 709 | assertFailure( | |
| 710 | failure, | |
| 711 | new ProviderValidationError({ | |
| 712 | operation: "ProviderService.startSession", | |
| 713 | issue: "Provider 'codex' returned a session without threadId.", | |
| 714 | }), | |
| 715 | ); | |
| 716 | }), | |
| 717 | ); | |
| 718 | }); |
apps/server/src/provider/Layers/ProviderService.ts created+579| ... | ... | @@ -0,0 +1,579 @@ |
| 1 | /** | |
| 2 | * ProviderServiceLive - Cross-provider orchestration layer. | |
| 3 | * | |
| 4 | * Routes validated transport/API calls to provider adapters through | |
| 5 | * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a | |
| 6 | * unified provider event stream for subscribers. | |
| 7 | * | |
| 8 | * It does not implement provider protocol details (adapter concern). | |
| 9 | * | |
| 10 | * @module ProviderServiceLive | |
| 11 | */ | |
| 12 | import { | |
| 13 | NonNegativeInt, | |
| 14 | ProviderSessionId, | |
| 15 | ThreadId, | |
| 16 | ProviderInterruptTurnInput, | |
| 17 | ProviderRespondToRequestInput, | |
| 18 | ProviderSendTurnInput, | |
| 19 | ProviderSessionStartInput, | |
| 20 | ProviderStopSessionInput, | |
| 21 | type ProviderRuntimeEvent, | |
| 22 | type ProviderSession, | |
| 23 | } from "@t3tools/contracts"; | |
| 24 | import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect"; | |
| 25 | ||
| 26 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 27 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 28 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; | |
| 29 | import { | |
| 30 | ProviderSessionDirectory, | |
| 31 | type ProviderSessionBinding, | |
| 32 | } from "../Services/ProviderSessionDirectory.ts"; | |
| 33 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 34 | ||
| 35 | export interface ProviderServiceLiveOptions { | |
| 36 | readonly canonicalEventLogPath?: string; | |
| 37 | } | |
| 38 | ||
| 39 | const ProviderRollbackConversationInput = Schema.Struct({ | |
| 40 | sessionId: ProviderSessionId, | |
| 41 | numTurns: NonNegativeInt, | |
| 42 | }); | |
| 43 | ||
| 44 | function toValidationError( | |
| 45 | operation: string, | |
| 46 | issue: string, | |
| 47 | cause?: unknown, | |
| 48 | ): ProviderValidationError { | |
| 49 | return new ProviderValidationError({ | |
| 50 | operation, | |
| 51 | issue, | |
| 52 | ...(cause !== undefined ? { cause } : {}), | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | const decodeInputOrValidationError = <S extends Schema.Top>(input: { | |
| 57 | readonly operation: string; | |
| 58 | readonly schema: S; | |
| 59 | readonly payload: unknown; | |
| 60 | }) => | |
| 61 | Schema.decodeUnknownEffect(input.schema)(input.payload).pipe( | |
| 62 | Effect.mapError( | |
| 63 | (schemaError) => | |
| 64 | new ProviderValidationError({ | |
| 65 | operation: input.operation, | |
| 66 | issue: SchemaIssue.makeFormatterDefault()(schemaError.issue), | |
| 67 | cause: schemaError, | |
| 68 | }), | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" { | |
| 73 | switch (session.status) { | |
| 74 | case "connecting": | |
| 75 | return "starting"; | |
| 76 | case "error": | |
| 77 | return "error"; | |
| 78 | case "closed": | |
| 79 | return "stopped"; | |
| 80 | case "ready": | |
| 81 | case "running": | |
| 82 | default: | |
| 83 | return "running"; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | function toRuntimePayloadFromSession(session: ProviderSession): Record<string, unknown> { | |
| 88 | return { | |
| 89 | cwd: session.cwd ?? null, | |
| 90 | model: session.model ?? null, | |
| 91 | activeTurnId: session.activeTurnId ?? null, | |
| 92 | lastError: session.lastError ?? null, | |
| 93 | }; | |
| 94 | } | |
| 95 | ||
| 96 | const makeProviderService = (options?: ProviderServiceLiveOptions) => | |
| 97 | Effect.gen(function* () { | |
| 98 | const canonicalEventLogger = | |
| 99 | options?.canonicalEventLogPath !== undefined | |
| 100 | ? makeEventNdjsonLogger(options.canonicalEventLogPath) | |
| 101 | : undefined; | |
| 102 | ||
| 103 | const registry = yield* ProviderAdapterRegistry; | |
| 104 | const directory = yield* ProviderSessionDirectory; | |
| 105 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | |
| 106 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | |
| 107 | const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>( | |
| 108 | new Map(), | |
| 109 | ); | |
| 110 | ||
| 111 | const canonicalizeRuntimeEventSession = ( | |
| 112 | event: ProviderRuntimeEvent, | |
| 113 | ): Effect.Effect<ProviderRuntimeEvent> => | |
| 114 | Ref.get(routedSessionAliasesRef).pipe( | |
| 115 | Effect.map((aliases) => { | |
| 116 | for (const [staleSessionId, liveSessionId] of aliases) { | |
| 117 | if (liveSessionId === event.sessionId) { | |
| 118 | return { | |
| 119 | ...event, | |
| 120 | sessionId: staleSessionId, | |
| 121 | } satisfies ProviderRuntimeEvent; | |
| 122 | } | |
| 123 | } | |
| 124 | return event; | |
| 125 | }), | |
| 126 | ); | |
| 127 | ||
| 128 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | |
| 129 | canonicalizeRuntimeEventSession(event).pipe( | |
| 130 | Effect.tap((canonicalEvent) => | |
| 131 | Effect.sync(() => { | |
| 132 | canonicalEventLogger?.write({ | |
| 133 | observedAt: new Date().toISOString(), | |
| 134 | event: canonicalEvent, | |
| 135 | }); | |
| 136 | }), | |
| 137 | ), | |
| 138 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), | |
| 139 | Effect.asVoid, | |
| 140 | ); | |
| 141 | ||
| 142 | const upsertSessionBinding = ( | |
| 143 | session: ProviderSession, | |
| 144 | operation: string, | |
| 145 | threadId: ThreadId, | |
| 146 | ) => | |
| 147 | Effect.gen(function* () { | |
| 148 | const providerThreadId = session.threadId; | |
| 149 | if (!providerThreadId) { | |
| 150 | return yield* toValidationError( | |
| 151 | operation, | |
| 152 | `Provider '${session.provider}' returned a session without threadId.`, | |
| 153 | ); | |
| 154 | } | |
| 155 | ||
| 156 | yield* directory.upsert({ | |
| 157 | sessionId: session.sessionId, | |
| 158 | provider: session.provider, | |
| 159 | threadId, | |
| 160 | providerThreadId, | |
| 161 | status: toRuntimeStatus(session), | |
| 162 | ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), | |
| 163 | runtimePayload: toRuntimePayloadFromSession(session), | |
| 164 | }); | |
| 165 | ||
| 166 | return providerThreadId; | |
| 167 | }); | |
| 168 | ||
| 169 | const clearAliasKey = (staleSessionId: ProviderSessionId) => | |
| 170 | Ref.update(routedSessionAliasesRef, (current) => { | |
| 171 | if (!current.has(staleSessionId)) { | |
| 172 | return current; | |
| 173 | } | |
| 174 | const next = new Map(current); | |
| 175 | next.delete(staleSessionId); | |
| 176 | return next; | |
| 177 | }); | |
| 178 | ||
| 179 | const clearAliasesReferencing = (sessionId: ProviderSessionId) => | |
| 180 | Ref.update(routedSessionAliasesRef, (current) => { | |
| 181 | let changed = false; | |
| 182 | const next = new Map<ProviderSessionId, ProviderSessionId>(); | |
| 183 | for (const [key, value] of current) { | |
| 184 | if (key === sessionId || value === sessionId) { | |
| 185 | changed = true; | |
| 186 | continue; | |
| 187 | } | |
| 188 | next.set(key, value); | |
| 189 | } | |
| 190 | return changed ? next : current; | |
| 191 | }); | |
| 192 | ||
| 193 | const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => | |
| 194 | Ref.update(routedSessionAliasesRef, (current) => { | |
| 195 | const existing = current.get(staleSessionId); | |
| 196 | if (existing === liveSessionId) { | |
| 197 | return current; | |
| 198 | } | |
| 199 | const next = new Map(current); | |
| 200 | next.set(staleSessionId, liveSessionId); | |
| 201 | return next; | |
| 202 | }); | |
| 203 | ||
| 204 | const providers = yield* registry.listProviders(); | |
| 205 | const adapters = yield* Effect.forEach(providers, (provider) => | |
| 206 | registry.getByProvider(provider), | |
| 207 | ); | |
| 208 | ||
| 209 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | |
| 210 | publishRuntimeEvent(event); | |
| 211 | ||
| 212 | const worker = Effect.forever( | |
| 213 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), | |
| 214 | ); | |
| 215 | yield* Effect.forkScoped(worker); | |
| 216 | ||
| 217 | yield* Effect.forEach(adapters, (adapter) => | |
| 218 | Stream.runForEach(adapter.streamEvents, (event) => | |
| 219 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), | |
| 220 | ).pipe(Effect.forkScoped), | |
| 221 | ).pipe(Effect.asVoid); | |
| 222 | ||
| 223 | const recoverSessionForThread = (input: { | |
| 224 | readonly staleSessionId: ProviderSessionId; | |
| 225 | readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId }; | |
| 226 | readonly operation: string; | |
| 227 | }) => | |
| 228 | Effect.gen(function* () { | |
| 229 | const adapter = yield* registry.getByProvider(input.binding.provider); | |
| 230 | const activeSessions = yield* adapter.listSessions(); | |
| 231 | const resumeThreadId = input.binding.providerThreadId ?? undefined; | |
| 232 | const hasResumeCursor = | |
| 233 | input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; | |
| 234 | const existing = | |
| 235 | resumeThreadId === undefined | |
| 236 | ? undefined | |
| 237 | : activeSessions.find((session) => session.threadId === resumeThreadId); | |
| 238 | if (existing) { | |
| 239 | const existingProviderThreadId = yield* upsertSessionBinding( | |
| 240 | existing, | |
| 241 | `${input.operation}:upsertExistingSession`, | |
| 242 | input.binding.threadId, | |
| 243 | ); | |
| 244 | yield* directory.upsert({ | |
| 245 | sessionId: input.staleSessionId, | |
| 246 | provider: existing.provider, | |
| 247 | threadId: input.binding.threadId, | |
| 248 | providerThreadId: existingProviderThreadId, | |
| 249 | ...(existing.resumeCursor !== undefined ? { resumeCursor: existing.resumeCursor } : {}), | |
| 250 | }); | |
| 251 | if (existing.sessionId !== input.staleSessionId) { | |
| 252 | yield* setAlias(input.staleSessionId, existing.sessionId); | |
| 253 | } else { | |
| 254 | yield* clearAliasKey(input.staleSessionId); | |
| 255 | } | |
| 256 | return { | |
| 257 | adapter, | |
| 258 | sessionId: existing.sessionId, | |
| 259 | } as const; | |
| 260 | } | |
| 261 | ||
| 262 | if (!resumeThreadId && !hasResumeCursor) { | |
| 263 | return yield* toValidationError( | |
| 264 | input.operation, | |
| 265 | `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 269 | const resumed = yield* adapter.startSession({ | |
| 270 | provider: input.binding.provider, | |
| 271 | ...(resumeThreadId ? { resumeThreadId } : {}), | |
| 272 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | |
| 273 | }); | |
| 274 | if (resumed.provider !== adapter.provider) { | |
| 275 | return yield* toValidationError( | |
| 276 | input.operation, | |
| 277 | `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, | |
| 278 | ); | |
| 279 | } | |
| 280 | ||
| 281 | const resumedProviderThreadId = yield* upsertSessionBinding( | |
| 282 | resumed, | |
| 283 | `${input.operation}:upsertRecoveredSession`, | |
| 284 | input.binding.threadId, | |
| 285 | ); | |
| 286 | ||
| 287 | yield* directory.upsert({ | |
| 288 | sessionId: input.staleSessionId, | |
| 289 | provider: resumed.provider, | |
| 290 | threadId: input.binding.threadId, | |
| 291 | providerThreadId: resumedProviderThreadId, | |
| 292 | ...(resumed.resumeCursor !== undefined ? { resumeCursor: resumed.resumeCursor } : {}), | |
| 293 | }); | |
| 294 | ||
| 295 | if (resumed.sessionId !== input.staleSessionId) { | |
| 296 | yield* setAlias(input.staleSessionId, resumed.sessionId); | |
| 297 | } else { | |
| 298 | yield* clearAliasKey(input.staleSessionId); | |
| 299 | } | |
| 300 | ||
| 301 | return { | |
| 302 | adapter, | |
| 303 | sessionId: resumed.sessionId, | |
| 304 | } as const; | |
| 305 | }); | |
| 306 | ||
| 307 | const resolveRoutableSession = (input: { | |
| 308 | readonly sessionId: ProviderSessionId; | |
| 309 | readonly operation: string; | |
| 310 | readonly allowRecovery: boolean; | |
| 311 | }) => | |
| 312 | Effect.gen(function* () { | |
| 313 | const bindingOption = yield* directory.getBinding(input.sessionId); | |
| 314 | const binding = Option.getOrUndefined(bindingOption); | |
| 315 | if (!binding) { | |
| 316 | return yield* new ProviderSessionNotFoundError({ | |
| 317 | sessionId: input.sessionId, | |
| 318 | }); | |
| 319 | } | |
| 320 | if (!binding.threadId) { | |
| 321 | return yield* toValidationError( | |
| 322 | input.operation, | |
| 323 | `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`, | |
| 324 | ); | |
| 325 | } | |
| 326 | const bindingWithThreadId: ProviderSessionBinding & { | |
| 327 | readonly threadId: ThreadId; | |
| 328 | } = { | |
| 329 | ...binding, | |
| 330 | threadId: binding.threadId, | |
| 331 | }; | |
| 332 | const adapter = yield* registry.getByProvider(binding.provider); | |
| 333 | ||
| 334 | const hasRequestedSession = yield* adapter.hasSession(input.sessionId); | |
| 335 | if (hasRequestedSession) { | |
| 336 | yield* clearAliasKey(input.sessionId); | |
| 337 | return { | |
| 338 | adapter, | |
| 339 | sessionId: input.sessionId, | |
| 340 | isActive: true, | |
| 341 | } as const; | |
| 342 | } | |
| 343 | ||
| 344 | const alias = yield* Ref.get(routedSessionAliasesRef).pipe( | |
| 345 | Effect.map((aliases) => aliases.get(input.sessionId)), | |
| 346 | ); | |
| 347 | if (alias) { | |
| 348 | const aliasIsActive = yield* adapter.hasSession(alias); | |
| 349 | if (aliasIsActive) { | |
| 350 | return { | |
| 351 | adapter, | |
| 352 | sessionId: alias, | |
| 353 | isActive: true, | |
| 354 | } as const; | |
| 355 | } | |
| 356 | yield* clearAliasKey(input.sessionId); | |
| 357 | } | |
| 358 | ||
| 359 | if (!input.allowRecovery) { | |
| 360 | return { | |
| 361 | adapter, | |
| 362 | sessionId: input.sessionId, | |
| 363 | isActive: false, | |
| 364 | } as const; | |
| 365 | } | |
| 366 | ||
| 367 | const recovered = yield* recoverSessionForThread({ | |
| 368 | staleSessionId: input.sessionId, | |
| 369 | binding: bindingWithThreadId, | |
| 370 | operation: input.operation, | |
| 371 | }); | |
| 372 | ||
| 373 | return { | |
| 374 | adapter: recovered.adapter, | |
| 375 | sessionId: recovered.sessionId, | |
| 376 | isActive: true, | |
| 377 | } as const; | |
| 378 | }); | |
| 379 | ||
| 380 | const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) => | |
| 381 | Effect.gen(function* () { | |
| 382 | const parsed = yield* decodeInputOrValidationError({ | |
| 383 | operation: "ProviderService.startSession", | |
| 384 | schema: ProviderSessionStartInput, | |
| 385 | payload: rawInput, | |
| 386 | }); | |
| 387 | ||
| 388 | const input = { | |
| 389 | ...parsed, | |
| 390 | provider: parsed.provider ?? "codex", | |
| 391 | approvalPolicy: parsed.approvalPolicy ?? "never", | |
| 392 | sandboxMode: parsed.sandboxMode ?? "workspace-write", | |
| 393 | }; | |
| 394 | const adapter = yield* registry.getByProvider(input.provider); | |
| 395 | const session = yield* adapter.startSession(input); | |
| 396 | ||
| 397 | if (session.provider !== adapter.provider) { | |
| 398 | return yield* toValidationError( | |
| 399 | "ProviderService.startSession", | |
| 400 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, | |
| 401 | ); | |
| 402 | } | |
| 403 | ||
| 404 | yield* upsertSessionBinding(session, "ProviderService.startSession", threadId); | |
| 405 | ||
| 406 | return session; | |
| 407 | }); | |
| 408 | ||
| 409 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => | |
| 410 | Effect.gen(function* () { | |
| 411 | const parsed = yield* decodeInputOrValidationError({ | |
| 412 | operation: "ProviderService.sendTurn", | |
| 413 | schema: ProviderSendTurnInput, | |
| 414 | payload: rawInput, | |
| 415 | }); | |
| 416 | ||
| 417 | const input = { | |
| 418 | ...parsed, | |
| 419 | attachments: parsed.attachments ?? [], | |
| 420 | }; | |
| 421 | if (!input.input && input.attachments.length === 0) { | |
| 422 | return yield* toValidationError( | |
| 423 | "ProviderService.sendTurn", | |
| 424 | "Either input text or at least one attachment is required", | |
| 425 | ); | |
| 426 | } | |
| 427 | const routed = yield* resolveRoutableSession({ | |
| 428 | sessionId: input.sessionId, | |
| 429 | operation: "ProviderService.sendTurn", | |
| 430 | allowRecovery: true, | |
| 431 | }); | |
| 432 | const turn = yield* routed.adapter.sendTurn({ | |
| 433 | ...input, | |
| 434 | sessionId: routed.sessionId, | |
| 435 | }); | |
| 436 | const threadId = yield* directory | |
| 437 | .getThreadId(input.sessionId) | |
| 438 | .pipe(Effect.map(Option.getOrUndefined)); | |
| 439 | if (!threadId) { | |
| 440 | return yield* toValidationError( | |
| 441 | "ProviderService.sendTurn", | |
| 442 | `No thread id is tracked for provider session '${input.sessionId}'.`, | |
| 443 | ); | |
| 444 | } | |
| 445 | yield* directory.upsert({ | |
| 446 | sessionId: input.sessionId, | |
| 447 | provider: routed.adapter.provider, | |
| 448 | threadId, | |
| 449 | providerThreadId: turn.threadId, | |
| 450 | status: "running", | |
| 451 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), | |
| 452 | runtimePayload: { | |
| 453 | activeTurnId: turn.turnId, | |
| 454 | lastRuntimeEvent: "provider.sendTurn", | |
| 455 | lastRuntimeEventAt: new Date().toISOString(), | |
| 456 | }, | |
| 457 | }); | |
| 458 | return turn; | |
| 459 | }); | |
| 460 | ||
| 461 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => | |
| 462 | Effect.gen(function* () { | |
| 463 | const input = yield* decodeInputOrValidationError({ | |
| 464 | operation: "ProviderService.interruptTurn", | |
| 465 | schema: ProviderInterruptTurnInput, | |
| 466 | payload: rawInput, | |
| 467 | }); | |
| 468 | const routed = yield* resolveRoutableSession({ | |
| 469 | sessionId: input.sessionId, | |
| 470 | operation: "ProviderService.interruptTurn", | |
| 471 | allowRecovery: true, | |
| 472 | }); | |
| 473 | yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId); | |
| 474 | }); | |
| 475 | ||
| 476 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => | |
| 477 | Effect.gen(function* () { | |
| 478 | const input = yield* decodeInputOrValidationError({ | |
| 479 | operation: "ProviderService.respondToRequest", | |
| 480 | schema: ProviderRespondToRequestInput, | |
| 481 | payload: rawInput, | |
| 482 | }); | |
| 483 | const routed = yield* resolveRoutableSession({ | |
| 484 | sessionId: input.sessionId, | |
| 485 | operation: "ProviderService.respondToRequest", | |
| 486 | allowRecovery: true, | |
| 487 | }); | |
| 488 | yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision); | |
| 489 | }); | |
| 490 | ||
| 491 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => | |
| 492 | Effect.gen(function* () { | |
| 493 | const input = yield* decodeInputOrValidationError({ | |
| 494 | operation: "ProviderService.stopSession", | |
| 495 | schema: ProviderStopSessionInput, | |
| 496 | payload: rawInput, | |
| 497 | }); | |
| 498 | const routed = yield* resolveRoutableSession({ | |
| 499 | sessionId: input.sessionId, | |
| 500 | operation: "ProviderService.stopSession", | |
| 501 | allowRecovery: false, | |
| 502 | }); | |
| 503 | if (routed.isActive) { | |
| 504 | yield* routed.adapter.stopSession(routed.sessionId); | |
| 505 | } | |
| 506 | if (routed.sessionId !== input.sessionId) { | |
| 507 | yield* directory.remove(routed.sessionId); | |
| 508 | yield* clearAliasesReferencing(routed.sessionId); | |
| 509 | } | |
| 510 | yield* directory.remove(input.sessionId); | |
| 511 | yield* clearAliasesReferencing(input.sessionId); | |
| 512 | }); | |
| 513 | ||
| 514 | const listSessions: ProviderServiceShape["listSessions"] = () => | |
| 515 | Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( | |
| 516 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | |
| 517 | ); | |
| 518 | ||
| 519 | const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => | |
| 520 | Effect.gen(function* () { | |
| 521 | const input = yield* decodeInputOrValidationError({ | |
| 522 | operation: "ProviderService.rollbackConversation", | |
| 523 | schema: ProviderRollbackConversationInput, | |
| 524 | payload: rawInput, | |
| 525 | }); | |
| 526 | if (input.numTurns === 0) { | |
| 527 | return; | |
| 528 | } | |
| 529 | const routed = yield* resolveRoutableSession({ | |
| 530 | sessionId: input.sessionId, | |
| 531 | operation: "ProviderService.rollbackConversation", | |
| 532 | allowRecovery: true, | |
| 533 | }); | |
| 534 | yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); | |
| 535 | }); | |
| 536 | ||
| 537 | const stopAll: ProviderServiceShape["stopAll"] = () => | |
| 538 | Effect.gen(function* () { | |
| 539 | const sessionIds = yield* directory.listSessionIds(); | |
| 540 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); | |
| 541 | yield* Effect.forEach(sessionIds, (sessionId) => | |
| 542 | directory.getProvider(sessionId).pipe( | |
| 543 | Effect.flatMap((provider) => | |
| 544 | directory.upsert({ | |
| 545 | sessionId, | |
| 546 | provider, | |
| 547 | status: "stopped", | |
| 548 | runtimePayload: { | |
| 549 | activeTurnId: null, | |
| 550 | lastRuntimeEvent: "provider.stopAll", | |
| 551 | lastRuntimeEventAt: new Date().toISOString(), | |
| 552 | }, | |
| 553 | }), | |
| 554 | ), | |
| 555 | ), | |
| 556 | ).pipe(Effect.asVoid); | |
| 557 | // Keep persisted session bindings so stale sessions can be resumed after | |
| 558 | // process restart via providerThreadId. | |
| 559 | yield* Ref.set(routedSessionAliasesRef, new Map()); | |
| 560 | }); | |
| 561 | ||
| 562 | return { | |
| 563 | startSession, | |
| 564 | sendTurn, | |
| 565 | interruptTurn, | |
| 566 | respondToRequest, | |
| 567 | stopSession, | |
| 568 | listSessions, | |
| 569 | rollbackConversation, | |
| 570 | stopAll, | |
| 571 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 572 | } satisfies ProviderServiceShape; | |
| 573 | }); | |
| 574 | ||
| 575 | export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService()); | |
| 576 | ||
| 577 | export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { | |
| 578 | return Layer.effect(ProviderService, makeProviderService(options)); | |
| 579 | } |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts created+227| ... | ... | @@ -0,0 +1,227 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; | |
| 7 | import { it, assert } from "@effect/vitest"; | |
| 8 | import { assertFailure, assertSome } from "@effect/vitest/utils"; | |
| 9 | import { Effect, Layer, Option } from "effect"; | |
| 10 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 11 | ||
| 12 | import { | |
| 13 | makeSqlitePersistenceLive, | |
| 14 | SqlitePersistenceMemory, | |
| 15 | } from "../../persistence/Layers/Sqlite.ts"; | |
| 16 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | |
| 17 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 18 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 19 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 20 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | |
| 21 | ||
| 22 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { | |
| 23 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 24 | Layer.provide(persistenceLayer), | |
| 25 | ); | |
| 26 | return Layer.mergeAll( | |
| 27 | runtimeRepositoryLayer, | |
| 28 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | |
| 29 | ); | |
| 30 | } | |
| 31 | ||
| 32 | it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => { | |
| 33 | it("upserts, reads, and removes session bindings", () => | |
| 34 | Effect.gen(function* () { | |
| 35 | const directory = yield* ProviderSessionDirectory; | |
| 36 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 37 | ||
| 38 | const sessionId = ProviderSessionId.makeUnsafe("sess-1"); | |
| 39 | const initialThreadId = ThreadId.makeUnsafe("thread-1"); | |
| 40 | ||
| 41 | yield* directory.upsert({ | |
| 42 | sessionId, | |
| 43 | provider: "codex", | |
| 44 | threadId: initialThreadId, | |
| 45 | }); | |
| 46 | ||
| 47 | const provider = yield* directory.getProvider(sessionId); | |
| 48 | assert.equal(provider, "codex"); | |
| 49 | const resolvedThreadId = yield* directory.getThreadId(sessionId); | |
| 50 | assertSome(resolvedThreadId, initialThreadId); | |
| 51 | ||
| 52 | const nextThreadId = ThreadId.makeUnsafe("thread-2"); | |
| 53 | ||
| 54 | yield* directory.upsert({ | |
| 55 | sessionId: sessionId, | |
| 56 | provider: "codex", | |
| 57 | threadId: nextThreadId, | |
| 58 | }); | |
| 59 | const updatedThreadId = yield* directory.getThreadId(sessionId); | |
| 60 | assertSome(updatedThreadId, nextThreadId); | |
| 61 | const updatedBinding = yield* directory.getBinding(sessionId); | |
| 62 | assert.equal(Option.isSome(updatedBinding), true); | |
| 63 | if (Option.isSome(updatedBinding)) { | |
| 64 | assert.equal(updatedBinding.value.threadId, nextThreadId); | |
| 65 | assert.equal(updatedBinding.value.providerThreadId, null); | |
| 66 | } | |
| 67 | ||
| 68 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 69 | providerSessionId: sessionId, | |
| 70 | }); | |
| 71 | assert.equal(Option.isSome(runtime), true); | |
| 72 | if (Option.isSome(runtime)) { | |
| 73 | assert.equal(runtime.value.threadId, nextThreadId); | |
| 74 | assert.equal(runtime.value.providerThreadId, null); | |
| 75 | assert.equal(runtime.value.status, "running"); | |
| 76 | assert.equal(runtime.value.adapterKey, "codex"); | |
| 77 | } | |
| 78 | ||
| 79 | const sessionIds = yield* directory.listSessionIds(); | |
| 80 | assert.deepEqual(sessionIds, [sessionId]); | |
| 81 | ||
| 82 | yield* directory.remove(sessionId); | |
| 83 | const missingProvider = yield* directory.getProvider(sessionId).pipe(Effect.result); | |
| 84 | assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); | |
| 85 | })); | |
| 86 | ||
| 87 | it("fails upsert when thread id is unavailable", () => | |
| 88 | Effect.gen(function* () { | |
| 89 | const directory = yield* ProviderSessionDirectory; | |
| 90 | const result = yield* Effect.result( | |
| 91 | directory.upsert({ | |
| 92 | sessionId: ProviderSessionId.makeUnsafe("sess-no-thread"), | |
| 93 | provider: "codex", | |
| 94 | }), | |
| 95 | ); | |
| 96 | assertFailure( | |
| 97 | result, | |
| 98 | new ProviderValidationError({ | |
| 99 | operation: "ProviderSessionDirectory.upsert", | |
| 100 | issue: "threadId must be a non-empty string.", | |
| 101 | }), | |
| 102 | ); | |
| 103 | })); | |
| 104 | ||
| 105 | it("persists runtime fields and merges payload updates", () => | |
| 106 | Effect.gen(function* () { | |
| 107 | const directory = yield* ProviderSessionDirectory; | |
| 108 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 109 | ||
| 110 | const sessionId = ProviderSessionId.makeUnsafe("sess-runtime"); | |
| 111 | const threadId = ThreadId.makeUnsafe("thread-runtime"); | |
| 112 | const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime"); | |
| 113 | ||
| 114 | yield* directory.upsert({ | |
| 115 | sessionId, | |
| 116 | provider: "codex", | |
| 117 | threadId, | |
| 118 | providerThreadId, | |
| 119 | status: "starting", | |
| 120 | resumeCursor: { | |
| 121 | resumeThreadId: "provider-thread-runtime", | |
| 122 | }, | |
| 123 | runtimePayload: { | |
| 124 | cwd: "/tmp/project", | |
| 125 | model: "gpt-5-codex", | |
| 126 | }, | |
| 127 | }); | |
| 128 | ||
| 129 | yield* directory.upsert({ | |
| 130 | sessionId, | |
| 131 | provider: "codex", | |
| 132 | status: "running", | |
| 133 | runtimePayload: { | |
| 134 | activeTurnId: "turn-1", | |
| 135 | }, | |
| 136 | }); | |
| 137 | ||
| 138 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 139 | providerSessionId: sessionId, | |
| 140 | }); | |
| 141 | assert.equal(Option.isSome(runtime), true); | |
| 142 | if (Option.isSome(runtime)) { | |
| 143 | assert.equal(runtime.value.threadId, threadId); | |
| 144 | assert.equal(runtime.value.providerThreadId, providerThreadId); | |
| 145 | assert.equal(runtime.value.status, "running"); | |
| 146 | assert.deepEqual(runtime.value.resumeCursor, { | |
| 147 | resumeThreadId: providerThreadId, | |
| 148 | }); | |
| 149 | assert.deepEqual(runtime.value.runtimePayload, { | |
| 150 | cwd: "/tmp/project", | |
| 151 | model: "gpt-5-codex", | |
| 152 | activeTurnId: "turn-1", | |
| 153 | }); | |
| 154 | } | |
| 155 | })); | |
| 156 | ||
| 157 | it("clears providerThreadId when explicitly set to null", () => | |
| 158 | Effect.gen(function* () { | |
| 159 | const directory = yield* ProviderSessionDirectory; | |
| 160 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 161 | ||
| 162 | const sessionId = ProviderSessionId.makeUnsafe("sess-clear-provider-thread-id"); | |
| 163 | const threadId = ThreadId.makeUnsafe("thread-clear-provider-thread-id"); | |
| 164 | const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-to-clear"); | |
| 165 | ||
| 166 | yield* directory.upsert({ | |
| 167 | sessionId, | |
| 168 | provider: "codex", | |
| 169 | threadId, | |
| 170 | adapterKey: "custom-adapter", | |
| 171 | providerThreadId, | |
| 172 | }); | |
| 173 | ||
| 174 | yield* directory.upsert({ | |
| 175 | sessionId, | |
| 176 | provider: "codex", | |
| 177 | providerThreadId: null, | |
| 178 | }); | |
| 179 | ||
| 180 | const runtime = yield* runtimeRepository.getBySessionId({ | |
| 181 | providerSessionId: sessionId, | |
| 182 | }); | |
| 183 | assert.equal(Option.isSome(runtime), true); | |
| 184 | if (Option.isSome(runtime)) { | |
| 185 | assert.equal(runtime.value.providerThreadId, null); | |
| 186 | assert.equal(runtime.value.adapterKey, "custom-adapter"); | |
| 187 | } | |
| 188 | })); | |
| 189 | ||
| 190 | it("rehydrates persisted mappings across layer restart", () => | |
| 191 | Effect.gen(function* () { | |
| 192 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); | |
| 193 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | |
| 194 | const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath)); | |
| 195 | ||
| 196 | const sessionId = ProviderSessionId.makeUnsafe("sess-restart"); | |
| 197 | const threadId = ThreadId.makeUnsafe("thread-restart"); | |
| 198 | ||
| 199 | yield* Effect.gen(function* () { | |
| 200 | const directory = yield* ProviderSessionDirectory; | |
| 201 | yield* directory.upsert({ | |
| 202 | sessionId, | |
| 203 | provider: "codex", | |
| 204 | threadId, | |
| 205 | }); | |
| 206 | }).pipe(Effect.provide(directoryLayer)); | |
| 207 | ||
| 208 | yield* Effect.gen(function* () { | |
| 209 | const directory = yield* ProviderSessionDirectory; | |
| 210 | const sql = yield* SqlClient.SqlClient; | |
| 211 | const provider = yield* directory.getProvider(sessionId); | |
| 212 | assert.equal(provider, "codex"); | |
| 213 | ||
| 214 | const resolvedThreadId = yield* directory.getThreadId(sessionId); | |
| 215 | assertSome(resolvedThreadId, threadId); | |
| 216 | ||
| 217 | const legacyTableRows = yield* sql<{ readonly name: string }>` | |
| 218 | SELECT name | |
| 219 | FROM sqlite_master | |
| 220 | WHERE type = 'table' AND name = 'provider_sessions' | |
| 221 | `; | |
| 222 | assert.equal(legacyTableRows.length, 0); | |
| 223 | }).pipe(Effect.provide(directoryLayer)); | |
| 224 | ||
| 225 | fs.rmSync(tempDir, { recursive: true, force: true }); | |
| 226 | }).pipe(Effect.provide(NodeServices.layer))); | |
| 227 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts created+196| ... | ... | @@ -0,0 +1,196 @@ |
| 1 | import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, Option } from "effect"; | |
| 3 | ||
| 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 5 | import { | |
| 6 | ProviderSessionDirectoryPersistenceError, | |
| 7 | ProviderSessionNotFoundError, | |
| 8 | ProviderValidationError, | |
| 9 | } from "../Errors.ts"; | |
| 10 | import { | |
| 11 | ProviderSessionDirectory, | |
| 12 | type ProviderSessionBinding, | |
| 13 | type ProviderSessionDirectoryShape, | |
| 14 | } from "../Services/ProviderSessionDirectory.ts"; | |
| 15 | ||
| 16 | function toPersistenceError(operation: string) { | |
| 17 | return (cause: unknown) => | |
| 18 | new ProviderSessionDirectoryPersistenceError({ | |
| 19 | operation, | |
| 20 | detail: `Failed to execute ${operation}.`, | |
| 21 | cause, | |
| 22 | }); | |
| 23 | } | |
| 24 | ||
| 25 | function decodeProviderKind( | |
| 26 | providerName: string, | |
| 27 | operation: string, | |
| 28 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { | |
| 29 | if (providerName === "codex" || providerName === "claudeCode") { | |
| 30 | return Effect.succeed(providerName); | |
| 31 | } | |
| 32 | return Effect.fail( | |
| 33 | new ProviderSessionDirectoryPersistenceError({ | |
| 34 | operation, | |
| 35 | detail: `Unknown persisted provider '${providerName}'.`, | |
| 36 | }), | |
| 37 | ); | |
| 38 | } | |
| 39 | ||
| 40 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 41 | return value !== null && typeof value === "object" && !Array.isArray(value); | |
| 42 | } | |
| 43 | ||
| 44 | function mergeRuntimePayload( | |
| 45 | existing: unknown | null, | |
| 46 | next: unknown | null | undefined, | |
| 47 | ): unknown | null { | |
| 48 | if (next === undefined) { | |
| 49 | return existing ?? null; | |
| 50 | } | |
| 51 | if (isRecord(existing) && isRecord(next)) { | |
| 52 | return { | |
| 53 | ...existing, | |
| 54 | ...next, | |
| 55 | }; | |
| 56 | } | |
| 57 | return next; | |
| 58 | } | |
| 59 | ||
| 60 | const makeProviderSessionDirectory = Effect.gen(function* () { | |
| 61 | const repository = yield* ProviderSessionRuntimeRepository; | |
| 62 | ||
| 63 | const getBinding = (sessionId: ProviderSessionId) => | |
| 64 | repository.getBySessionId({ providerSessionId: sessionId }).pipe( | |
| 65 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), | |
| 66 | Effect.flatMap((runtime) => | |
| 67 | Option.match(runtime, { | |
| 68 | onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()), | |
| 69 | onSome: (value) => | |
| 70 | decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( | |
| 71 | Effect.map((provider) => | |
| 72 | Option.some({ | |
| 73 | sessionId: value.providerSessionId, | |
| 74 | provider, | |
| 75 | threadId: value.threadId, | |
| 76 | adapterKey: value.adapterKey, | |
| 77 | providerThreadId: value.providerThreadId, | |
| 78 | status: value.status, | |
| 79 | resumeCursor: value.resumeCursor, | |
| 80 | runtimePayload: value.runtimePayload, | |
| 81 | }), | |
| 82 | ), | |
| 83 | ), | |
| 84 | }), | |
| 85 | ), | |
| 86 | ); | |
| 87 | ||
| 88 | const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { | |
| 89 | const existing = yield* repository | |
| 90 | .getBySessionId({ | |
| 91 | providerSessionId: binding.sessionId, | |
| 92 | }) | |
| 93 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId"))); | |
| 94 | ||
| 95 | const existingRuntime = Option.getOrUndefined(existing); | |
| 96 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; | |
| 97 | if (!resolvedThreadId) { | |
| 98 | return yield* new ProviderValidationError({ | |
| 99 | operation: "ProviderSessionDirectory.upsert", | |
| 100 | issue: "threadId must be a non-empty string.", | |
| 101 | }); | |
| 102 | } | |
| 103 | ||
| 104 | const now = new Date().toISOString(); | |
| 105 | yield* repository | |
| 106 | .upsert({ | |
| 107 | providerSessionId: binding.sessionId, | |
| 108 | threadId: resolvedThreadId, | |
| 109 | providerName: binding.provider, | |
| 110 | adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, | |
| 111 | providerThreadId: | |
| 112 | binding.providerThreadId !== undefined | |
| 113 | ? binding.providerThreadId | |
| 114 | : (existingRuntime?.providerThreadId ?? null), | |
| 115 | status: binding.status ?? existingRuntime?.status ?? "running", | |
| 116 | lastSeenAt: now, | |
| 117 | resumeCursor: | |
| 118 | binding.resumeCursor !== undefined | |
| 119 | ? binding.resumeCursor | |
| 120 | : (existingRuntime?.resumeCursor ?? null), | |
| 121 | runtimePayload: mergeRuntimePayload( | |
| 122 | existingRuntime?.runtimePayload ?? null, | |
| 123 | binding.runtimePayload, | |
| 124 | ), | |
| 125 | }) | |
| 126 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); | |
| 127 | }); | |
| 128 | ||
| 129 | const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => | |
| 130 | getBinding(sessionId).pipe( | |
| 131 | Effect.flatMap((binding) => | |
| 132 | Option.match(binding, { | |
| 133 | onSome: (value) => Effect.succeed(value.provider), | |
| 134 | onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), | |
| 135 | }), | |
| 136 | ), | |
| 137 | ); | |
| 138 | ||
| 139 | const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) => | |
| 140 | getBinding(sessionId); | |
| 141 | ||
| 142 | const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => | |
| 143 | getBinding(sessionId).pipe( | |
| 144 | Effect.flatMap((binding) => | |
| 145 | Option.match(binding, { | |
| 146 | onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), | |
| 147 | onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), | |
| 148 | }), | |
| 149 | ), | |
| 150 | ); | |
| 151 | ||
| 152 | const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => | |
| 153 | repository | |
| 154 | .deleteBySessionId({ providerSessionId: sessionId }) | |
| 155 | .pipe( | |
| 156 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), | |
| 157 | ); | |
| 158 | ||
| 159 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => | |
| 160 | repository.list().pipe( | |
| 161 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), | |
| 162 | Effect.flatMap((rows) => | |
| 163 | Effect.forEach( | |
| 164 | rows, | |
| 165 | (row) => | |
| 166 | decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( | |
| 167 | Effect.map((provider) => ({ | |
| 168 | sessionId: row.providerSessionId, | |
| 169 | provider, | |
| 170 | threadId: row.threadId, | |
| 171 | })), | |
| 172 | ), | |
| 173 | { concurrency: "unbounded" }, | |
| 174 | ), | |
| 175 | ), | |
| 176 | Effect.map((bindings) => bindings.map((binding) => binding.sessionId)), | |
| 177 | ); | |
| 178 | ||
| 179 | return { | |
| 180 | upsert, | |
| 181 | getProvider, | |
| 182 | getBinding: getBindingBySessionId, | |
| 183 | getThreadId, | |
| 184 | remove, | |
| 185 | listSessionIds, | |
| 186 | } satisfies ProviderSessionDirectoryShape; | |
| 187 | }); | |
| 188 | ||
| 189 | export const ProviderSessionDirectoryLive = Layer.effect( | |
| 190 | ProviderSessionDirectory, | |
| 191 | makeProviderSessionDirectory, | |
| 192 | ); | |
| 193 | ||
| 194 | export function makeProviderSessionDirectoryLive() { | |
| 195 | return Layer.effect(ProviderSessionDirectory, makeProviderSessionDirectory); | |
| 196 | } |
apps/server/src/provider/Services/CodexAdapter.ts created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | /** | |
| 2 | * CodexAdapter - Codex implementation of the generic provider adapter contract. | |
| 3 | * | |
| 4 | * This service owns Codex app-server process / JSON-RPC semantics and emits | |
| 5 | * Codex provider events. It does not perform cross-provider routing, shared | |
| 6 | * event fan-out, or checkpoint orchestration. | |
| 7 | * | |
| 8 | * Uses Effect `ServiceMap.Service` for dependency injection and returns the | |
| 9 | * shared provider-adapter error channel with `provider: "codex"` context. | |
| 10 | * | |
| 11 | * @module CodexAdapter | |
| 12 | */ | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | ||
| 15 | import type { ProviderAdapterError } from "../Errors.ts"; | |
| 16 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | |
| 17 | ||
| 18 | /** | |
| 19 | * CodexAdapterShape - Service API for the Codex provider adapter. | |
| 20 | */ | |
| 21 | export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | |
| 22 | readonly provider: "codex"; | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * CodexAdapter - Service tag for Codex provider adapter operations. | |
| 27 | */ | |
| 28 | export class CodexAdapter extends ServiceMap.Service<CodexAdapter, CodexAdapterShape>()( | |
| 29 | "t3/provider/Services/CodexAdapter", | |
| 30 | ) {} |
apps/server/src/provider/Services/ProviderAdapter.ts created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | /** | |
| 2 | * ProviderAdapter - Provider-specific runtime adapter contract. | |
| 3 | * | |
| 4 | * Defines the provider-native session/protocol operations that `ProviderService` | |
| 5 | * routes to after resolving the target provider. Implementations should focus | |
| 6 | * on provider behavior only and avoid cross-provider orchestration concerns. | |
| 7 | * | |
| 8 | * @module ProviderAdapter | |
| 9 | */ | |
| 10 | import type { | |
| 11 | ApprovalRequestId, | |
| 12 | ProviderApprovalDecision, | |
| 13 | ProviderKind, | |
| 14 | ProviderRuntimeEvent, | |
| 15 | ProviderSendTurnInput, | |
| 16 | ProviderSession, | |
| 17 | ProviderSessionId, | |
| 18 | ProviderSessionStartInput, | |
| 19 | ProviderThreadId, | |
| 20 | ProviderTurnId, | |
| 21 | ProviderTurnStartResult, | |
| 22 | } from "@t3tools/contracts"; | |
| 23 | import type { Effect } from "effect"; | |
| 24 | import type { Stream } from "effect"; | |
| 25 | ||
| 26 | export interface ProviderThreadTurnSnapshot { | |
| 27 | readonly id: ProviderTurnId; | |
| 28 | readonly items: ReadonlyArray<unknown>; | |
| 29 | } | |
| 30 | ||
| 31 | export interface ProviderThreadSnapshot { | |
| 32 | readonly threadId: ProviderThreadId; | |
| 33 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; | |
| 34 | } | |
| 35 | ||
| 36 | export interface ProviderAdapterShape<TError> { | |
| 37 | /** | |
| 38 | * Provider kind implemented by this adapter. | |
| 39 | */ | |
| 40 | readonly provider: ProviderKind; | |
| 41 | ||
| 42 | /** | |
| 43 | * Start a provider-backed session. | |
| 44 | */ | |
| 45 | readonly startSession: ( | |
| 46 | input: ProviderSessionStartInput, | |
| 47 | ) => Effect.Effect<ProviderSession, TError>; | |
| 48 | ||
| 49 | /** | |
| 50 | * Send a turn to an active provider session. | |
| 51 | */ | |
| 52 | readonly sendTurn: ( | |
| 53 | input: ProviderSendTurnInput, | |
| 54 | ) => Effect.Effect<ProviderTurnStartResult, TError>; | |
| 55 | ||
| 56 | /** | |
| 57 | * Interrupt an active turn. | |
| 58 | */ | |
| 59 | readonly interruptTurn: ( | |
| 60 | sessionId: ProviderSessionId, | |
| 61 | turnId?: ProviderTurnId, | |
| 62 | ) => Effect.Effect<void, TError>; | |
| 63 | ||
| 64 | /** | |
| 65 | * Respond to an interactive approval request. | |
| 66 | */ | |
| 67 | readonly respondToRequest: ( | |
| 68 | sessionId: ProviderSessionId, | |
| 69 | requestId: ApprovalRequestId, | |
| 70 | decision: ProviderApprovalDecision, | |
| 71 | ) => Effect.Effect<void, TError>; | |
| 72 | ||
| 73 | /** | |
| 74 | * Stop one provider session. | |
| 75 | */ | |
| 76 | readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>; | |
| 77 | ||
| 78 | /** | |
| 79 | * List currently active provider sessions for this adapter. | |
| 80 | */ | |
| 81 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; | |
| 82 | ||
| 83 | /** | |
| 84 | * Check whether this adapter owns an active session id. | |
| 85 | */ | |
| 86 | readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>; | |
| 87 | ||
| 88 | /** | |
| 89 | * Read a provider thread snapshot. | |
| 90 | */ | |
| 91 | readonly readThread: ( | |
| 92 | sessionId: ProviderSessionId, | |
| 93 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; | |
| 94 | ||
| 95 | /** | |
| 96 | * Roll back a provider thread by N turns. | |
| 97 | */ | |
| 98 | readonly rollbackThread: ( | |
| 99 | sessionId: ProviderSessionId, | |
| 100 | numTurns: number, | |
| 101 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; | |
| 102 | ||
| 103 | /** | |
| 104 | * Stop all sessions owned by this adapter. | |
| 105 | */ | |
| 106 | readonly stopAll: () => Effect.Effect<void, TError>; | |
| 107 | ||
| 108 | /** | |
| 109 | * Canonical runtime event stream emitted by this adapter. | |
| 110 | */ | |
| 111 | readonly streamEvents: Stream.Stream<ProviderRuntimeEvent>; | |
| 112 | } |
apps/server/src/provider/Services/ProviderAdapterRegistry.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | /** | |
| 2 | * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. | |
| 3 | * | |
| 4 | * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). | |
| 5 | * It does not own session lifecycle or routing rules; `ProviderService` uses | |
| 6 | * this registry together with `ProviderSessionDirectory`. | |
| 7 | * | |
| 8 | * @module ProviderAdapterRegistry | |
| 9 | */ | |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 11 | import { ServiceMap } from "effect"; | |
| 12 | import type { Effect } from "effect"; | |
| 13 | ||
| 14 | import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; | |
| 15 | import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | |
| 16 | ||
| 17 | /** | |
| 18 | * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind. | |
| 19 | */ | |
| 20 | export interface ProviderAdapterRegistryShape { | |
| 21 | /** | |
| 22 | * Resolve the adapter for a provider kind. | |
| 23 | */ | |
| 24 | readonly getByProvider: ( | |
| 25 | provider: ProviderKind, | |
| 26 | ) => Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, ProviderUnsupportedError>; | |
| 27 | ||
| 28 | /** | |
| 29 | * List provider kinds currently registered. | |
| 30 | */ | |
| 31 | readonly listProviders: () => Effect.Effect<ReadonlyArray<ProviderKind>>; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * ProviderAdapterRegistry - Service tag for provider adapter lookup. | |
| 36 | */ | |
| 37 | export class ProviderAdapterRegistry extends ServiceMap.Service< | |
| 38 | ProviderAdapterRegistry, | |
| 39 | ProviderAdapterRegistryShape | |
| 40 | >()("t3/provider/Services/ProviderAdapterRegistry") {} | |
| 41 | ||
| 42 | // Dummy comment for workflow testing. |
apps/server/src/provider/Services/ProviderService.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | /** | |
| 2 | * ProviderService - Service interface for provider sessions, turns, and checkpoints. | |
| 3 | * | |
| 4 | * Acts as the cross-provider facade used by transports (WebSocket/RPC). It | |
| 5 | * resolves provider adapters through `ProviderAdapterRegistry`, routes | |
| 6 | * session-scoped calls via `ProviderSessionDirectory`, and exposes one unified | |
| 7 | * provider event stream to callers. | |
| 8 | * | |
| 9 | * Uses Effect `ServiceMap.Service` for dependency injection and returns typed | |
| 10 | * domain errors for validation, session, codex, and checkpoint workflows. | |
| 11 | * | |
| 12 | * @module ProviderService | |
| 13 | */ | |
| 14 | import type { | |
| 15 | ProviderInterruptTurnInput, | |
| 16 | ProviderRespondToRequestInput, | |
| 17 | ProviderRuntimeEvent, | |
| 18 | ProviderSendTurnInput, | |
| 19 | ProviderSession, | |
| 20 | ProviderSessionId, | |
| 21 | ProviderSessionStartInput, | |
| 22 | ProviderStopSessionInput, | |
| 23 | ThreadId, | |
| 24 | ProviderTurnStartResult, | |
| 25 | } from "@t3tools/contracts"; | |
| 26 | import { ServiceMap } from "effect"; | |
| 27 | import type { Effect, Stream } from "effect"; | |
| 28 | ||
| 29 | import type { ProviderServiceError } from "../Errors.ts"; | |
| 30 | ||
| 31 | /** | |
| 32 | * ProviderServiceShape - Service API for provider session and turn orchestration. | |
| 33 | */ | |
| 34 | export interface ProviderServiceShape { | |
| 35 | /** | |
| 36 | * Start a provider session. | |
| 37 | */ | |
| 38 | readonly startSession: ( | |
| 39 | threadId: ThreadId, | |
| 40 | input: ProviderSessionStartInput, | |
| 41 | ) => Effect.Effect<ProviderSession, ProviderServiceError>; | |
| 42 | ||
| 43 | /** | |
| 44 | * Send a provider turn. | |
| 45 | */ | |
| 46 | readonly sendTurn: ( | |
| 47 | input: ProviderSendTurnInput, | |
| 48 | ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>; | |
| 49 | ||
| 50 | /** | |
| 51 | * Interrupt a running provider turn. | |
| 52 | */ | |
| 53 | readonly interruptTurn: ( | |
| 54 | input: ProviderInterruptTurnInput, | |
| 55 | ) => Effect.Effect<void, ProviderServiceError>; | |
| 56 | ||
| 57 | /** | |
| 58 | * Respond to a provider approval request. | |
| 59 | */ | |
| 60 | readonly respondToRequest: ( | |
| 61 | input: ProviderRespondToRequestInput, | |
| 62 | ) => Effect.Effect<void, ProviderServiceError>; | |
| 63 | ||
| 64 | /** | |
| 65 | * Stop a provider session. | |
| 66 | */ | |
| 67 | readonly stopSession: ( | |
| 68 | input: ProviderStopSessionInput, | |
| 69 | ) => Effect.Effect<void, ProviderServiceError>; | |
| 70 | ||
| 71 | /** | |
| 72 | * List active provider sessions. | |
| 73 | * | |
| 74 | * Aggregates runtime session lists from all registered adapters. | |
| 75 | */ | |
| 76 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; | |
| 77 | ||
| 78 | /** | |
| 79 | * Roll back provider conversation state by a number of turns. | |
| 80 | */ | |
| 81 | readonly rollbackConversation: (input: { | |
| 82 | readonly sessionId: ProviderSessionId; | |
| 83 | readonly numTurns: number; | |
| 84 | }) => Effect.Effect<void, ProviderServiceError>; | |
| 85 | ||
| 86 | /** | |
| 87 | * Stop all active provider sessions. | |
| 88 | * | |
| 89 | * Persisted session bindings remain so stale sessions can be resumed after | |
| 90 | * process restart. | |
| 91 | * | |
| 92 | * Runtime alias mappings are cleared as part of shutdown. | |
| 93 | */ | |
| 94 | readonly stopAll: () => Effect.Effect<void, ProviderServiceError>; | |
| 95 | ||
| 96 | /** | |
| 97 | * Canonical provider runtime event stream. | |
| 98 | * | |
| 99 | * Fan-out is owned by ProviderService (not by a standalone event-bus service). | |
| 100 | */ | |
| 101 | readonly streamEvents: Stream.Stream<ProviderRuntimeEvent>; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * ProviderService - Service tag for provider orchestration. | |
| 106 | */ | |
| 107 | export class ProviderService extends ServiceMap.Service<ProviderService, ProviderServiceShape>()( | |
| 108 | "t3/provider/Services/ProviderService", | |
| 109 | ) {} |
apps/server/src/provider/Services/ProviderSessionDirectory.ts created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | /** | |
| 2 | * ProviderSessionDirectory - Session ownership index across provider adapters. | |
| 3 | * | |
| 4 | * Tracks which provider owns each `sessionId` so `ProviderService` can route | |
| 5 | * session-scoped calls to the correct adapter. It is metadata only and does not | |
| 6 | * perform provider RPC or checkpoint operations. | |
| 7 | * | |
| 8 | * @module ProviderSessionDirectory | |
| 9 | */ | |
| 10 | import type { | |
| 11 | ProviderKind, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderSessionRuntimeStatus, | |
| 14 | ProviderThreadId, | |
| 15 | ThreadId, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import { Option, ServiceMap } from "effect"; | |
| 18 | import type { Effect } from "effect"; | |
| 19 | ||
| 20 | import type { | |
| 21 | ProviderSessionDirectoryPersistenceError, | |
| 22 | ProviderSessionNotFoundError, | |
| 23 | ProviderValidationError, | |
| 24 | } from "../Errors.ts"; | |
| 25 | ||
| 26 | export interface ProviderSessionBinding { | |
| 27 | readonly sessionId: ProviderSessionId; | |
| 28 | readonly provider: ProviderKind; | |
| 29 | readonly threadId?: ThreadId; | |
| 30 | readonly adapterKey?: string; | |
| 31 | readonly providerThreadId?: ProviderThreadId | null; | |
| 32 | readonly status?: ProviderSessionRuntimeStatus; | |
| 33 | readonly resumeCursor?: unknown | null; | |
| 34 | readonly runtimePayload?: unknown | null; | |
| 35 | } | |
| 36 | ||
| 37 | export type ProviderSessionDirectoryReadError = | |
| 38 | | ProviderSessionNotFoundError | |
| 39 | | ProviderSessionDirectoryPersistenceError; | |
| 40 | ||
| 41 | export type ProviderSessionDirectoryWriteError = | |
| 42 | | ProviderValidationError | |
| 43 | | ProviderSessionDirectoryPersistenceError; | |
| 44 | ||
| 45 | /** | |
| 46 | * ProviderSessionDirectoryShape - Service API for provider session ownership metadata. | |
| 47 | */ | |
| 48 | export interface ProviderSessionDirectoryShape { | |
| 49 | /** | |
| 50 | * Record or update ownership for one provider session. | |
| 51 | * | |
| 52 | * Preserves existing persisted fields when omitted and shallow-merges | |
| 53 | * runtime payload objects. | |
| 54 | */ | |
| 55 | readonly upsert: ( | |
| 56 | binding: ProviderSessionBinding, | |
| 57 | ) => Effect.Effect<void, ProviderSessionDirectoryWriteError>; | |
| 58 | ||
| 59 | /** | |
| 60 | * Resolve the provider owner for a session id. | |
| 61 | */ | |
| 62 | readonly getProvider: ( | |
| 63 | sessionId: ProviderSessionId, | |
| 64 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; | |
| 65 | ||
| 66 | /** | |
| 67 | * Resolve the full tracked binding for a session id. | |
| 68 | */ | |
| 69 | readonly getBinding: ( | |
| 70 | sessionId: ProviderSessionId, | |
| 71 | ) => Effect.Effect<Option.Option<ProviderSessionBinding>, ProviderSessionDirectoryReadError>; | |
| 72 | ||
| 73 | /** | |
| 74 | * Resolve the tracked thread id for a session, if known. | |
| 75 | */ | |
| 76 | readonly getThreadId: ( | |
| 77 | sessionId: ProviderSessionId, | |
| 78 | ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>; | |
| 79 | ||
| 80 | /** | |
| 81 | * Remove a session binding. | |
| 82 | */ | |
| 83 | readonly remove: ( | |
| 84 | sessionId: ProviderSessionId, | |
| 85 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; | |
| 86 | ||
| 87 | /** | |
| 88 | * List tracked session ids. | |
| 89 | */ | |
| 90 | readonly listSessionIds: () => Effect.Effect< | |
| 91 | ReadonlyArray<ProviderSessionId>, | |
| 92 | ProviderSessionDirectoryPersistenceError | |
| 93 | >; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * ProviderSessionDirectory - Service tag for session ownership lookup. | |
| 98 | */ | |
| 99 | export class ProviderSessionDirectory extends ServiceMap.Service< | |
| 100 | ProviderSessionDirectory, | |
| 101 | ProviderSessionDirectoryShape | |
| 102 | >()("t3/provider/Services/ProviderSessionDirectory") {} |
apps/server/src/providerManager.test.ts deleted-879| ... | ... | @@ -1,879 +0,0 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { ProviderManager } from "./providerManager"; | |
| 4 | ||
| 5 | describe("ProviderManager", () => { | |
| 6 | it("detaches provider event listener and ends thread log streams on dispose", () => { | |
| 7 | const manager = new ProviderManager(); | |
| 8 | const internals = manager as unknown as { | |
| 9 | codex: { listenerCount: (event: string) => number }; | |
| 10 | onCodexEvent: (event: { | |
| 11 | id: string; | |
| 12 | kind: "notification"; | |
| 13 | provider: "codex"; | |
| 14 | sessionId: string; | |
| 15 | createdAt: string; | |
| 16 | method: string; | |
| 17 | threadId: string; | |
| 18 | }) => void; | |
| 19 | threadLogStreams: Map<string, { writableEnded: boolean; destroyed: boolean }>; | |
| 20 | }; | |
| 21 | internals.onCodexEvent({ | |
| 22 | id: "evt-1", | |
| 23 | kind: "notification", | |
| 24 | provider: "codex", | |
| 25 | sessionId: "sess-1", | |
| 26 | createdAt: new Date().toISOString(), | |
| 27 | method: "thread/started", | |
| 28 | threadId: "thread-1", | |
| 29 | }); | |
| 30 | const stream = internals.threadLogStreams.get("thread-1"); | |
| 31 | expect(stream).toBeDefined(); | |
| 32 | if (!stream) return; | |
| 33 | ||
| 34 | expect(internals.codex.listenerCount("event")).toBe(1); | |
| 35 | manager.dispose(); | |
| 36 | ||
| 37 | expect(internals.codex.listenerCount("event")).toBe(0); | |
| 38 | expect(stream.writableEnded || stream.destroyed).toBe(true); | |
| 39 | expect(internals.threadLogStreams.size).toBe(0); | |
| 40 | }); | |
| 41 | ||
| 42 | it("allows multiple dispose calls", () => { | |
| 43 | const manager = new ProviderManager(); | |
| 44 | ||
| 45 | manager.dispose(); | |
| 46 | expect(() => manager.dispose()).not.toThrow(); | |
| 47 | }); | |
| 48 | ||
| 49 | it("rejects request responses for unknown sessions", async () => { | |
| 50 | const manager = new ProviderManager(); | |
| 51 | ||
| 52 | await expect( | |
| 53 | manager.respondToRequest({ | |
| 54 | sessionId: "missing-session", | |
| 55 | requestId: "req-1", | |
| 56 | decision: "accept", | |
| 57 | }), | |
| 58 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 59 | ||
| 60 | manager.dispose(); | |
| 61 | }); | |
| 62 | ||
| 63 | it("rejects checkpoint operations for unknown sessions", async () => { | |
| 64 | const manager = new ProviderManager(); | |
| 65 | ||
| 66 | await expect( | |
| 67 | manager.listCheckpoints({ | |
| 68 | sessionId: "missing-session", | |
| 69 | }), | |
| 70 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 71 | ||
| 72 | await expect( | |
| 73 | manager.revertToCheckpoint({ | |
| 74 | sessionId: "missing-session", | |
| 75 | turnCount: 0, | |
| 76 | }), | |
| 77 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 78 | ||
| 79 | await expect( | |
| 80 | manager.getCheckpointDiff({ | |
| 81 | sessionId: "missing-session", | |
| 82 | fromTurnCount: 0, | |
| 83 | toTurnCount: 1, | |
| 84 | }), | |
| 85 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 86 | ||
| 87 | manager.dispose(); | |
| 88 | }); | |
| 89 | ||
| 90 | it("derives checkpoints from thread turns", async () => { | |
| 91 | const manager = new ProviderManager(); | |
| 92 | const codex = ( | |
| 93 | manager as unknown as { | |
| 94 | codex: { | |
| 95 | hasSession: (sessionId: string) => boolean; | |
| 96 | readThread: (sessionId: string) => Promise<{ | |
| 97 | threadId: string; | |
| 98 | turns: Array<{ id: string; items: unknown[] }>; | |
| 99 | }>; | |
| 100 | }; | |
| 101 | } | |
| 102 | ).codex; | |
| 103 | ||
| 104 | codex.hasSession = () => true; | |
| 105 | codex.readThread = async () => ({ | |
| 106 | threadId: "thr_1", | |
| 107 | turns: [ | |
| 108 | { | |
| 109 | id: "turn_1", | |
| 110 | items: [ | |
| 111 | { | |
| 112 | type: "userMessage", | |
| 113 | content: [{ type: "text", text: "Refactor the logger" }], | |
| 114 | }, | |
| 115 | { | |
| 116 | type: "agentMessage", | |
| 117 | text: "I refactored it.", | |
| 118 | }, | |
| 119 | ], | |
| 120 | }, | |
| 121 | ], | |
| 122 | }); | |
| 123 | ||
| 124 | const result = await manager.listCheckpoints({ | |
| 125 | sessionId: "sess_1", | |
| 126 | }); | |
| 127 | ||
| 128 | expect(result).toEqual({ | |
| 129 | threadId: "thr_1", | |
| 130 | checkpoints: [ | |
| 131 | { | |
| 132 | id: "root", | |
| 133 | turnCount: 0, | |
| 134 | messageCount: 0, | |
| 135 | label: "Start of conversation", | |
| 136 | isCurrent: false, | |
| 137 | }, | |
| 138 | { | |
| 139 | id: "turn_1", | |
| 140 | turnCount: 1, | |
| 141 | messageCount: 2, | |
| 142 | label: "Turn 1", | |
| 143 | preview: "Refactor the logger", | |
| 144 | isCurrent: true, | |
| 145 | }, | |
| 146 | ], | |
| 147 | }); | |
| 148 | ||
| 149 | manager.dispose(); | |
| 150 | }); | |
| 151 | ||
| 152 | it("rolls back from a selected checkpoint turn count", async () => { | |
| 153 | const manager = new ProviderManager(); | |
| 154 | const internals = manager as unknown as { | |
| 155 | codex: { | |
| 156 | hasSession: (sessionId: string) => boolean; | |
| 157 | readThread: (sessionId: string) => Promise<{ | |
| 158 | threadId: string; | |
| 159 | turns: Array<{ id: string; items: unknown[] }>; | |
| 160 | }>; | |
| 161 | rollbackThread: ( | |
| 162 | sessionId: string, | |
| 163 | numTurns: number, | |
| 164 | ) => Promise<{ | |
| 165 | threadId: string; | |
| 166 | turns: Array<{ id: string; items: unknown[] }>; | |
| 167 | }>; | |
| 168 | }; | |
| 169 | filesystemCheckpointStore: { | |
| 170 | hasCheckpoint: (input: { | |
| 171 | cwd: string; | |
| 172 | threadId: string; | |
| 173 | turnCount: number; | |
| 174 | }) => Promise<boolean>; | |
| 175 | restoreCheckpoint: (input: { | |
| 176 | cwd: string; | |
| 177 | threadId: string; | |
| 178 | turnCount: number; | |
| 179 | }) => Promise<boolean>; | |
| 180 | pruneAfterTurn: (input: { | |
| 181 | cwd: string; | |
| 182 | threadId: string; | |
| 183 | maxTurnCount: number; | |
| 184 | }) => Promise<void>; | |
| 185 | }; | |
| 186 | sessionCheckpointCwds: Map<string, string>; | |
| 187 | }; | |
| 188 | ||
| 189 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; | |
| 190 | internals.codex.hasSession = () => true; | |
| 191 | internals.codex.readThread = async () => ({ | |
| 192 | threadId: "thr_1", | |
| 193 | turns: [ | |
| 194 | { id: "turn_1", items: [] }, | |
| 195 | { id: "turn_2", items: [] }, | |
| 196 | { id: "turn_3", items: [] }, | |
| 197 | ], | |
| 198 | }); | |
| 199 | internals.codex.rollbackThread = async (sessionId, numTurns) => { | |
| 200 | rollbackCalls.push({ sessionId, numTurns }); | |
| 201 | return { | |
| 202 | threadId: "thr_1", | |
| 203 | turns: [{ id: "turn_1", items: [] }], | |
| 204 | }; | |
| 205 | }; | |
| 206 | const hasCheckpoint = vi.fn(async () => true); | |
| 207 | const restoreCheckpoint = vi.fn(async () => true); | |
| 208 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 209 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 210 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 211 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 212 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 213 | ||
| 214 | const result = await manager.revertToCheckpoint({ | |
| 215 | sessionId: "sess_1", | |
| 216 | turnCount: 1, | |
| 217 | }); | |
| 218 | ||
| 219 | expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]); | |
| 220 | expect(result.threadId).toBe("thr_1"); | |
| 221 | expect(result.turnCount).toBe(1); | |
| 222 | expect(result.messageCount).toBe(0); | |
| 223 | expect(result.rolledBackTurns).toBe(2); | |
| 224 | expect(result.checkpoints).toHaveLength(2); | |
| 225 | expect(hasCheckpoint).toHaveBeenCalledWith({ | |
| 226 | cwd: "/repo", | |
| 227 | threadId: "thr_1", | |
| 228 | turnCount: 1, | |
| 229 | }); | |
| 230 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 231 | cwd: "/repo", | |
| 232 | threadId: "thr_1", | |
| 233 | turnCount: 1, | |
| 234 | }); | |
| 235 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 236 | cwd: "/repo", | |
| 237 | threadId: "thr_1", | |
| 238 | maxTurnCount: 1, | |
| 239 | }); | |
| 240 | ||
| 241 | manager.dispose(); | |
| 242 | }); | |
| 243 | ||
| 244 | it("returns diff text for a checkpoint range", async () => { | |
| 245 | const manager = new ProviderManager(); | |
| 246 | const internals = manager as unknown as { | |
| 247 | codex: { | |
| 248 | hasSession: (sessionId: string) => boolean; | |
| 249 | readThread: (sessionId: string) => Promise<{ | |
| 250 | threadId: string; | |
| 251 | turns: Array<{ id: string; items: unknown[] }>; | |
| 252 | }>; | |
| 253 | }; | |
| 254 | filesystemCheckpointStore: { | |
| 255 | diffCheckpoints: (input: { | |
| 256 | cwd: string; | |
| 257 | threadId: string; | |
| 258 | fromTurnCount: number; | |
| 259 | toTurnCount: number; | |
| 260 | }) => Promise<string>; | |
| 261 | }; | |
| 262 | sessionCheckpointCwds: Map<string, string>; | |
| 263 | }; | |
| 264 | ||
| 265 | internals.codex.hasSession = () => true; | |
| 266 | internals.codex.readThread = async () => ({ | |
| 267 | threadId: "thr_1", | |
| 268 | turns: [ | |
| 269 | { id: "turn_1", items: [] }, | |
| 270 | { id: "turn_2", items: [] }, | |
| 271 | ], | |
| 272 | }); | |
| 273 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 274 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 275 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 276 | ||
| 277 | const result = await manager.getCheckpointDiff({ | |
| 278 | sessionId: "sess_1", | |
| 279 | fromTurnCount: 1, | |
| 280 | toTurnCount: 2, | |
| 281 | }); | |
| 282 | ||
| 283 | expect(result).toEqual({ | |
| 284 | threadId: "thr_1", | |
| 285 | fromTurnCount: 1, | |
| 286 | toTurnCount: 2, | |
| 287 | diff: "diff --git a/a.ts b/a.ts", | |
| 288 | }); | |
| 289 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 290 | cwd: "/repo", | |
| 291 | threadId: "thr_1", | |
| 292 | fromTurnCount: 1, | |
| 293 | toTurnCount: 2, | |
| 294 | }); | |
| 295 | ||
| 296 | manager.dispose(); | |
| 297 | }); | |
| 298 | ||
| 299 | it("emits checkpoint/captured after turn completion checkpoint capture", async () => { | |
| 300 | const manager = new ProviderManager(); | |
| 301 | const events: Array<{ method: string; payload?: unknown; threadId?: string }> = []; | |
| 302 | manager.on("event", (event) => { | |
| 303 | events.push({ | |
| 304 | method: event.method, | |
| 305 | ...(event.payload !== undefined ? { payload: event.payload } : {}), | |
| 306 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 307 | }); | |
| 308 | }); | |
| 309 | ||
| 310 | const internals = manager as unknown as { | |
| 311 | onCodexEvent: (event: { | |
| 312 | id: string; | |
| 313 | kind: "notification"; | |
| 314 | provider: "codex"; | |
| 315 | sessionId: string; | |
| 316 | createdAt: string; | |
| 317 | method: string; | |
| 318 | threadId: string; | |
| 319 | }) => void; | |
| 320 | codex: { | |
| 321 | hasSession: (sessionId: string) => boolean; | |
| 322 | readThread: (sessionId: string) => Promise<{ | |
| 323 | threadId: string; | |
| 324 | turns: Array<{ id: string; items: unknown[] }>; | |
| 325 | }>; | |
| 326 | }; | |
| 327 | filesystemCheckpointStore: { | |
| 328 | captureCheckpoint: (input: { | |
| 329 | cwd: string; | |
| 330 | threadId: string; | |
| 331 | turnCount: number; | |
| 332 | }) => Promise<void>; | |
| 333 | }; | |
| 334 | sessionCheckpointCwds: Map<string, string>; | |
| 335 | }; | |
| 336 | ||
| 337 | internals.codex.hasSession = () => true; | |
| 338 | internals.codex.readThread = async () => ({ | |
| 339 | threadId: "thr_1", | |
| 340 | turns: [{ id: "turn_1", items: [] }], | |
| 341 | }); | |
| 342 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 343 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 344 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 345 | ||
| 346 | internals.onCodexEvent({ | |
| 347 | id: "evt-turn-complete", | |
| 348 | kind: "notification", | |
| 349 | provider: "codex", | |
| 350 | sessionId: "sess_1", | |
| 351 | createdAt: "2026-02-19T00:00:00.000Z", | |
| 352 | method: "turn/completed", | |
| 353 | threadId: "thr_1", | |
| 354 | }); | |
| 355 | ||
| 356 | await vi.waitFor(() => { | |
| 357 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 358 | cwd: "/repo", | |
| 359 | threadId: "thr_1", | |
| 360 | turnCount: 1, | |
| 361 | }); | |
| 362 | expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true); | |
| 363 | }); | |
| 364 | ||
| 365 | const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); | |
| 366 | expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); | |
| 367 | expect(checkpointCapturedEvent?.payload).toEqual({ | |
| 368 | threadId: "thr_1", | |
| 369 | turnCount: 1, | |
| 370 | }); | |
| 371 | ||
| 372 | manager.dispose(); | |
| 373 | }); | |
| 374 | ||
| 375 | it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { | |
| 376 | const manager = new ProviderManager(); | |
| 377 | const internals = manager as unknown as { | |
| 378 | codex: { | |
| 379 | hasSession: (sessionId: string) => boolean; | |
| 380 | listSessions: () => Array<{ | |
| 381 | sessionId: string; | |
| 382 | provider: "codex"; | |
| 383 | status: "ready"; | |
| 384 | createdAt: string; | |
| 385 | updatedAt: string; | |
| 386 | cwd?: string; | |
| 387 | }>; | |
| 388 | readThread: (sessionId: string) => Promise<{ | |
| 389 | threadId: string; | |
| 390 | turns: Array<{ id: string; items: unknown[] }>; | |
| 391 | }>; | |
| 392 | }; | |
| 393 | filesystemCheckpointStore: { | |
| 394 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 395 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 396 | captureCheckpoint: (input: { | |
| 397 | cwd: string; | |
| 398 | threadId: string; | |
| 399 | turnCount: number; | |
| 400 | }) => Promise<void>; | |
| 401 | diffCheckpoints: (input: { | |
| 402 | cwd: string; | |
| 403 | threadId: string; | |
| 404 | fromTurnCount: number; | |
| 405 | toTurnCount: number; | |
| 406 | }) => Promise<string>; | |
| 407 | }; | |
| 408 | sessionCheckpointCwds: Map<string, string>; | |
| 409 | }; | |
| 410 | ||
| 411 | internals.codex.hasSession = () => true; | |
| 412 | internals.codex.listSessions = () => [ | |
| 413 | { | |
| 414 | sessionId: "sess_1", | |
| 415 | provider: "codex", | |
| 416 | status: "ready", | |
| 417 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 418 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 419 | cwd: "/repo", | |
| 420 | }, | |
| 421 | ]; | |
| 422 | internals.codex.readThread = async () => ({ | |
| 423 | threadId: "thr_1", | |
| 424 | turns: [ | |
| 425 | { id: "turn_1", items: [] }, | |
| 426 | { id: "turn_2", items: [] }, | |
| 427 | ], | |
| 428 | }); | |
| 429 | const isGitRepository = vi.fn(async () => true); | |
| 430 | const ensureRootCheckpoint = vi.fn(async () => true); | |
| 431 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 432 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 433 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 434 | internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; | |
| 435 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 436 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 437 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 438 | ||
| 439 | const result = await manager.getCheckpointDiff({ | |
| 440 | sessionId: "sess_1", | |
| 441 | fromTurnCount: 1, | |
| 442 | toTurnCount: 2, | |
| 443 | }); | |
| 444 | ||
| 445 | expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); | |
| 446 | expect(isGitRepository).toHaveBeenCalledWith("/repo"); | |
| 447 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 448 | cwd: "/repo", | |
| 449 | threadId: "thr_1", | |
| 450 | turnCount: 2, | |
| 451 | }); | |
| 452 | expect(ensureRootCheckpoint).toHaveBeenCalledWith({ | |
| 453 | cwd: "/repo", | |
| 454 | threadId: "thr_1", | |
| 455 | }); | |
| 456 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 457 | cwd: "/repo", | |
| 458 | threadId: "thr_1", | |
| 459 | fromTurnCount: 1, | |
| 460 | toTurnCount: 2, | |
| 461 | }); | |
| 462 | expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo"); | |
| 463 | ||
| 464 | manager.dispose(); | |
| 465 | }); | |
| 466 | ||
| 467 | it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => { | |
| 468 | const manager = new ProviderManager(); | |
| 469 | const internals = manager as unknown as { | |
| 470 | codex: { | |
| 471 | hasSession: (sessionId: string) => boolean; | |
| 472 | listSessions: () => Array<{ | |
| 473 | sessionId: string; | |
| 474 | provider: "codex"; | |
| 475 | status: "ready"; | |
| 476 | createdAt: string; | |
| 477 | updatedAt: string; | |
| 478 | cwd?: string; | |
| 479 | }>; | |
| 480 | readThread: (sessionId: string) => Promise<{ | |
| 481 | threadId: string; | |
| 482 | turns: Array<{ id: string; items: unknown[] }>; | |
| 483 | }>; | |
| 484 | }; | |
| 485 | filesystemCheckpointStore: { | |
| 486 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 487 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 488 | captureCheckpoint: (input: { | |
| 489 | cwd: string; | |
| 490 | threadId: string; | |
| 491 | turnCount: number; | |
| 492 | }) => Promise<void>; | |
| 493 | diffCheckpoints: (input: { | |
| 494 | cwd: string; | |
| 495 | threadId: string; | |
| 496 | fromTurnCount: number; | |
| 497 | toTurnCount: number; | |
| 498 | }) => Promise<string>; | |
| 499 | }; | |
| 500 | sessionCheckpointCwds: Map<string, string>; | |
| 501 | }; | |
| 502 | ||
| 503 | const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); | |
| 504 | try { | |
| 505 | internals.codex.hasSession = () => true; | |
| 506 | internals.codex.listSessions = () => [ | |
| 507 | { | |
| 508 | sessionId: "sess_1", | |
| 509 | provider: "codex", | |
| 510 | status: "ready", | |
| 511 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 512 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 513 | }, | |
| 514 | ]; | |
| 515 | internals.codex.readThread = async () => ({ | |
| 516 | threadId: "thr_1", | |
| 517 | turns: [{ id: "turn_1", items: [] }], | |
| 518 | }); | |
| 519 | const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); | |
| 520 | const ensureRootCheckpoint = vi.fn(async () => true); | |
| 521 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 522 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 523 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 524 | internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; | |
| 525 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 526 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 527 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 528 | ||
| 529 | const result = await manager.getCheckpointDiff({ | |
| 530 | sessionId: "sess_1", | |
| 531 | fromTurnCount: 0, | |
| 532 | toTurnCount: 1, | |
| 533 | }); | |
| 534 | ||
| 535 | expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); | |
| 536 | expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo"); | |
| 537 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 538 | cwd: "/fallback-repo", | |
| 539 | threadId: "thr_1", | |
| 540 | turnCount: 1, | |
| 541 | }); | |
| 542 | expect(ensureRootCheckpoint).toHaveBeenCalledWith({ | |
| 543 | cwd: "/fallback-repo", | |
| 544 | threadId: "thr_1", | |
| 545 | }); | |
| 546 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 547 | cwd: "/fallback-repo", | |
| 548 | threadId: "thr_1", | |
| 549 | fromTurnCount: 0, | |
| 550 | toTurnCount: 1, | |
| 551 | }); | |
| 552 | expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo"); | |
| 553 | } finally { | |
| 554 | processCwd.mockRestore(); | |
| 555 | manager.dispose(); | |
| 556 | } | |
| 557 | }); | |
| 558 | ||
| 559 | it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { | |
| 560 | const manager = new ProviderManager(); | |
| 561 | const internals = manager as unknown as { | |
| 562 | codex: { | |
| 563 | hasSession: (sessionId: string) => boolean; | |
| 564 | listSessions: () => Array<{ | |
| 565 | sessionId: string; | |
| 566 | provider: "codex"; | |
| 567 | status: "ready"; | |
| 568 | createdAt: string; | |
| 569 | updatedAt: string; | |
| 570 | cwd?: string; | |
| 571 | }>; | |
| 572 | }; | |
| 573 | filesystemCheckpointStore: { | |
| 574 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 575 | }; | |
| 576 | sessionCheckpointCwds: Map<string, string>; | |
| 577 | }; | |
| 578 | ||
| 579 | const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); | |
| 580 | try { | |
| 581 | internals.codex.hasSession = () => true; | |
| 582 | internals.codex.listSessions = () => [ | |
| 583 | { | |
| 584 | sessionId: "sess_1", | |
| 585 | provider: "codex", | |
| 586 | status: "ready", | |
| 587 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 588 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 589 | cwd: "/session-cwd", | |
| 590 | }, | |
| 591 | ]; | |
| 592 | const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); | |
| 593 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 594 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 595 | ||
| 596 | await expect( | |
| 597 | manager.getCheckpointDiff({ | |
| 598 | sessionId: "sess_1", | |
| 599 | fromTurnCount: 0, | |
| 600 | toTurnCount: 1, | |
| 601 | }), | |
| 602 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 603 | expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); | |
| 604 | expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); | |
| 605 | } finally { | |
| 606 | processCwd.mockRestore(); | |
| 607 | manager.dispose(); | |
| 608 | } | |
| 609 | }); | |
| 610 | ||
| 611 | it("fails revert when filesystem checkpoints are unavailable for the session", async () => { | |
| 612 | const manager = new ProviderManager(); | |
| 613 | const internals = manager as unknown as { | |
| 614 | codex: { | |
| 615 | hasSession: (sessionId: string) => boolean; | |
| 616 | listSessions: () => Array<{ | |
| 617 | sessionId: string; | |
| 618 | provider: "codex"; | |
| 619 | status: "ready"; | |
| 620 | createdAt: string; | |
| 621 | updatedAt: string; | |
| 622 | cwd?: string; | |
| 623 | }>; | |
| 624 | }; | |
| 625 | filesystemCheckpointStore: { | |
| 626 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 627 | }; | |
| 628 | sessionCheckpointCwds: Map<string, string>; | |
| 629 | }; | |
| 630 | ||
| 631 | internals.codex.hasSession = () => true; | |
| 632 | internals.codex.listSessions = () => [ | |
| 633 | { | |
| 634 | sessionId: "sess_1", | |
| 635 | provider: "codex", | |
| 636 | status: "ready", | |
| 637 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 638 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 639 | }, | |
| 640 | ]; | |
| 641 | internals.filesystemCheckpointStore.isGitRepository = async () => false; | |
| 642 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 643 | ||
| 644 | await expect( | |
| 645 | manager.revertToCheckpoint({ | |
| 646 | sessionId: "sess_1", | |
| 647 | turnCount: 1, | |
| 648 | }), | |
| 649 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 650 | ||
| 651 | manager.dispose(); | |
| 652 | }); | |
| 653 | ||
| 654 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { | |
| 655 | const manager = new ProviderManager(); | |
| 656 | const internals = manager as unknown as { | |
| 657 | codex: { | |
| 658 | hasSession: (sessionId: string) => boolean; | |
| 659 | readThread: (sessionId: string) => Promise<{ | |
| 660 | threadId: string; | |
| 661 | turns: Array<{ id: string; items: unknown[] }>; | |
| 662 | }>; | |
| 663 | rollbackThread: ( | |
| 664 | sessionId: string, | |
| 665 | numTurns: number, | |
| 666 | ) => Promise<{ | |
| 667 | threadId: string; | |
| 668 | turns: Array<{ id: string; items: unknown[] }>; | |
| 669 | }>; | |
| 670 | }; | |
| 671 | filesystemCheckpointStore: { | |
| 672 | hasCheckpoint: (input: { | |
| 673 | cwd: string; | |
| 674 | threadId: string; | |
| 675 | turnCount: number; | |
| 676 | }) => Promise<boolean>; | |
| 677 | restoreCheckpoint: (input: { | |
| 678 | cwd: string; | |
| 679 | threadId: string; | |
| 680 | turnCount: number; | |
| 681 | }) => Promise<boolean>; | |
| 682 | pruneAfterTurn: (input: { | |
| 683 | cwd: string; | |
| 684 | threadId: string; | |
| 685 | maxTurnCount: number; | |
| 686 | }) => Promise<void>; | |
| 687 | }; | |
| 688 | sessionCheckpointCwds: Map<string, string>; | |
| 689 | }; | |
| 690 | ||
| 691 | internals.codex.hasSession = () => true; | |
| 692 | const callOrder: string[] = []; | |
| 693 | internals.codex.readThread = async () => ({ | |
| 694 | threadId: "thr_1", | |
| 695 | turns: [ | |
| 696 | { id: "turn_1", items: [] }, | |
| 697 | { id: "turn_2", items: [] }, | |
| 698 | ], | |
| 699 | }); | |
| 700 | internals.codex.rollbackThread = async () => { | |
| 701 | callOrder.push("rollback"); | |
| 702 | return { | |
| 703 | threadId: "thr_1", | |
| 704 | turns: [{ id: "turn_1", items: [] }], | |
| 705 | }; | |
| 706 | }; | |
| 707 | const hasCheckpoint = vi.fn(async () => true); | |
| 708 | const restoreCheckpoint = vi.fn(async () => { | |
| 709 | callOrder.push("restore"); | |
| 710 | return true; | |
| 711 | }); | |
| 712 | const pruneAfterTurn = vi.fn(async () => { | |
| 713 | callOrder.push("prune"); | |
| 714 | return undefined; | |
| 715 | }); | |
| 716 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 717 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 718 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 719 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 720 | ||
| 721 | const result = await manager.revertToCheckpoint({ | |
| 722 | sessionId: "sess_1", | |
| 723 | turnCount: 1, | |
| 724 | }); | |
| 725 | ||
| 726 | expect(result.turnCount).toBe(1); | |
| 727 | expect(hasCheckpoint).toHaveBeenCalledWith({ | |
| 728 | cwd: "/repo", | |
| 729 | threadId: "thr_1", | |
| 730 | turnCount: 1, | |
| 731 | }); | |
| 732 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 733 | cwd: "/repo", | |
| 734 | threadId: "thr_1", | |
| 735 | turnCount: 1, | |
| 736 | }); | |
| 737 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 738 | cwd: "/repo", | |
| 739 | threadId: "thr_1", | |
| 740 | maxTurnCount: 1, | |
| 741 | }); | |
| 742 | expect(callOrder).toEqual(["restore", "rollback", "prune"]); | |
| 743 | ||
| 744 | manager.dispose(); | |
| 745 | }); | |
| 746 | ||
| 747 | it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => { | |
| 748 | const manager = new ProviderManager(); | |
| 749 | const internals = manager as unknown as { | |
| 750 | codex: { | |
| 751 | hasSession: (sessionId: string) => boolean; | |
| 752 | readThread: (sessionId: string) => Promise<{ | |
| 753 | threadId: string; | |
| 754 | turns: Array<{ id: string; items: unknown[] }>; | |
| 755 | }>; | |
| 756 | rollbackThread: ( | |
| 757 | sessionId: string, | |
| 758 | numTurns: number, | |
| 759 | ) => Promise<{ | |
| 760 | threadId: string; | |
| 761 | turns: Array<{ id: string; items: unknown[] }>; | |
| 762 | }>; | |
| 763 | }; | |
| 764 | filesystemCheckpointStore: { | |
| 765 | hasCheckpoint: (input: { | |
| 766 | cwd: string; | |
| 767 | threadId: string; | |
| 768 | turnCount: number; | |
| 769 | }) => Promise<boolean>; | |
| 770 | restoreCheckpoint: (input: { | |
| 771 | cwd: string; | |
| 772 | threadId: string; | |
| 773 | turnCount: number; | |
| 774 | }) => Promise<boolean>; | |
| 775 | pruneAfterTurn: (input: { | |
| 776 | cwd: string; | |
| 777 | threadId: string; | |
| 778 | maxTurnCount: number; | |
| 779 | }) => Promise<void>; | |
| 780 | }; | |
| 781 | sessionCheckpointCwds: Map<string, string>; | |
| 782 | }; | |
| 783 | ||
| 784 | internals.codex.hasSession = () => true; | |
| 785 | internals.codex.readThread = async () => ({ | |
| 786 | threadId: "thr_1", | |
| 787 | turns: [ | |
| 788 | { id: "turn_1", items: [] }, | |
| 789 | { id: "turn_2", items: [] }, | |
| 790 | ], | |
| 791 | }); | |
| 792 | internals.codex.rollbackThread = async () => ({ | |
| 793 | threadId: "thr_1", | |
| 794 | turns: [], | |
| 795 | }); | |
| 796 | const hasCheckpoint = vi.fn(async () => false); | |
| 797 | const restoreCheckpoint = vi.fn(async () => true); | |
| 798 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 799 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 800 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 801 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 802 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 803 | ||
| 804 | const result = await manager.revertToCheckpoint({ | |
| 805 | sessionId: "sess_1", | |
| 806 | turnCount: 0, | |
| 807 | }); | |
| 808 | ||
| 809 | expect(result.turnCount).toBe(0); | |
| 810 | expect(hasCheckpoint).not.toHaveBeenCalled(); | |
| 811 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 812 | cwd: "/repo", | |
| 813 | threadId: "thr_1", | |
| 814 | turnCount: 0, | |
| 815 | }); | |
| 816 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 817 | cwd: "/repo", | |
| 818 | threadId: "thr_1", | |
| 819 | maxTurnCount: 0, | |
| 820 | }); | |
| 821 | ||
| 822 | manager.dispose(); | |
| 823 | }); | |
| 824 | ||
| 825 | it("fails before rollback when filesystem checkpoint is missing", async () => { | |
| 826 | const manager = new ProviderManager(); | |
| 827 | const internals = manager as unknown as { | |
| 828 | codex: { | |
| 829 | hasSession: (sessionId: string) => boolean; | |
| 830 | readThread: (sessionId: string) => Promise<{ | |
| 831 | threadId: string; | |
| 832 | turns: Array<{ id: string; items: unknown[] }>; | |
| 833 | }>; | |
| 834 | rollbackThread: ( | |
| 835 | sessionId: string, | |
| 836 | numTurns: number, | |
| 837 | ) => Promise<{ | |
| 838 | threadId: string; | |
| 839 | turns: Array<{ id: string; items: unknown[] }>; | |
| 840 | }>; | |
| 841 | }; | |
| 842 | filesystemCheckpointStore: { | |
| 843 | hasCheckpoint: (input: { | |
| 844 | cwd: string; | |
| 845 | threadId: string; | |
| 846 | turnCount: number; | |
| 847 | }) => Promise<boolean>; | |
| 848 | }; | |
| 849 | sessionCheckpointCwds: Map<string, string>; | |
| 850 | }; | |
| 851 | ||
| 852 | const rollbackSpy = vi.fn(async () => ({ | |
| 853 | threadId: "thr_1", | |
| 854 | turns: [{ id: "turn_1", items: [] }], | |
| 855 | })); | |
| 856 | ||
| 857 | internals.codex.hasSession = () => true; | |
| 858 | internals.codex.readThread = async () => ({ | |
| 859 | threadId: "thr_1", | |
| 860 | turns: [ | |
| 861 | { id: "turn_1", items: [] }, | |
| 862 | { id: "turn_2", items: [] }, | |
| 863 | ], | |
| 864 | }); | |
| 865 | internals.codex.rollbackThread = rollbackSpy; | |
| 866 | internals.filesystemCheckpointStore.hasCheckpoint = async () => false; | |
| 867 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 868 | ||
| 869 | await expect( | |
| 870 | manager.revertToCheckpoint({ | |
| 871 | sessionId: "sess_1", | |
| 872 | turnCount: 1, | |
| 873 | }), | |
| 874 | ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1."); | |
| 875 | expect(rollbackSpy).not.toHaveBeenCalled(); | |
| 876 | ||
| 877 | manager.dispose(); | |
| 878 | }); | |
| 879 | }); |
apps/server/src/providerManager.ts deleted-642| ... | ... | @@ -1,642 +0,0 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | import { randomUUID } from "node:crypto"; | |
| 5 | ||
| 6 | import { | |
| 7 | type ProviderCheckpoint, | |
| 8 | type ProviderEvent, | |
| 9 | type ProviderGetCheckpointDiffInput, | |
| 10 | type ProviderGetCheckpointDiffResult, | |
| 11 | type ProviderInterruptTurnInput, | |
| 12 | type ProviderListCheckpointsInput, | |
| 13 | type ProviderListCheckpointsResult, | |
| 14 | type ProviderRevertToCheckpointInput, | |
| 15 | type ProviderRevertToCheckpointResult, | |
| 16 | type ProviderRespondToRequestInput, | |
| 17 | type ProviderSendTurnInput, | |
| 18 | type ProviderSession, | |
| 19 | type ProviderSessionStartInput, | |
| 20 | type ProviderStopSessionInput, | |
| 21 | type ProviderTurnStartResult, | |
| 22 | providerInterruptTurnInputSchema, | |
| 23 | providerGetCheckpointDiffInputSchema, | |
| 24 | providerListCheckpointsInputSchema, | |
| 25 | providerRevertToCheckpointInputSchema, | |
| 26 | providerRespondToRequestInputSchema, | |
| 27 | providerSendTurnInputSchema, | |
| 28 | providerSessionStartInputSchema, | |
| 29 | providerStopSessionInputSchema, | |
| 30 | } from "@t3tools/contracts"; | |
| 31 | import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; | |
| 32 | import { CodexAppServerManager } from "./codexAppServerManager"; | |
| 33 | import { FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 34 | ||
| 35 | export interface ProviderManagerEvents { | |
| 36 | event: [event: ProviderEvent]; | |
| 37 | } | |
| 38 | ||
| 39 | function asObject(value: unknown): Record<string, unknown> | undefined { | |
| 40 | if (!value || typeof value !== "object") { | |
| 41 | return undefined; | |
| 42 | } | |
| 43 | return value as Record<string, unknown>; | |
| 44 | } | |
| 45 | ||
| 46 | function asString(value: unknown): string | undefined { | |
| 47 | return typeof value === "string" ? value : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | function asArray(value: unknown): unknown[] { | |
| 51 | return Array.isArray(value) ? value : []; | |
| 52 | } | |
| 53 | ||
| 54 | function trimToPreview(value: string): string { | |
| 55 | const normalized = value.replace(/\s+/g, " ").trim(); | |
| 56 | if (normalized.length <= 120) { | |
| 57 | return normalized; | |
| 58 | } | |
| 59 | return `${normalized.slice(0, 117)}...`; | |
| 60 | } | |
| 61 | ||
| 62 | function summarizeUserMessageContent(content: unknown[]): string | undefined { | |
| 63 | const segments: string[] = []; | |
| 64 | ||
| 65 | for (const part of content) { | |
| 66 | const record = asObject(part); | |
| 67 | const type = asString(record?.type); | |
| 68 | if (!type) continue; | |
| 69 | ||
| 70 | if (type === "text") { | |
| 71 | const text = asString(record?.text); | |
| 72 | if (text && text.trim().length > 0) { | |
| 73 | segments.push(text.trim()); | |
| 74 | } | |
| 75 | continue; | |
| 76 | } | |
| 77 | ||
| 78 | if (type === "image") { | |
| 79 | segments.push("[Image attachment]"); | |
| 80 | continue; | |
| 81 | } | |
| 82 | ||
| 83 | if (type === "localImage") { | |
| 84 | segments.push("[Local image attachment]"); | |
| 85 | continue; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | if (segments.length === 0) { | |
| 90 | return undefined; | |
| 91 | } | |
| 92 | return trimToPreview(segments.join(" ")); | |
| 93 | } | |
| 94 | ||
| 95 | function summarizeTurn(turn: CodexThreadTurnSnapshot): { | |
| 96 | messageCountDelta: number; | |
| 97 | preview?: string; | |
| 98 | } { | |
| 99 | let messageCountDelta = 0; | |
| 100 | let preview: string | undefined; | |
| 101 | ||
| 102 | for (const item of turn.items) { | |
| 103 | const record = asObject(item); | |
| 104 | const type = asString(record?.type); | |
| 105 | if (!type) continue; | |
| 106 | ||
| 107 | if (type === "userMessage") { | |
| 108 | messageCountDelta += 1; | |
| 109 | if (!preview) { | |
| 110 | const content = asArray(record?.content); | |
| 111 | preview = summarizeUserMessageContent(content); | |
| 112 | } | |
| 113 | continue; | |
| 114 | } | |
| 115 | ||
| 116 | if (type === "agentMessage") { | |
| 117 | messageCountDelta += 1; | |
| 118 | if (!preview) { | |
| 119 | const text = asString(record?.text); | |
| 120 | if (text && text.trim().length > 0) { | |
| 121 | preview = trimToPreview(text); | |
| 122 | } | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | return { | |
| 128 | messageCountDelta, | |
| 129 | ...(preview ? { preview } : {}), | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { | |
| 134 | const checkpoints: ProviderCheckpoint[] = []; | |
| 135 | let messageCount = 0; | |
| 136 | const isEmpty = turns.length === 0; | |
| 137 | checkpoints.push({ | |
| 138 | id: "root", | |
| 139 | turnCount: 0, | |
| 140 | messageCount: 0, | |
| 141 | label: "Start of conversation", | |
| 142 | isCurrent: isEmpty, | |
| 143 | }); | |
| 144 | ||
| 145 | for (let index = 0; index < turns.length; index += 1) { | |
| 146 | const turn = turns[index]; | |
| 147 | if (!turn) continue; | |
| 148 | const turnSummary = summarizeTurn(turn); | |
| 149 | messageCount += turnSummary.messageCountDelta; | |
| 150 | checkpoints.push({ | |
| 151 | id: turn.id, | |
| 152 | turnCount: index + 1, | |
| 153 | messageCount, | |
| 154 | label: `Turn ${index + 1}`, | |
| 155 | ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), | |
| 156 | isCurrent: index === turns.length - 1, | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | return checkpoints; | |
| 161 | } | |
| 162 | ||
| 163 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | |
| 164 | private readonly codex = new CodexAppServerManager(); | |
| 165 | private readonly filesystemCheckpointStore = new FilesystemCheckpointStore(); | |
| 166 | private readonly threadLogsDir: string; | |
| 167 | private readonly threadLogStreams = new Map<string, fs.WriteStream>(); | |
| 168 | private readonly sessionThreadIds = new Map<string, string>(); | |
| 169 | private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>(); | |
| 170 | private readonly sessionCheckpointCwds = new Map<string, string>(); | |
| 171 | private readonly filesystemLocks = new Map<string, Promise<void>>(); | |
| 172 | private disposed = false; | |
| 173 | private readonly onCodexEvent = (event: ProviderEvent) => { | |
| 174 | if (this.disposed) { | |
| 175 | return; | |
| 176 | } | |
| 177 | ||
| 178 | this.routeEventToThreadLog(event); | |
| 179 | this.emit("event", event); | |
| 180 | this.maybeCaptureFilesystemCheckpoint(event); | |
| 181 | }; | |
| 182 | ||
| 183 | constructor() { | |
| 184 | super(); | |
| 185 | ||
| 186 | const logsDir = path.resolve(process.cwd(), ".logs"); | |
| 187 | this.threadLogsDir = path.join(logsDir, "threads"); | |
| 188 | fs.mkdirSync(this.threadLogsDir, { recursive: true }); | |
| 189 | ||
| 190 | this.codex.on("event", this.onCodexEvent); | |
| 191 | } | |
| 192 | ||
| 193 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 194 | const input = providerSessionStartInputSchema.parse(raw); | |
| 195 | if (input.provider !== "codex") { | |
| 196 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | |
| 197 | } | |
| 198 | ||
| 199 | const session = await this.codex.startSession(input); | |
| 200 | if (session.threadId) { | |
| 201 | this.sessionThreadIds.set(session.sessionId, session.threadId); | |
| 202 | this.flushPendingSessionEvents(session.sessionId, session.threadId); | |
| 203 | } | |
| 204 | await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => { | |
| 205 | const message = | |
| 206 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; | |
| 207 | this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); | |
| 208 | }); | |
| 209 | return session; | |
| 210 | } | |
| 211 | ||
| 212 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 213 | const input = providerSendTurnInputSchema.parse(raw); | |
| 214 | if (!this.codex.hasSession(input.sessionId)) { | |
| 215 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 216 | } | |
| 217 | ||
| 218 | return this.codex.sendTurn(input); | |
| 219 | } | |
| 220 | ||
| 221 | async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> { | |
| 222 | const input = providerInterruptTurnInputSchema.parse(raw); | |
| 223 | if (!this.codex.hasSession(input.sessionId)) { | |
| 224 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 225 | } | |
| 226 | ||
| 227 | await this.codex.interruptTurn(input.sessionId, input.turnId); | |
| 228 | } | |
| 229 | ||
| 230 | async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> { | |
| 231 | const input = providerRespondToRequestInputSchema.parse(raw); | |
| 232 | if (!this.codex.hasSession(input.sessionId)) { | |
| 233 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 234 | } | |
| 235 | ||
| 236 | await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision); | |
| 237 | } | |
| 238 | ||
| 239 | stopSession(raw: ProviderStopSessionInput): void { | |
| 240 | const input = providerStopSessionInputSchema.parse(raw); | |
| 241 | this.codex.stopSession(input.sessionId); | |
| 242 | this.sessionThreadIds.delete(input.sessionId); | |
| 243 | this.pendingEventsBySession.delete(input.sessionId); | |
| 244 | this.sessionCheckpointCwds.delete(input.sessionId); | |
| 245 | this.filesystemLocks.delete(input.sessionId); | |
| 246 | } | |
| 247 | ||
| 248 | listSessions(): ProviderSession[] { | |
| 249 | return this.codex.listSessions(); | |
| 250 | } | |
| 251 | ||
| 252 | async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> { | |
| 253 | const input = providerListCheckpointsInputSchema.parse(raw); | |
| 254 | if (!this.codex.hasSession(input.sessionId)) { | |
| 255 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 256 | } | |
| 257 | ||
| 258 | const snapshot = await this.codex.readThread(input.sessionId); | |
| 259 | return { | |
| 260 | threadId: snapshot.threadId, | |
| 261 | checkpoints: buildCheckpoints(snapshot.turns), | |
| 262 | }; | |
| 263 | } | |
| 264 | ||
| 265 | async getCheckpointDiff( | |
| 266 | raw: ProviderGetCheckpointDiffInput, | |
| 267 | ): Promise<ProviderGetCheckpointDiffResult> { | |
| 268 | const input = providerGetCheckpointDiffInputSchema.parse(raw); | |
| 269 | if (!this.codex.hasSession(input.sessionId)) { | |
| 270 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 271 | } | |
| 272 | ||
| 273 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | |
| 274 | if (!checkpointCwd) { | |
| 275 | throw new Error("Filesystem checkpoints are unavailable for this session."); | |
| 276 | } | |
| 277 | ||
| 278 | return this.withFilesystemLock(input.sessionId, async () => { | |
| 279 | const snapshot = await this.codex.readThread(input.sessionId); | |
| 280 | if (input.toTurnCount > snapshot.turns.length) { | |
| 281 | throw new Error( | |
| 282 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, | |
| 283 | ); | |
| 284 | } | |
| 285 | ||
| 286 | const diff = await this.filesystemCheckpointStore.diffCheckpoints({ | |
| 287 | cwd: checkpointCwd, | |
| 288 | threadId: snapshot.threadId, | |
| 289 | fromTurnCount: input.fromTurnCount, | |
| 290 | toTurnCount: input.toTurnCount, | |
| 291 | }); | |
| 292 | ||
| 293 | return { | |
| 294 | threadId: snapshot.threadId, | |
| 295 | fromTurnCount: input.fromTurnCount, | |
| 296 | toTurnCount: input.toTurnCount, | |
| 297 | diff, | |
| 298 | }; | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | async revertToCheckpoint( | |
| 303 | raw: ProviderRevertToCheckpointInput, | |
| 304 | ): Promise<ProviderRevertToCheckpointResult> { | |
| 305 | const input = providerRevertToCheckpointInputSchema.parse(raw); | |
| 306 | if (!this.codex.hasSession(input.sessionId)) { | |
| 307 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 308 | } | |
| 309 | ||
| 310 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | |
| 311 | if (!checkpointCwd) { | |
| 312 | throw new Error("Filesystem checkpoints are unavailable for this session."); | |
| 313 | } | |
| 314 | return this.withFilesystemLock(input.sessionId, async () => { | |
| 315 | const beforeSnapshot = await this.codex.readThread(input.sessionId); | |
| 316 | const currentTurnCount = beforeSnapshot.turns.length; | |
| 317 | if (input.turnCount > currentTurnCount) { | |
| 318 | throw new Error( | |
| 319 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, | |
| 320 | ); | |
| 321 | } | |
| 322 | ||
| 323 | if (checkpointCwd && input.turnCount > 0) { | |
| 324 | const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ | |
| 325 | cwd: checkpointCwd, | |
| 326 | threadId: beforeSnapshot.threadId, | |
| 327 | turnCount: input.turnCount, | |
| 328 | }); | |
| 329 | if (!hasCheckpoint) { | |
| 330 | throw new Error( | |
| 331 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 332 | ); | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ | |
| 337 | cwd: checkpointCwd, | |
| 338 | threadId: beforeSnapshot.threadId, | |
| 339 | turnCount: input.turnCount, | |
| 340 | }); | |
| 341 | if (!restored) { | |
| 342 | throw new Error( | |
| 343 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 344 | ); | |
| 345 | } | |
| 346 | ||
| 347 | const requestedRollbackTurns = currentTurnCount - input.turnCount; | |
| 348 | const afterSnapshot = | |
| 349 | requestedRollbackTurns > 0 | |
| 350 | ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) | |
| 351 | : beforeSnapshot; | |
| 352 | ||
| 353 | await this.filesystemCheckpointStore.pruneAfterTurn({ | |
| 354 | cwd: checkpointCwd, | |
| 355 | threadId: afterSnapshot.threadId, | |
| 356 | maxTurnCount: afterSnapshot.turns.length, | |
| 357 | }); | |
| 358 | ||
| 359 | const checkpoints = buildCheckpoints(afterSnapshot.turns); | |
| 360 | const currentCheckpoint = | |
| 361 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | |
| 362 | checkpoints[checkpoints.length - 1] ?? | |
| 363 | checkpoints[0]; | |
| 364 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); | |
| 365 | ||
| 366 | return { | |
| 367 | threadId: afterSnapshot.threadId, | |
| 368 | turnCount: currentCheckpoint?.turnCount ?? 0, | |
| 369 | messageCount: currentCheckpoint?.messageCount ?? 0, | |
| 370 | rolledBackTurns, | |
| 371 | checkpoints, | |
| 372 | }; | |
| 373 | }); | |
| 374 | } | |
| 375 | ||
| 376 | stopAll(): void { | |
| 377 | this.codex.stopAll(); | |
| 378 | } | |
| 379 | ||
| 380 | dispose(): void { | |
| 381 | if (this.disposed) { | |
| 382 | return; | |
| 383 | } | |
| 384 | ||
| 385 | this.disposed = true; | |
| 386 | this.codex.off("event", this.onCodexEvent); | |
| 387 | for (const stream of this.threadLogStreams.values()) { | |
| 388 | stream.end(); | |
| 389 | } | |
| 390 | this.threadLogStreams.clear(); | |
| 391 | this.sessionThreadIds.clear(); | |
| 392 | this.pendingEventsBySession.clear(); | |
| 393 | this.sessionCheckpointCwds.clear(); | |
| 394 | this.filesystemLocks.clear(); | |
| 395 | } | |
| 396 | ||
| 397 | private async initializeFilesystemCheckpointing( | |
| 398 | session: ProviderSession, | |
| 399 | preferredCwd?: string, | |
| 400 | ): Promise<void> { | |
| 401 | const cwd = preferredCwd ?? session.cwd ?? process.cwd(); | |
| 402 | ||
| 403 | await this.withFilesystemLock(session.sessionId, async () => { | |
| 404 | const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); | |
| 405 | if (!supportsGit) { | |
| 406 | this.sessionCheckpointCwds.delete(session.sessionId); | |
| 407 | return; | |
| 408 | } | |
| 409 | ||
| 410 | const snapshot = await this.codex.readThread(session.sessionId); | |
| 411 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ | |
| 412 | cwd, | |
| 413 | threadId: snapshot.threadId, | |
| 414 | }); | |
| 415 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 416 | cwd, | |
| 417 | threadId: snapshot.threadId, | |
| 418 | turnCount: snapshot.turns.length, | |
| 419 | }); | |
| 420 | if (this.codex.hasSession(session.sessionId)) { | |
| 421 | this.sessionCheckpointCwds.set(session.sessionId, cwd); | |
| 422 | } | |
| 423 | }); | |
| 424 | } | |
| 425 | ||
| 426 | private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise<string | null> { | |
| 427 | const existingCwd = this.sessionCheckpointCwds.get(sessionId); | |
| 428 | if (existingCwd) { | |
| 429 | return existingCwd; | |
| 430 | } | |
| 431 | ||
| 432 | const session = this.codex | |
| 433 | .listSessions() | |
| 434 | .find((candidate) => candidate.sessionId === sessionId); | |
| 435 | const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; | |
| 436 | ||
| 437 | await this.withFilesystemLock(sessionId, async () => { | |
| 438 | const currentCwd = this.sessionCheckpointCwds.get(sessionId); | |
| 439 | if (currentCwd) { | |
| 440 | return; | |
| 441 | } | |
| 442 | ||
| 443 | const cwdSupport = await Promise.all( | |
| 444 | candidateCwds.map(async (cwd) => ({ | |
| 445 | cwd, | |
| 446 | supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), | |
| 447 | })), | |
| 448 | ); | |
| 449 | const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; | |
| 450 | if (supportedCwd) { | |
| 451 | const snapshot = await this.codex.readThread(sessionId); | |
| 452 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ | |
| 453 | cwd: supportedCwd, | |
| 454 | threadId: snapshot.threadId, | |
| 455 | }); | |
| 456 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 457 | cwd: supportedCwd, | |
| 458 | threadId: snapshot.threadId, | |
| 459 | turnCount: snapshot.turns.length, | |
| 460 | }); | |
| 461 | if (this.codex.hasSession(sessionId)) { | |
| 462 | this.sessionCheckpointCwds.set(sessionId, supportedCwd); | |
| 463 | } | |
| 464 | return; | |
| 465 | } | |
| 466 | ||
| 467 | this.sessionCheckpointCwds.delete(sessionId); | |
| 468 | }); | |
| 469 | ||
| 470 | return this.sessionCheckpointCwds.get(sessionId) ?? null; | |
| 471 | } | |
| 472 | ||
| 473 | private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { | |
| 474 | if (event.kind !== "notification" || event.method !== "turn/completed") { | |
| 475 | return; | |
| 476 | } | |
| 477 | ||
| 478 | const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); | |
| 479 | if (!checkpointCwd) { | |
| 480 | void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) | |
| 481 | .then(async (initializedCwd) => { | |
| 482 | if (!initializedCwd || !this.codex.hasSession(event.sessionId)) { | |
| 483 | return; | |
| 484 | } | |
| 485 | const snapshot = await this.codex.readThread(event.sessionId); | |
| 486 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); | |
| 487 | }) | |
| 488 | .catch((error) => { | |
| 489 | const message = | |
| 490 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; | |
| 491 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); | |
| 492 | }); | |
| 493 | return; | |
| 494 | } | |
| 495 | ||
| 496 | void this.withFilesystemLock(event.sessionId, async () => { | |
| 497 | if (!this.codex.hasSession(event.sessionId)) { | |
| 498 | return; | |
| 499 | } | |
| 500 | ||
| 501 | const snapshot = await this.codex.readThread(event.sessionId); | |
| 502 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 503 | cwd: checkpointCwd, | |
| 504 | threadId: snapshot.threadId, | |
| 505 | turnCount: snapshot.turns.length, | |
| 506 | }); | |
| 507 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); | |
| 508 | }).catch((error) => { | |
| 509 | const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; | |
| 510 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); | |
| 511 | }); | |
| 512 | } | |
| 513 | ||
| 514 | private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { | |
| 515 | this.emit("event", { | |
| 516 | id: randomUUID(), | |
| 517 | kind: "notification", | |
| 518 | provider: "codex", | |
| 519 | sessionId, | |
| 520 | createdAt: new Date().toISOString(), | |
| 521 | method: "checkpoint/captured", | |
| 522 | threadId, | |
| 523 | payload: { | |
| 524 | threadId, | |
| 525 | turnCount, | |
| 526 | }, | |
| 527 | }); | |
| 528 | } | |
| 529 | ||
| 530 | private emitFilesystemCheckpointError( | |
| 531 | sessionId: string, | |
| 532 | message: string, | |
| 533 | threadId?: string, | |
| 534 | ): void { | |
| 535 | this.emit("event", { | |
| 536 | id: randomUUID(), | |
| 537 | kind: "error", | |
| 538 | provider: "codex", | |
| 539 | sessionId, | |
| 540 | createdAt: new Date().toISOString(), | |
| 541 | method: "checkpoint/filesystemError", | |
| 542 | message, | |
| 543 | ...(threadId ? { threadId } : {}), | |
| 544 | }); | |
| 545 | } | |
| 546 | ||
| 547 | private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> { | |
| 548 | const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); | |
| 549 | const next = previous.catch(() => undefined).then(fn); | |
| 550 | const completion = next.then( | |
| 551 | () => undefined, | |
| 552 | () => undefined, | |
| 553 | ); | |
| 554 | this.filesystemLocks.set(sessionId, completion); | |
| 555 | return next.finally(() => { | |
| 556 | const tracked = this.filesystemLocks.get(sessionId); | |
| 557 | if (tracked === completion) { | |
| 558 | this.filesystemLocks.delete(sessionId); | |
| 559 | } | |
| 560 | }); | |
| 561 | } | |
| 562 | ||
| 563 | private routeEventToThreadLog(event: ProviderEvent): void { | |
| 564 | const threadId = this.resolveThreadId(event); | |
| 565 | if (!threadId) { | |
| 566 | const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; | |
| 567 | pending.push(event); | |
| 568 | this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); | |
| 569 | return; | |
| 570 | } | |
| 571 | ||
| 572 | this.flushPendingSessionEvents(event.sessionId, threadId); | |
| 573 | this.writeThreadEvent(threadId, event); | |
| 574 | } | |
| 575 | ||
| 576 | private flushPendingSessionEvents(sessionId: string, threadId: string): void { | |
| 577 | const pending = this.pendingEventsBySession.get(sessionId); | |
| 578 | if (!pending || pending.length === 0) { | |
| 579 | return; | |
| 580 | } | |
| 581 | ||
| 582 | for (const event of pending) { | |
| 583 | this.writeThreadEvent(threadId, event); | |
| 584 | } | |
| 585 | this.pendingEventsBySession.delete(sessionId); | |
| 586 | } | |
| 587 | ||
| 588 | private resolveThreadId(event: ProviderEvent): string | undefined { | |
| 589 | const fromPayload = this.readThreadIdFromPayload(event.payload); | |
| 590 | const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId); | |
| 591 | ||
| 592 | if (threadId) { | |
| 593 | this.sessionThreadIds.set(event.sessionId, threadId); | |
| 594 | } | |
| 595 | ||
| 596 | return threadId; | |
| 597 | } | |
| 598 | ||
| 599 | private readThreadIdFromPayload(payload: unknown): string | undefined { | |
| 600 | if (!payload || typeof payload !== "object") { | |
| 601 | return undefined; | |
| 602 | } | |
| 603 | ||
| 604 | const record = payload as Record<string, unknown>; | |
| 605 | const direct = record.threadId; | |
| 606 | if (typeof direct === "string" && direct.length > 0) { | |
| 607 | return direct; | |
| 608 | } | |
| 609 | ||
| 610 | const thread = record.thread; | |
| 611 | if (!thread || typeof thread !== "object") { | |
| 612 | return undefined; | |
| 613 | } | |
| 614 | ||
| 615 | const nested = (thread as Record<string, unknown>).id; | |
| 616 | if (typeof nested === "string" && nested.length > 0) { | |
| 617 | return nested; | |
| 618 | } | |
| 619 | ||
| 620 | return undefined; | |
| 621 | } | |
| 622 | ||
| 623 | private writeThreadEvent(threadId: string, event: ProviderEvent): void { | |
| 624 | const stream = this.getOrCreateThreadLogStream(threadId); | |
| 625 | stream.write(`${JSON.stringify(event)}\n`); | |
| 626 | } | |
| 627 | ||
| 628 | private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { | |
| 629 | const cached = this.threadLogStreams.get(threadId); | |
| 630 | if (cached) { | |
| 631 | return cached; | |
| 632 | } | |
| 633 | ||
| 634 | const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 635 | const stream = fs.createWriteStream( | |
| 636 | path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), | |
| 637 | { flags: "a" }, | |
| 638 | ); | |
| 639 | this.threadLogStreams.set(threadId, stream); | |
| 640 | return stream; | |
| 641 | } | |
| 642 | } |
apps/server/src/ptyAdapter.test.ts deleted-54| ... | ... | @@ -1,54 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; | |
| 8 | ||
| 9 | function fileMode(filePath: string): number { | |
| 10 | return fs.statSync(filePath).mode & 0o777; | |
| 11 | } | |
| 12 | ||
| 13 | describe("ensureNodePtySpawnHelperExecutable", () => { | |
| 14 | const tempDirs: string[] = []; | |
| 15 | ||
| 16 | afterEach(() => { | |
| 17 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 18 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 19 | } | |
| 20 | }); | |
| 21 | ||
| 22 | it("adds executable bits when helper exists but is not executable", () => { | |
| 23 | if (process.platform === "win32") return; | |
| 24 | ||
| 25 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 26 | tempDirs.push(dir); | |
| 27 | const helperPath = path.join(dir, "spawn-helper"); | |
| 28 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 29 | fs.chmodSync(helperPath, 0o644); | |
| 30 | ||
| 31 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 32 | ||
| 33 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | |
| 34 | }); | |
| 35 | ||
| 36 | it("keeps executable helper as executable", () => { | |
| 37 | if (process.platform === "win32") return; | |
| 38 | ||
| 39 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); | |
| 40 | tempDirs.push(dir); | |
| 41 | const helperPath = path.join(dir, "spawn-helper"); | |
| 42 | fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); | |
| 43 | fs.chmodSync(helperPath, 0o755); | |
| 44 | ||
| 45 | ensureNodePtySpawnHelperExecutable(helperPath); | |
| 46 | ||
| 47 | expect(fileMode(helperPath) & 0o111).toBe(0o111); | |
| 48 | }); | |
| 49 | ||
| 50 | it("does not throw when helper path is missing", () => { | |
| 51 | const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); | |
| 52 | expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow(); | |
| 53 | }); | |
| 54 | }); |
apps/server/src/ptyAdapter.ts deleted-131| ... | ... | @@ -1,131 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import * as nodePty from "node-pty"; | |
| 6 | ||
| 7 | export interface PtyExitEvent { | |
| 8 | exitCode: number; | |
| 9 | signal: number | null; | |
| 10 | } | |
| 11 | ||
| 12 | export interface PtyProcess { | |
| 13 | readonly pid: number; | |
| 14 | write(data: string): void; | |
| 15 | resize(cols: number, rows: number): void; | |
| 16 | kill(signal?: string): void; | |
| 17 | onData(callback: (data: string) => void): () => void; | |
| 18 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 19 | } | |
| 20 | ||
| 21 | export interface PtySpawnInput { | |
| 22 | shell: string; | |
| 23 | args?: string[]; | |
| 24 | cwd: string; | |
| 25 | cols: number; | |
| 26 | rows: number; | |
| 27 | env: NodeJS.ProcessEnv; | |
| 28 | } | |
| 29 | ||
| 30 | export interface PtyAdapter { | |
| 31 | spawn(input: PtySpawnInput): PtyProcess; | |
| 32 | } | |
| 33 | ||
| 34 | const requireForNodePty = createRequire(import.meta.url); | |
| 35 | let didEnsureSpawnHelperExecutable = false; | |
| 36 | ||
| 37 | function resolveNodePtySpawnHelperPath(): string | null { | |
| 38 | try { | |
| 39 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | |
| 40 | const packageDir = path.dirname(packageJsonPath); | |
| 41 | const candidates = [ | |
| 42 | path.join(packageDir, "build", "Release", "spawn-helper"), | |
| 43 | path.join(packageDir, "build", "Debug", "spawn-helper"), | |
| 44 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | |
| 45 | ]; | |
| 46 | ||
| 47 | for (const candidate of candidates) { | |
| 48 | if (fs.existsSync(candidate)) { | |
| 49 | return candidate; | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | return null; | |
| 54 | } catch { | |
| 55 | return null; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void { | |
| 60 | if (process.platform === "win32") return; | |
| 61 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | |
| 62 | ||
| 63 | const helperPath = explicitPath ?? resolveNodePtySpawnHelperPath(); | |
| 64 | if (!helperPath) return; | |
| 65 | if (!explicitPath) { | |
| 66 | didEnsureSpawnHelperExecutable = true; | |
| 67 | } | |
| 68 | ||
| 69 | try { | |
| 70 | const stat = fs.statSync(helperPath); | |
| 71 | const mode = stat.mode & 0o777; | |
| 72 | if ((mode & 0o111) === 0) { | |
| 73 | fs.chmodSync(helperPath, mode | 0o111); | |
| 74 | } | |
| 75 | } catch { | |
| 76 | // Best effort only. If chmod fails, node-pty spawn will surface the real error. | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | class NodePtyProcess implements PtyProcess { | |
| 81 | constructor(private readonly process: nodePty.IPty) {} | |
| 82 | ||
| 83 | get pid(): number { | |
| 84 | return this.process.pid; | |
| 85 | } | |
| 86 | ||
| 87 | write(data: string): void { | |
| 88 | this.process.write(data); | |
| 89 | } | |
| 90 | ||
| 91 | resize(cols: number, rows: number): void { | |
| 92 | this.process.resize(cols, rows); | |
| 93 | } | |
| 94 | ||
| 95 | kill(signal?: string): void { | |
| 96 | this.process.kill(signal); | |
| 97 | } | |
| 98 | ||
| 99 | onData(callback: (data: string) => void): () => void { | |
| 100 | const disposable = this.process.onData(callback); | |
| 101 | return () => { | |
| 102 | disposable.dispose(); | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 107 | const disposable = this.process.onExit((event) => { | |
| 108 | callback({ | |
| 109 | exitCode: event.exitCode, | |
| 110 | signal: event.signal ?? null, | |
| 111 | }); | |
| 112 | }); | |
| 113 | return () => { | |
| 114 | disposable.dispose(); | |
| 115 | }; | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | export class NodePtyAdapter implements PtyAdapter { | |
| 120 | spawn(input: PtySpawnInput): PtyProcess { | |
| 121 | ensureNodePtySpawnHelperExecutable(); | |
| 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 123 | cwd: input.cwd, | |
| 124 | cols: input.cols, | |
| 125 | rows: input.rows, | |
| 126 | env: input.env, | |
| 127 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 128 | }); | |
| 129 | return new NodePtyProcess(ptyProcess); | |
| 130 | } | |
| 131 | } |
apps/server/src/serverLayers.ts created+110| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | import path from "node:path"; | |
| 2 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { Effect, Layer } from "effect"; | |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 6 | ||
| 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | |
| 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | |
| 9 | import { ServerConfig } from "./config"; | |
| 10 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 11 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 12 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 13 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 14 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 15 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 16 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | |
| 20 | import { ProviderUnsupportedError } from "./provider/Errors"; | |
| 21 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | |
| 22 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | |
| 23 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | |
| 24 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | |
| 25 | import { ProviderService } from "./provider/Services/ProviderService"; | |
| 26 | ||
| 27 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | |
| 28 | import { KeybindingsLive } from "./keybindings"; | |
| 29 | import { GitManagerLive } from "./git/Layers/GitManager"; | |
| 30 | import { GitCoreLive } from "./git/Layers/GitCore"; | |
| 31 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; | |
| 32 | import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; | |
| 33 | import { GitServiceLive } from "./git/Layers/GitService"; | |
| 34 | import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; | |
| 35 | import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; | |
| 36 | ||
| 37 | export function makeServerProviderLayer(): Layer.Layer< | |
| 38 | ProviderService, | |
| 39 | ProviderUnsupportedError, | |
| 40 | SqlClient.SqlClient | ServerConfig | |
| 41 | > { | |
| 42 | return Effect.gen(function* () { | |
| 43 | const { stateDir } = yield* ServerConfig; | |
| 44 | const providerLogsDir = path.join(stateDir, "logs", "providers"); | |
| 45 | const codexAdapterLayer = makeCodexAdapterLive({ | |
| 46 | nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), | |
| 47 | }); | |
| 48 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); | |
| 49 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 50 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 51 | ); | |
| 52 | return makeProviderServiceLive({ | |
| 53 | canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), | |
| 54 | }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); | |
| 55 | }).pipe(Layer.unwrap); | |
| 56 | } | |
| 57 | ||
| 58 | export function makeServerRuntimeServicesLayer() { | |
| 59 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 60 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 61 | Layer.provide(OrchestrationEventStoreLive), | |
| 62 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 63 | ); | |
| 64 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | |
| 65 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | |
| 66 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | |
| 67 | Layer.provideMerge(checkpointStoreLayer), | |
| 68 | ); | |
| 69 | ||
| 70 | const runtimeServicesLayer = Layer.mergeAll( | |
| 71 | orchestrationLayer, | |
| 72 | OrchestrationProjectionSnapshotQueryLive, | |
| 73 | checkpointStoreLayer, | |
| 74 | checkpointDiffQueryLayer, | |
| 75 | ); | |
| 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | |
| 77 | Layer.provideMerge(runtimeServicesLayer), | |
| 78 | ); | |
| 79 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | |
| 80 | Layer.provideMerge(runtimeServicesLayer), | |
| 81 | ); | |
| 82 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | |
| 83 | Layer.provideMerge(runtimeServicesLayer), | |
| 84 | ); | |
| 85 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | |
| 86 | Layer.provideMerge(runtimeIngestionLayer), | |
| 87 | Layer.provideMerge(providerCommandReactorLayer), | |
| 88 | Layer.provideMerge(checkpointReactorLayer), | |
| 89 | ); | |
| 90 | ||
| 91 | const terminalLayer = TerminalManagerLive.pipe( | |
| 92 | Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), | |
| 93 | ); | |
| 94 | ||
| 95 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); | |
| 96 | ||
| 97 | const gitManagerLayer = GitManagerLive.pipe( | |
| 98 | Layer.provideMerge(gitCoreLayer), | |
| 99 | Layer.provideMerge(GitHubCliLive), | |
| 100 | Layer.provideMerge(CodexTextGenerationLive), | |
| 101 | ); | |
| 102 | ||
| 103 | return Layer.mergeAll( | |
| 104 | orchestrationReactorLayer, | |
| 105 | gitCoreLayer, | |
| 106 | gitManagerLayer, | |
| 107 | terminalLayer, | |
| 108 | KeybindingsLive, | |
| 109 | ).pipe(Layer.provideMerge(NodeServices.layer)); | |
| 110 | } |
apps/server/src/terminal/Layers/BunPTY.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; | |
| 3 | ||
| 4 | class BunPtyProcess implements PtyProcess { | |
| 5 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 6 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 7 | private readonly decoder = new TextDecoder(); | |
| 8 | private didExit = false; | |
| 9 | ||
| 10 | constructor(private readonly process: Bun.Subprocess) { | |
| 11 | void this.process.exited | |
| 12 | .then((exitCode) => { | |
| 13 | this.emitExit({ | |
| 14 | exitCode: Number.isInteger(exitCode) ? exitCode : 0, | |
| 15 | signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, | |
| 16 | }); | |
| 17 | }) | |
| 18 | .catch(() => { | |
| 19 | this.emitExit({ exitCode: 1, signal: null }); | |
| 20 | }); | |
| 21 | } | |
| 22 | ||
| 23 | get pid(): number { | |
| 24 | return this.process.pid; | |
| 25 | } | |
| 26 | ||
| 27 | write(data: string): void { | |
| 28 | if (!this.process.terminal) { | |
| 29 | throw new Error("Bun PTY terminal handle is unavailable"); | |
| 30 | } | |
| 31 | this.process.terminal.write(data); | |
| 32 | } | |
| 33 | ||
| 34 | resize(cols: number, rows: number): void { | |
| 35 | if (!this.process.terminal?.resize) { | |
| 36 | throw new Error("Bun PTY resize is unavailable"); | |
| 37 | } | |
| 38 | this.process.terminal.resize(cols, rows); | |
| 39 | } | |
| 40 | ||
| 41 | kill(signal?: string): void { | |
| 42 | if (!signal) { | |
| 43 | this.process.kill(); | |
| 44 | return; | |
| 45 | } | |
| 46 | this.process.kill(signal as NodeJS.Signals); | |
| 47 | } | |
| 48 | ||
| 49 | onData(callback: (data: string) => void): () => void { | |
| 50 | this.dataListeners.add(callback); | |
| 51 | return () => { | |
| 52 | this.dataListeners.delete(callback); | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 57 | this.exitListeners.add(callback); | |
| 58 | return () => { | |
| 59 | this.exitListeners.delete(callback); | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | emitData(data: Uint8Array): void { | |
| 64 | if (this.didExit) return; | |
| 65 | const text = this.decoder.decode(data, { stream: true }); | |
| 66 | if (text.length === 0) return; | |
| 67 | for (const listener of this.dataListeners) { | |
| 68 | listener(text); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | private emitExit(event: PtyExitEvent): void { | |
| 73 | if (this.didExit) return; | |
| 74 | this.didExit = true; | |
| 75 | ||
| 76 | const remainder = this.decoder.decode(); | |
| 77 | if (remainder.length > 0) { | |
| 78 | for (const listener of this.dataListeners) { | |
| 79 | listener(remainder); | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | for (const listener of this.exitListeners) { | |
| 84 | listener(event); | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | export const BunPtyAdapterLive = Layer.effect( | |
| 90 | PtyAdapter, | |
| 91 | Effect.gen(function* () { | |
| 92 | if (process.platform === "win32") { | |
| 93 | return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); | |
| 94 | } | |
| 95 | return { | |
| 96 | spawn: (input) => | |
| 97 | Effect.sync(() => { | |
| 98 | let processHandle: BunPtyProcess | null = null; | |
| 99 | const command = [input.shell, ...(input.args ?? [])]; | |
| 100 | const subprocess = Bun.spawn(command, { | |
| 101 | cwd: input.cwd, | |
| 102 | env: input.env, | |
| 103 | terminal: { | |
| 104 | cols: input.cols, | |
| 105 | rows: input.rows, | |
| 106 | data: (_terminal, data) => { | |
| 107 | processHandle?.emitData(data); | |
| 108 | }, | |
| 109 | }, | |
| 110 | }); | |
| 111 | processHandle = new BunPtyProcess(subprocess); | |
| 112 | return processHandle; | |
| 113 | }), | |
| 114 | } satisfies PtyAdapterShape; | |
| 115 | }), | |
| 116 | ); |
apps/server/src/terminal/Layers/Manager.test.ts created+672| ... | ... | @@ -0,0 +1,672 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalEvent, | |
| 8 | type TerminalOpenInput, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import { | |
| 13 | PtySpawnError, | |
| 14 | type PtyAdapterShape, | |
| 15 | type PtyExitEvent, | |
| 16 | type PtyProcess, | |
| 17 | type PtySpawnInput, | |
| 18 | } from "../Services/PTY"; | |
| 19 | import { TerminalManagerRuntime } from "./Manager"; | |
| 20 | import { Effect, Encoding } from "effect"; | |
| 21 | ||
| 22 | class FakePtyProcess implements PtyProcess { | |
| 23 | readonly writes: string[] = []; | |
| 24 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | |
| 25 | readonly killSignals: Array<string | undefined> = []; | |
| 26 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 27 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 28 | killed = false; | |
| 29 | ||
| 30 | constructor(readonly pid: number) {} | |
| 31 | ||
| 32 | write(data: string): void { | |
| 33 | this.writes.push(data); | |
| 34 | } | |
| 35 | ||
| 36 | resize(cols: number, rows: number): void { | |
| 37 | this.resizeCalls.push({ cols, rows }); | |
| 38 | } | |
| 39 | ||
| 40 | kill(signal?: string): void { | |
| 41 | this.killed = true; | |
| 42 | this.killSignals.push(signal); | |
| 43 | } | |
| 44 | ||
| 45 | onData(callback: (data: string) => void): () => void { | |
| 46 | this.dataListeners.add(callback); | |
| 47 | return () => { | |
| 48 | this.dataListeners.delete(callback); | |
| 49 | }; | |
| 50 | } | |
| 51 | ||
| 52 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 53 | this.exitListeners.add(callback); | |
| 54 | return () => { | |
| 55 | this.exitListeners.delete(callback); | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | emitData(data: string): void { | |
| 60 | for (const listener of this.dataListeners) { | |
| 61 | listener(data); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | emitExit(event: PtyExitEvent): void { | |
| 66 | for (const listener of this.exitListeners) { | |
| 67 | listener(event); | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | class FakePtyAdapter implements PtyAdapterShape { | |
| 73 | readonly spawnInputs: PtySpawnInput[] = []; | |
| 74 | readonly processes: FakePtyProcess[] = []; | |
| 75 | readonly spawnFailures: Error[] = []; | |
| 76 | private nextPid = 9000; | |
| 77 | ||
| 78 | constructor(private readonly mode: "sync" | "async" = "sync") {} | |
| 79 | ||
| 80 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError> { | |
| 81 | this.spawnInputs.push(input); | |
| 82 | const failure = this.spawnFailures.shift(); | |
| 83 | if (failure) { | |
| 84 | return Effect.fail( | |
| 85 | new PtySpawnError({ | |
| 86 | adapter: "fake", | |
| 87 | message: "Failed to spawn PTY process", | |
| 88 | cause: failure, | |
| 89 | }), | |
| 90 | ); | |
| 91 | } | |
| 92 | const process = new FakePtyProcess(this.nextPid++); | |
| 93 | this.processes.push(process); | |
| 94 | if (this.mode === "async") { | |
| 95 | return Effect.tryPromise({ | |
| 96 | try: async () => process, | |
| 97 | catch: (cause) => | |
| 98 | new PtySpawnError({ | |
| 99 | adapter: "fake", | |
| 100 | message: "Failed to spawn PTY process", | |
| 101 | cause, | |
| 102 | }), | |
| 103 | }); | |
| 104 | } | |
| 105 | return Effect.succeed(process); | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { | |
| 110 | const started = Date.now(); | |
| 111 | return new Promise((resolve, reject) => { | |
| 112 | const poll = () => { | |
| 113 | if (predicate()) { | |
| 114 | resolve(); | |
| 115 | return; | |
| 116 | } | |
| 117 | if (Date.now() - started > timeoutMs) { | |
| 118 | reject(new Error("Timed out waiting for condition")); | |
| 119 | return; | |
| 120 | } | |
| 121 | setTimeout(poll, 15); | |
| 122 | }; | |
| 123 | poll(); | |
| 124 | }); | |
| 125 | } | |
| 126 | ||
| 127 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | |
| 128 | return { | |
| 129 | threadId: "thread-1", | |
| 130 | cwd: process.cwd(), | |
| 131 | cols: 100, | |
| 132 | rows: 24, | |
| 133 | ...overrides, | |
| 134 | }; | |
| 135 | } | |
| 136 | ||
| 137 | function historyLogName(threadId: string): string { | |
| 138 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; | |
| 139 | } | |
| 140 | ||
| 141 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | |
| 142 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 143 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 144 | return `${threadPart}.log`; | |
| 145 | } | |
| 146 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; | |
| 147 | } | |
| 148 | ||
| 149 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | |
| 150 | return path.join(logsDir, historyLogName(threadId)); | |
| 151 | } | |
| 152 | ||
| 153 | function multiTerminalHistoryLogPath( | |
| 154 | logsDir: string, | |
| 155 | threadId = "thread-1", | |
| 156 | terminalId = "default", | |
| 157 | ): string { | |
| 158 | return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | |
| 159 | } | |
| 160 | ||
| 161 | describe("TerminalManager", () => { | |
| 162 | const tempDirs: string[] = []; | |
| 163 | ||
| 164 | afterEach(() => { | |
| 165 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 166 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 167 | } | |
| 168 | }); | |
| 169 | ||
| 170 | function makeManager( | |
| 171 | historyLineLimit = 5, | |
| 172 | options: { | |
| 173 | shellResolver?: () => string; | |
| 174 | subprocessChecker?: (terminalPid: number) => Promise<boolean>; | |
| 175 | subprocessPollIntervalMs?: number; | |
| 176 | processKillGraceMs?: number; | |
| 177 | maxRetainedInactiveSessions?: number; | |
| 178 | ptyAdapter?: FakePtyAdapter; | |
| 179 | } = {}, | |
| 180 | ) { | |
| 181 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); | |
| 182 | tempDirs.push(logsDir); | |
| 183 | const ptyAdapter = options.ptyAdapter ?? new FakePtyAdapter(); | |
| 184 | const manager = new TerminalManagerRuntime({ | |
| 185 | logsDir, | |
| 186 | ptyAdapter, | |
| 187 | historyLineLimit, | |
| 188 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 189 | ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), | |
| 190 | ...(options.subprocessPollIntervalMs | |
| 191 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | |
| 192 | : {}), | |
| 193 | ...(options.processKillGraceMs ? { processKillGraceMs: options.processKillGraceMs } : {}), | |
| 194 | ...(options.maxRetainedInactiveSessions | |
| 195 | ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } | |
| 196 | : {}), | |
| 197 | }); | |
| 198 | return { logsDir, ptyAdapter, manager }; | |
| 199 | } | |
| 200 | ||
| 201 | it("spawns lazily and reuses running terminal per thread", async () => { | |
| 202 | const { manager, ptyAdapter } = makeManager(); | |
| 203 | const [first, second] = await Promise.all([ | |
| 204 | manager.open(openInput()), | |
| 205 | manager.open(openInput()), | |
| 206 | ]); | |
| 207 | const third = await manager.open(openInput()); | |
| 208 | ||
| 209 | expect(first.threadId).toBe("thread-1"); | |
| 210 | expect(first.terminalId).toBe("default"); | |
| 211 | expect(second.threadId).toBe("thread-1"); | |
| 212 | expect(third.threadId).toBe("thread-1"); | |
| 213 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | |
| 214 | ||
| 215 | manager.dispose(); | |
| 216 | }); | |
| 217 | ||
| 218 | it("supports asynchronous PTY spawn effects", async () => { | |
| 219 | const { manager, ptyAdapter } = makeManager(5, { ptyAdapter: new FakePtyAdapter("async") }); | |
| 220 | ||
| 221 | const snapshot = await manager.open(openInput()); | |
| 222 | ||
| 223 | expect(snapshot.status).toBe("running"); | |
| 224 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | |
| 225 | expect(ptyAdapter.processes).toHaveLength(1); | |
| 226 | ||
| 227 | manager.dispose(); | |
| 228 | }); | |
| 229 | ||
| 230 | it("forwards write and resize to active pty process", async () => { | |
| 231 | const { manager, ptyAdapter } = makeManager(); | |
| 232 | await manager.open(openInput()); | |
| 233 | const process = ptyAdapter.processes[0]; | |
| 234 | expect(process).toBeDefined(); | |
| 235 | if (!process) return; | |
| 236 | ||
| 237 | await manager.write({ threadId: "thread-1", data: "ls\n" }); | |
| 238 | await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); | |
| 239 | ||
| 240 | expect(process.writes).toEqual(["ls\n"]); | |
| 241 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | |
| 242 | ||
| 243 | manager.dispose(); | |
| 244 | }); | |
| 245 | ||
| 246 | it("resizes running terminal on open when a different size is requested", async () => { | |
| 247 | const { manager, ptyAdapter } = makeManager(); | |
| 248 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 249 | const process = ptyAdapter.processes[0]; | |
| 250 | expect(process).toBeDefined(); | |
| 251 | if (!process) return; | |
| 252 | ||
| 253 | await manager.open(openInput({ cols: 140, rows: 40 })); | |
| 254 | ||
| 255 | expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); | |
| 256 | ||
| 257 | manager.dispose(); | |
| 258 | }); | |
| 259 | ||
| 260 | it("preserves existing terminal size on open when size is omitted", async () => { | |
| 261 | const { manager, ptyAdapter } = makeManager(); | |
| 262 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 263 | const ptyProcess = ptyAdapter.processes[0]; | |
| 264 | expect(ptyProcess).toBeDefined(); | |
| 265 | if (!ptyProcess) return; | |
| 266 | ||
| 267 | await manager.open({ | |
| 268 | threadId: "thread-1", | |
| 269 | cwd: globalThis.process.cwd(), | |
| 270 | }); | |
| 271 | ||
| 272 | expect(ptyProcess.resizeCalls).toEqual([]); | |
| 273 | ||
| 274 | ptyProcess.emitExit({ exitCode: 0, signal: 0 }); | |
| 275 | await manager.open({ | |
| 276 | threadId: "thread-1", | |
| 277 | cwd: globalThis.process.cwd(), | |
| 278 | }); | |
| 279 | ||
| 280 | const resumedSpawn = ptyAdapter.spawnInputs[1]; | |
| 281 | expect(resumedSpawn).toBeDefined(); | |
| 282 | if (!resumedSpawn) return; | |
| 283 | expect(resumedSpawn.cols).toBe(100); | |
| 284 | expect(resumedSpawn.rows).toBe(24); | |
| 285 | ||
| 286 | manager.dispose(); | |
| 287 | }); | |
| 288 | ||
| 289 | it("uses default dimensions when opening a new terminal without size hints", async () => { | |
| 290 | const { manager, ptyAdapter } = makeManager(); | |
| 291 | await manager.open({ | |
| 292 | threadId: "thread-1", | |
| 293 | cwd: process.cwd(), | |
| 294 | }); | |
| 295 | ||
| 296 | const spawned = ptyAdapter.spawnInputs[0]; | |
| 297 | expect(spawned).toBeDefined(); | |
| 298 | if (!spawned) return; | |
| 299 | expect(spawned.cols).toBe(120); | |
| 300 | expect(spawned.rows).toBe(30); | |
| 301 | ||
| 302 | manager.dispose(); | |
| 303 | }); | |
| 304 | ||
| 305 | it("supports multiple terminals per thread with isolated sessions", async () => { | |
| 306 | const { manager, ptyAdapter } = makeManager(); | |
| 307 | await manager.open(openInput({ terminalId: "default" })); | |
| 308 | await manager.open(openInput({ terminalId: "term-2" })); | |
| 309 | ||
| 310 | const first = ptyAdapter.processes[0]; | |
| 311 | const second = ptyAdapter.processes[1]; | |
| 312 | expect(first).toBeDefined(); | |
| 313 | expect(second).toBeDefined(); | |
| 314 | if (!first || !second) return; | |
| 315 | ||
| 316 | await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | |
| 317 | await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | |
| 318 | ||
| 319 | expect(first.writes).toEqual(["pwd\n"]); | |
| 320 | expect(second.writes).toEqual(["ls\n"]); | |
| 321 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 322 | ||
| 323 | manager.dispose(); | |
| 324 | }); | |
| 325 | ||
| 326 | it("clears transcript and emits cleared event", async () => { | |
| 327 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 328 | const events: TerminalEvent[] = []; | |
| 329 | manager.on("event", (event) => { | |
| 330 | events.push(event); | |
| 331 | }); | |
| 332 | await manager.open(openInput()); | |
| 333 | const process = ptyAdapter.processes[0]; | |
| 334 | expect(process).toBeDefined(); | |
| 335 | if (!process) return; | |
| 336 | ||
| 337 | process.emitData("hello\n"); | |
| 338 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 339 | await manager.clear({ threadId: "thread-1" }); | |
| 340 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 341 | ||
| 342 | expect(events.some((event) => event.type === "cleared")).toBe(true); | |
| 343 | expect( | |
| 344 | events.some( | |
| 345 | (event) => | |
| 346 | event.type === "cleared" && | |
| 347 | event.threadId === "thread-1" && | |
| 348 | event.terminalId === "default", | |
| 349 | ), | |
| 350 | ).toBe(true); | |
| 351 | ||
| 352 | manager.dispose(); | |
| 353 | }); | |
| 354 | ||
| 355 | it("restarts terminal with empty transcript and respawns pty", async () => { | |
| 356 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 357 | await manager.open(openInput()); | |
| 358 | const firstProcess = ptyAdapter.processes[0]; | |
| 359 | expect(firstProcess).toBeDefined(); | |
| 360 | if (!firstProcess) return; | |
| 361 | firstProcess.emitData("before restart\n"); | |
| 362 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 363 | ||
| 364 | const snapshot = await manager.restart(openInput()); | |
| 365 | expect(snapshot.history).toBe(""); | |
| 366 | expect(snapshot.status).toBe("running"); | |
| 367 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 368 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 369 | ||
| 370 | manager.dispose(); | |
| 371 | }); | |
| 372 | ||
| 373 | it("emits exited event and reopens with clean transcript after exit", async () => { | |
| 374 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 375 | const events: TerminalEvent[] = []; | |
| 376 | manager.on("event", (event) => { | |
| 377 | events.push(event); | |
| 378 | }); | |
| 379 | await manager.open(openInput()); | |
| 380 | const process = ptyAdapter.processes[0]; | |
| 381 | expect(process).toBeDefined(); | |
| 382 | if (!process) return; | |
| 383 | process.emitData("old data\n"); | |
| 384 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 385 | process.emitExit({ exitCode: 0, signal: 0 }); | |
| 386 | ||
| 387 | await waitFor(() => events.some((event) => event.type === "exited")); | |
| 388 | const reopened = await manager.open(openInput()); | |
| 389 | ||
| 390 | expect(reopened.history).toBe(""); | |
| 391 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 392 | expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); | |
| 393 | ||
| 394 | manager.dispose(); | |
| 395 | }); | |
| 396 | ||
| 397 | it("ignores trailing writes after terminal exit", async () => { | |
| 398 | const { manager, ptyAdapter } = makeManager(); | |
| 399 | await manager.open(openInput()); | |
| 400 | const process = ptyAdapter.processes[0]; | |
| 401 | expect(process).toBeDefined(); | |
| 402 | if (!process) return; | |
| 403 | ||
| 404 | process.emitExit({ exitCode: 0, signal: 0 }); | |
| 405 | ||
| 406 | await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined(); | |
| 407 | expect(process.writes).toEqual([]); | |
| 408 | ||
| 409 | manager.dispose(); | |
| 410 | }); | |
| 411 | ||
| 412 | it("emits subprocess activity events when child-process state changes", async () => { | |
| 413 | let hasRunningSubprocess = false; | |
| 414 | const { manager } = makeManager(5, { | |
| 415 | subprocessChecker: async () => hasRunningSubprocess, | |
| 416 | subprocessPollIntervalMs: 20, | |
| 417 | }); | |
| 418 | const events: TerminalEvent[] = []; | |
| 419 | manager.on("event", (event) => { | |
| 420 | events.push(event); | |
| 421 | }); | |
| 422 | ||
| 423 | await manager.open(openInput()); | |
| 424 | await waitFor(() => events.some((event) => event.type === "started")); | |
| 425 | expect(events.some((event) => event.type === "activity")).toBe(false); | |
| 426 | ||
| 427 | hasRunningSubprocess = true; | |
| 428 | await waitFor( | |
| 429 | () => | |
| 430 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | |
| 431 | 1_200, | |
| 432 | ); | |
| 433 | ||
| 434 | hasRunningSubprocess = false; | |
| 435 | await waitFor( | |
| 436 | () => | |
| 437 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | |
| 438 | 1_200, | |
| 439 | ); | |
| 440 | ||
| 441 | manager.dispose(); | |
| 442 | }); | |
| 443 | ||
| 444 | it("caps persisted history to configured line limit", async () => { | |
| 445 | const { manager, ptyAdapter } = makeManager(3); | |
| 446 | await manager.open(openInput()); | |
| 447 | const process = ptyAdapter.processes[0]; | |
| 448 | expect(process).toBeDefined(); | |
| 449 | if (!process) return; | |
| 450 | ||
| 451 | process.emitData("line1\nline2\nline3\nline4\n"); | |
| 452 | await manager.close({ threadId: "thread-1" }); | |
| 453 | ||
| 454 | const reopened = await manager.open(openInput()); | |
| 455 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | |
| 456 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | |
| 457 | ||
| 458 | manager.dispose(); | |
| 459 | }); | |
| 460 | ||
| 461 | it("deletes history file when close(deleteHistory=true)", async () => { | |
| 462 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 463 | await manager.open(openInput()); | |
| 464 | const process = ptyAdapter.processes[0]; | |
| 465 | expect(process).toBeDefined(); | |
| 466 | if (!process) return; | |
| 467 | process.emitData("bye\n"); | |
| 468 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 469 | ||
| 470 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 471 | expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); | |
| 472 | ||
| 473 | manager.dispose(); | |
| 474 | }); | |
| 475 | ||
| 476 | it("closes all terminals for a thread when close omits terminalId", async () => { | |
| 477 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 478 | await manager.open(openInput({ terminalId: "default" })); | |
| 479 | await manager.open(openInput({ terminalId: "sidecar" })); | |
| 480 | const defaultProcess = ptyAdapter.processes[0]; | |
| 481 | const sidecarProcess = ptyAdapter.processes[1]; | |
| 482 | expect(defaultProcess).toBeDefined(); | |
| 483 | expect(sidecarProcess).toBeDefined(); | |
| 484 | if (!defaultProcess || !sidecarProcess) return; | |
| 485 | ||
| 486 | defaultProcess.emitData("default\n"); | |
| 487 | sidecarProcess.emitData("sidecar\n"); | |
| 488 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); | |
| 489 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); | |
| 490 | ||
| 491 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 492 | ||
| 493 | expect(defaultProcess.killed).toBe(true); | |
| 494 | expect(sidecarProcess.killed).toBe(true); | |
| 495 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); | |
| 496 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); | |
| 497 | ||
| 498 | manager.dispose(); | |
| 499 | }); | |
| 500 | ||
| 501 | it("escalates terminal shutdown to SIGKILL when process does not exit in time", async () => { | |
| 502 | const { manager, ptyAdapter } = makeManager(5, { processKillGraceMs: 10 }); | |
| 503 | await manager.open(openInput()); | |
| 504 | const process = ptyAdapter.processes[0]; | |
| 505 | expect(process).toBeDefined(); | |
| 506 | if (!process) return; | |
| 507 | ||
| 508 | await manager.close({ threadId: "thread-1" }); | |
| 509 | await waitFor(() => process.killSignals.includes("SIGKILL")); | |
| 510 | ||
| 511 | expect(process.killSignals[0]).toBe("SIGTERM"); | |
| 512 | expect(process.killSignals).toContain("SIGKILL"); | |
| 513 | ||
| 514 | manager.dispose(); | |
| 515 | }); | |
| 516 | ||
| 517 | it("evicts oldest inactive terminal sessions when retention limit is exceeded", async () => { | |
| 518 | const { manager, ptyAdapter } = makeManager(5, { maxRetainedInactiveSessions: 1 }); | |
| 519 | ||
| 520 | await manager.open(openInput({ threadId: "thread-1" })); | |
| 521 | await manager.open(openInput({ threadId: "thread-2" })); | |
| 522 | ||
| 523 | const first = ptyAdapter.processes[0]; | |
| 524 | const second = ptyAdapter.processes[1]; | |
| 525 | expect(first).toBeDefined(); | |
| 526 | expect(second).toBeDefined(); | |
| 527 | if (!first || !second) return; | |
| 528 | ||
| 529 | first.emitExit({ exitCode: 0, signal: 0 }); | |
| 530 | await new Promise((resolve) => setTimeout(resolve, 5)); | |
| 531 | second.emitExit({ exitCode: 0, signal: 0 }); | |
| 532 | ||
| 533 | await waitFor(() => { | |
| 534 | const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; | |
| 535 | return sessions.size === 1; | |
| 536 | }); | |
| 537 | ||
| 538 | const sessions = (manager as unknown as { sessions: Map<string, unknown> }).sessions; | |
| 539 | const keys = [...sessions.keys()]; | |
| 540 | expect(keys).toEqual(["thread-2\u0000default"]); | |
| 541 | ||
| 542 | manager.dispose(); | |
| 543 | }); | |
| 544 | ||
| 545 | it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { | |
| 546 | const { manager, logsDir } = makeManager(); | |
| 547 | const legacyPath = path.join(logsDir, "thread-1.log"); | |
| 548 | const nextPath = historyLogPath(logsDir); | |
| 549 | fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); | |
| 550 | ||
| 551 | const snapshot = await manager.open(openInput()); | |
| 552 | ||
| 553 | expect(snapshot.history).toBe("legacy-line\n"); | |
| 554 | expect(fs.existsSync(nextPath)).toBe(true); | |
| 555 | expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); | |
| 556 | expect(fs.existsSync(legacyPath)).toBe(false); | |
| 557 | ||
| 558 | manager.dispose(); | |
| 559 | }); | |
| 560 | ||
| 561 | it("retries with fallback shells when preferred shell spawn fails", async () => { | |
| 562 | const { manager, ptyAdapter } = makeManager(5, { | |
| 563 | shellResolver: () => "/definitely/missing-shell -l", | |
| 564 | }); | |
| 565 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | |
| 566 | ||
| 567 | const snapshot = await manager.open(openInput()); | |
| 568 | ||
| 569 | expect(snapshot.status).toBe("running"); | |
| 570 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | |
| 571 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); | |
| 572 | ||
| 573 | if (process.platform === "win32") { | |
| 574 | expect( | |
| 575 | ptyAdapter.spawnInputs.some( | |
| 576 | (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", | |
| 577 | ), | |
| 578 | ).toBe(true); | |
| 579 | } else { | |
| 580 | expect( | |
| 581 | ptyAdapter.spawnInputs.some((input) => | |
| 582 | ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), | |
| 583 | ), | |
| 584 | ).toBe(true); | |
| 585 | } | |
| 586 | ||
| 587 | manager.dispose(); | |
| 588 | }); | |
| 589 | ||
| 590 | it("filters app runtime env variables from terminal sessions", async () => { | |
| 591 | const originalValues = new Map<string, string | undefined>(); | |
| 592 | const setEnv = (key: string, value: string | undefined) => { | |
| 593 | if (!originalValues.has(key)) { | |
| 594 | originalValues.set(key, process.env[key]); | |
| 595 | } | |
| 596 | if (value === undefined) { | |
| 597 | delete process.env[key]; | |
| 598 | return; | |
| 599 | } | |
| 600 | process.env[key] = value; | |
| 601 | }; | |
| 602 | const restoreEnv = () => { | |
| 603 | for (const [key, value] of originalValues) { | |
| 604 | if (value === undefined) { | |
| 605 | delete process.env[key]; | |
| 606 | } else { | |
| 607 | process.env[key] = value; | |
| 608 | } | |
| 609 | } | |
| 610 | }; | |
| 611 | ||
| 612 | setEnv("PORT", "5173"); | |
| 613 | setEnv("T3CODE_PORT", "3773"); | |
| 614 | setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); | |
| 615 | setEnv("TEST_TERMINAL_KEEP", "keep-me"); | |
| 616 | ||
| 617 | try { | |
| 618 | const { manager, ptyAdapter } = makeManager(); | |
| 619 | await manager.open(openInput()); | |
| 620 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 621 | expect(spawnInput).toBeDefined(); | |
| 622 | if (!spawnInput) return; | |
| 623 | ||
| 624 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 625 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 626 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 627 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 628 | ||
| 629 | manager.dispose(); | |
| 630 | } finally { | |
| 631 | restoreEnv(); | |
| 632 | } | |
| 633 | }); | |
| 634 | ||
| 635 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 636 | const { manager, ptyAdapter } = makeManager(); | |
| 637 | await manager.open( | |
| 638 | openInput({ | |
| 639 | env: { | |
| 640 | T3CODE_PROJECT_ROOT: "/repo", | |
| 641 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 642 | CUSTOM_FLAG: "1", | |
| 643 | }, | |
| 644 | }), | |
| 645 | ); | |
| 646 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 647 | expect(spawnInput).toBeDefined(); | |
| 648 | if (!spawnInput) return; | |
| 649 | ||
| 650 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 651 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 652 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 653 | ||
| 654 | manager.dispose(); | |
| 655 | }); | |
| 656 | ||
| 657 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 658 | if (process.platform === "win32") return; | |
| 659 | const { manager, ptyAdapter } = makeManager(5, { | |
| 660 | shellResolver: () => "/bin/zsh", | |
| 661 | }); | |
| 662 | await manager.open(openInput()); | |
| 663 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 664 | expect(spawnInput).toBeDefined(); | |
| 665 | if (!spawnInput) return; | |
| 666 | ||
| 667 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 668 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 669 | ||
| 670 | manager.dispose(); | |
| 671 | }); | |
| 672 | }); |
apps/server/src/terminal/Layers/Manager.ts created+1224| ... | ... | @@ -0,0 +1,1224 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | TerminalClearInput, | |
| 8 | TerminalCloseInput, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalWriteInput, | |
| 12 | type TerminalEvent, | |
| 13 | type TerminalSessionSnapshot, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Effect, Encoding, Layer, Path, Schema } from "effect"; | |
| 16 | ||
| 17 | import { createLogger } from "../../logger"; | |
| 18 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; | |
| 19 | import { runProcess } from "../../processRunner"; | |
| 20 | import { ServerConfig } from "../../config"; | |
| 21 | import { | |
| 22 | ShellCandidate, | |
| 23 | TerminalError, | |
| 24 | TerminalManager, | |
| 25 | TerminalManagerShape, | |
| 26 | TerminalSessionState, | |
| 27 | TerminalStartInput, | |
| 28 | } from "../Services/Manager"; | |
| 29 | ||
| 30 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | |
| 31 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | |
| 32 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | |
| 33 | const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; | |
| 34 | const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; | |
| 35 | const DEFAULT_OPEN_COLS = 120; | |
| 36 | const DEFAULT_OPEN_ROWS = 30; | |
| 37 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | |
| 38 | ||
| 39 | const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); | |
| 40 | const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); | |
| 41 | const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); | |
| 42 | const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); | |
| 43 | const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); | |
| 44 | ||
| 45 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | |
| 46 | ||
| 47 | function defaultShellResolver(): string { | |
| 48 | if (process.platform === "win32") { | |
| 49 | return process.env.ComSpec ?? "cmd.exe"; | |
| 50 | } | |
| 51 | return process.env.SHELL ?? "bash"; | |
| 52 | } | |
| 53 | ||
| 54 | function normalizeShellCommand(value: string | undefined): string | null { | |
| 55 | if (!value) return null; | |
| 56 | const trimmed = value.trim(); | |
| 57 | if (trimmed.length === 0) return null; | |
| 58 | ||
| 59 | if (process.platform === "win32") { | |
| 60 | return trimmed; | |
| 61 | } | |
| 62 | ||
| 63 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | |
| 64 | if (!firstToken) return null; | |
| 65 | return firstToken.replace(/^['"]|['"]$/g, ""); | |
| 66 | } | |
| 67 | ||
| 68 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 69 | if (!command || command.length === 0) return null; | |
| 70 | const shellName = path.basename(command).toLowerCase(); | |
| 71 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 72 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 73 | } | |
| 74 | return { shell: command }; | |
| 75 | } | |
| 76 | ||
| 77 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 78 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 79 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 80 | } | |
| 81 | ||
| 82 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 83 | const seen = new Set<string>(); | |
| 84 | const ordered: ShellCandidate[] = []; | |
| 85 | for (const candidate of candidates) { | |
| 86 | if (!candidate) continue; | |
| 87 | const key = formatShellCandidate(candidate); | |
| 88 | if (seen.has(key)) continue; | |
| 89 | seen.add(key); | |
| 90 | ordered.push(candidate); | |
| 91 | } | |
| 92 | return ordered; | |
| 93 | } | |
| 94 | ||
| 95 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 96 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 97 | ||
| 98 | if (process.platform === "win32") { | |
| 99 | return uniqueShellCandidates([ | |
| 100 | requested, | |
| 101 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 102 | shellCandidateFromCommand("powershell.exe"), | |
| 103 | shellCandidateFromCommand("cmd.exe"), | |
| 104 | ]); | |
| 105 | } | |
| 106 | ||
| 107 | return uniqueShellCandidates([ | |
| 108 | requested, | |
| 109 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 110 | shellCandidateFromCommand("/bin/zsh"), | |
| 111 | shellCandidateFromCommand("/bin/bash"), | |
| 112 | shellCandidateFromCommand("/bin/sh"), | |
| 113 | shellCandidateFromCommand("zsh"), | |
| 114 | shellCandidateFromCommand("bash"), | |
| 115 | shellCandidateFromCommand("sh"), | |
| 116 | ]); | |
| 117 | } | |
| 118 | ||
| 119 | function isRetryableShellSpawnError(error: unknown): boolean { | |
| 120 | const queue: unknown[] = [error]; | |
| 121 | const seen = new Set<unknown>(); | |
| 122 | const messages: string[] = []; | |
| 123 | ||
| 124 | while (queue.length > 0) { | |
| 125 | const current = queue.shift(); | |
| 126 | if (!current || seen.has(current)) { | |
| 127 | continue; | |
| 128 | } | |
| 129 | seen.add(current); | |
| 130 | ||
| 131 | if (typeof current === "string") { | |
| 132 | messages.push(current); | |
| 133 | continue; | |
| 134 | } | |
| 135 | ||
| 136 | if (current instanceof Error) { | |
| 137 | messages.push(current.message); | |
| 138 | const cause = (current as { cause?: unknown }).cause; | |
| 139 | if (cause) { | |
| 140 | queue.push(cause); | |
| 141 | } | |
| 142 | continue; | |
| 143 | } | |
| 144 | ||
| 145 | if (typeof current === "object") { | |
| 146 | const value = current as { message?: unknown; cause?: unknown }; | |
| 147 | if (typeof value.message === "string") { | |
| 148 | messages.push(value.message); | |
| 149 | } | |
| 150 | if (value.cause) { | |
| 151 | queue.push(value.cause); | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | const message = messages.join(" ").toLowerCase(); | |
| 157 | return ( | |
| 158 | message.includes("posix_spawnp failed") || | |
| 159 | message.includes("enoent") || | |
| 160 | message.includes("not found") || | |
| 161 | message.includes("file not found") || | |
| 162 | message.includes("no such file") | |
| 163 | ); | |
| 164 | } | |
| 165 | ||
| 166 | async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 167 | const command = [ | |
| 168 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | |
| 169 | "if ($children) { exit 0 }", | |
| 170 | "exit 1", | |
| 171 | ].join("; "); | |
| 172 | try { | |
| 173 | const result = await runProcess( | |
| 174 | "powershell.exe", | |
| 175 | ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 176 | { | |
| 177 | timeoutMs: 1_500, | |
| 178 | allowNonZeroExit: true, | |
| 179 | maxBufferBytes: 32_768, | |
| 180 | outputMode: "truncate", | |
| 181 | }, | |
| 182 | ); | |
| 183 | return result.code === 0; | |
| 184 | } catch { | |
| 185 | return false; | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 190 | try { | |
| 191 | const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { | |
| 192 | timeoutMs: 1_000, | |
| 193 | allowNonZeroExit: true, | |
| 194 | maxBufferBytes: 32_768, | |
| 195 | outputMode: "truncate", | |
| 196 | }); | |
| 197 | if (pgrepResult.code === 0) { | |
| 198 | return pgrepResult.stdout.trim().length > 0; | |
| 199 | } | |
| 200 | if (pgrepResult.code === 1) { | |
| 201 | return false; | |
| 202 | } | |
| 203 | } catch { | |
| 204 | // Fall back to ps when pgrep is unavailable. | |
| 205 | } | |
| 206 | ||
| 207 | try { | |
| 208 | const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { | |
| 209 | timeoutMs: 1_000, | |
| 210 | allowNonZeroExit: true, | |
| 211 | maxBufferBytes: 262_144, | |
| 212 | outputMode: "truncate", | |
| 213 | }); | |
| 214 | if (psResult.code !== 0) { | |
| 215 | return false; | |
| 216 | } | |
| 217 | ||
| 218 | for (const line of psResult.stdout.split(/\r?\n/g)) { | |
| 219 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 220 | const pid = Number(pidRaw); | |
| 221 | const ppid = Number(ppidRaw); | |
| 222 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | |
| 223 | if (ppid === terminalPid) { | |
| 224 | return true; | |
| 225 | } | |
| 226 | } | |
| 227 | return false; | |
| 228 | } catch { | |
| 229 | return false; | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> { | |
| 234 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | |
| 235 | return false; | |
| 236 | } | |
| 237 | if (process.platform === "win32") { | |
| 238 | return checkWindowsSubprocessActivity(terminalPid); | |
| 239 | } | |
| 240 | return checkPosixSubprocessActivity(terminalPid); | |
| 241 | } | |
| 242 | ||
| 243 | function capHistory(history: string, maxLines: number): string { | |
| 244 | if (history.length === 0) return history; | |
| 245 | const hasTrailingNewline = history.endsWith("\n"); | |
| 246 | const lines = history.split("\n"); | |
| 247 | if (hasTrailingNewline) { | |
| 248 | lines.pop(); | |
| 249 | } | |
| 250 | if (lines.length <= maxLines) return history; | |
| 251 | const capped = lines.slice(lines.length - maxLines).join("\n"); | |
| 252 | return hasTrailingNewline ? `${capped}\n` : capped; | |
| 253 | } | |
| 254 | ||
| 255 | function legacySafeThreadId(threadId: string): string { | |
| 256 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 257 | } | |
| 258 | ||
| 259 | function toSafeThreadId(threadId: string): string { | |
| 260 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; | |
| 261 | } | |
| 262 | ||
| 263 | function toSafeTerminalId(terminalId: string): string { | |
| 264 | return Encoding.encodeBase64Url(terminalId); | |
| 265 | } | |
| 266 | ||
| 267 | function toSessionKey(threadId: string, terminalId: string): string { | |
| 268 | return `${threadId}\u0000${terminalId}`; | |
| 269 | } | |
| 270 | ||
| 271 | function shouldExcludeTerminalEnvKey(key: string): boolean { | |
| 272 | const normalizedKey = key.toUpperCase(); | |
| 273 | if (normalizedKey.startsWith("T3CODE_")) { | |
| 274 | return true; | |
| 275 | } | |
| 276 | if (normalizedKey.startsWith("VITE_")) { | |
| 277 | return true; | |
| 278 | } | |
| 279 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | |
| 280 | } | |
| 281 | ||
| 282 | function createTerminalSpawnEnv( | |
| 283 | baseEnv: NodeJS.ProcessEnv, | |
| 284 | runtimeEnv?: Record<string, string> | null, | |
| 285 | ): NodeJS.ProcessEnv { | |
| 286 | const spawnEnv: NodeJS.ProcessEnv = {}; | |
| 287 | for (const [key, value] of Object.entries(baseEnv)) { | |
| 288 | if (value === undefined) continue; | |
| 289 | if (shouldExcludeTerminalEnvKey(key)) continue; | |
| 290 | spawnEnv[key] = value; | |
| 291 | } | |
| 292 | if (runtimeEnv) { | |
| 293 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 294 | spawnEnv[key] = value; | |
| 295 | } | |
| 296 | } | |
| 297 | return spawnEnv; | |
| 298 | } | |
| 299 | ||
| 300 | function normalizedRuntimeEnv( | |
| 301 | env: Record<string, string> | undefined, | |
| 302 | ): Record<string, string> | null { | |
| 303 | if (!env) return null; | |
| 304 | const entries = Object.entries(env); | |
| 305 | if (entries.length === 0) return null; | |
| 306 | return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); | |
| 307 | } | |
| 308 | ||
| 309 | interface TerminalManagerEvents { | |
| 310 | event: [event: TerminalEvent]; | |
| 311 | } | |
| 312 | ||
| 313 | interface TerminalManagerOptions { | |
| 314 | logsDir?: string; | |
| 315 | historyLineLimit?: number; | |
| 316 | ptyAdapter: PtyAdapterShape; | |
| 317 | shellResolver?: () => string; | |
| 318 | subprocessChecker?: TerminalSubprocessChecker; | |
| 319 | subprocessPollIntervalMs?: number; | |
| 320 | processKillGraceMs?: number; | |
| 321 | maxRetainedInactiveSessions?: number; | |
| 322 | } | |
| 323 | ||
| 324 | export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> { | |
| 325 | private readonly sessions = new Map<string, TerminalSessionState>(); | |
| 326 | private readonly logsDir: string; | |
| 327 | private readonly historyLineLimit: number; | |
| 328 | private readonly ptyAdapter: PtyAdapterShape; | |
| 329 | private readonly shellResolver: () => string; | |
| 330 | private readonly persistQueues = new Map<string, Promise<void>>(); | |
| 331 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 332 | private readonly pendingPersistHistory = new Map<string, string>(); | |
| 333 | private readonly threadLocks = new Map<string, Promise<void>>(); | |
| 334 | private readonly persistDebounceMs: number; | |
| 335 | private readonly subprocessChecker: TerminalSubprocessChecker; | |
| 336 | private readonly subprocessPollIntervalMs: number; | |
| 337 | private readonly processKillGraceMs: number; | |
| 338 | private readonly maxRetainedInactiveSessions: number; | |
| 339 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; | |
| 340 | private subprocessPollInFlight = false; | |
| 341 | private readonly killEscalationTimers = new Map<PtyProcess, ReturnType<typeof setTimeout>>(); | |
| 342 | private readonly logger = createLogger("terminal"); | |
| 343 | ||
| 344 | constructor(options: TerminalManagerOptions) { | |
| 345 | super(); | |
| 346 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); | |
| 347 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | |
| 348 | this.ptyAdapter = options.ptyAdapter; | |
| 349 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | |
| 350 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | |
| 351 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | |
| 352 | this.subprocessPollIntervalMs = | |
| 353 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | |
| 354 | this.processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | |
| 355 | this.maxRetainedInactiveSessions = | |
| 356 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; | |
| 357 | fs.mkdirSync(this.logsDir, { recursive: true }); | |
| 358 | } | |
| 359 | ||
| 360 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 361 | const input = decodeTerminalOpenInput(raw); | |
| 362 | return this.runWithThreadLock(input.threadId, async () => { | |
| 363 | await this.assertValidCwd(input.cwd); | |
| 364 | ||
| 365 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 366 | const existing = this.sessions.get(sessionKey); | |
| 367 | if (!existing) { | |
| 368 | await this.flushPersistQueue(input.threadId, input.terminalId); | |
| 369 | const history = await this.readHistory(input.threadId, input.terminalId); | |
| 370 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 371 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 372 | const session: TerminalSessionState = { | |
| 373 | threadId: input.threadId, | |
| 374 | terminalId: input.terminalId, | |
| 375 | cwd: input.cwd, | |
| 376 | status: "starting", | |
| 377 | pid: null, | |
| 378 | history, | |
| 379 | exitCode: null, | |
| 380 | exitSignal: null, | |
| 381 | updatedAt: new Date().toISOString(), | |
| 382 | cols, | |
| 383 | rows, | |
| 384 | process: null, | |
| 385 | unsubscribeData: null, | |
| 386 | unsubscribeExit: null, | |
| 387 | hasRunningSubprocess: false, | |
| 388 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 389 | }; | |
| 390 | this.sessions.set(sessionKey, session); | |
| 391 | this.evictInactiveSessionsIfNeeded(); | |
| 392 | await this.startSession(session, { ...input, cols, rows }, "started"); | |
| 393 | return this.snapshot(session); | |
| 394 | } | |
| 395 | ||
| 396 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 397 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 398 | const targetCols = input.cols ?? existing.cols; | |
| 399 | const targetRows = input.rows ?? existing.rows; | |
| 400 | const runtimeEnvChanged = | |
| 401 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 402 | ||
| 403 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 404 | this.stopProcess(existing); | |
| 405 | existing.cwd = input.cwd; | |
| 406 | existing.runtimeEnv = nextRuntimeEnv; | |
| 407 | existing.history = ""; | |
| 408 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 409 | } else if (existing.status === "exited" || existing.status === "error") { | |
| 410 | existing.runtimeEnv = nextRuntimeEnv; | |
| 411 | existing.history = ""; | |
| 412 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 413 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 414 | existing.runtimeEnv = nextRuntimeEnv; | |
| 415 | } | |
| 416 | ||
| 417 | if (!existing.process) { | |
| 418 | await this.startSession( | |
| 419 | existing, | |
| 420 | { ...input, cols: targetCols, rows: targetRows }, | |
| 421 | "started", | |
| 422 | ); | |
| 423 | return this.snapshot(existing); | |
| 424 | } | |
| 425 | ||
| 426 | if (existing.cols !== targetCols || existing.rows !== targetRows) { | |
| 427 | existing.cols = targetCols; | |
| 428 | existing.rows = targetRows; | |
| 429 | existing.process.resize(targetCols, targetRows); | |
| 430 | existing.updatedAt = new Date().toISOString(); | |
| 431 | } | |
| 432 | ||
| 433 | return this.snapshot(existing); | |
| 434 | }); | |
| 435 | } | |
| 436 | ||
| 437 | async write(raw: TerminalWriteInput): Promise<void> { | |
| 438 | const input = decodeTerminalWriteInput(raw); | |
| 439 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 440 | if (!session.process || session.status !== "running") { | |
| 441 | if (session.status === "exited") { | |
| 442 | return; | |
| 443 | } | |
| 444 | throw new Error( | |
| 445 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 446 | ); | |
| 447 | } | |
| 448 | session.process.write(input.data); | |
| 449 | } | |
| 450 | ||
| 451 | async resize(raw: TerminalResizeInput): Promise<void> { | |
| 452 | const input = decodeTerminalResizeInput(raw); | |
| 453 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 454 | if (!session.process || session.status !== "running") { | |
| 455 | throw new Error( | |
| 456 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 457 | ); | |
| 458 | } | |
| 459 | session.cols = input.cols; | |
| 460 | session.rows = input.rows; | |
| 461 | session.updatedAt = new Date().toISOString(); | |
| 462 | session.process.resize(input.cols, input.rows); | |
| 463 | } | |
| 464 | ||
| 465 | async clear(raw: TerminalClearInput): Promise<void> { | |
| 466 | const input = decodeTerminalClearInput(raw); | |
| 467 | await this.runWithThreadLock(input.threadId, async () => { | |
| 468 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 469 | session.history = ""; | |
| 470 | session.updatedAt = new Date().toISOString(); | |
| 471 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 472 | this.emitEvent({ | |
| 473 | type: "cleared", | |
| 474 | threadId: input.threadId, | |
| 475 | terminalId: input.terminalId, | |
| 476 | createdAt: new Date().toISOString(), | |
| 477 | }); | |
| 478 | }); | |
| 479 | } | |
| 480 | ||
| 481 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 482 | const input = decodeTerminalOpenInput(raw); | |
| 483 | return this.runWithThreadLock(input.threadId, async () => { | |
| 484 | await this.assertValidCwd(input.cwd); | |
| 485 | ||
| 486 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 487 | let session = this.sessions.get(sessionKey); | |
| 488 | if (!session) { | |
| 489 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 490 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 491 | session = { | |
| 492 | threadId: input.threadId, | |
| 493 | terminalId: input.terminalId, | |
| 494 | cwd: input.cwd, | |
| 495 | status: "starting", | |
| 496 | pid: null, | |
| 497 | history: "", | |
| 498 | exitCode: null, | |
| 499 | exitSignal: null, | |
| 500 | updatedAt: new Date().toISOString(), | |
| 501 | cols, | |
| 502 | rows, | |
| 503 | process: null, | |
| 504 | unsubscribeData: null, | |
| 505 | unsubscribeExit: null, | |
| 506 | hasRunningSubprocess: false, | |
| 507 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 508 | }; | |
| 509 | this.sessions.set(sessionKey, session); | |
| 510 | this.evictInactiveSessionsIfNeeded(); | |
| 511 | } else { | |
| 512 | this.stopProcess(session); | |
| 513 | session.cwd = input.cwd; | |
| 514 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 515 | } | |
| 516 | ||
| 517 | const cols = input.cols ?? session.cols; | |
| 518 | const rows = input.rows ?? session.rows; | |
| 519 | ||
| 520 | session.history = ""; | |
| 521 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 522 | await this.startSession(session, { ...input, cols, rows }, "restarted"); | |
| 523 | return this.snapshot(session); | |
| 524 | }); | |
| 525 | } | |
| 526 | ||
| 527 | async close(raw: TerminalCloseInput): Promise<void> { | |
| 528 | const input = decodeTerminalCloseInput(raw); | |
| 529 | await this.runWithThreadLock(input.threadId, async () => { | |
| 530 | if (input.terminalId) { | |
| 531 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | |
| 532 | return; | |
| 533 | } | |
| 534 | ||
| 535 | const threadSessions = this.sessionsForThread(input.threadId); | |
| 536 | for (const session of threadSessions) { | |
| 537 | this.stopProcess(session); | |
| 538 | this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); | |
| 539 | } | |
| 540 | await Promise.all( | |
| 541 | threadSessions.map((session) => | |
| 542 | this.flushPersistQueue(session.threadId, session.terminalId), | |
| 543 | ), | |
| 544 | ); | |
| 545 | ||
| 546 | if (input.deleteHistory) { | |
| 547 | await this.deleteAllHistoryForThread(input.threadId); | |
| 548 | } | |
| 549 | this.updateSubprocessPollingState(); | |
| 550 | }); | |
| 551 | } | |
| 552 | ||
| 553 | dispose(): void { | |
| 554 | this.stopSubprocessPolling(); | |
| 555 | const sessions = [...this.sessions.values()]; | |
| 556 | this.sessions.clear(); | |
| 557 | for (const session of sessions) { | |
| 558 | this.stopProcess(session); | |
| 559 | } | |
| 560 | for (const timer of this.persistTimers.values()) { | |
| 561 | clearTimeout(timer); | |
| 562 | } | |
| 563 | this.persistTimers.clear(); | |
| 564 | for (const timer of this.killEscalationTimers.values()) { | |
| 565 | clearTimeout(timer); | |
| 566 | } | |
| 567 | this.killEscalationTimers.clear(); | |
| 568 | this.pendingPersistHistory.clear(); | |
| 569 | this.threadLocks.clear(); | |
| 570 | this.persistQueues.clear(); | |
| 571 | } | |
| 572 | ||
| 573 | private async startSession( | |
| 574 | session: TerminalSessionState, | |
| 575 | input: TerminalStartInput, | |
| 576 | eventType: "started" | "restarted", | |
| 577 | ): Promise<void> { | |
| 578 | this.stopProcess(session); | |
| 579 | ||
| 580 | session.status = "starting"; | |
| 581 | session.cwd = input.cwd; | |
| 582 | session.cols = input.cols; | |
| 583 | session.rows = input.rows; | |
| 584 | session.exitCode = null; | |
| 585 | session.exitSignal = null; | |
| 586 | session.hasRunningSubprocess = false; | |
| 587 | session.updatedAt = new Date().toISOString(); | |
| 588 | ||
| 589 | let ptyProcess: PtyProcess | null = null; | |
| 590 | let startedShell: string | null = null; | |
| 591 | try { | |
| 592 | const shellCandidates = resolveShellCandidates(this.shellResolver); | |
| 593 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 594 | let lastSpawnError: unknown = null; | |
| 595 | ||
| 596 | const spawnWithCandidate = (candidate: ShellCandidate) => | |
| 597 | Effect.runPromise( | |
| 598 | this.ptyAdapter.spawn({ | |
| 599 | shell: candidate.shell, | |
| 600 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 601 | cwd: session.cwd, | |
| 602 | cols: session.cols, | |
| 603 | rows: session.rows, | |
| 604 | env: terminalEnv, | |
| 605 | }), | |
| 606 | ); | |
| 607 | ||
| 608 | const trySpawn = async ( | |
| 609 | candidates: ShellCandidate[], | |
| 610 | index = 0, | |
| 611 | ): Promise<{ process: PtyProcess; shellLabel: string } | null> => { | |
| 612 | if (index >= candidates.length) { | |
| 613 | return null; | |
| 614 | } | |
| 615 | const candidate = candidates[index]; | |
| 616 | if (!candidate) { | |
| 617 | return null; | |
| 618 | } | |
| 619 | ||
| 620 | try { | |
| 621 | const process = await spawnWithCandidate(candidate); | |
| 622 | return { process, shellLabel: formatShellCandidate(candidate) }; | |
| 623 | } catch (error) { | |
| 624 | lastSpawnError = error; | |
| 625 | if (!isRetryableShellSpawnError(error)) { | |
| 626 | throw error; | |
| 627 | } | |
| 628 | return trySpawn(candidates, index + 1); | |
| 629 | } | |
| 630 | }; | |
| 631 | ||
| 632 | const spawnResult = await trySpawn(shellCandidates); | |
| 633 | if (spawnResult) { | |
| 634 | ptyProcess = spawnResult.process; | |
| 635 | startedShell = spawnResult.shellLabel; | |
| 636 | } | |
| 637 | ||
| 638 | if (!ptyProcess) { | |
| 639 | const detail = | |
| 640 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; | |
| 641 | const tried = | |
| 642 | shellCandidates.length > 0 | |
| 643 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 644 | : ""; | |
| 645 | throw new Error(`${detail}.${tried}`.trim()); | |
| 646 | } | |
| 647 | ||
| 648 | session.process = ptyProcess; | |
| 649 | session.pid = ptyProcess.pid; | |
| 650 | session.status = "running"; | |
| 651 | session.updatedAt = new Date().toISOString(); | |
| 652 | session.unsubscribeData = ptyProcess.onData((data) => { | |
| 653 | this.onProcessData(session, data); | |
| 654 | }); | |
| 655 | session.unsubscribeExit = ptyProcess.onExit((event) => { | |
| 656 | this.onProcessExit(session, event); | |
| 657 | }); | |
| 658 | this.updateSubprocessPollingState(); | |
| 659 | this.emitEvent({ | |
| 660 | type: eventType, | |
| 661 | threadId: session.threadId, | |
| 662 | terminalId: session.terminalId, | |
| 663 | createdAt: new Date().toISOString(), | |
| 664 | snapshot: this.snapshot(session), | |
| 665 | }); | |
| 666 | } catch (error) { | |
| 667 | if (ptyProcess) { | |
| 668 | this.killProcessWithEscalation(ptyProcess, session.threadId, session.terminalId); | |
| 669 | } | |
| 670 | session.status = "error"; | |
| 671 | session.pid = null; | |
| 672 | session.process = null; | |
| 673 | session.hasRunningSubprocess = false; | |
| 674 | session.updatedAt = new Date().toISOString(); | |
| 675 | this.evictInactiveSessionsIfNeeded(); | |
| 676 | this.updateSubprocessPollingState(); | |
| 677 | const message = error instanceof Error ? error.message : "Terminal start failed"; | |
| 678 | this.emitEvent({ | |
| 679 | type: "error", | |
| 680 | threadId: session.threadId, | |
| 681 | terminalId: session.terminalId, | |
| 682 | createdAt: new Date().toISOString(), | |
| 683 | message, | |
| 684 | }); | |
| 685 | this.logger.error("failed to start terminal", { | |
| 686 | threadId: session.threadId, | |
| 687 | terminalId: session.terminalId, | |
| 688 | error: message, | |
| 689 | ...(startedShell ? { shell: startedShell } : {}), | |
| 690 | }); | |
| 691 | } | |
| 692 | } | |
| 693 | ||
| 694 | private onProcessData(session: TerminalSessionState, data: string): void { | |
| 695 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); | |
| 696 | session.updatedAt = new Date().toISOString(); | |
| 697 | this.queuePersist(session.threadId, session.terminalId, session.history); | |
| 698 | this.emitEvent({ | |
| 699 | type: "output", | |
| 700 | threadId: session.threadId, | |
| 701 | terminalId: session.terminalId, | |
| 702 | createdAt: new Date().toISOString(), | |
| 703 | data, | |
| 704 | }); | |
| 705 | } | |
| 706 | ||
| 707 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { | |
| 708 | this.clearKillEscalationTimer(session.process); | |
| 709 | this.cleanupProcessHandles(session); | |
| 710 | session.process = null; | |
| 711 | session.pid = null; | |
| 712 | session.hasRunningSubprocess = false; | |
| 713 | session.status = "exited"; | |
| 714 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | |
| 715 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | |
| 716 | session.updatedAt = new Date().toISOString(); | |
| 717 | this.emitEvent({ | |
| 718 | type: "exited", | |
| 719 | threadId: session.threadId, | |
| 720 | terminalId: session.terminalId, | |
| 721 | createdAt: new Date().toISOString(), | |
| 722 | exitCode: session.exitCode, | |
| 723 | exitSignal: session.exitSignal, | |
| 724 | }); | |
| 725 | this.evictInactiveSessionsIfNeeded(); | |
| 726 | this.updateSubprocessPollingState(); | |
| 727 | } | |
| 728 | ||
| 729 | private stopProcess(session: TerminalSessionState): void { | |
| 730 | const process = session.process; | |
| 731 | if (!process) return; | |
| 732 | this.cleanupProcessHandles(session); | |
| 733 | session.process = null; | |
| 734 | session.pid = null; | |
| 735 | session.hasRunningSubprocess = false; | |
| 736 | session.status = "exited"; | |
| 737 | session.updatedAt = new Date().toISOString(); | |
| 738 | this.killProcessWithEscalation(process, session.threadId, session.terminalId); | |
| 739 | this.evictInactiveSessionsIfNeeded(); | |
| 740 | this.updateSubprocessPollingState(); | |
| 741 | } | |
| 742 | ||
| 743 | private cleanupProcessHandles(session: TerminalSessionState): void { | |
| 744 | session.unsubscribeData?.(); | |
| 745 | session.unsubscribeData = null; | |
| 746 | session.unsubscribeExit?.(); | |
| 747 | session.unsubscribeExit = null; | |
| 748 | } | |
| 749 | ||
| 750 | private clearKillEscalationTimer(process: PtyProcess | null): void { | |
| 751 | if (!process) return; | |
| 752 | const timer = this.killEscalationTimers.get(process); | |
| 753 | if (!timer) return; | |
| 754 | clearTimeout(timer); | |
| 755 | this.killEscalationTimers.delete(process); | |
| 756 | } | |
| 757 | ||
| 758 | private killProcessWithEscalation( | |
| 759 | process: PtyProcess, | |
| 760 | threadId: string, | |
| 761 | terminalId: string, | |
| 762 | ): void { | |
| 763 | this.clearKillEscalationTimer(process); | |
| 764 | try { | |
| 765 | process.kill("SIGTERM"); | |
| 766 | } catch (error) { | |
| 767 | const message = error instanceof Error ? error.message : String(error); | |
| 768 | this.logger.warn("failed to kill terminal process", { | |
| 769 | threadId, | |
| 770 | terminalId, | |
| 771 | signal: "SIGTERM", | |
| 772 | error: message, | |
| 773 | }); | |
| 774 | return; | |
| 775 | } | |
| 776 | ||
| 777 | const timer = setTimeout(() => { | |
| 778 | this.killEscalationTimers.delete(process); | |
| 779 | try { | |
| 780 | process.kill("SIGKILL"); | |
| 781 | } catch (error) { | |
| 782 | const message = error instanceof Error ? error.message : String(error); | |
| 783 | this.logger.warn("failed to force-kill terminal process", { | |
| 784 | threadId, | |
| 785 | terminalId, | |
| 786 | signal: "SIGKILL", | |
| 787 | error: message, | |
| 788 | }); | |
| 789 | } | |
| 790 | }, this.processKillGraceMs); | |
| 791 | timer.unref?.(); | |
| 792 | this.killEscalationTimers.set(process, timer); | |
| 793 | } | |
| 794 | ||
| 795 | private evictInactiveSessionsIfNeeded(): void { | |
| 796 | const inactiveSessions = [...this.sessions.values()].filter( | |
| 797 | (session) => session.status !== "running", | |
| 798 | ); | |
| 799 | if (inactiveSessions.length <= this.maxRetainedInactiveSessions) { | |
| 800 | return; | |
| 801 | } | |
| 802 | ||
| 803 | inactiveSessions.sort( | |
| 804 | (left, right) => | |
| 805 | left.updatedAt.localeCompare(right.updatedAt) || | |
| 806 | left.threadId.localeCompare(right.threadId) || | |
| 807 | left.terminalId.localeCompare(right.terminalId), | |
| 808 | ); | |
| 809 | const toEvict = inactiveSessions.length - this.maxRetainedInactiveSessions; | |
| 810 | for (const session of inactiveSessions.slice(0, toEvict)) { | |
| 811 | const key = toSessionKey(session.threadId, session.terminalId); | |
| 812 | this.sessions.delete(key); | |
| 813 | this.clearPersistTimer(session.threadId, session.terminalId); | |
| 814 | this.pendingPersistHistory.delete(key); | |
| 815 | this.persistQueues.delete(key); | |
| 816 | this.clearKillEscalationTimer(session.process); | |
| 817 | } | |
| 818 | } | |
| 819 | ||
| 820 | private queuePersist(threadId: string, terminalId: string, history: string): void { | |
| 821 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 822 | this.pendingPersistHistory.set(persistenceKey, history); | |
| 823 | this.schedulePersist(threadId, terminalId); | |
| 824 | } | |
| 825 | ||
| 826 | private async persistHistory( | |
| 827 | threadId: string, | |
| 828 | terminalId: string, | |
| 829 | history: string, | |
| 830 | ): Promise<void> { | |
| 831 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 832 | this.clearPersistTimer(threadId, terminalId); | |
| 833 | this.pendingPersistHistory.delete(persistenceKey); | |
| 834 | await this.enqueuePersistWrite(threadId, terminalId, history); | |
| 835 | } | |
| 836 | ||
| 837 | private enqueuePersistWrite( | |
| 838 | threadId: string, | |
| 839 | terminalId: string, | |
| 840 | history: string, | |
| 841 | ): Promise<void> { | |
| 842 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 843 | const task = async () => { | |
| 844 | await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); | |
| 845 | }; | |
| 846 | const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); | |
| 847 | const next = previous | |
| 848 | .catch(() => undefined) | |
| 849 | .then(task) | |
| 850 | .catch((error) => { | |
| 851 | this.logger.warn("failed to persist terminal history", { | |
| 852 | threadId, | |
| 853 | terminalId, | |
| 854 | error: error instanceof Error ? error.message : String(error), | |
| 855 | }); | |
| 856 | }); | |
| 857 | this.persistQueues.set(persistenceKey, next); | |
| 858 | const finalized = next.finally(() => { | |
| 859 | if (this.persistQueues.get(persistenceKey) === next) { | |
| 860 | this.persistQueues.delete(persistenceKey); | |
| 861 | } | |
| 862 | if ( | |
| 863 | this.pendingPersistHistory.has(persistenceKey) && | |
| 864 | !this.persistTimers.has(persistenceKey) | |
| 865 | ) { | |
| 866 | this.schedulePersist(threadId, terminalId); | |
| 867 | } | |
| 868 | }); | |
| 869 | void finalized.catch(() => undefined); | |
| 870 | return finalized; | |
| 871 | } | |
| 872 | ||
| 873 | private schedulePersist(threadId: string, terminalId: string): void { | |
| 874 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 875 | if (this.persistTimers.has(persistenceKey)) return; | |
| 876 | const timer = setTimeout(() => { | |
| 877 | this.persistTimers.delete(persistenceKey); | |
| 878 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 879 | if (pendingHistory === undefined) return; | |
| 880 | this.pendingPersistHistory.delete(persistenceKey); | |
| 881 | void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 882 | }, this.persistDebounceMs); | |
| 883 | this.persistTimers.set(persistenceKey, timer); | |
| 884 | } | |
| 885 | ||
| 886 | private clearPersistTimer(threadId: string, terminalId: string): void { | |
| 887 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 888 | const timer = this.persistTimers.get(persistenceKey); | |
| 889 | if (!timer) return; | |
| 890 | clearTimeout(timer); | |
| 891 | this.persistTimers.delete(persistenceKey); | |
| 892 | } | |
| 893 | ||
| 894 | private async readHistory(threadId: string, terminalId: string): Promise<string> { | |
| 895 | const nextPath = this.historyPath(threadId, terminalId); | |
| 896 | try { | |
| 897 | const raw = await fs.promises.readFile(nextPath, "utf8"); | |
| 898 | const capped = capHistory(raw, this.historyLineLimit); | |
| 899 | if (capped !== raw) { | |
| 900 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 901 | } | |
| 902 | return capped; | |
| 903 | } catch (error) { | |
| 904 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { | |
| 905 | throw error; | |
| 906 | } | |
| 907 | } | |
| 908 | ||
| 909 | if (terminalId !== DEFAULT_TERMINAL_ID) { | |
| 910 | return ""; | |
| 911 | } | |
| 912 | ||
| 913 | const legacyPath = this.legacyHistoryPath(threadId); | |
| 914 | try { | |
| 915 | const raw = await fs.promises.readFile(legacyPath, "utf8"); | |
| 916 | const capped = capHistory(raw, this.historyLineLimit); | |
| 917 | ||
| 918 | // Migrate legacy transcript filename to the terminal-scoped path. | |
| 919 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 920 | try { | |
| 921 | await fs.promises.rm(legacyPath, { force: true }); | |
| 922 | } catch (cleanupError) { | |
| 923 | this.logger.warn("failed to remove legacy terminal history", { | |
| 924 | threadId, | |
| 925 | error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), | |
| 926 | }); | |
| 927 | } | |
| 928 | ||
| 929 | return capped; | |
| 930 | } catch (error) { | |
| 931 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 932 | return ""; | |
| 933 | } | |
| 934 | throw error; | |
| 935 | } | |
| 936 | } | |
| 937 | ||
| 938 | private async deleteHistory(threadId: string, terminalId: string): Promise<void> { | |
| 939 | const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; | |
| 940 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 941 | deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); | |
| 942 | } | |
| 943 | try { | |
| 944 | await Promise.all(deletions); | |
| 945 | } catch (error) { | |
| 946 | this.logger.warn("failed to delete terminal history", { | |
| 947 | threadId, | |
| 948 | terminalId, | |
| 949 | error: error instanceof Error ? error.message : String(error), | |
| 950 | }); | |
| 951 | } | |
| 952 | } | |
| 953 | ||
| 954 | private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> { | |
| 955 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 956 | this.clearPersistTimer(threadId, terminalId); | |
| 957 | ||
| 958 | while (true) { | |
| 959 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 960 | if (pendingHistory !== undefined) { | |
| 961 | this.pendingPersistHistory.delete(persistenceKey); | |
| 962 | await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 963 | } | |
| 964 | ||
| 965 | const pending = this.persistQueues.get(persistenceKey); | |
| 966 | if (!pending) { | |
| 967 | return; | |
| 968 | } | |
| 969 | await pending.catch(() => undefined); | |
| 970 | } | |
| 971 | } | |
| 972 | ||
| 973 | private updateSubprocessPollingState(): void { | |
| 974 | const hasRunningSessions = [...this.sessions.values()].some( | |
| 975 | (session) => session.status === "running" && session.pid !== null, | |
| 976 | ); | |
| 977 | if (hasRunningSessions) { | |
| 978 | this.ensureSubprocessPolling(); | |
| 979 | return; | |
| 980 | } | |
| 981 | this.stopSubprocessPolling(); | |
| 982 | } | |
| 983 | ||
| 984 | private ensureSubprocessPolling(): void { | |
| 985 | if (this.subprocessPollTimer) return; | |
| 986 | this.subprocessPollTimer = setInterval(() => { | |
| 987 | void this.pollSubprocessActivity(); | |
| 988 | }, this.subprocessPollIntervalMs); | |
| 989 | this.subprocessPollTimer.unref?.(); | |
| 990 | void this.pollSubprocessActivity(); | |
| 991 | } | |
| 992 | ||
| 993 | private stopSubprocessPolling(): void { | |
| 994 | if (!this.subprocessPollTimer) return; | |
| 995 | clearInterval(this.subprocessPollTimer); | |
| 996 | this.subprocessPollTimer = null; | |
| 997 | } | |
| 998 | ||
| 999 | private async pollSubprocessActivity(): Promise<void> { | |
| 1000 | if (this.subprocessPollInFlight) return; | |
| 1001 | ||
| 1002 | const runningSessions = [...this.sessions.values()].filter( | |
| 1003 | (session): session is TerminalSessionState & { pid: number } => | |
| 1004 | session.status === "running" && Number.isInteger(session.pid), | |
| 1005 | ); | |
| 1006 | if (runningSessions.length === 0) { | |
| 1007 | this.stopSubprocessPolling(); | |
| 1008 | return; | |
| 1009 | } | |
| 1010 | ||
| 1011 | this.subprocessPollInFlight = true; | |
| 1012 | try { | |
| 1013 | await Promise.all( | |
| 1014 | runningSessions.map(async (session) => { | |
| 1015 | const terminalPid = session.pid; | |
| 1016 | let hasRunningSubprocess = false; | |
| 1017 | try { | |
| 1018 | hasRunningSubprocess = await this.subprocessChecker(terminalPid); | |
| 1019 | } catch (error) { | |
| 1020 | this.logger.warn("failed to check terminal subprocess activity", { | |
| 1021 | threadId: session.threadId, | |
| 1022 | terminalId: session.terminalId, | |
| 1023 | terminalPid, | |
| 1024 | error: error instanceof Error ? error.message : String(error), | |
| 1025 | }); | |
| 1026 | return; | |
| 1027 | } | |
| 1028 | ||
| 1029 | const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); | |
| 1030 | if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { | |
| 1031 | return; | |
| 1032 | } | |
| 1033 | if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { | |
| 1034 | return; | |
| 1035 | } | |
| 1036 | ||
| 1037 | liveSession.hasRunningSubprocess = hasRunningSubprocess; | |
| 1038 | liveSession.updatedAt = new Date().toISOString(); | |
| 1039 | this.emitEvent({ | |
| 1040 | type: "activity", | |
| 1041 | threadId: liveSession.threadId, | |
| 1042 | terminalId: liveSession.terminalId, | |
| 1043 | createdAt: new Date().toISOString(), | |
| 1044 | hasRunningSubprocess, | |
| 1045 | }); | |
| 1046 | }), | |
| 1047 | ); | |
| 1048 | } finally { | |
| 1049 | this.subprocessPollInFlight = false; | |
| 1050 | } | |
| 1051 | } | |
| 1052 | ||
| 1053 | private async assertValidCwd(cwd: string): Promise<void> { | |
| 1054 | let stats: fs.Stats; | |
| 1055 | try { | |
| 1056 | stats = await fs.promises.stat(cwd); | |
| 1057 | } catch (error) { | |
| 1058 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 1059 | throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); | |
| 1060 | } | |
| 1061 | throw error; | |
| 1062 | } | |
| 1063 | if (!stats.isDirectory()) { | |
| 1064 | throw new Error(`Terminal cwd is not a directory: ${cwd}`); | |
| 1065 | } | |
| 1066 | } | |
| 1067 | ||
| 1068 | private async closeSession( | |
| 1069 | threadId: string, | |
| 1070 | terminalId: string, | |
| 1071 | deleteHistory: boolean, | |
| 1072 | ): Promise<void> { | |
| 1073 | const key = toSessionKey(threadId, terminalId); | |
| 1074 | const session = this.sessions.get(key); | |
| 1075 | if (session) { | |
| 1076 | this.stopProcess(session); | |
| 1077 | this.sessions.delete(key); | |
| 1078 | } | |
| 1079 | this.updateSubprocessPollingState(); | |
| 1080 | await this.flushPersistQueue(threadId, terminalId); | |
| 1081 | if (deleteHistory) { | |
| 1082 | await this.deleteHistory(threadId, terminalId); | |
| 1083 | } | |
| 1084 | } | |
| 1085 | ||
| 1086 | private sessionsForThread(threadId: string): TerminalSessionState[] { | |
| 1087 | return [...this.sessions.values()].filter((session) => session.threadId === threadId); | |
| 1088 | } | |
| 1089 | ||
| 1090 | private async deleteAllHistoryForThread(threadId: string): Promise<void> { | |
| 1091 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | |
| 1092 | try { | |
| 1093 | const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); | |
| 1094 | const removals = entries | |
| 1095 | .filter((entry) => entry.isFile()) | |
| 1096 | .map((entry) => entry.name) | |
| 1097 | .filter( | |
| 1098 | (name) => | |
| 1099 | name === `${toSafeThreadId(threadId)}.log` || | |
| 1100 | name === `${legacySafeThreadId(threadId)}.log` || | |
| 1101 | name.startsWith(threadPrefix), | |
| 1102 | ) | |
| 1103 | .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); | |
| 1104 | await Promise.all(removals); | |
| 1105 | } catch (error) { | |
| 1106 | this.logger.warn("failed to delete terminal histories for thread", { | |
| 1107 | threadId, | |
| 1108 | error: error instanceof Error ? error.message : String(error), | |
| 1109 | }); | |
| 1110 | } | |
| 1111 | } | |
| 1112 | ||
| 1113 | private requireSession(threadId: string, terminalId: string): TerminalSessionState { | |
| 1114 | const session = this.sessions.get(toSessionKey(threadId, terminalId)); | |
| 1115 | if (!session) { | |
| 1116 | throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); | |
| 1117 | } | |
| 1118 | return session; | |
| 1119 | } | |
| 1120 | ||
| 1121 | private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | |
| 1122 | return { | |
| 1123 | threadId: session.threadId, | |
| 1124 | terminalId: session.terminalId, | |
| 1125 | cwd: session.cwd, | |
| 1126 | status: session.status, | |
| 1127 | pid: session.pid, | |
| 1128 | history: session.history, | |
| 1129 | exitCode: session.exitCode, | |
| 1130 | exitSignal: session.exitSignal, | |
| 1131 | updatedAt: session.updatedAt, | |
| 1132 | }; | |
| 1133 | } | |
| 1134 | ||
| 1135 | private emitEvent(event: TerminalEvent): void { | |
| 1136 | this.emit("event", event); | |
| 1137 | } | |
| 1138 | ||
| 1139 | private historyPath(threadId: string, terminalId: string): string { | |
| 1140 | const threadPart = toSafeThreadId(threadId); | |
| 1141 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 1142 | return path.join(this.logsDir, `${threadPart}.log`); | |
| 1143 | } | |
| 1144 | return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | |
| 1145 | } | |
| 1146 | ||
| 1147 | private legacyHistoryPath(threadId: string): string { | |
| 1148 | return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); | |
| 1149 | } | |
| 1150 | ||
| 1151 | private async runWithThreadLock<T>(threadId: string, task: () => Promise<T>): Promise<T> { | |
| 1152 | const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); | |
| 1153 | let release: () => void = () => {}; | |
| 1154 | const current = new Promise<void>((resolve) => { | |
| 1155 | release = resolve; | |
| 1156 | }); | |
| 1157 | this.threadLocks.set(threadId, current); | |
| 1158 | await previous.catch(() => undefined); | |
| 1159 | try { | |
| 1160 | return await task(); | |
| 1161 | } finally { | |
| 1162 | release(); | |
| 1163 | if (this.threadLocks.get(threadId) === current) { | |
| 1164 | this.threadLocks.delete(threadId); | |
| 1165 | } | |
| 1166 | } | |
| 1167 | } | |
| 1168 | } | |
| 1169 | ||
| 1170 | export const TerminalManagerLive = Layer.effect( | |
| 1171 | TerminalManager, | |
| 1172 | Effect.gen(function* () { | |
| 1173 | const { stateDir } = yield* ServerConfig; | |
| 1174 | const { join } = yield* Path.Path; | |
| 1175 | const logsDir = join(stateDir, "logs", "terminals"); | |
| 1176 | ||
| 1177 | const ptyAdapter = yield* PtyAdapter; | |
| 1178 | const runtime = yield* Effect.acquireRelease( | |
| 1179 | Effect.sync(() => new TerminalManagerRuntime({ logsDir, ptyAdapter })), | |
| 1180 | (r) => Effect.sync(() => r.dispose()), | |
| 1181 | ); | |
| 1182 | ||
| 1183 | return { | |
| 1184 | open: (input) => | |
| 1185 | Effect.tryPromise({ | |
| 1186 | try: () => runtime.open(input), | |
| 1187 | catch: (cause) => new TerminalError({ message: "Failed to open terminal", cause }), | |
| 1188 | }), | |
| 1189 | write: (input) => | |
| 1190 | Effect.tryPromise({ | |
| 1191 | try: () => runtime.write(input), | |
| 1192 | catch: (cause) => new TerminalError({ message: "Failed to write to terminal", cause }), | |
| 1193 | }), | |
| 1194 | resize: (input) => | |
| 1195 | Effect.tryPromise({ | |
| 1196 | try: () => runtime.resize(input), | |
| 1197 | catch: (cause) => new TerminalError({ message: "Failed to resize terminal", cause }), | |
| 1198 | }), | |
| 1199 | clear: (input) => | |
| 1200 | Effect.tryPromise({ | |
| 1201 | try: () => runtime.clear(input), | |
| 1202 | catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }), | |
| 1203 | }), | |
| 1204 | restart: (input) => | |
| 1205 | Effect.tryPromise({ | |
| 1206 | try: () => runtime.restart(input), | |
| 1207 | catch: (cause) => new TerminalError({ message: "Failed to restart terminal", cause }), | |
| 1208 | }), | |
| 1209 | close: (input) => | |
| 1210 | Effect.tryPromise({ | |
| 1211 | try: () => runtime.close(input), | |
| 1212 | catch: (cause) => new TerminalError({ message: "Failed to close terminal", cause }), | |
| 1213 | }), | |
| 1214 | subscribe: (listener) => | |
| 1215 | Effect.sync(() => { | |
| 1216 | runtime.on("event", listener); | |
| 1217 | return () => { | |
| 1218 | runtime.off("event", listener); | |
| 1219 | }; | |
| 1220 | }), | |
| 1221 | dispose: Effect.sync(() => runtime.dispose()), | |
| 1222 | } satisfies TerminalManagerShape; | |
| 1223 | }), | |
| 1224 | ); |
apps/server/src/terminal/Layers/NodePTY.test.ts created+45| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | import { FileSystem, Path, Effect } from "effect"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | ||
| 4 | import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; | |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | ||
| 7 | it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { | |
| 8 | it.effect("adds executable bits when helper exists but is not executable", () => | |
| 9 | Effect.gen(function* () { | |
| 10 | if (process.platform === "win32") return; | |
| 11 | ||
| 12 | const fs = yield* FileSystem.FileSystem; | |
| 13 | const path = yield* Path.Path; | |
| 14 | ||
| 15 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | |
| 16 | const helperPath = path.join(dir, "spawn-helper"); | |
| 17 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | |
| 18 | yield* fs.chmod(helperPath, 0o644); | |
| 19 | ||
| 20 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | |
| 21 | ||
| 22 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | |
| 23 | assert.equal(mode & 0o111, 0o111); | |
| 24 | }), | |
| 25 | ); | |
| 26 | ||
| 27 | it.effect("keeps executable helper as executable", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | if (process.platform === "win32") return; | |
| 30 | ||
| 31 | const fs = yield* FileSystem.FileSystem; | |
| 32 | const path = yield* Path.Path; | |
| 33 | ||
| 34 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); | |
| 35 | const helperPath = path.join(dir, "spawn-helper"); | |
| 36 | yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); | |
| 37 | yield* fs.chmod(helperPath, 0o755); | |
| 38 | ||
| 39 | yield* ensureNodePtySpawnHelperExecutable(helperPath); | |
| 40 | ||
| 41 | const mode = (yield* fs.stat(helperPath)).mode & 0o777; | |
| 42 | assert.equal(mode & 0o111, 0o111); | |
| 43 | }), | |
| 44 | ); | |
| 45 | }); |
apps/server/src/terminal/Layers/NodePTY.ts created+117| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | import { createRequire } from "node:module"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; | |
| 5 | ||
| 6 | let didEnsureSpawnHelperExecutable = false; | |
| 7 | ||
| 8 | const resolveNodePtySpawnHelperPath = Effect.gen(function* () { | |
| 9 | const requireForNodePty = createRequire(import.meta.url); | |
| 10 | const path = yield* Path.Path; | |
| 11 | const fs = yield* FileSystem.FileSystem; | |
| 12 | ||
| 13 | const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); | |
| 14 | const packageDir = path.dirname(packageJsonPath); | |
| 15 | const candidates = [ | |
| 16 | path.join(packageDir, "build", "Release", "spawn-helper"), | |
| 17 | path.join(packageDir, "build", "Debug", "spawn-helper"), | |
| 18 | path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), | |
| 19 | ]; | |
| 20 | ||
| 21 | for (const candidate of candidates) { | |
| 22 | if (yield* fs.exists(candidate)) { | |
| 23 | return candidate; | |
| 24 | } | |
| 25 | } | |
| 26 | return null; | |
| 27 | }).pipe(Effect.orElseSucceed(() => null)); | |
| 28 | ||
| 29 | export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { | |
| 30 | const fs = yield* FileSystem.FileSystem; | |
| 31 | if (process.platform === "win32") return; | |
| 32 | if (!explicitPath && didEnsureSpawnHelperExecutable) return; | |
| 33 | ||
| 34 | const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); | |
| 35 | if (!helperPath) return; | |
| 36 | if (!explicitPath) { | |
| 37 | didEnsureSpawnHelperExecutable = true; | |
| 38 | } | |
| 39 | ||
| 40 | if (!(yield* fs.exists(helperPath))) { | |
| 41 | return; | |
| 42 | } | |
| 43 | ||
| 44 | // Best-effort: avoid FileSystem.stat in packaged mode where some fs metadata can be missing. | |
| 45 | yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); | |
| 46 | }); | |
| 47 | ||
| 48 | class NodePtyProcess implements PtyProcess { | |
| 49 | constructor(private readonly process: import("node-pty").IPty) {} | |
| 50 | ||
| 51 | get pid(): number { | |
| 52 | return this.process.pid; | |
| 53 | } | |
| 54 | ||
| 55 | write(data: string): void { | |
| 56 | this.process.write(data); | |
| 57 | } | |
| 58 | ||
| 59 | resize(cols: number, rows: number): void { | |
| 60 | this.process.resize(cols, rows); | |
| 61 | } | |
| 62 | ||
| 63 | kill(signal?: string): void { | |
| 64 | this.process.kill(signal); | |
| 65 | } | |
| 66 | ||
| 67 | onData(callback: (data: string) => void): () => void { | |
| 68 | const disposable = this.process.onData(callback); | |
| 69 | return () => { | |
| 70 | disposable.dispose(); | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 75 | const disposable = this.process.onExit((event) => { | |
| 76 | callback({ | |
| 77 | exitCode: event.exitCode, | |
| 78 | signal: event.signal ?? null, | |
| 79 | }); | |
| 80 | }); | |
| 81 | return () => { | |
| 82 | disposable.dispose(); | |
| 83 | }; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | export const NodePtyAdapterLive = Layer.effect( | |
| 88 | PtyAdapter, | |
| 89 | Effect.gen(function* () { | |
| 90 | const fs = yield* FileSystem.FileSystem; | |
| 91 | const path = yield* Path.Path; | |
| 92 | ||
| 93 | const nodePty = yield* Effect.promise(() => import("node-pty")); | |
| 94 | ||
| 95 | const ensureNodePtySpawnHelperExecutableCached = yield* Effect.cached( | |
| 96 | ensureNodePtySpawnHelperExecutable().pipe( | |
| 97 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 98 | Effect.provideService(Path.Path, path), | |
| 99 | Effect.orElseSucceed(() => undefined), | |
| 100 | ), | |
| 101 | ); | |
| 102 | ||
| 103 | return { | |
| 104 | spawn: Effect.fn(function* (input) { | |
| 105 | yield* ensureNodePtySpawnHelperExecutableCached; | |
| 106 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 107 | cwd: input.cwd, | |
| 108 | cols: input.cols, | |
| 109 | rows: input.rows, | |
| 110 | env: input.env, | |
| 111 | name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", | |
| 112 | }); | |
| 113 | return new NodePtyProcess(ptyProcess); | |
| 114 | }), | |
| 115 | } satisfies PtyAdapterShape; | |
| 116 | }), | |
| 117 | ); |
apps/server/src/terminal/Services/Manager.ts created+117| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | /** | |
| 2 | * TerminalManager - Terminal session orchestration service interface. | |
| 3 | * | |
| 4 | * Owns terminal lifecycle operations, output fanout, and session state | |
| 5 | * transitions for thread-scoped terminals. | |
| 6 | * | |
| 7 | * @module TerminalManager | |
| 8 | */ | |
| 9 | import { | |
| 10 | TerminalClearInput, | |
| 11 | TerminalCloseInput, | |
| 12 | TerminalEvent, | |
| 13 | TerminalOpenInput, | |
| 14 | TerminalResizeInput, | |
| 15 | TerminalSessionSnapshot, | |
| 16 | TerminalSessionStatus, | |
| 17 | TerminalWriteInput, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { PtyProcess } from "./PTY"; | |
| 20 | import { Effect, Schema, ServiceMap } from "effect"; | |
| 21 | ||
| 22 | export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", { | |
| 23 | message: Schema.String, | |
| 24 | cause: Schema.optional(Schema.Defect), | |
| 25 | }) {} | |
| 26 | ||
| 27 | export interface TerminalSessionState { | |
| 28 | threadId: string; | |
| 29 | terminalId: string; | |
| 30 | cwd: string; | |
| 31 | status: TerminalSessionStatus; | |
| 32 | pid: number | null; | |
| 33 | history: string; | |
| 34 | exitCode: number | null; | |
| 35 | exitSignal: number | null; | |
| 36 | updatedAt: string; | |
| 37 | cols: number; | |
| 38 | rows: number; | |
| 39 | process: PtyProcess | null; | |
| 40 | unsubscribeData: (() => void) | null; | |
| 41 | unsubscribeExit: (() => void) | null; | |
| 42 | hasRunningSubprocess: boolean; | |
| 43 | runtimeEnv: Record<string, string> | null; | |
| 44 | } | |
| 45 | ||
| 46 | export interface ShellCandidate { | |
| 47 | shell: string; | |
| 48 | args?: string[]; | |
| 49 | } | |
| 50 | ||
| 51 | export interface TerminalStartInput extends TerminalOpenInput { | |
| 52 | cols: number; | |
| 53 | rows: number; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * TerminalManagerShape - Service API for terminal session lifecycle operations. | |
| 58 | */ | |
| 59 | export interface TerminalManagerShape { | |
| 60 | /** | |
| 61 | * Open or attach to a terminal session. | |
| 62 | * | |
| 63 | * Reuses an existing session for the same thread/terminal id and restores | |
| 64 | * persisted history on first open. | |
| 65 | */ | |
| 66 | readonly open: ( | |
| 67 | input: TerminalOpenInput, | |
| 68 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Write input bytes to a terminal session. | |
| 72 | */ | |
| 73 | readonly write: (input: TerminalWriteInput) => Effect.Effect<void, TerminalError>; | |
| 74 | ||
| 75 | /** | |
| 76 | * Resize the PTY backing a terminal session. | |
| 77 | */ | |
| 78 | readonly resize: (input: TerminalResizeInput) => Effect.Effect<void, TerminalError>; | |
| 79 | ||
| 80 | /** | |
| 81 | * Clear terminal output history. | |
| 82 | */ | |
| 83 | readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>; | |
| 84 | ||
| 85 | /** | |
| 86 | * Restart a terminal session in place. | |
| 87 | * | |
| 88 | * Always resets history before spawning the new process. | |
| 89 | */ | |
| 90 | readonly restart: ( | |
| 91 | input: TerminalOpenInput, | |
| 92 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | |
| 93 | ||
| 94 | /** | |
| 95 | * Close an active terminal session. | |
| 96 | * | |
| 97 | * When `terminalId` is omitted, closes all sessions for the thread. | |
| 98 | */ | |
| 99 | readonly close: (input: TerminalCloseInput) => Effect.Effect<void, TerminalError>; | |
| 100 | ||
| 101 | /** | |
| 102 | * Subscribe to terminal runtime events. | |
| 103 | */ | |
| 104 | readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; | |
| 105 | ||
| 106 | /** | |
| 107 | * Dispose all managed terminal resources. | |
| 108 | */ | |
| 109 | readonly dispose: Effect.Effect<void>; | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * TerminalManager - Service tag for terminal session orchestration. | |
| 114 | */ | |
| 115 | export class TerminalManager extends ServiceMap.Service<TerminalManager, TerminalManagerShape>()( | |
| 116 | "t3/terminal/Services/Manager/TerminalManager", | |
| 117 | ) {} |
apps/server/src/terminal/Services/PTY.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | /** | |
| 2 | * PtyAdapter - Terminal PTY adapter service contract. | |
| 3 | * | |
| 4 | * Defines the process primitives required by terminal session management | |
| 5 | * without binding to a specific PTY implementation. | |
| 6 | * | |
| 7 | * @module PtyAdapter | |
| 8 | */ | |
| 9 | import { Effect, Schema, ServiceMap } from "effect"; | |
| 10 | ||
| 11 | /** | |
| 12 | * PtyError - Error type for PTY adapter operations. | |
| 13 | */ | |
| 14 | export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", { | |
| 15 | adapter: Schema.String, | |
| 16 | message: Schema.String, | |
| 17 | cause: Schema.optional(Schema.Defect), | |
| 18 | }) {} | |
| 19 | ||
| 20 | export interface PtyExitEvent { | |
| 21 | exitCode: number; | |
| 22 | signal: number | null; | |
| 23 | } | |
| 24 | ||
| 25 | export interface PtyProcess { | |
| 26 | readonly pid: number; | |
| 27 | write(data: string): void; | |
| 28 | resize(cols: number, rows: number): void; | |
| 29 | kill(signal?: string): void; | |
| 30 | onData(callback: (data: string) => void): () => void; | |
| 31 | onExit(callback: (event: PtyExitEvent) => void): () => void; | |
| 32 | } | |
| 33 | ||
| 34 | export interface PtySpawnInput { | |
| 35 | shell: string; | |
| 36 | args?: string[]; | |
| 37 | cwd: string; | |
| 38 | cols: number; | |
| 39 | rows: number; | |
| 40 | env: NodeJS.ProcessEnv; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * PtyAdapterShape - Service API for spawning and controlling PTY processes. | |
| 45 | */ | |
| 46 | export interface PtyAdapterShape { | |
| 47 | /** | |
| 48 | * Spawn a PTY process for a terminal session. | |
| 49 | */ | |
| 50 | spawn(input: PtySpawnInput): Effect.Effect<PtyProcess, PtySpawnError>; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * PtyAdapter - Service tag for PTY process integration. | |
| 55 | */ | |
| 56 | export class PtyAdapter extends ServiceMap.Service<PtyAdapter, PtyAdapterShape>()( | |
| 57 | "t3/terminal/Services/PTY/PtyAdapter", | |
| 58 | ) {} |
apps/server/src/terminalManager.test.ts deleted-566| ... | ... | @@ -1,566 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalEvent, | |
| 8 | type TerminalOpenInput, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | |
| 13 | import { TerminalManager } from "./terminalManager"; | |
| 14 | ||
| 15 | class FakePtyProcess implements PtyProcess { | |
| 16 | readonly writes: string[] = []; | |
| 17 | readonly resizeCalls: Array<{ cols: number; rows: number }> = []; | |
| 18 | private readonly dataListeners = new Set<(data: string) => void>(); | |
| 19 | private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); | |
| 20 | killed = false; | |
| 21 | ||
| 22 | constructor(readonly pid: number) {} | |
| 23 | ||
| 24 | write(data: string): void { | |
| 25 | this.writes.push(data); | |
| 26 | } | |
| 27 | ||
| 28 | resize(cols: number, rows: number): void { | |
| 29 | this.resizeCalls.push({ cols, rows }); | |
| 30 | } | |
| 31 | ||
| 32 | kill(): void { | |
| 33 | this.killed = true; | |
| 34 | } | |
| 35 | ||
| 36 | onData(callback: (data: string) => void): () => void { | |
| 37 | this.dataListeners.add(callback); | |
| 38 | return () => { | |
| 39 | this.dataListeners.delete(callback); | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | onExit(callback: (event: PtyExitEvent) => void): () => void { | |
| 44 | this.exitListeners.add(callback); | |
| 45 | return () => { | |
| 46 | this.exitListeners.delete(callback); | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | emitData(data: string): void { | |
| 51 | for (const listener of this.dataListeners) { | |
| 52 | listener(data); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | emitExit(event: PtyExitEvent): void { | |
| 57 | for (const listener of this.exitListeners) { | |
| 58 | listener(event); | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | class FakePtyAdapter implements PtyAdapter { | |
| 64 | readonly spawnInputs: PtySpawnInput[] = []; | |
| 65 | readonly processes: FakePtyProcess[] = []; | |
| 66 | readonly spawnFailures: Error[] = []; | |
| 67 | private nextPid = 9000; | |
| 68 | ||
| 69 | spawn(input: PtySpawnInput): PtyProcess { | |
| 70 | this.spawnInputs.push(input); | |
| 71 | const failure = this.spawnFailures.shift(); | |
| 72 | if (failure) { | |
| 73 | throw failure; | |
| 74 | } | |
| 75 | const process = new FakePtyProcess(this.nextPid++); | |
| 76 | this.processes.push(process); | |
| 77 | return process; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> { | |
| 82 | const started = Date.now(); | |
| 83 | return new Promise((resolve, reject) => { | |
| 84 | const poll = () => { | |
| 85 | if (predicate()) { | |
| 86 | resolve(); | |
| 87 | return; | |
| 88 | } | |
| 89 | if (Date.now() - started > timeoutMs) { | |
| 90 | reject(new Error("Timed out waiting for condition")); | |
| 91 | return; | |
| 92 | } | |
| 93 | setTimeout(poll, 15); | |
| 94 | }; | |
| 95 | poll(); | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput { | |
| 100 | return { | |
| 101 | threadId: "thread-1", | |
| 102 | cwd: process.cwd(), | |
| 103 | cols: 100, | |
| 104 | rows: 24, | |
| 105 | ...overrides, | |
| 106 | }; | |
| 107 | } | |
| 108 | ||
| 109 | function historyLogName(threadId: string): string { | |
| 110 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; | |
| 111 | } | |
| 112 | ||
| 113 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | |
| 114 | const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | |
| 115 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 116 | return `${threadPart}.log`; | |
| 117 | } | |
| 118 | return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`; | |
| 119 | } | |
| 120 | ||
| 121 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | |
| 122 | return path.join(logsDir, historyLogName(threadId)); | |
| 123 | } | |
| 124 | ||
| 125 | function multiTerminalHistoryLogPath( | |
| 126 | logsDir: string, | |
| 127 | threadId = "thread-1", | |
| 128 | terminalId = "default", | |
| 129 | ): string { | |
| 130 | return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); | |
| 131 | } | |
| 132 | ||
| 133 | describe("TerminalManager", () => { | |
| 134 | const tempDirs: string[] = []; | |
| 135 | ||
| 136 | afterEach(() => { | |
| 137 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 138 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 139 | } | |
| 140 | }); | |
| 141 | ||
| 142 | function makeManager( | |
| 143 | historyLineLimit = 5, | |
| 144 | options: { | |
| 145 | shellResolver?: () => string; | |
| 146 | subprocessChecker?: (terminalPid: number) => Promise<boolean>; | |
| 147 | subprocessPollIntervalMs?: number; | |
| 148 | } = {}, | |
| 149 | ) { | |
| 150 | const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); | |
| 151 | tempDirs.push(logsDir); | |
| 152 | const ptyAdapter = new FakePtyAdapter(); | |
| 153 | const manager = new TerminalManager({ | |
| 154 | logsDir, | |
| 155 | ptyAdapter, | |
| 156 | historyLineLimit, | |
| 157 | shellResolver: options.shellResolver ?? (() => "/bin/bash"), | |
| 158 | ...(options.subprocessChecker ? { subprocessChecker: options.subprocessChecker } : {}), | |
| 159 | ...(options.subprocessPollIntervalMs | |
| 160 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | |
| 161 | : {}), | |
| 162 | }); | |
| 163 | return { logsDir, ptyAdapter, manager }; | |
| 164 | } | |
| 165 | ||
| 166 | it("spawns lazily and reuses running terminal per thread", async () => { | |
| 167 | const { manager, ptyAdapter } = makeManager(); | |
| 168 | const [first, second] = await Promise.all([ | |
| 169 | manager.open(openInput()), | |
| 170 | manager.open(openInput()), | |
| 171 | ]); | |
| 172 | const third = await manager.open(openInput()); | |
| 173 | ||
| 174 | expect(first.threadId).toBe("thread-1"); | |
| 175 | expect(first.terminalId).toBe("default"); | |
| 176 | expect(second.threadId).toBe("thread-1"); | |
| 177 | expect(third.threadId).toBe("thread-1"); | |
| 178 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | |
| 179 | ||
| 180 | manager.dispose(); | |
| 181 | }); | |
| 182 | ||
| 183 | it("forwards write and resize to active pty process", async () => { | |
| 184 | const { manager, ptyAdapter } = makeManager(); | |
| 185 | await manager.open(openInput()); | |
| 186 | const process = ptyAdapter.processes[0]; | |
| 187 | expect(process).toBeDefined(); | |
| 188 | if (!process) return; | |
| 189 | ||
| 190 | await manager.write({ threadId: "thread-1", data: "ls\n" }); | |
| 191 | await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 }); | |
| 192 | ||
| 193 | expect(process.writes).toEqual(["ls\n"]); | |
| 194 | expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); | |
| 195 | ||
| 196 | manager.dispose(); | |
| 197 | }); | |
| 198 | ||
| 199 | it("resizes running terminal on open when a different size is requested", async () => { | |
| 200 | const { manager, ptyAdapter } = makeManager(); | |
| 201 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 202 | const process = ptyAdapter.processes[0]; | |
| 203 | expect(process).toBeDefined(); | |
| 204 | if (!process) return; | |
| 205 | ||
| 206 | await manager.open(openInput({ cols: 140, rows: 40 })); | |
| 207 | ||
| 208 | expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); | |
| 209 | ||
| 210 | manager.dispose(); | |
| 211 | }); | |
| 212 | ||
| 213 | it("preserves existing terminal size on open when size is omitted", async () => { | |
| 214 | const { manager, ptyAdapter } = makeManager(); | |
| 215 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 216 | const ptyProcess = ptyAdapter.processes[0]; | |
| 217 | expect(ptyProcess).toBeDefined(); | |
| 218 | if (!ptyProcess) return; | |
| 219 | ||
| 220 | await manager.open({ | |
| 221 | threadId: "thread-1", | |
| 222 | cwd: globalThis.process.cwd(), | |
| 223 | }); | |
| 224 | ||
| 225 | expect(ptyProcess.resizeCalls).toEqual([]); | |
| 226 | ||
| 227 | ptyProcess.emitExit({ exitCode: 0, signal: 0 }); | |
| 228 | await manager.open({ | |
| 229 | threadId: "thread-1", | |
| 230 | cwd: globalThis.process.cwd(), | |
| 231 | }); | |
| 232 | ||
| 233 | const resumedSpawn = ptyAdapter.spawnInputs[1]; | |
| 234 | expect(resumedSpawn).toBeDefined(); | |
| 235 | if (!resumedSpawn) return; | |
| 236 | expect(resumedSpawn.cols).toBe(100); | |
| 237 | expect(resumedSpawn.rows).toBe(24); | |
| 238 | ||
| 239 | manager.dispose(); | |
| 240 | }); | |
| 241 | ||
| 242 | it("uses default dimensions when opening a new terminal without size hints", async () => { | |
| 243 | const { manager, ptyAdapter } = makeManager(); | |
| 244 | await manager.open({ | |
| 245 | threadId: "thread-1", | |
| 246 | cwd: process.cwd(), | |
| 247 | }); | |
| 248 | ||
| 249 | const spawned = ptyAdapter.spawnInputs[0]; | |
| 250 | expect(spawned).toBeDefined(); | |
| 251 | if (!spawned) return; | |
| 252 | expect(spawned.cols).toBe(120); | |
| 253 | expect(spawned.rows).toBe(30); | |
| 254 | ||
| 255 | manager.dispose(); | |
| 256 | }); | |
| 257 | ||
| 258 | it("supports multiple terminals per thread with isolated sessions", async () => { | |
| 259 | const { manager, ptyAdapter } = makeManager(); | |
| 260 | await manager.open(openInput({ terminalId: "default" })); | |
| 261 | await manager.open(openInput({ terminalId: "term-2" })); | |
| 262 | ||
| 263 | const first = ptyAdapter.processes[0]; | |
| 264 | const second = ptyAdapter.processes[1]; | |
| 265 | expect(first).toBeDefined(); | |
| 266 | expect(second).toBeDefined(); | |
| 267 | if (!first || !second) return; | |
| 268 | ||
| 269 | await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); | |
| 270 | await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); | |
| 271 | ||
| 272 | expect(first.writes).toEqual(["pwd\n"]); | |
| 273 | expect(second.writes).toEqual(["ls\n"]); | |
| 274 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 275 | ||
| 276 | manager.dispose(); | |
| 277 | }); | |
| 278 | ||
| 279 | it("clears transcript and emits cleared event", async () => { | |
| 280 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 281 | const events: TerminalEvent[] = []; | |
| 282 | manager.on("event", (event) => { | |
| 283 | events.push(event); | |
| 284 | }); | |
| 285 | await manager.open(openInput()); | |
| 286 | const process = ptyAdapter.processes[0]; | |
| 287 | expect(process).toBeDefined(); | |
| 288 | if (!process) return; | |
| 289 | ||
| 290 | process.emitData("hello\n"); | |
| 291 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 292 | await manager.clear({ threadId: "thread-1" }); | |
| 293 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 294 | ||
| 295 | expect(events.some((event) => event.type === "cleared")).toBe(true); | |
| 296 | expect( | |
| 297 | events.some( | |
| 298 | (event) => | |
| 299 | event.type === "cleared" && | |
| 300 | event.threadId === "thread-1" && | |
| 301 | event.terminalId === "default", | |
| 302 | ), | |
| 303 | ).toBe(true); | |
| 304 | ||
| 305 | manager.dispose(); | |
| 306 | }); | |
| 307 | ||
| 308 | it("restarts terminal with empty transcript and respawns pty", async () => { | |
| 309 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 310 | await manager.open(openInput()); | |
| 311 | const firstProcess = ptyAdapter.processes[0]; | |
| 312 | expect(firstProcess).toBeDefined(); | |
| 313 | if (!firstProcess) return; | |
| 314 | firstProcess.emitData("before restart\n"); | |
| 315 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 316 | ||
| 317 | const snapshot = await manager.restart(openInput()); | |
| 318 | expect(snapshot.history).toBe(""); | |
| 319 | expect(snapshot.status).toBe("running"); | |
| 320 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 321 | await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); | |
| 322 | ||
| 323 | manager.dispose(); | |
| 324 | }); | |
| 325 | ||
| 326 | it("emits exited event and reopens with clean transcript after exit", async () => { | |
| 327 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 328 | const events: TerminalEvent[] = []; | |
| 329 | manager.on("event", (event) => { | |
| 330 | events.push(event); | |
| 331 | }); | |
| 332 | await manager.open(openInput()); | |
| 333 | const process = ptyAdapter.processes[0]; | |
| 334 | expect(process).toBeDefined(); | |
| 335 | if (!process) return; | |
| 336 | process.emitData("old data\n"); | |
| 337 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 338 | process.emitExit({ exitCode: 0, signal: 0 }); | |
| 339 | ||
| 340 | await waitFor(() => events.some((event) => event.type === "exited")); | |
| 341 | const reopened = await manager.open(openInput()); | |
| 342 | ||
| 343 | expect(reopened.history).toBe(""); | |
| 344 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | |
| 345 | expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe(""); | |
| 346 | ||
| 347 | manager.dispose(); | |
| 348 | }); | |
| 349 | ||
| 350 | it("emits subprocess activity events when child-process state changes", async () => { | |
| 351 | let hasRunningSubprocess = false; | |
| 352 | const { manager } = makeManager(5, { | |
| 353 | subprocessChecker: async () => hasRunningSubprocess, | |
| 354 | subprocessPollIntervalMs: 20, | |
| 355 | }); | |
| 356 | const events: TerminalEvent[] = []; | |
| 357 | manager.on("event", (event) => { | |
| 358 | events.push(event); | |
| 359 | }); | |
| 360 | ||
| 361 | await manager.open(openInput()); | |
| 362 | await waitFor(() => events.some((event) => event.type === "started")); | |
| 363 | expect(events.some((event) => event.type === "activity")).toBe(false); | |
| 364 | ||
| 365 | hasRunningSubprocess = true; | |
| 366 | await waitFor( | |
| 367 | () => | |
| 368 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | |
| 369 | 1_200, | |
| 370 | ); | |
| 371 | ||
| 372 | hasRunningSubprocess = false; | |
| 373 | await waitFor( | |
| 374 | () => | |
| 375 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | |
| 376 | 1_200, | |
| 377 | ); | |
| 378 | ||
| 379 | manager.dispose(); | |
| 380 | }); | |
| 381 | ||
| 382 | it("caps persisted history to configured line limit", async () => { | |
| 383 | const { manager, ptyAdapter } = makeManager(3); | |
| 384 | await manager.open(openInput()); | |
| 385 | const process = ptyAdapter.processes[0]; | |
| 386 | expect(process).toBeDefined(); | |
| 387 | if (!process) return; | |
| 388 | ||
| 389 | process.emitData("line1\nline2\nline3\nline4\n"); | |
| 390 | await manager.close({ threadId: "thread-1" }); | |
| 391 | ||
| 392 | const reopened = await manager.open(openInput()); | |
| 393 | const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); | |
| 394 | expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); | |
| 395 | ||
| 396 | manager.dispose(); | |
| 397 | }); | |
| 398 | ||
| 399 | it("deletes history file when close(deleteHistory=true)", async () => { | |
| 400 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 401 | await manager.open(openInput()); | |
| 402 | const process = ptyAdapter.processes[0]; | |
| 403 | expect(process).toBeDefined(); | |
| 404 | if (!process) return; | |
| 405 | process.emitData("bye\n"); | |
| 406 | await waitFor(() => fs.existsSync(historyLogPath(logsDir))); | |
| 407 | ||
| 408 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 409 | expect(fs.existsSync(historyLogPath(logsDir))).toBe(false); | |
| 410 | ||
| 411 | manager.dispose(); | |
| 412 | }); | |
| 413 | ||
| 414 | it("closes all terminals for a thread when close omits terminalId", async () => { | |
| 415 | const { manager, ptyAdapter, logsDir } = makeManager(); | |
| 416 | await manager.open(openInput({ terminalId: "default" })); | |
| 417 | await manager.open(openInput({ terminalId: "sidecar" })); | |
| 418 | const defaultProcess = ptyAdapter.processes[0]; | |
| 419 | const sidecarProcess = ptyAdapter.processes[1]; | |
| 420 | expect(defaultProcess).toBeDefined(); | |
| 421 | expect(sidecarProcess).toBeDefined(); | |
| 422 | if (!defaultProcess || !sidecarProcess) return; | |
| 423 | ||
| 424 | defaultProcess.emitData("default\n"); | |
| 425 | sidecarProcess.emitData("sidecar\n"); | |
| 426 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); | |
| 427 | await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); | |
| 428 | ||
| 429 | await manager.close({ threadId: "thread-1", deleteHistory: true }); | |
| 430 | ||
| 431 | expect(defaultProcess.killed).toBe(true); | |
| 432 | expect(sidecarProcess.killed).toBe(true); | |
| 433 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe(false); | |
| 434 | expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe(false); | |
| 435 | ||
| 436 | manager.dispose(); | |
| 437 | }); | |
| 438 | ||
| 439 | it("migrates legacy transcript filenames to terminal-scoped history path on open", async () => { | |
| 440 | const { manager, logsDir } = makeManager(); | |
| 441 | const legacyPath = path.join(logsDir, "thread-1.log"); | |
| 442 | const nextPath = historyLogPath(logsDir); | |
| 443 | fs.writeFileSync(legacyPath, "legacy-line\n", "utf8"); | |
| 444 | ||
| 445 | const snapshot = await manager.open(openInput()); | |
| 446 | ||
| 447 | expect(snapshot.history).toBe("legacy-line\n"); | |
| 448 | expect(fs.existsSync(nextPath)).toBe(true); | |
| 449 | expect(fs.readFileSync(nextPath, "utf8")).toBe("legacy-line\n"); | |
| 450 | expect(fs.existsSync(legacyPath)).toBe(false); | |
| 451 | ||
| 452 | manager.dispose(); | |
| 453 | }); | |
| 454 | ||
| 455 | it("retries with fallback shells when preferred shell spawn fails", async () => { | |
| 456 | const { manager, ptyAdapter } = makeManager(5, { | |
| 457 | shellResolver: () => "/definitely/missing-shell -l", | |
| 458 | }); | |
| 459 | ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); | |
| 460 | ||
| 461 | const snapshot = await manager.open(openInput()); | |
| 462 | ||
| 463 | expect(snapshot.status).toBe("running"); | |
| 464 | expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); | |
| 465 | expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); | |
| 466 | ||
| 467 | if (process.platform === "win32") { | |
| 468 | expect( | |
| 469 | ptyAdapter.spawnInputs.some( | |
| 470 | (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", | |
| 471 | ), | |
| 472 | ).toBe(true); | |
| 473 | } else { | |
| 474 | expect( | |
| 475 | ptyAdapter.spawnInputs.some((input) => | |
| 476 | ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), | |
| 477 | ), | |
| 478 | ).toBe(true); | |
| 479 | } | |
| 480 | ||
| 481 | manager.dispose(); | |
| 482 | }); | |
| 483 | ||
| 484 | it("filters app runtime env variables from terminal sessions", async () => { | |
| 485 | const originalValues = new Map<string, string | undefined>(); | |
| 486 | const setEnv = (key: string, value: string | undefined) => { | |
| 487 | if (!originalValues.has(key)) { | |
| 488 | originalValues.set(key, process.env[key]); | |
| 489 | } | |
| 490 | if (value === undefined) { | |
| 491 | delete process.env[key]; | |
| 492 | return; | |
| 493 | } | |
| 494 | process.env[key] = value; | |
| 495 | }; | |
| 496 | const restoreEnv = () => { | |
| 497 | for (const [key, value] of originalValues) { | |
| 498 | if (value === undefined) { | |
| 499 | delete process.env[key]; | |
| 500 | } else { | |
| 501 | process.env[key] = value; | |
| 502 | } | |
| 503 | } | |
| 504 | }; | |
| 505 | ||
| 506 | setEnv("PORT", "5173"); | |
| 507 | setEnv("T3CODE_PORT", "3773"); | |
| 508 | setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); | |
| 509 | setEnv("TEST_TERMINAL_KEEP", "keep-me"); | |
| 510 | ||
| 511 | try { | |
| 512 | const { manager, ptyAdapter } = makeManager(); | |
| 513 | await manager.open(openInput()); | |
| 514 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 515 | expect(spawnInput).toBeDefined(); | |
| 516 | if (!spawnInput) return; | |
| 517 | ||
| 518 | expect(spawnInput.env.PORT).toBeUndefined(); | |
| 519 | expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); | |
| 520 | expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); | |
| 521 | expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); | |
| 522 | ||
| 523 | manager.dispose(); | |
| 524 | } finally { | |
| 525 | restoreEnv(); | |
| 526 | } | |
| 527 | }); | |
| 528 | ||
| 529 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 530 | const { manager, ptyAdapter } = makeManager(); | |
| 531 | await manager.open( | |
| 532 | openInput({ | |
| 533 | env: { | |
| 534 | T3CODE_PROJECT_ROOT: "/repo", | |
| 535 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 536 | CUSTOM_FLAG: "1", | |
| 537 | }, | |
| 538 | }), | |
| 539 | ); | |
| 540 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 541 | expect(spawnInput).toBeDefined(); | |
| 542 | if (!spawnInput) return; | |
| 543 | ||
| 544 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 545 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 546 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 547 | ||
| 548 | manager.dispose(); | |
| 549 | }); | |
| 550 | ||
| 551 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 552 | if (process.platform === "win32") return; | |
| 553 | const { manager, ptyAdapter } = makeManager(5, { | |
| 554 | shellResolver: () => "/bin/zsh", | |
| 555 | }); | |
| 556 | await manager.open(openInput()); | |
| 557 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 558 | expect(spawnInput).toBeDefined(); | |
| 559 | if (!spawnInput) return; | |
| 560 | ||
| 561 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 562 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 563 | ||
| 564 | manager.dispose(); | |
| 565 | }); | |
| 566 | }); |
apps/server/src/terminalManager.ts deleted-1043| ... | ... | @@ -1,1043 +0,0 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_TERMINAL_ID, | |
| 7 | type TerminalClearInput, | |
| 8 | type TerminalCloseInput, | |
| 9 | type TerminalEvent, | |
| 10 | type TerminalOpenInput, | |
| 11 | type TerminalResizeInput, | |
| 12 | type TerminalSessionSnapshot, | |
| 13 | type TerminalSessionStatus, | |
| 14 | type TerminalWriteInput, | |
| 15 | terminalClearInputSchema, | |
| 16 | terminalCloseInputSchema, | |
| 17 | terminalOpenInputSchema, | |
| 18 | terminalResizeInputSchema, | |
| 19 | terminalWriteInputSchema, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | ||
| 22 | import { createLogger } from "./logger"; | |
| 23 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | |
| 24 | import { runProcess } from "./processRunner"; | |
| 25 | ||
| 26 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; | |
| 27 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; | |
| 28 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; | |
| 29 | const DEFAULT_OPEN_COLS = 120; | |
| 30 | const DEFAULT_OPEN_ROWS = 30; | |
| 31 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | |
| 32 | ||
| 33 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | |
| 34 | ||
| 35 | export interface TerminalManagerEvents { | |
| 36 | event: [event: TerminalEvent]; | |
| 37 | } | |
| 38 | ||
| 39 | export interface TerminalManagerOptions { | |
| 40 | logsDir?: string; | |
| 41 | historyLineLimit?: number; | |
| 42 | ptyAdapter?: PtyAdapter; | |
| 43 | shellResolver?: () => string; | |
| 44 | subprocessChecker?: TerminalSubprocessChecker; | |
| 45 | subprocessPollIntervalMs?: number; | |
| 46 | } | |
| 47 | ||
| 48 | interface TerminalSessionState { | |
| 49 | threadId: string; | |
| 50 | terminalId: string; | |
| 51 | cwd: string; | |
| 52 | status: TerminalSessionStatus; | |
| 53 | pid: number | null; | |
| 54 | history: string; | |
| 55 | exitCode: number | null; | |
| 56 | exitSignal: number | null; | |
| 57 | updatedAt: string; | |
| 58 | cols: number; | |
| 59 | rows: number; | |
| 60 | process: PtyProcess | null; | |
| 61 | unsubscribeData: (() => void) | null; | |
| 62 | unsubscribeExit: (() => void) | null; | |
| 63 | hasRunningSubprocess: boolean; | |
| 64 | runtimeEnv: Record<string, string> | null; | |
| 65 | } | |
| 66 | ||
| 67 | interface ShellCandidate { | |
| 68 | shell: string; | |
| 69 | args?: string[]; | |
| 70 | } | |
| 71 | ||
| 72 | type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number }; | |
| 73 | ||
| 74 | function defaultShellResolver(): string { | |
| 75 | if (process.platform === "win32") { | |
| 76 | return process.env.ComSpec ?? "cmd.exe"; | |
| 77 | } | |
| 78 | return process.env.SHELL ?? "bash"; | |
| 79 | } | |
| 80 | ||
| 81 | function normalizeShellCommand(value: string | undefined): string | null { | |
| 82 | if (!value) return null; | |
| 83 | const trimmed = value.trim(); | |
| 84 | if (trimmed.length === 0) return null; | |
| 85 | ||
| 86 | if (process.platform === "win32") { | |
| 87 | return trimmed; | |
| 88 | } | |
| 89 | ||
| 90 | const firstToken = trimmed.split(/\s+/g)[0]?.trim(); | |
| 91 | if (!firstToken) return null; | |
| 92 | return firstToken.replace(/^['"]|['"]$/g, ""); | |
| 93 | } | |
| 94 | ||
| 95 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 96 | if (!command || command.length === 0) return null; | |
| 97 | const shellName = path.basename(command).toLowerCase(); | |
| 98 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 99 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 100 | } | |
| 101 | return { shell: command }; | |
| 102 | } | |
| 103 | ||
| 104 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 105 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 106 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 107 | } | |
| 108 | ||
| 109 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 110 | const seen = new Set<string>(); | |
| 111 | const ordered: ShellCandidate[] = []; | |
| 112 | for (const candidate of candidates) { | |
| 113 | if (!candidate) continue; | |
| 114 | const key = formatShellCandidate(candidate); | |
| 115 | if (seen.has(key)) continue; | |
| 116 | seen.add(key); | |
| 117 | ordered.push(candidate); | |
| 118 | } | |
| 119 | return ordered; | |
| 120 | } | |
| 121 | ||
| 122 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 123 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 124 | ||
| 125 | if (process.platform === "win32") { | |
| 126 | return uniqueShellCandidates([ | |
| 127 | requested, | |
| 128 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 129 | shellCandidateFromCommand("powershell.exe"), | |
| 130 | shellCandidateFromCommand("cmd.exe"), | |
| 131 | ]); | |
| 132 | } | |
| 133 | ||
| 134 | return uniqueShellCandidates([ | |
| 135 | requested, | |
| 136 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 137 | shellCandidateFromCommand("/bin/zsh"), | |
| 138 | shellCandidateFromCommand("/bin/bash"), | |
| 139 | shellCandidateFromCommand("/bin/sh"), | |
| 140 | shellCandidateFromCommand("zsh"), | |
| 141 | shellCandidateFromCommand("bash"), | |
| 142 | shellCandidateFromCommand("sh"), | |
| 143 | ]); | |
| 144 | } | |
| 145 | ||
| 146 | function isRetryableShellSpawnError(error: unknown): boolean { | |
| 147 | if (!(error instanceof Error)) return false; | |
| 148 | const message = error.message.toLowerCase(); | |
| 149 | return ( | |
| 150 | message.includes("posix_spawnp failed") || | |
| 151 | message.includes("enoent") || | |
| 152 | message.includes("not found") || | |
| 153 | message.includes("file not found") || | |
| 154 | message.includes("no such file") | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 159 | const command = [ | |
| 160 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | |
| 161 | "if ($children) { exit 0 }", | |
| 162 | "exit 1", | |
| 163 | ].join("; "); | |
| 164 | try { | |
| 165 | const result = await runProcess( | |
| 166 | "powershell.exe", | |
| 167 | ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 168 | { | |
| 169 | timeoutMs: 1_500, | |
| 170 | allowNonZeroExit: true, | |
| 171 | maxBufferBytes: 32_768, | |
| 172 | outputMode: "truncate", | |
| 173 | }, | |
| 174 | ); | |
| 175 | return result.code === 0; | |
| 176 | } catch { | |
| 177 | return false; | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> { | |
| 182 | try { | |
| 183 | const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], { | |
| 184 | timeoutMs: 1_000, | |
| 185 | allowNonZeroExit: true, | |
| 186 | maxBufferBytes: 32_768, | |
| 187 | outputMode: "truncate", | |
| 188 | }); | |
| 189 | if (pgrepResult.code === 0) { | |
| 190 | return pgrepResult.stdout.trim().length > 0; | |
| 191 | } | |
| 192 | if (pgrepResult.code === 1) { | |
| 193 | return false; | |
| 194 | } | |
| 195 | } catch { | |
| 196 | // Fall back to ps when pgrep is unavailable. | |
| 197 | } | |
| 198 | ||
| 199 | try { | |
| 200 | const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], { | |
| 201 | timeoutMs: 1_000, | |
| 202 | allowNonZeroExit: true, | |
| 203 | maxBufferBytes: 262_144, | |
| 204 | outputMode: "truncate", | |
| 205 | }); | |
| 206 | if (psResult.code !== 0) { | |
| 207 | return false; | |
| 208 | } | |
| 209 | ||
| 210 | for (const line of psResult.stdout.split(/\r?\n/g)) { | |
| 211 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 212 | const pid = Number(pidRaw); | |
| 213 | const ppid = Number(ppidRaw); | |
| 214 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | |
| 215 | if (ppid === terminalPid) { | |
| 216 | return true; | |
| 217 | } | |
| 218 | } | |
| 219 | return false; | |
| 220 | } catch { | |
| 221 | return false; | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> { | |
| 226 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | |
| 227 | return false; | |
| 228 | } | |
| 229 | if (process.platform === "win32") { | |
| 230 | return checkWindowsSubprocessActivity(terminalPid); | |
| 231 | } | |
| 232 | return checkPosixSubprocessActivity(terminalPid); | |
| 233 | } | |
| 234 | ||
| 235 | function capHistory(history: string, maxLines: number): string { | |
| 236 | if (history.length === 0) return history; | |
| 237 | const hasTrailingNewline = history.endsWith("\n"); | |
| 238 | const lines = history.split("\n"); | |
| 239 | if (hasTrailingNewline) { | |
| 240 | lines.pop(); | |
| 241 | } | |
| 242 | if (lines.length <= maxLines) return history; | |
| 243 | const capped = lines.slice(lines.length - maxLines).join("\n"); | |
| 244 | return hasTrailingNewline ? `${capped}\n` : capped; | |
| 245 | } | |
| 246 | ||
| 247 | function legacySafeThreadId(threadId: string): string { | |
| 248 | return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 249 | } | |
| 250 | ||
| 251 | function toSafeThreadId(threadId: string): string { | |
| 252 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | |
| 253 | } | |
| 254 | ||
| 255 | function toSafeTerminalId(terminalId: string): string { | |
| 256 | return Buffer.from(terminalId, "utf8").toString("base64url"); | |
| 257 | } | |
| 258 | ||
| 259 | function toSessionKey(threadId: string, terminalId: string): string { | |
| 260 | return `${threadId}\u0000${terminalId}`; | |
| 261 | } | |
| 262 | ||
| 263 | function shouldExcludeTerminalEnvKey(key: string): boolean { | |
| 264 | const normalizedKey = key.toUpperCase(); | |
| 265 | if (normalizedKey.startsWith("T3CODE_")) { | |
| 266 | return true; | |
| 267 | } | |
| 268 | if (normalizedKey.startsWith("VITE_")) { | |
| 269 | return true; | |
| 270 | } | |
| 271 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); | |
| 272 | } | |
| 273 | ||
| 274 | function createTerminalSpawnEnv( | |
| 275 | baseEnv: NodeJS.ProcessEnv, | |
| 276 | runtimeEnv?: Record<string, string> | null, | |
| 277 | ): NodeJS.ProcessEnv { | |
| 278 | const spawnEnv: NodeJS.ProcessEnv = {}; | |
| 279 | for (const [key, value] of Object.entries(baseEnv)) { | |
| 280 | if (value === undefined) continue; | |
| 281 | if (shouldExcludeTerminalEnvKey(key)) continue; | |
| 282 | spawnEnv[key] = value; | |
| 283 | } | |
| 284 | if (runtimeEnv) { | |
| 285 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 286 | spawnEnv[key] = value; | |
| 287 | } | |
| 288 | } | |
| 289 | return spawnEnv; | |
| 290 | } | |
| 291 | ||
| 292 | function normalizedRuntimeEnv( | |
| 293 | env: Record<string, string> | undefined, | |
| 294 | ): Record<string, string> | null { | |
| 295 | if (!env) return null; | |
| 296 | const entries = Object.entries(env); | |
| 297 | if (entries.length === 0) return null; | |
| 298 | return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); | |
| 299 | } | |
| 300 | ||
| 301 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | |
| 302 | private readonly sessions = new Map<string, TerminalSessionState>(); | |
| 303 | private readonly logsDir: string; | |
| 304 | private readonly historyLineLimit: number; | |
| 305 | private readonly ptyAdapter: PtyAdapter; | |
| 306 | private readonly shellResolver: () => string; | |
| 307 | private readonly persistQueues = new Map<string, Promise<void>>(); | |
| 308 | private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 309 | private readonly pendingPersistHistory = new Map<string, string>(); | |
| 310 | private readonly threadLocks = new Map<string, Promise<void>>(); | |
| 311 | private readonly persistDebounceMs: number; | |
| 312 | private readonly subprocessChecker: TerminalSubprocessChecker; | |
| 313 | private readonly subprocessPollIntervalMs: number; | |
| 314 | private subprocessPollTimer: ReturnType<typeof setInterval> | null = null; | |
| 315 | private subprocessPollInFlight = false; | |
| 316 | private readonly logger = createLogger("terminal"); | |
| 317 | ||
| 318 | constructor(options: TerminalManagerOptions = {}) { | |
| 319 | super(); | |
| 320 | this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); | |
| 321 | this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; | |
| 322 | this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); | |
| 323 | this.shellResolver = options.shellResolver ?? defaultShellResolver; | |
| 324 | this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; | |
| 325 | this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; | |
| 326 | this.subprocessPollIntervalMs = | |
| 327 | options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; | |
| 328 | fs.mkdirSync(this.logsDir, { recursive: true }); | |
| 329 | } | |
| 330 | ||
| 331 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 332 | const input = terminalOpenInputSchema.parse(raw); | |
| 333 | return this.runWithThreadLock(input.threadId, async () => { | |
| 334 | await this.assertValidCwd(input.cwd); | |
| 335 | ||
| 336 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 337 | const existing = this.sessions.get(sessionKey); | |
| 338 | if (!existing) { | |
| 339 | await this.flushPersistQueue(input.threadId, input.terminalId); | |
| 340 | const history = await this.readHistory(input.threadId, input.terminalId); | |
| 341 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 342 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 343 | const session: TerminalSessionState = { | |
| 344 | threadId: input.threadId, | |
| 345 | terminalId: input.terminalId, | |
| 346 | cwd: input.cwd, | |
| 347 | status: "starting", | |
| 348 | pid: null, | |
| 349 | history, | |
| 350 | exitCode: null, | |
| 351 | exitSignal: null, | |
| 352 | updatedAt: new Date().toISOString(), | |
| 353 | cols, | |
| 354 | rows, | |
| 355 | process: null, | |
| 356 | unsubscribeData: null, | |
| 357 | unsubscribeExit: null, | |
| 358 | hasRunningSubprocess: false, | |
| 359 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 360 | }; | |
| 361 | this.sessions.set(sessionKey, session); | |
| 362 | this.startSession(session, { ...input, cols, rows }, "started"); | |
| 363 | return this.snapshot(session); | |
| 364 | } | |
| 365 | ||
| 366 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 367 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 368 | const targetCols = input.cols ?? existing.cols; | |
| 369 | const targetRows = input.rows ?? existing.rows; | |
| 370 | const runtimeEnvChanged = | |
| 371 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 372 | ||
| 373 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 374 | this.stopProcess(existing); | |
| 375 | existing.cwd = input.cwd; | |
| 376 | existing.runtimeEnv = nextRuntimeEnv; | |
| 377 | existing.history = ""; | |
| 378 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 379 | } else if (existing.status === "exited" || existing.status === "error") { | |
| 380 | existing.runtimeEnv = nextRuntimeEnv; | |
| 381 | existing.history = ""; | |
| 382 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); | |
| 383 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 384 | existing.runtimeEnv = nextRuntimeEnv; | |
| 385 | } | |
| 386 | ||
| 387 | if (!existing.process) { | |
| 388 | this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); | |
| 389 | return this.snapshot(existing); | |
| 390 | } | |
| 391 | ||
| 392 | if (existing.cols !== targetCols || existing.rows !== targetRows) { | |
| 393 | existing.cols = targetCols; | |
| 394 | existing.rows = targetRows; | |
| 395 | existing.process.resize(targetCols, targetRows); | |
| 396 | existing.updatedAt = new Date().toISOString(); | |
| 397 | } | |
| 398 | ||
| 399 | return this.snapshot(existing); | |
| 400 | }); | |
| 401 | } | |
| 402 | ||
| 403 | async write(raw: TerminalWriteInput): Promise<void> { | |
| 404 | const input = terminalWriteInputSchema.parse(raw); | |
| 405 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 406 | if (!session.process || session.status !== "running") { | |
| 407 | throw new Error( | |
| 408 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 409 | ); | |
| 410 | } | |
| 411 | session.process.write(input.data); | |
| 412 | } | |
| 413 | ||
| 414 | async resize(raw: TerminalResizeInput): Promise<void> { | |
| 415 | const input = terminalResizeInputSchema.parse(raw); | |
| 416 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 417 | if (!session.process || session.status !== "running") { | |
| 418 | throw new Error( | |
| 419 | `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, | |
| 420 | ); | |
| 421 | } | |
| 422 | session.cols = input.cols; | |
| 423 | session.rows = input.rows; | |
| 424 | session.updatedAt = new Date().toISOString(); | |
| 425 | session.process.resize(input.cols, input.rows); | |
| 426 | } | |
| 427 | ||
| 428 | async clear(raw: TerminalClearInput): Promise<void> { | |
| 429 | const input = terminalClearInputSchema.parse(raw); | |
| 430 | await this.runWithThreadLock(input.threadId, async () => { | |
| 431 | const session = this.requireSession(input.threadId, input.terminalId); | |
| 432 | session.history = ""; | |
| 433 | session.updatedAt = new Date().toISOString(); | |
| 434 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 435 | this.emitEvent({ | |
| 436 | type: "cleared", | |
| 437 | threadId: input.threadId, | |
| 438 | terminalId: input.terminalId, | |
| 439 | createdAt: new Date().toISOString(), | |
| 440 | }); | |
| 441 | }); | |
| 442 | } | |
| 443 | ||
| 444 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 445 | const input = terminalOpenInputSchema.parse(raw); | |
| 446 | return this.runWithThreadLock(input.threadId, async () => { | |
| 447 | await this.assertValidCwd(input.cwd); | |
| 448 | ||
| 449 | const sessionKey = toSessionKey(input.threadId, input.terminalId); | |
| 450 | let session = this.sessions.get(sessionKey); | |
| 451 | if (!session) { | |
| 452 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 453 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 454 | session = { | |
| 455 | threadId: input.threadId, | |
| 456 | terminalId: input.terminalId, | |
| 457 | cwd: input.cwd, | |
| 458 | status: "starting", | |
| 459 | pid: null, | |
| 460 | history: "", | |
| 461 | exitCode: null, | |
| 462 | exitSignal: null, | |
| 463 | updatedAt: new Date().toISOString(), | |
| 464 | cols, | |
| 465 | rows, | |
| 466 | process: null, | |
| 467 | unsubscribeData: null, | |
| 468 | unsubscribeExit: null, | |
| 469 | hasRunningSubprocess: false, | |
| 470 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 471 | }; | |
| 472 | this.sessions.set(sessionKey, session); | |
| 473 | } else { | |
| 474 | this.stopProcess(session); | |
| 475 | session.cwd = input.cwd; | |
| 476 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 477 | } | |
| 478 | ||
| 479 | const cols = input.cols ?? session.cols; | |
| 480 | const rows = input.rows ?? session.rows; | |
| 481 | ||
| 482 | session.history = ""; | |
| 483 | await this.persistHistory(input.threadId, input.terminalId, session.history); | |
| 484 | this.startSession(session, { ...input, cols, rows }, "restarted"); | |
| 485 | return this.snapshot(session); | |
| 486 | }); | |
| 487 | } | |
| 488 | ||
| 489 | async close(raw: TerminalCloseInput): Promise<void> { | |
| 490 | const input = terminalCloseInputSchema.parse(raw); | |
| 491 | await this.runWithThreadLock(input.threadId, async () => { | |
| 492 | if (input.terminalId) { | |
| 493 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | |
| 494 | return; | |
| 495 | } | |
| 496 | ||
| 497 | const threadSessions = this.sessionsForThread(input.threadId); | |
| 498 | for (const session of threadSessions) { | |
| 499 | this.stopProcess(session); | |
| 500 | this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); | |
| 501 | } | |
| 502 | await Promise.all( | |
| 503 | threadSessions.map((session) => | |
| 504 | this.flushPersistQueue(session.threadId, session.terminalId), | |
| 505 | ), | |
| 506 | ); | |
| 507 | ||
| 508 | if (input.deleteHistory) { | |
| 509 | await this.deleteAllHistoryForThread(input.threadId); | |
| 510 | } | |
| 511 | this.updateSubprocessPollingState(); | |
| 512 | }); | |
| 513 | } | |
| 514 | ||
| 515 | dispose(): void { | |
| 516 | this.stopSubprocessPolling(); | |
| 517 | const sessions = [...this.sessions.values()]; | |
| 518 | this.sessions.clear(); | |
| 519 | for (const session of sessions) { | |
| 520 | this.stopProcess(session); | |
| 521 | } | |
| 522 | for (const timer of this.persistTimers.values()) { | |
| 523 | clearTimeout(timer); | |
| 524 | } | |
| 525 | this.persistTimers.clear(); | |
| 526 | this.pendingPersistHistory.clear(); | |
| 527 | this.threadLocks.clear(); | |
| 528 | this.persistQueues.clear(); | |
| 529 | } | |
| 530 | ||
| 531 | private startSession( | |
| 532 | session: TerminalSessionState, | |
| 533 | input: TerminalStartInput, | |
| 534 | eventType: "started" | "restarted", | |
| 535 | ): void { | |
| 536 | this.stopProcess(session); | |
| 537 | ||
| 538 | session.status = "starting"; | |
| 539 | session.cwd = input.cwd; | |
| 540 | session.cols = input.cols; | |
| 541 | session.rows = input.rows; | |
| 542 | session.exitCode = null; | |
| 543 | session.exitSignal = null; | |
| 544 | session.hasRunningSubprocess = false; | |
| 545 | session.updatedAt = new Date().toISOString(); | |
| 546 | ||
| 547 | let ptyProcess: PtyProcess | null = null; | |
| 548 | let startedShell: string | null = null; | |
| 549 | try { | |
| 550 | const shellCandidates = resolveShellCandidates(this.shellResolver); | |
| 551 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 552 | let lastSpawnError: unknown = null; | |
| 553 | ||
| 554 | for (const candidate of shellCandidates) { | |
| 555 | try { | |
| 556 | ptyProcess = this.ptyAdapter.spawn({ | |
| 557 | shell: candidate.shell, | |
| 558 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 559 | cwd: session.cwd, | |
| 560 | cols: session.cols, | |
| 561 | rows: session.rows, | |
| 562 | env: terminalEnv, | |
| 563 | }); | |
| 564 | startedShell = formatShellCandidate(candidate); | |
| 565 | break; | |
| 566 | } catch (error) { | |
| 567 | lastSpawnError = error; | |
| 568 | if (!isRetryableShellSpawnError(error)) { | |
| 569 | throw error; | |
| 570 | } | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | if (!ptyProcess) { | |
| 575 | const detail = | |
| 576 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; | |
| 577 | const tried = | |
| 578 | shellCandidates.length > 0 | |
| 579 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 580 | : ""; | |
| 581 | throw new Error(`${detail}.${tried}`.trim()); | |
| 582 | } | |
| 583 | ||
| 584 | session.process = ptyProcess; | |
| 585 | session.pid = ptyProcess.pid; | |
| 586 | session.status = "running"; | |
| 587 | session.updatedAt = new Date().toISOString(); | |
| 588 | session.unsubscribeData = ptyProcess.onData((data) => { | |
| 589 | this.onProcessData(session, data); | |
| 590 | }); | |
| 591 | session.unsubscribeExit = ptyProcess.onExit((event) => { | |
| 592 | this.onProcessExit(session, event); | |
| 593 | }); | |
| 594 | this.updateSubprocessPollingState(); | |
| 595 | this.emitEvent({ | |
| 596 | type: eventType, | |
| 597 | threadId: session.threadId, | |
| 598 | terminalId: session.terminalId, | |
| 599 | createdAt: new Date().toISOString(), | |
| 600 | snapshot: this.snapshot(session), | |
| 601 | }); | |
| 602 | } catch (error) { | |
| 603 | if (ptyProcess) { | |
| 604 | try { | |
| 605 | ptyProcess.kill(); | |
| 606 | } catch { | |
| 607 | // Ignore kill errors during failed startup cleanup. | |
| 608 | } | |
| 609 | } | |
| 610 | session.status = "error"; | |
| 611 | session.pid = null; | |
| 612 | session.process = null; | |
| 613 | session.hasRunningSubprocess = false; | |
| 614 | session.updatedAt = new Date().toISOString(); | |
| 615 | this.updateSubprocessPollingState(); | |
| 616 | const message = error instanceof Error ? error.message : "Terminal start failed"; | |
| 617 | this.emitEvent({ | |
| 618 | type: "error", | |
| 619 | threadId: session.threadId, | |
| 620 | terminalId: session.terminalId, | |
| 621 | createdAt: new Date().toISOString(), | |
| 622 | message, | |
| 623 | }); | |
| 624 | this.logger.error("failed to start terminal", { | |
| 625 | threadId: session.threadId, | |
| 626 | terminalId: session.terminalId, | |
| 627 | error: message, | |
| 628 | ...(startedShell ? { shell: startedShell } : {}), | |
| 629 | }); | |
| 630 | } | |
| 631 | } | |
| 632 | ||
| 633 | private onProcessData(session: TerminalSessionState, data: string): void { | |
| 634 | session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); | |
| 635 | session.updatedAt = new Date().toISOString(); | |
| 636 | this.queuePersist(session.threadId, session.terminalId, session.history); | |
| 637 | this.emitEvent({ | |
| 638 | type: "output", | |
| 639 | threadId: session.threadId, | |
| 640 | terminalId: session.terminalId, | |
| 641 | createdAt: new Date().toISOString(), | |
| 642 | data, | |
| 643 | }); | |
| 644 | } | |
| 645 | ||
| 646 | private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void { | |
| 647 | this.cleanupProcessHandles(session); | |
| 648 | session.process = null; | |
| 649 | session.pid = null; | |
| 650 | session.hasRunningSubprocess = false; | |
| 651 | session.status = "exited"; | |
| 652 | session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; | |
| 653 | session.exitSignal = Number.isInteger(event.signal) ? event.signal : null; | |
| 654 | session.updatedAt = new Date().toISOString(); | |
| 655 | this.emitEvent({ | |
| 656 | type: "exited", | |
| 657 | threadId: session.threadId, | |
| 658 | terminalId: session.terminalId, | |
| 659 | createdAt: new Date().toISOString(), | |
| 660 | exitCode: session.exitCode, | |
| 661 | exitSignal: session.exitSignal, | |
| 662 | }); | |
| 663 | this.updateSubprocessPollingState(); | |
| 664 | } | |
| 665 | ||
| 666 | private stopProcess(session: TerminalSessionState): void { | |
| 667 | const process = session.process; | |
| 668 | if (!process) return; | |
| 669 | this.cleanupProcessHandles(session); | |
| 670 | session.process = null; | |
| 671 | session.pid = null; | |
| 672 | session.hasRunningSubprocess = false; | |
| 673 | session.status = "exited"; | |
| 674 | session.updatedAt = new Date().toISOString(); | |
| 675 | try { | |
| 676 | process.kill(); | |
| 677 | } catch (error) { | |
| 678 | const message = error instanceof Error ? error.message : String(error); | |
| 679 | this.logger.warn("failed to kill terminal process", { | |
| 680 | threadId: session.threadId, | |
| 681 | terminalId: session.terminalId, | |
| 682 | error: message, | |
| 683 | }); | |
| 684 | } | |
| 685 | this.updateSubprocessPollingState(); | |
| 686 | } | |
| 687 | ||
| 688 | private cleanupProcessHandles(session: TerminalSessionState): void { | |
| 689 | session.unsubscribeData?.(); | |
| 690 | session.unsubscribeData = null; | |
| 691 | session.unsubscribeExit?.(); | |
| 692 | session.unsubscribeExit = null; | |
| 693 | } | |
| 694 | ||
| 695 | private queuePersist(threadId: string, terminalId: string, history: string): void { | |
| 696 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 697 | this.pendingPersistHistory.set(persistenceKey, history); | |
| 698 | this.schedulePersist(threadId, terminalId); | |
| 699 | } | |
| 700 | ||
| 701 | private async persistHistory( | |
| 702 | threadId: string, | |
| 703 | terminalId: string, | |
| 704 | history: string, | |
| 705 | ): Promise<void> { | |
| 706 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 707 | this.clearPersistTimer(threadId, terminalId); | |
| 708 | this.pendingPersistHistory.delete(persistenceKey); | |
| 709 | await this.enqueuePersistWrite(threadId, terminalId, history); | |
| 710 | } | |
| 711 | ||
| 712 | private enqueuePersistWrite( | |
| 713 | threadId: string, | |
| 714 | terminalId: string, | |
| 715 | history: string, | |
| 716 | ): Promise<void> { | |
| 717 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 718 | const task = async () => { | |
| 719 | await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); | |
| 720 | }; | |
| 721 | const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); | |
| 722 | const next = previous | |
| 723 | .catch(() => undefined) | |
| 724 | .then(task) | |
| 725 | .catch((error) => { | |
| 726 | this.logger.warn("failed to persist terminal history", { | |
| 727 | threadId, | |
| 728 | terminalId, | |
| 729 | error: error instanceof Error ? error.message : String(error), | |
| 730 | }); | |
| 731 | }); | |
| 732 | this.persistQueues.set(persistenceKey, next); | |
| 733 | const finalized = next.finally(() => { | |
| 734 | if (this.persistQueues.get(persistenceKey) === next) { | |
| 735 | this.persistQueues.delete(persistenceKey); | |
| 736 | } | |
| 737 | if ( | |
| 738 | this.pendingPersistHistory.has(persistenceKey) && | |
| 739 | !this.persistTimers.has(persistenceKey) | |
| 740 | ) { | |
| 741 | this.schedulePersist(threadId, terminalId); | |
| 742 | } | |
| 743 | }); | |
| 744 | void finalized.catch(() => undefined); | |
| 745 | return finalized; | |
| 746 | } | |
| 747 | ||
| 748 | private schedulePersist(threadId: string, terminalId: string): void { | |
| 749 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 750 | if (this.persistTimers.has(persistenceKey)) return; | |
| 751 | const timer = setTimeout(() => { | |
| 752 | this.persistTimers.delete(persistenceKey); | |
| 753 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 754 | if (pendingHistory === undefined) return; | |
| 755 | this.pendingPersistHistory.delete(persistenceKey); | |
| 756 | void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 757 | }, this.persistDebounceMs); | |
| 758 | this.persistTimers.set(persistenceKey, timer); | |
| 759 | } | |
| 760 | ||
| 761 | private clearPersistTimer(threadId: string, terminalId: string): void { | |
| 762 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 763 | const timer = this.persistTimers.get(persistenceKey); | |
| 764 | if (!timer) return; | |
| 765 | clearTimeout(timer); | |
| 766 | this.persistTimers.delete(persistenceKey); | |
| 767 | } | |
| 768 | ||
| 769 | private async readHistory(threadId: string, terminalId: string): Promise<string> { | |
| 770 | const nextPath = this.historyPath(threadId, terminalId); | |
| 771 | try { | |
| 772 | const raw = await fs.promises.readFile(nextPath, "utf8"); | |
| 773 | const capped = capHistory(raw, this.historyLineLimit); | |
| 774 | if (capped !== raw) { | |
| 775 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 776 | } | |
| 777 | return capped; | |
| 778 | } catch (error) { | |
| 779 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { | |
| 780 | throw error; | |
| 781 | } | |
| 782 | } | |
| 783 | ||
| 784 | if (terminalId !== DEFAULT_TERMINAL_ID) { | |
| 785 | return ""; | |
| 786 | } | |
| 787 | ||
| 788 | const legacyPath = this.legacyHistoryPath(threadId); | |
| 789 | try { | |
| 790 | const raw = await fs.promises.readFile(legacyPath, "utf8"); | |
| 791 | const capped = capHistory(raw, this.historyLineLimit); | |
| 792 | ||
| 793 | // Migrate legacy transcript filename to the terminal-scoped path. | |
| 794 | await fs.promises.writeFile(nextPath, capped, "utf8"); | |
| 795 | try { | |
| 796 | await fs.promises.rm(legacyPath, { force: true }); | |
| 797 | } catch (cleanupError) { | |
| 798 | this.logger.warn("failed to remove legacy terminal history", { | |
| 799 | threadId, | |
| 800 | error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), | |
| 801 | }); | |
| 802 | } | |
| 803 | ||
| 804 | return capped; | |
| 805 | } catch (error) { | |
| 806 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 807 | return ""; | |
| 808 | } | |
| 809 | throw error; | |
| 810 | } | |
| 811 | } | |
| 812 | ||
| 813 | private async deleteHistory(threadId: string, terminalId: string): Promise<void> { | |
| 814 | const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; | |
| 815 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 816 | deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); | |
| 817 | } | |
| 818 | try { | |
| 819 | await Promise.all(deletions); | |
| 820 | } catch (error) { | |
| 821 | this.logger.warn("failed to delete terminal history", { | |
| 822 | threadId, | |
| 823 | terminalId, | |
| 824 | error: error instanceof Error ? error.message : String(error), | |
| 825 | }); | |
| 826 | } | |
| 827 | } | |
| 828 | ||
| 829 | private async flushPersistQueue(threadId: string, terminalId: string): Promise<void> { | |
| 830 | const persistenceKey = toSessionKey(threadId, terminalId); | |
| 831 | this.clearPersistTimer(threadId, terminalId); | |
| 832 | ||
| 833 | while (true) { | |
| 834 | const pendingHistory = this.pendingPersistHistory.get(persistenceKey); | |
| 835 | if (pendingHistory !== undefined) { | |
| 836 | this.pendingPersistHistory.delete(persistenceKey); | |
| 837 | await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); | |
| 838 | } | |
| 839 | ||
| 840 | const pending = this.persistQueues.get(persistenceKey); | |
| 841 | if (!pending) { | |
| 842 | return; | |
| 843 | } | |
| 844 | await pending.catch(() => undefined); | |
| 845 | } | |
| 846 | } | |
| 847 | ||
| 848 | private updateSubprocessPollingState(): void { | |
| 849 | const hasRunningSessions = [...this.sessions.values()].some( | |
| 850 | (session) => session.status === "running" && session.pid !== null, | |
| 851 | ); | |
| 852 | if (hasRunningSessions) { | |
| 853 | this.ensureSubprocessPolling(); | |
| 854 | return; | |
| 855 | } | |
| 856 | this.stopSubprocessPolling(); | |
| 857 | } | |
| 858 | ||
| 859 | private ensureSubprocessPolling(): void { | |
| 860 | if (this.subprocessPollTimer) return; | |
| 861 | this.subprocessPollTimer = setInterval(() => { | |
| 862 | void this.pollSubprocessActivity(); | |
| 863 | }, this.subprocessPollIntervalMs); | |
| 864 | this.subprocessPollTimer.unref?.(); | |
| 865 | void this.pollSubprocessActivity(); | |
| 866 | } | |
| 867 | ||
| 868 | private stopSubprocessPolling(): void { | |
| 869 | if (!this.subprocessPollTimer) return; | |
| 870 | clearInterval(this.subprocessPollTimer); | |
| 871 | this.subprocessPollTimer = null; | |
| 872 | } | |
| 873 | ||
| 874 | private async pollSubprocessActivity(): Promise<void> { | |
| 875 | if (this.subprocessPollInFlight) return; | |
| 876 | ||
| 877 | const runningSessions = [...this.sessions.values()].filter( | |
| 878 | (session): session is TerminalSessionState & { pid: number } => | |
| 879 | session.status === "running" && Number.isInteger(session.pid), | |
| 880 | ); | |
| 881 | if (runningSessions.length === 0) { | |
| 882 | this.stopSubprocessPolling(); | |
| 883 | return; | |
| 884 | } | |
| 885 | ||
| 886 | this.subprocessPollInFlight = true; | |
| 887 | try { | |
| 888 | await Promise.all( | |
| 889 | runningSessions.map(async (session) => { | |
| 890 | const terminalPid = session.pid; | |
| 891 | let hasRunningSubprocess = false; | |
| 892 | try { | |
| 893 | hasRunningSubprocess = await this.subprocessChecker(terminalPid); | |
| 894 | } catch (error) { | |
| 895 | this.logger.warn("failed to check terminal subprocess activity", { | |
| 896 | threadId: session.threadId, | |
| 897 | terminalId: session.terminalId, | |
| 898 | terminalPid, | |
| 899 | error: error instanceof Error ? error.message : String(error), | |
| 900 | }); | |
| 901 | return; | |
| 902 | } | |
| 903 | ||
| 904 | const liveSession = this.sessions.get(toSessionKey(session.threadId, session.terminalId)); | |
| 905 | if (!liveSession || liveSession.status !== "running" || liveSession.pid !== terminalPid) { | |
| 906 | return; | |
| 907 | } | |
| 908 | if (liveSession.hasRunningSubprocess === hasRunningSubprocess) { | |
| 909 | return; | |
| 910 | } | |
| 911 | ||
| 912 | liveSession.hasRunningSubprocess = hasRunningSubprocess; | |
| 913 | liveSession.updatedAt = new Date().toISOString(); | |
| 914 | this.emitEvent({ | |
| 915 | type: "activity", | |
| 916 | threadId: liveSession.threadId, | |
| 917 | terminalId: liveSession.terminalId, | |
| 918 | createdAt: new Date().toISOString(), | |
| 919 | hasRunningSubprocess, | |
| 920 | }); | |
| 921 | }), | |
| 922 | ); | |
| 923 | } finally { | |
| 924 | this.subprocessPollInFlight = false; | |
| 925 | } | |
| 926 | } | |
| 927 | ||
| 928 | private async assertValidCwd(cwd: string): Promise<void> { | |
| 929 | let stats: fs.Stats; | |
| 930 | try { | |
| 931 | stats = await fs.promises.stat(cwd); | |
| 932 | } catch (error) { | |
| 933 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { | |
| 934 | throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error }); | |
| 935 | } | |
| 936 | throw error; | |
| 937 | } | |
| 938 | if (!stats.isDirectory()) { | |
| 939 | throw new Error(`Terminal cwd is not a directory: ${cwd}`); | |
| 940 | } | |
| 941 | } | |
| 942 | ||
| 943 | private async closeSession( | |
| 944 | threadId: string, | |
| 945 | terminalId: string, | |
| 946 | deleteHistory: boolean, | |
| 947 | ): Promise<void> { | |
| 948 | const key = toSessionKey(threadId, terminalId); | |
| 949 | const session = this.sessions.get(key); | |
| 950 | if (session) { | |
| 951 | this.stopProcess(session); | |
| 952 | this.sessions.delete(key); | |
| 953 | } | |
| 954 | this.updateSubprocessPollingState(); | |
| 955 | await this.flushPersistQueue(threadId, terminalId); | |
| 956 | if (deleteHistory) { | |
| 957 | await this.deleteHistory(threadId, terminalId); | |
| 958 | } | |
| 959 | } | |
| 960 | ||
| 961 | private sessionsForThread(threadId: string): TerminalSessionState[] { | |
| 962 | return [...this.sessions.values()].filter((session) => session.threadId === threadId); | |
| 963 | } | |
| 964 | ||
| 965 | private async deleteAllHistoryForThread(threadId: string): Promise<void> { | |
| 966 | const threadPrefix = `${toSafeThreadId(threadId)}_`; | |
| 967 | try { | |
| 968 | const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); | |
| 969 | const removals = entries | |
| 970 | .filter((entry) => entry.isFile()) | |
| 971 | .map((entry) => entry.name) | |
| 972 | .filter( | |
| 973 | (name) => | |
| 974 | name === `${toSafeThreadId(threadId)}.log` || | |
| 975 | name === `${legacySafeThreadId(threadId)}.log` || | |
| 976 | name.startsWith(threadPrefix), | |
| 977 | ) | |
| 978 | .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); | |
| 979 | await Promise.all(removals); | |
| 980 | } catch (error) { | |
| 981 | this.logger.warn("failed to delete terminal histories for thread", { | |
| 982 | threadId, | |
| 983 | error: error instanceof Error ? error.message : String(error), | |
| 984 | }); | |
| 985 | } | |
| 986 | } | |
| 987 | ||
| 988 | private requireSession(threadId: string, terminalId: string): TerminalSessionState { | |
| 989 | const session = this.sessions.get(toSessionKey(threadId, terminalId)); | |
| 990 | if (!session) { | |
| 991 | throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); | |
| 992 | } | |
| 993 | return session; | |
| 994 | } | |
| 995 | ||
| 996 | private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | |
| 997 | return { | |
| 998 | threadId: session.threadId, | |
| 999 | terminalId: session.terminalId, | |
| 1000 | cwd: session.cwd, | |
| 1001 | status: session.status, | |
| 1002 | pid: session.pid, | |
| 1003 | history: session.history, | |
| 1004 | exitCode: session.exitCode, | |
| 1005 | exitSignal: session.exitSignal, | |
| 1006 | updatedAt: session.updatedAt, | |
| 1007 | }; | |
| 1008 | } | |
| 1009 | ||
| 1010 | private emitEvent(event: TerminalEvent): void { | |
| 1011 | this.emit("event", event); | |
| 1012 | } | |
| 1013 | ||
| 1014 | private historyPath(threadId: string, terminalId: string): string { | |
| 1015 | const threadPart = toSafeThreadId(threadId); | |
| 1016 | if (terminalId === DEFAULT_TERMINAL_ID) { | |
| 1017 | return path.join(this.logsDir, `${threadPart}.log`); | |
| 1018 | } | |
| 1019 | return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); | |
| 1020 | } | |
| 1021 | ||
| 1022 | private legacyHistoryPath(threadId: string): string { | |
| 1023 | return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`); | |
| 1024 | } | |
| 1025 | ||
| 1026 | private async runWithThreadLock<T>(threadId: string, task: () => Promise<T>): Promise<T> { | |
| 1027 | const previous = this.threadLocks.get(threadId) ?? Promise.resolve(); | |
| 1028 | let release: () => void = () => {}; | |
| 1029 | const current = new Promise<void>((resolve) => { | |
| 1030 | release = resolve; | |
| 1031 | }); | |
| 1032 | this.threadLocks.set(threadId, current); | |
| 1033 | await previous.catch(() => undefined); | |
| 1034 | try { | |
| 1035 | return await task(); | |
| 1036 | } finally { | |
| 1037 | release(); | |
| 1038 | if (this.threadLocks.get(threadId) === current) { | |
| 1039 | this.threadLocks.delete(threadId); | |
| 1040 | } | |
| 1041 | } | |
| 1042 | } | |
| 1043 | } |
apps/server/src/workspaceEntries.chunking.test.ts+8-7| ... | ... | @@ -2,13 +2,14 @@ import { assert, beforeEach, describe, it, vi } from "vitest"; |
| 2 | 2 | import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; |
| 3 | 3 | |
| 4 | 4 | const { runProcessMock } = vi.hoisted(() => ({ |
| 5 | runProcessMock: vi.fn< | |
| 6 | ( | |
| 7 | command: string, | |
| 8 | args: readonly string[], | |
| 9 | options?: ProcessRunOptions, | |
| 10 | ) => Promise<ProcessRunResult> | |
| 11 | >(), | |
| 5 | runProcessMock: | |
| 6 | vi.fn< | |
| 7 | ( | |
| 8 | command: string, | |
| 9 | args: readonly string[], | |
| 10 | options?: ProcessRunOptions, | |
| 11 | ) => Promise<ProcessRunResult> | |
| 12 | >(), | |
| 12 | 13 | })); |
| 13 | 14 | |
| 14 | 15 | vi.mock("./processRunner", () => ({ |
apps/server/src/workspaceEntries.test.ts+24-24| ... | ... | @@ -124,15 +124,15 @@ describe("searchWorkspaceEntries", () => { |
| 124 | 124 | |
| 125 | 125 | let rootReadCount = 0; |
| 126 | 126 | const originalReaddir = fsPromises.readdir.bind(fsPromises); |
| 127 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 128 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 129 | if (args[0] === cwd) { | |
| 130 | rootReadCount += 1; | |
| 131 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 132 | } | |
| 133 | return originalReaddir(...args); | |
| 134 | }) as typeof fsPromises.readdir, | |
| 135 | ); | |
| 127 | vi.spyOn(fsPromises, "readdir").mockImplementation((async ( | |
| 128 | ...args: Parameters<typeof fsPromises.readdir> | |
| 129 | ) => { | |
| 130 | if (args[0] === cwd) { | |
| 131 | rootReadCount += 1; | |
| 132 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 133 | } | |
| 134 | return originalReaddir(...args); | |
| 135 | }) as typeof fsPromises.readdir); | |
| 136 | 136 | |
| 137 | 137 | await Promise.all([ |
| 138 | 138 | searchWorkspaceEntries({ cwd, query: "", limit: 100 }), |
| ... | ... | @@ -152,22 +152,22 @@ describe("searchWorkspaceEntries", () => { |
| 152 | 152 | let activeReads = 0; |
| 153 | 153 | let peakReads = 0; |
| 154 | 154 | const originalReaddir = fsPromises.readdir.bind(fsPromises); |
| 155 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 156 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 157 | const target = args[0]; | |
| 158 | if (typeof target === "string" && target.startsWith(cwd)) { | |
| 159 | activeReads += 1; | |
| 160 | peakReads = Math.max(peakReads, activeReads); | |
| 161 | await new Promise((resolve) => setTimeout(resolve, 4)); | |
| 162 | try { | |
| 163 | return await originalReaddir(...args); | |
| 164 | } finally { | |
| 165 | activeReads -= 1; | |
| 166 | } | |
| 155 | vi.spyOn(fsPromises, "readdir").mockImplementation((async ( | |
| 156 | ...args: Parameters<typeof fsPromises.readdir> | |
| 157 | ) => { | |
| 158 | const target = args[0]; | |
| 159 | if (typeof target === "string" && target.startsWith(cwd)) { | |
| 160 | activeReads += 1; | |
| 161 | peakReads = Math.max(peakReads, activeReads); | |
| 162 | await new Promise((resolve) => setTimeout(resolve, 4)); | |
| 163 | try { | |
| 164 | return await originalReaddir(...args); | |
| 165 | } finally { | |
| 166 | activeReads -= 1; | |
| 167 | 167 | } |
| 168 | return originalReaddir(...args); | |
| 169 | }) as typeof fsPromises.readdir, | |
| 170 | ); | |
| 168 | } | |
| 169 | return originalReaddir(...args); | |
| 170 | }) as typeof fsPromises.readdir); | |
| 171 | 171 | |
| 172 | 172 | await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); |
| 173 | 173 |
apps/server/src/workspaceEntries.ts+7-9| ... | ... | @@ -4,10 +4,9 @@ import path from "node:path"; |
| 4 | 4 | import { runProcess } from "./processRunner"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | projectSearchEntriesInputSchema, | |
| 8 | type ProjectEntry, | |
| 9 | type ProjectSearchEntriesInput, | |
| 10 | type ProjectSearchEntriesResult, | |
| 7 | ProjectEntry, | |
| 8 | ProjectSearchEntriesInput, | |
| 9 | ProjectSearchEntriesResult, | |
| 11 | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | |
| 13 | 12 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| ... | ... | @@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 413 | 412 | export async function searchWorkspaceEntries( |
| 414 | 413 | input: ProjectSearchEntriesInput, |
| 415 | 414 | ): Promise<ProjectSearchEntriesResult> { |
| 416 | const parsed = projectSearchEntriesInputSchema.parse(input); | |
| 417 | const index = await getWorkspaceIndex(parsed.cwd); | |
| 418 | const normalizedQuery = normalizeQuery(parsed.query); | |
| 415 | const index = await getWorkspaceIndex(input.cwd); | |
| 416 | const normalizedQuery = normalizeQuery(input.query); | |
| 419 | 417 | const candidates = normalizedQuery |
| 420 | 418 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) |
| 421 | 419 | : index.entries; |
| ... | ... | @@ -427,7 +425,7 @@ export async function searchWorkspaceEntries( |
| 427 | 425 | }); |
| 428 | 426 | |
| 429 | 427 | return { |
| 430 | entries: ranked.slice(0, parsed.limit), | |
| 431 | truncated: index.truncated || ranked.length > parsed.limit, | |
| 428 | entries: ranked.slice(0, input.limit), | |
| 429 | truncated: index.truncated || ranked.length > input.limit, | |
| 432 | 430 | }; |
| 433 | 431 | } |
apps/server/src/wsServer.test.ts+927-535| ... | ... | @@ -1,22 +1,33 @@ |
| 1 | import * as Http from "node:http"; | |
| 1 | 2 | import fs from "node:fs"; |
| 2 | 3 | import os from "node:os"; |
| 3 | 4 | import path from "node:path"; |
| 4 | import { EventEmitter } from "node:events"; | |
| 5 | 5 | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; | |
| 6 | 8 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 7 | 9 | import { createServer } from "./wsServer"; |
| 8 | 10 | import WebSocket from "ws"; |
| 11 | import { ServerConfig, type ServerConfigShape } from "./config"; | |
| 12 | import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; | |
| 9 | 13 | |
| 10 | 14 | import { |
| 11 | 15 | DEFAULT_TERMINAL_ID, |
| 16 | EventId, | |
| 17 | ORCHESTRATION_WS_CHANNELS, | |
| 18 | ORCHESTRATION_WS_METHODS, | |
| 19 | ProviderItemId, | |
| 20 | ProviderSessionId, | |
| 21 | ProviderThreadId, | |
| 22 | ProviderTurnId, | |
| 12 | 23 | WS_CHANNELS, |
| 13 | 24 | WS_METHODS, |
| 25 | type WebSocketResponse, | |
| 26 | type ProviderRuntimeEvent, | |
| 14 | 27 | type KeybindingsConfig, |
| 15 | 28 | type ResolvedKeybindingsConfig, |
| 16 | 29 | type WsPush, |
| 17 | type WsResponse, | |
| 18 | 30 | } from "@t3tools/contracts"; |
| 19 | import { ProjectRegistry } from "./projectRegistry"; | |
| 20 | 31 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| 21 | 32 | import type { |
| 22 | 33 | TerminalClearInput, |
| ... | ... | @@ -27,7 +38,15 @@ import type { |
| 27 | 38 | TerminalSessionSnapshot, |
| 28 | 39 | TerminalWriteInput, |
| 29 | 40 | } from "@t3tools/contracts"; |
| 30 | import type { TerminalManager } from "./terminalManager"; | |
| 41 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; | |
| 42 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | |
| 43 | import { SqlClient } from "effect/unstable/sql"; | |
| 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | |
| 45 | import { Open, type OpenShape } from "./open"; | |
| 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | |
| 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | |
| 48 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | |
| 31 | 50 | |
| 32 | 51 | interface PendingMessages { |
| 33 | 52 | queue: unknown[]; |
| ... | ... | @@ -36,111 +55,147 @@ interface PendingMessages { |
| 36 | 55 | |
| 37 | 56 | const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); |
| 38 | 57 | |
| 39 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { | |
| 58 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 59 | const asProviderSessionId = (value: string): ProviderSessionId => | |
| 60 | ProviderSessionId.makeUnsafe(value); | |
| 61 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 62 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 63 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 64 | ||
| 65 | const defaultOpenService: OpenShape = { | |
| 66 | openBrowser: () => Effect.void, | |
| 67 | openInEditor: () => Effect.void, | |
| 68 | }; | |
| 69 | ||
| 70 | class MockTerminalManager implements TerminalManagerShape { | |
| 40 | 71 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 72 | private readonly listeners = new Set<(event: TerminalEvent) => void>(); | |
| 41 | 73 | |
| 42 | 74 | private key(threadId: string, terminalId: string): string { |
| 43 | 75 | return `${threadId}\u0000${terminalId}`; |
| 44 | 76 | } |
| 45 | 77 | |
| 46 | async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 47 | const now = new Date().toISOString(); | |
| 48 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 49 | const snapshot: TerminalSessionSnapshot = { | |
| 50 | threadId: input.threadId, | |
| 51 | terminalId, | |
| 52 | cwd: input.cwd, | |
| 53 | status: "running", | |
| 54 | pid: 4242, | |
| 55 | history: "", | |
| 56 | exitCode: null, | |
| 57 | exitSignal: null, | |
| 58 | updatedAt: now, | |
| 59 | }; | |
| 60 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 61 | queueMicrotask(() => { | |
| 62 | this.emit("event", { | |
| 63 | type: "started", | |
| 64 | threadId: input.threadId, | |
| 65 | terminalId, | |
| 66 | createdAt: now, | |
| 67 | snapshot, | |
| 68 | }); | |
| 69 | }); | |
| 70 | return snapshot; | |
| 78 | emitEvent(event: TerminalEvent): void { | |
| 79 | for (const listener of this.listeners) { | |
| 80 | listener(event); | |
| 81 | } | |
| 71 | 82 | } |
| 72 | 83 | |
| 73 | async write(input: TerminalWriteInput): Promise<void> { | |
| 74 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 75 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | |
| 76 | if (!existing) { | |
| 77 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | |
| 78 | } | |
| 79 | queueMicrotask(() => { | |
| 80 | this.emit("event", { | |
| 81 | type: "output", | |
| 84 | subscriptionCount(): number { | |
| 85 | return this.listeners.size; | |
| 86 | } | |
| 87 | ||
| 88 | readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => | |
| 89 | Effect.sync(() => { | |
| 90 | const now = new Date().toISOString(); | |
| 91 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 92 | const snapshot: TerminalSessionSnapshot = { | |
| 82 | 93 | threadId: input.threadId, |
| 83 | 94 | terminalId, |
| 84 | createdAt: new Date().toISOString(), | |
| 85 | data: input.data, | |
| 95 | cwd: input.cwd, | |
| 96 | status: "running", | |
| 97 | pid: 4242, | |
| 98 | history: "", | |
| 99 | exitCode: null, | |
| 100 | exitSignal: null, | |
| 101 | updatedAt: now, | |
| 102 | }; | |
| 103 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 104 | queueMicrotask(() => { | |
| 105 | this.emitEvent({ | |
| 106 | type: "started", | |
| 107 | threadId: input.threadId, | |
| 108 | terminalId, | |
| 109 | createdAt: now, | |
| 110 | snapshot, | |
| 111 | }); | |
| 86 | 112 | }); |
| 113 | return snapshot; | |
| 87 | 114 | }); |
| 88 | } | |
| 89 | 115 | |
| 90 | async resize(_input: TerminalResizeInput): Promise<void> {} | |
| 116 | readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => | |
| 117 | Effect.sync(() => { | |
| 118 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 119 | const existing = this.sessions.get(this.key(input.threadId, terminalId)); | |
| 120 | if (!existing) { | |
| 121 | throw new Error(`Unknown terminal thread: ${input.threadId}`); | |
| 122 | } | |
| 123 | queueMicrotask(() => { | |
| 124 | this.emitEvent({ | |
| 125 | type: "output", | |
| 126 | threadId: input.threadId, | |
| 127 | terminalId, | |
| 128 | createdAt: new Date().toISOString(), | |
| 129 | data: input.data, | |
| 130 | }); | |
| 131 | }); | |
| 132 | }); | |
| 91 | 133 | |
| 92 | async clear(input: TerminalClearInput): Promise<void> { | |
| 93 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 94 | queueMicrotask(() => { | |
| 95 | this.emit("event", { | |
| 96 | type: "cleared", | |
| 97 | threadId: input.threadId, | |
| 98 | terminalId, | |
| 99 | createdAt: new Date().toISOString(), | |
| 134 | readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; | |
| 135 | ||
| 136 | readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => | |
| 137 | Effect.sync(() => { | |
| 138 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 139 | queueMicrotask(() => { | |
| 140 | this.emitEvent({ | |
| 141 | type: "cleared", | |
| 142 | threadId: input.threadId, | |
| 143 | terminalId, | |
| 144 | createdAt: new Date().toISOString(), | |
| 145 | }); | |
| 100 | 146 | }); |
| 101 | 147 | }); |
| 102 | } | |
| 103 | 148 | |
| 104 | async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | |
| 105 | const now = new Date().toISOString(); | |
| 106 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 107 | const snapshot: TerminalSessionSnapshot = { | |
| 108 | threadId: input.threadId, | |
| 109 | terminalId, | |
| 110 | cwd: input.cwd, | |
| 111 | status: "running", | |
| 112 | pid: 5252, | |
| 113 | history: "", | |
| 114 | exitCode: null, | |
| 115 | exitSignal: null, | |
| 116 | updatedAt: now, | |
| 117 | }; | |
| 118 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 119 | queueMicrotask(() => { | |
| 120 | this.emit("event", { | |
| 121 | type: "restarted", | |
| 149 | readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => | |
| 150 | Effect.sync(() => { | |
| 151 | const now = new Date().toISOString(); | |
| 152 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | |
| 153 | const snapshot: TerminalSessionSnapshot = { | |
| 122 | 154 | threadId: input.threadId, |
| 123 | 155 | terminalId, |
| 124 | createdAt: now, | |
| 125 | snapshot, | |
| 156 | cwd: input.cwd, | |
| 157 | status: "running", | |
| 158 | pid: 5252, | |
| 159 | history: "", | |
| 160 | exitCode: null, | |
| 161 | exitSignal: null, | |
| 162 | updatedAt: now, | |
| 163 | }; | |
| 164 | this.sessions.set(this.key(input.threadId, terminalId), snapshot); | |
| 165 | queueMicrotask(() => { | |
| 166 | this.emitEvent({ | |
| 167 | type: "restarted", | |
| 168 | threadId: input.threadId, | |
| 169 | terminalId, | |
| 170 | createdAt: now, | |
| 171 | snapshot, | |
| 172 | }); | |
| 126 | 173 | }); |
| 174 | return snapshot; | |
| 127 | 175 | }); |
| 128 | return snapshot; | |
| 129 | } | |
| 130 | 176 | |
| 131 | async close(input: TerminalCloseInput): Promise<void> { | |
| 132 | if (input.terminalId) { | |
| 133 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | |
| 134 | return; | |
| 135 | } | |
| 136 | for (const key of [...this.sessions.keys()]) { | |
| 137 | if (key.startsWith(`${input.threadId}\u0000`)) { | |
| 138 | this.sessions.delete(key); | |
| 177 | readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => | |
| 178 | Effect.sync(() => { | |
| 179 | if (input.terminalId) { | |
| 180 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | |
| 181 | return; | |
| 139 | 182 | } |
| 140 | } | |
| 141 | } | |
| 183 | for (const key of this.sessions.keys()) { | |
| 184 | if (key.startsWith(`${input.threadId}\u0000`)) { | |
| 185 | this.sessions.delete(key); | |
| 186 | } | |
| 187 | } | |
| 188 | }); | |
| 142 | 189 | |
| 143 | dispose(): void {} | |
| 190 | readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => | |
| 191 | Effect.sync(() => { | |
| 192 | this.listeners.add(listener); | |
| 193 | return () => { | |
| 194 | this.listeners.delete(listener); | |
| 195 | }; | |
| 196 | }); | |
| 197 | ||
| 198 | readonly dispose: TerminalManagerShape["dispose"] = Effect.void; | |
| 144 | 199 | } |
| 145 | 200 | |
| 146 | 201 | function connectWs(port: number, token?: string): Promise<WebSocket> { |
| ... | ... | @@ -181,22 +236,68 @@ function waitForMessage(ws: WebSocket): Promise<unknown> { |
| 181 | 236 | }); |
| 182 | 237 | } |
| 183 | 238 | |
| 184 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { | |
| 239 | function asWebSocketResponse(message: unknown): WebSocketResponse | null { | |
| 240 | if (typeof message !== "object" || message === null) return null; | |
| 241 | if (!("id" in message)) return null; | |
| 242 | const id = (message as { id?: unknown }).id; | |
| 243 | if (typeof id !== "string") return null; | |
| 244 | return message as WebSocketResponse; | |
| 245 | } | |
| 246 | ||
| 247 | async function sendRequest( | |
| 248 | ws: WebSocket, | |
| 249 | method: string, | |
| 250 | params?: unknown, | |
| 251 | ): Promise<WebSocketResponse> { | |
| 185 | 252 | const id = crypto.randomUUID(); |
| 186 | const message = JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) }); | |
| 253 | const body = | |
| 254 | method === ORCHESTRATION_WS_METHODS.dispatchCommand | |
| 255 | ? { _tag: method, command: params } | |
| 256 | : params && typeof params === "object" && !Array.isArray(params) | |
| 257 | ? { _tag: method, ...(params as Record<string, unknown>) } | |
| 258 | : { _tag: method }; | |
| 259 | const message = JSON.stringify({ id, body }); | |
| 187 | 260 | ws.send(message); |
| 188 | 261 | |
| 189 | 262 | // Wait for response with matching id |
| 190 | 263 | while (true) { |
| 191 | const parsed = (await waitForMessage(ws)) as Record<string, unknown>; | |
| 264 | const parsed = asWebSocketResponse(await waitForMessage(ws)); | |
| 265 | if (!parsed) { | |
| 266 | continue; | |
| 267 | } | |
| 192 | 268 | if (parsed.id === id) { |
| 193 | return parsed as WsResponse; | |
| 269 | return parsed; | |
| 270 | } | |
| 271 | if (parsed.id === "unknown") { | |
| 272 | return parsed; | |
| 194 | 273 | } |
| 195 | 274 | } |
| 196 | 275 | } |
| 197 | 276 | |
| 277 | async function waitForPush( | |
| 278 | ws: WebSocket, | |
| 279 | channel: string, | |
| 280 | predicate?: (push: WsPush) => boolean, | |
| 281 | maxMessages = 120, | |
| 282 | ): Promise<WsPush> { | |
| 283 | const take = async (remaining: number): Promise<WsPush> => { | |
| 284 | if (remaining <= 0) { | |
| 285 | throw new Error(`Timed out waiting for push on ${channel}`); | |
| 286 | } | |
| 287 | const message = (await waitForMessage(ws)) as WsPush; | |
| 288 | if (message.type !== "push" || message.channel !== channel) { | |
| 289 | return take(remaining - 1); | |
| 290 | } | |
| 291 | if (!predicate || predicate(message)) { | |
| 292 | return message; | |
| 293 | } | |
| 294 | return take(remaining - 1); | |
| 295 | }; | |
| 296 | return take(maxMessages); | |
| 297 | } | |
| 298 | ||
| 198 | 299 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 199 | const resolved: ResolvedKeybindingsConfig = []; | |
| 300 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; | |
| 200 | 301 | for (const binding of bindings) { |
| 201 | 302 | const compiled = compileResolvedKeybindingRule(binding); |
| 202 | 303 | if (!compiled) { |
| ... | ... | @@ -209,20 +310,9 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon |
| 209 | 310 | |
| 210 | 311 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); |
| 211 | 312 | |
| 212 | function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { | |
| 213 | if (custom.length === 0) { | |
| 214 | return DEFAULT_RESOLVED_KEYBINDINGS; | |
| 215 | } | |
| 216 | ||
| 217 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 218 | const retainedDefaults = DEFAULT_KEYBINDINGS.filter( | |
| 219 | (binding) => !overriddenCommands.has(binding.command), | |
| 220 | ); | |
| 221 | return compileKeybindings([...retainedDefaults, ...custom].slice(-256)); | |
| 222 | } | |
| 223 | ||
| 224 | 313 | describe("WebSocket Server", () => { |
| 225 | let server: ReturnType<typeof createServer> | null = null; | |
| 314 | let server: Http.Server | null = null; | |
| 315 | let serverScope: Scope.Closeable | null = null; | |
| 226 | 316 | const connections: WebSocket[] = []; |
| 227 | 317 | const tempDirs: string[] = []; |
| 228 | 318 | |
| ... | ... | @@ -232,29 +322,88 @@ describe("WebSocket Server", () => { |
| 232 | 322 | return dir; |
| 233 | 323 | } |
| 234 | 324 | |
| 235 | function createTestServer( | |
| 325 | async function createTestServer( | |
| 236 | 326 | options: { |
| 327 | persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>; | |
| 237 | 328 | cwd?: string; |
| 329 | autoBootstrapProjectFromCwd?: boolean; | |
| 330 | logWebSocketEvents?: boolean; | |
| 238 | 331 | devUrl?: string; |
| 239 | 332 | authToken?: string; |
| 240 | 333 | stateDir?: string; |
| 241 | gitManager?: { | |
| 242 | status: (input: { cwd: string }) => Promise<unknown>; | |
| 243 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; | |
| 244 | }; | |
| 245 | terminalManager?: TerminalManager; | |
| 334 | providerLayer?: Layer.Layer<ProviderService, never>; | |
| 335 | open?: OpenShape; | |
| 336 | gitManager?: GitManagerShape; | |
| 337 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; | |
| 338 | terminalManager?: TerminalManagerShape; | |
| 246 | 339 | } = {}, |
| 247 | ): ReturnType<typeof createServer> { | |
| 340 | ): Promise<Http.Server> { | |
| 341 | if (serverScope) { | |
| 342 | throw new Error("Test server is already running"); | |
| 343 | } | |
| 344 | ||
| 248 | 345 | const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); |
| 249 | return createServer({ | |
| 346 | const scope = await Effect.runPromise(Scope.make("sequential")); | |
| 347 | const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; | |
| 348 | const providerLayer = options.providerLayer ?? makeServerProviderLayer(); | |
| 349 | const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); | |
| 350 | const serverConfigLayer = Layer.succeed(ServerConfig, { | |
| 351 | mode: "web", | |
| 250 | 352 | port: 0, |
| 353 | host: undefined, | |
| 251 | 354 | cwd: options.cwd ?? "/test/project", |
| 252 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), | |
| 253 | ...(options.authToken ? { authToken: options.authToken } : {}), | |
| 254 | projectRegistry: new ProjectRegistry(stateDir), | |
| 255 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), | |
| 256 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), | |
| 257 | }); | |
| 355 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | |
| 356 | stateDir, | |
| 357 | staticDir: undefined, | |
| 358 | devUrl: options.devUrl ? new URL(options.devUrl) : undefined, | |
| 359 | noBrowser: true, | |
| 360 | authToken: options.authToken, | |
| 361 | autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, | |
| 362 | logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), | |
| 363 | } satisfies ServerConfigShape); | |
| 364 | const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); | |
| 365 | const runtimeOverrides = Layer.mergeAll( | |
| 366 | options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, | |
| 367 | options.gitCore | |
| 368 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) | |
| 369 | : Layer.empty, | |
| 370 | options.terminalManager | |
| 371 | ? Layer.succeed(TerminalManager, options.terminalManager) | |
| 372 | : Layer.empty, | |
| 373 | ); | |
| 374 | const runtimeLayer = Layer.merge( | |
| 375 | Layer.merge( | |
| 376 | makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), | |
| 377 | infrastructureLayer, | |
| 378 | ), | |
| 379 | runtimeOverrides, | |
| 380 | ); | |
| 381 | const dependenciesLayer = Layer.empty.pipe( | |
| 382 | Layer.provideMerge(runtimeLayer), | |
| 383 | Layer.provideMerge(openLayer), | |
| 384 | Layer.provideMerge(serverConfigLayer), | |
| 385 | ); | |
| 386 | const runtimeServices = await Effect.runPromise( | |
| 387 | Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), | |
| 388 | ); | |
| 389 | ||
| 390 | try { | |
| 391 | const runtime = await Effect.runPromise( | |
| 392 | createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), | |
| 393 | ); | |
| 394 | serverScope = scope; | |
| 395 | return runtime; | |
| 396 | } catch (error) { | |
| 397 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 398 | throw error; | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | async function closeTestServer() { | |
| 403 | if (!serverScope) return; | |
| 404 | const scope = serverScope; | |
| 405 | serverScope = null; | |
| 406 | await Effect.runPromise(Scope.close(scope, Exit.void)); | |
| 258 | 407 | } |
| 259 | 408 | |
| 260 | 409 | afterEach(async () => { |
| ... | ... | @@ -262,9 +411,7 @@ describe("WebSocket Server", () => { |
| 262 | 411 | ws.close(); |
| 263 | 412 | } |
| 264 | 413 | connections.length = 0; |
| 265 | if (server) { | |
| 266 | await server.stop(); | |
| 267 | } | |
| 414 | await closeTestServer(); | |
| 268 | 415 | server = null; |
| 269 | 416 | for (const dir of tempDirs.splice(0, tempDirs.length)) { |
| 270 | 417 | fs.rmSync(dir, { recursive: true, force: true }); |
| ... | ... | @@ -273,10 +420,9 @@ describe("WebSocket Server", () => { |
| 273 | 420 | }); |
| 274 | 421 | |
| 275 | 422 | it("sends welcome message on connect", async () => { |
| 276 | server = createTestServer({ cwd: "/test/project" }); | |
| 423 | server = await createTestServer({ cwd: "/test/project" }); | |
| 277 | 424 | // Get the actual port after listen |
| 278 | await server.start(); | |
| 279 | const addr = server.httpServer.address(); | |
| 425 | const addr = server.address(); | |
| 280 | 426 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 281 | 427 | expect(port).toBeGreaterThan(0); |
| 282 | 428 | |
| ... | ... | @@ -292,17 +438,140 @@ describe("WebSocket Server", () => { |
| 292 | 438 | }); |
| 293 | 439 | }); |
| 294 | 440 | |
| 441 | it("bootstraps the cwd project on startup when enabled", async () => { | |
| 442 | server = await createTestServer({ | |
| 443 | cwd: "/test/bootstrap-workspace", | |
| 444 | autoBootstrapProjectFromCwd: true, | |
| 445 | }); | |
| 446 | const addr = server.address(); | |
| 447 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 448 | expect(port).toBeGreaterThan(0); | |
| 449 | ||
| 450 | const ws = await connectWs(port); | |
| 451 | connections.push(ws); | |
| 452 | const welcome = (await waitForMessage(ws)) as WsPush; // welcome | |
| 453 | expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); | |
| 454 | expect(welcome.data).toEqual( | |
| 455 | expect.objectContaining({ | |
| 456 | cwd: "/test/bootstrap-workspace", | |
| 457 | projectName: "bootstrap-workspace", | |
| 458 | bootstrapProjectId: expect.any(String), | |
| 459 | bootstrapThreadId: expect.any(String), | |
| 460 | }), | |
| 461 | ); | |
| 462 | ||
| 463 | const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); | |
| 464 | expect(snapshotResponse.error).toBeUndefined(); | |
| 465 | const snapshot = snapshotResponse.result as { | |
| 466 | projects: Array<{ | |
| 467 | id: string; | |
| 468 | workspaceRoot: string; | |
| 469 | title: string; | |
| 470 | defaultModel: string | null; | |
| 471 | }>; | |
| 472 | threads: Array<{ | |
| 473 | id: string; | |
| 474 | projectId: string; | |
| 475 | title: string; | |
| 476 | model: string; | |
| 477 | branch: string | null; | |
| 478 | worktreePath: string | null; | |
| 479 | }>; | |
| 480 | }; | |
| 481 | const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; | |
| 482 | const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; | |
| 483 | expect(bootstrapProjectId).toBeDefined(); | |
| 484 | expect(bootstrapThreadId).toBeDefined(); | |
| 485 | ||
| 486 | expect(snapshot.projects).toEqual( | |
| 487 | expect.arrayContaining([ | |
| 488 | expect.objectContaining({ | |
| 489 | id: bootstrapProjectId, | |
| 490 | workspaceRoot: "/test/bootstrap-workspace", | |
| 491 | title: "bootstrap-workspace", | |
| 492 | defaultModel: "gpt-5-codex", | |
| 493 | }), | |
| 494 | ]), | |
| 495 | ); | |
| 496 | expect(snapshot.threads).toEqual( | |
| 497 | expect.arrayContaining([ | |
| 498 | expect.objectContaining({ | |
| 499 | id: bootstrapThreadId, | |
| 500 | projectId: bootstrapProjectId, | |
| 501 | title: "New thread", | |
| 502 | model: "gpt-5-codex", | |
| 503 | branch: null, | |
| 504 | worktreePath: null, | |
| 505 | }), | |
| 506 | ]), | |
| 507 | ); | |
| 508 | }); | |
| 509 | ||
| 510 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { | |
| 511 | const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); | |
| 512 | const persistenceLayer = makeSqlitePersistenceLive( | |
| 513 | path.join(stateDir, "state.sqlite"), | |
| 514 | ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer<SqlClient.SqlClient, never>; | |
| 515 | const cwd = "/test/bootstrap-existing"; | |
| 516 | ||
| 517 | server = await createTestServer({ | |
| 518 | cwd, | |
| 519 | stateDir, | |
| 520 | persistenceLayer, | |
| 521 | autoBootstrapProjectFromCwd: true, | |
| 522 | }); | |
| 523 | let addr = server.address(); | |
| 524 | let port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 525 | expect(port).toBeGreaterThan(0); | |
| 526 | ||
| 527 | const firstWs = await connectWs(port); | |
| 528 | connections.push(firstWs); | |
| 529 | const firstWelcome = (await waitForMessage(firstWs)) as WsPush; | |
| 530 | const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) | |
| 531 | .bootstrapProjectId; | |
| 532 | const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) | |
| 533 | .bootstrapThreadId; | |
| 534 | expect(firstBootstrapProjectId).toBeDefined(); | |
| 535 | expect(firstBootstrapThreadId).toBeDefined(); | |
| 536 | ||
| 537 | firstWs.close(); | |
| 538 | await closeTestServer(); | |
| 539 | server = null; | |
| 540 | ||
| 541 | server = await createTestServer({ | |
| 542 | cwd, | |
| 543 | stateDir, | |
| 544 | persistenceLayer, | |
| 545 | autoBootstrapProjectFromCwd: true, | |
| 546 | }); | |
| 547 | addr = server.address(); | |
| 548 | port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 549 | expect(port).toBeGreaterThan(0); | |
| 550 | ||
| 551 | const secondWs = await connectWs(port); | |
| 552 | connections.push(secondWs); | |
| 553 | const secondWelcome = (await waitForMessage(secondWs)) as WsPush; | |
| 554 | expect(secondWelcome.channel).toBe(WS_CHANNELS.serverWelcome); | |
| 555 | expect(secondWelcome.data).toEqual( | |
| 556 | expect.objectContaining({ | |
| 557 | cwd, | |
| 558 | projectName: "bootstrap-existing", | |
| 559 | bootstrapProjectId: firstBootstrapProjectId, | |
| 560 | bootstrapThreadId: firstBootstrapThreadId, | |
| 561 | }), | |
| 562 | ); | |
| 563 | }); | |
| 564 | ||
| 295 | 565 | it("logs outbound websocket push events in dev mode", async () => { |
| 296 | 566 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => { |
| 297 | 567 | // Keep test output clean while verifying websocket logs. |
| 298 | 568 | }); |
| 299 | 569 | |
| 300 | server = createTestServer({ | |
| 570 | server = await createTestServer({ | |
| 301 | 571 | cwd: "/test/project", |
| 302 | 572 | devUrl: "http://localhost:5173", |
| 303 | 573 | }); |
| 304 | await server.start(); | |
| 305 | const addr = server.httpServer.address(); | |
| 574 | const addr = server.address(); | |
| 306 | 575 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 307 | 576 | expect(port).toBeGreaterThan(0); |
| 308 | 577 | |
| ... | ... | @@ -323,11 +592,12 @@ describe("WebSocket Server", () => { |
| 323 | 592 | }); |
| 324 | 593 | |
| 325 | 594 | it("responds to server.getConfig", async () => { |
| 326 | const fakeHome = makeTempDir("t3code-home-"); | |
| 327 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 328 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 329 | await server.start(); | |
| 330 | const addr = server.httpServer.address(); | |
| 595 | const stateDir = makeTempDir("t3code-state-get-config-"); | |
| 596 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 597 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | |
| 598 | ||
| 599 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 600 | const addr = server.address(); | |
| 331 | 601 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 332 | 602 | |
| 333 | 603 | const ws = await connectWs(port); |
| ... | ... | @@ -340,174 +610,226 @@ describe("WebSocket Server", () => { |
| 340 | 610 | expect(response.error).toBeUndefined(); |
| 341 | 611 | expect(response.result).toEqual({ |
| 342 | 612 | cwd: "/my/workspace", |
| 613 | keybindingsConfigPath: keybindingsPath, | |
| 343 | 614 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 615 | issues: [], | |
| 344 | 616 | }); |
| 345 | 617 | }); |
| 346 | 618 | |
| 347 | it("reads keybindings from ~/.t3/keybindings.json", async () => { | |
| 348 | const fakeHome = makeTempDir("t3code-home-"); | |
| 349 | const configDir = path.join(fakeHome, ".t3"); | |
| 350 | fs.mkdirSync(configDir, { recursive: true }); | |
| 351 | fs.writeFileSync( | |
| 352 | path.join(configDir, "keybindings.json"), | |
| 353 | JSON.stringify([ | |
| 354 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 355 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 356 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 357 | ]), | |
| 358 | "utf8", | |
| 359 | ); | |
| 360 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 619 | it("bootstraps default keybindings file when missing", async () => { | |
| 620 | const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); | |
| 621 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 622 | expect(fs.existsSync(keybindingsPath)).toBe(false); | |
| 361 | 623 | |
| 362 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 363 | await server.start(); | |
| 364 | const addr = server.httpServer.address(); | |
| 624 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 625 | const addr = server.address(); | |
| 365 | 626 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 366 | 627 | |
| 367 | 628 | const ws = await connectWs(port); |
| 368 | 629 | connections.push(ws); |
| 369 | ||
| 370 | 630 | await waitForMessage(ws); |
| 371 | 631 | |
| 372 | 632 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 373 | 633 | expect(response.error).toBeUndefined(); |
| 374 | 634 | expect(response.result).toEqual({ |
| 375 | 635 | cwd: "/my/workspace", |
| 376 | keybindings: mergeWithDefaultsForTest([ | |
| 377 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 378 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 379 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 380 | ]), | |
| 636 | keybindingsConfigPath: keybindingsPath, | |
| 637 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 638 | issues: [], | |
| 381 | 639 | }); |
| 640 | ||
| 641 | const persistedConfig = JSON.parse( | |
| 642 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 643 | ) as KeybindingsConfig; | |
| 644 | expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); | |
| 382 | 645 | }); |
| 383 | 646 | |
| 384 | it("warns and ignores invalid keybinding entries", async () => { | |
| 385 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 386 | const fakeHome = makeTempDir("t3code-home-invalid-entry-"); | |
| 387 | const configDir = path.join(fakeHome, ".t3"); | |
| 388 | fs.mkdirSync(configDir, { recursive: true }); | |
| 389 | fs.writeFileSync( | |
| 390 | path.join(configDir, "keybindings.json"), | |
| 391 | JSON.stringify([ | |
| 392 | { key: "mod+j", command: "terminal.toggle" }, | |
| 393 | { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, | |
| 394 | ]), | |
| 395 | "utf8", | |
| 396 | ); | |
| 397 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 647 | it("falls back to defaults and reports malformed keybindings config issues", async () => { | |
| 648 | const stateDir = makeTempDir("t3code-state-malformed-keybindings-"); | |
| 649 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 650 | fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); | |
| 398 | 651 | |
| 399 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 400 | await server.start(); | |
| 401 | const addr = server.httpServer.address(); | |
| 652 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 653 | const addr = server.address(); | |
| 402 | 654 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 403 | 655 | |
| 404 | 656 | const ws = await connectWs(port); |
| 405 | 657 | connections.push(ws); |
| 406 | ||
| 407 | 658 | await waitForMessage(ws); |
| 408 | 659 | |
| 409 | 660 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 410 | 661 | expect(response.error).toBeUndefined(); |
| 411 | 662 | expect(response.result).toEqual({ |
| 412 | 663 | cwd: "/my/workspace", |
| 413 | keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 664 | keybindingsConfigPath: keybindingsPath, | |
| 665 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 666 | issues: [ | |
| 667 | { | |
| 668 | kind: "keybindings.malformed-config", | |
| 669 | message: expect.stringContaining("expected JSON array"), | |
| 670 | }, | |
| 671 | ], | |
| 414 | 672 | }); |
| 415 | expect( | |
| 416 | warnSpy.mock.calls.some(([message]) => | |
| 417 | String(message).includes("ignoring invalid keybinding entries"), | |
| 418 | ), | |
| 419 | ).toBe(true); | |
| 673 | expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); | |
| 420 | 674 | }); |
| 421 | 675 | |
| 422 | it("warns and ignores keybindings with malformed when expressions", async () => { | |
| 423 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 424 | const fakeHome = makeTempDir("t3code-home-invalid-when-"); | |
| 425 | const configDir = path.join(fakeHome, ".t3"); | |
| 426 | fs.mkdirSync(configDir, { recursive: true }); | |
| 676 | it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { | |
| 677 | const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); | |
| 678 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 427 | 679 | fs.writeFileSync( |
| 428 | path.join(configDir, "keybindings.json"), | |
| 429 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle", when: "terminalFocus && (" }]), | |
| 680 | keybindingsPath, | |
| 681 | JSON.stringify([ | |
| 682 | { key: "mod+j", command: "terminal.toggle" }, | |
| 683 | { key: "mod+shift+d+o", command: "terminal.new" }, | |
| 684 | { key: "mod+x", command: "not-a-real-command" }, | |
| 685 | ]), | |
| 430 | 686 | "utf8", |
| 431 | 687 | ); |
| 432 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 433 | 688 | |
| 434 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 435 | await server.start(); | |
| 436 | const addr = server.httpServer.address(); | |
| 689 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 690 | const addr = server.address(); | |
| 437 | 691 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 438 | 692 | |
| 439 | 693 | const ws = await connectWs(port); |
| 440 | 694 | connections.push(ws); |
| 441 | ||
| 442 | 695 | await waitForMessage(ws); |
| 443 | 696 | |
| 444 | 697 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 445 | 698 | expect(response.error).toBeUndefined(); |
| 446 | expect(response.result).toEqual({ | |
| 447 | cwd: "/my/workspace", | |
| 448 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 449 | }); | |
| 450 | expect( | |
| 451 | warnSpy.mock.calls.some(([message]) => | |
| 452 | String(message).includes("ignoring invalid keybinding entries"), | |
| 453 | ), | |
| 454 | ).toBe(true); | |
| 699 | const result = response.result as { | |
| 700 | cwd: string; | |
| 701 | keybindingsConfigPath: string; | |
| 702 | keybindings: ResolvedKeybindingsConfig; | |
| 703 | issues: Array<{ kind: string; index?: number; message: string }>; | |
| 704 | }; | |
| 705 | expect(result.cwd).toBe("/my/workspace"); | |
| 706 | expect(result.keybindingsConfigPath).toBe(keybindingsPath); | |
| 707 | expect(result.issues).toEqual([ | |
| 708 | { | |
| 709 | kind: "keybindings.invalid-entry", | |
| 710 | index: 1, | |
| 711 | message: expect.any(String), | |
| 712 | }, | |
| 713 | { | |
| 714 | kind: "keybindings.invalid-entry", | |
| 715 | index: 2, | |
| 716 | message: expect.any(String), | |
| 717 | }, | |
| 718 | ]); | |
| 719 | expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); | |
| 720 | expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); | |
| 721 | expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); | |
| 455 | 722 | }); |
| 456 | 723 | |
| 457 | it("reads keybindings once at startup and caches the resolved config", async () => { | |
| 458 | const fakeHome = makeTempDir("t3code-home-cached-config-"); | |
| 459 | const configDir = path.join(fakeHome, ".t3"); | |
| 460 | fs.mkdirSync(configDir, { recursive: true }); | |
| 461 | const keybindingsPath = path.join(configDir, "keybindings.json"); | |
| 462 | fs.writeFileSync( | |
| 463 | keybindingsPath, | |
| 464 | JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]), | |
| 465 | "utf8", | |
| 724 | it("pushes server.configUpdated issues when keybindings file changes", async () => { | |
| 725 | const stateDir = makeTempDir("t3code-state-keybindings-watch-"); | |
| 726 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 727 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | |
| 728 | ||
| 729 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 730 | const addr = server.address(); | |
| 731 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 732 | ||
| 733 | const ws = await connectWs(port); | |
| 734 | connections.push(ws); | |
| 735 | await waitForMessage(ws); | |
| 736 | ||
| 737 | fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); | |
| 738 | const malformedPush = await waitForPush( | |
| 739 | ws, | |
| 740 | WS_CHANNELS.serverConfigUpdated, | |
| 741 | (push) => | |
| 742 | Array.isArray((push.data as { issues?: unknown[] }).issues) && | |
| 743 | Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) && | |
| 744 | (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind === | |
| 745 | "keybindings.malformed-config", | |
| 466 | 746 | ); |
| 467 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 747 | expect(malformedPush.data).toEqual({ | |
| 748 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], | |
| 749 | }); | |
| 750 | ||
| 751 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | |
| 752 | const successPush = await waitForPush( | |
| 753 | ws, | |
| 754 | WS_CHANNELS.serverConfigUpdated, | |
| 755 | (push) => | |
| 756 | Array.isArray((push.data as { issues?: unknown[] }).issues) && | |
| 757 | (push.data as { issues: unknown[] }).issues.length === 0, | |
| 758 | ); | |
| 759 | expect(successPush.data).toEqual({ issues: [] }); | |
| 760 | }); | |
| 468 | 761 | |
| 469 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 470 | await server.start(); | |
| 471 | const addr = server.httpServer.address(); | |
| 762 | it("routes shell.openInEditor through the injected open service", async () => { | |
| 763 | const openCalls: Array<{ cwd: string; editor: string }> = []; | |
| 764 | const openService: OpenShape = { | |
| 765 | openBrowser: () => Effect.void, | |
| 766 | openInEditor: (input) => { | |
| 767 | openCalls.push({ cwd: input.cwd, editor: input.editor }); | |
| 768 | return Effect.void; | |
| 769 | }, | |
| 770 | }; | |
| 771 | ||
| 772 | server = await createTestServer({ cwd: "/my/workspace", open: openService }); | |
| 773 | const addr = server.address(); | |
| 472 | 774 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 473 | 775 | |
| 474 | 776 | const ws = await connectWs(port); |
| 475 | 777 | connections.push(ws); |
| 476 | 778 | await waitForMessage(ws); |
| 477 | 779 | |
| 478 | const firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 479 | expect(firstResponse.error).toBeUndefined(); | |
| 480 | expect(firstResponse.result).toEqual({ | |
| 780 | const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { | |
| 481 | 781 | cwd: "/my/workspace", |
| 482 | keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), | |
| 782 | editor: "cursor", | |
| 483 | 783 | }); |
| 784 | expect(response.error).toBeUndefined(); | |
| 785 | expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); | |
| 786 | }); | |
| 484 | 787 | |
| 788 | it("reads keybindings from the configured state directory", async () => { | |
| 789 | const stateDir = makeTempDir("t3code-state-keybindings-"); | |
| 790 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 485 | 791 | fs.writeFileSync( |
| 486 | 792 | keybindingsPath, |
| 487 | JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]), | |
| 793 | JSON.stringify([ | |
| 794 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 795 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 796 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 797 | ]), | |
| 488 | 798 | "utf8", |
| 489 | 799 | ); |
| 800 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 801 | const addr = server.address(); | |
| 802 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 803 | ||
| 804 | const ws = await connectWs(port); | |
| 805 | connections.push(ws); | |
| 490 | 806 | |
| 491 | const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 492 | expect(secondResponse.error).toBeUndefined(); | |
| 493 | expect(secondResponse.result).toEqual(firstResponse.result); | |
| 807 | await waitForMessage(ws); | |
| 808 | ||
| 809 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 810 | expect(response.error).toBeUndefined(); | |
| 811 | const persistedConfig = JSON.parse( | |
| 812 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 813 | ) as KeybindingsConfig; | |
| 814 | expect(response.result).toEqual({ | |
| 815 | cwd: "/my/workspace", | |
| 816 | keybindingsConfigPath: keybindingsPath, | |
| 817 | keybindings: compileKeybindings(persistedConfig), | |
| 818 | issues: [], | |
| 819 | }); | |
| 494 | 820 | }); |
| 495 | 821 | |
| 496 | 822 | it("upserts keybinding rules and updates cached server config", async () => { |
| 497 | const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); | |
| 498 | const configDir = path.join(fakeHome, ".t3"); | |
| 499 | fs.mkdirSync(configDir, { recursive: true }); | |
| 500 | const keybindingsPath = path.join(configDir, "keybindings.json"); | |
| 823 | const stateDir = makeTempDir("t3code-state-upsert-keybinding-"); | |
| 824 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 501 | 825 | fs.writeFileSync( |
| 502 | 826 | keybindingsPath, |
| 503 | 827 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), |
| 504 | 828 | "utf8", |
| 505 | 829 | ); |
| 506 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 507 | 830 | |
| 508 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 509 | await server.start(); | |
| 510 | const addr = server.httpServer.address(); | |
| 831 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | |
| 832 | const addr = server.address(); | |
| 511 | 833 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 512 | 834 | |
| 513 | 835 | const ws = await connectWs(port); |
| ... | ... | @@ -519,174 +841,266 @@ describe("WebSocket Server", () => { |
| 519 | 841 | command: "script.run-tests.run", |
| 520 | 842 | }); |
| 521 | 843 | expect(upsertResponse.error).toBeUndefined(); |
| 844 | const persistedConfig = JSON.parse( | |
| 845 | fs.readFileSync(keybindingsPath, "utf8"), | |
| 846 | ) as KeybindingsConfig; | |
| 847 | const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); | |
| 848 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | |
| 849 | expect(persistedCommands.has(defaultRule.command)).toBe(true); | |
| 850 | } | |
| 851 | expect(persistedCommands.has("script.run-tests.run")).toBe(true); | |
| 522 | 852 | expect(upsertResponse.result).toEqual({ |
| 523 | keybindings: mergeWithDefaultsForTest([ | |
| 524 | { key: "mod+j", command: "terminal.toggle" }, | |
| 525 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 526 | ]), | |
| 853 | keybindings: compileKeybindings(persistedConfig), | |
| 854 | issues: [], | |
| 527 | 855 | }); |
| 528 | 856 | |
| 529 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ | |
| 530 | key: string; | |
| 531 | command: string; | |
| 532 | }>; | |
| 533 | expect(persistedConfig).toEqual([ | |
| 534 | { key: "mod+j", command: "terminal.toggle" }, | |
| 535 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 536 | ]); | |
| 537 | ||
| 538 | 857 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 539 | 858 | expect(configResponse.error).toBeUndefined(); |
| 540 | 859 | expect(configResponse.result).toEqual({ |
| 541 | 860 | cwd: "/my/workspace", |
| 542 | keybindings: mergeWithDefaultsForTest([ | |
| 543 | { key: "mod+j", command: "terminal.toggle" }, | |
| 544 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 545 | ]), | |
| 861 | keybindingsConfigPath: keybindingsPath, | |
| 862 | keybindings: compileKeybindings(persistedConfig), | |
| 863 | issues: [], | |
| 546 | 864 | }); |
| 547 | 865 | }); |
| 548 | 866 | |
| 549 | it("warns and ignores unsupported keybindings config format", async () => { | |
| 550 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 551 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); | |
| 552 | const configDir = path.join(fakeHome, ".t3"); | |
| 553 | fs.mkdirSync(configDir, { recursive: true }); | |
| 554 | fs.writeFileSync( | |
| 555 | path.join(configDir, "keybindings.json"), | |
| 556 | JSON.stringify({ "terminal.toggle": "mod+j" }), | |
| 557 | "utf8", | |
| 558 | ); | |
| 559 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 560 | ||
| 561 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 562 | await server.start(); | |
| 563 | const addr = server.httpServer.address(); | |
| 867 | it("returns error for unknown methods", async () => { | |
| 868 | server = await createTestServer({ cwd: "/test" }); | |
| 869 | const addr = server.address(); | |
| 564 | 870 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 565 | 871 | |
| 566 | 872 | const ws = await connectWs(port); |
| 567 | 873 | connections.push(ws); |
| 568 | 874 | |
| 875 | // Consume welcome push | |
| 569 | 876 | await waitForMessage(ws); |
| 570 | 877 | |
| 571 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 572 | expect(response.error).toBeUndefined(); | |
| 573 | expect(response.result).toEqual({ | |
| 574 | cwd: "/my/workspace", | |
| 575 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 576 | }); | |
| 577 | expect( | |
| 578 | warnSpy.mock.calls.some(([message]) => | |
| 579 | String(message).includes("unsupported format; expected array"), | |
| 580 | ), | |
| 581 | ).toBe(true); | |
| 878 | const response = await sendRequest(ws, "nonexistent.method"); | |
| 879 | expect(response.error).toBeDefined(); | |
| 880 | expect(response.error!.message).toContain("Invalid request format"); | |
| 582 | 881 | }); |
| 583 | 882 | |
| 584 | it("warns and ignores malformed keybindings config files", async () => { | |
| 585 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 586 | const fakeHome = makeTempDir("t3code-home-invalid-"); | |
| 587 | const configDir = path.join(fakeHome, ".t3"); | |
| 588 | fs.mkdirSync(configDir, { recursive: true }); | |
| 589 | fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); | |
| 590 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 591 | ||
| 592 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 593 | await server.start(); | |
| 594 | const addr = server.httpServer.address(); | |
| 883 | it("returns error when requesting turn diff for unknown thread", async () => { | |
| 884 | server = await createTestServer({ cwd: "/test" }); | |
| 885 | const addr = server.address(); | |
| 595 | 886 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 596 | 887 | |
| 597 | 888 | const ws = await connectWs(port); |
| 598 | 889 | connections.push(ws); |
| 599 | ||
| 600 | 890 | await waitForMessage(ws); |
| 601 | 891 | |
| 602 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 603 | expect(response.error).toBeUndefined(); | |
| 604 | expect(response.result).toEqual({ | |
| 605 | cwd: "/my/workspace", | |
| 606 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | |
| 892 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 893 | threadId: "thread-missing", | |
| 894 | fromTurnCount: 1, | |
| 895 | toTurnCount: 2, | |
| 607 | 896 | }); |
| 608 | expect( | |
| 609 | warnSpy.mock.calls.some(([message]) => | |
| 610 | String(message).includes("ignoring malformed keybindings config"), | |
| 611 | ), | |
| 612 | ).toBe(true); | |
| 897 | expect(response.result).toBeUndefined(); | |
| 898 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); | |
| 613 | 899 | }); |
| 614 | 900 | |
| 615 | it("returns error for unknown methods", async () => { | |
| 616 | server = createTestServer({ cwd: "/test" }); | |
| 617 | await server.start(); | |
| 618 | const addr = server.httpServer.address(); | |
| 901 | it("returns error when requesting turn diff with an inverted range", async () => { | |
| 902 | server = await createTestServer({ cwd: "/test" }); | |
| 903 | const addr = server.address(); | |
| 619 | 904 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 620 | 905 | |
| 621 | 906 | const ws = await connectWs(port); |
| 622 | 907 | connections.push(ws); |
| 623 | ||
| 624 | // Consume welcome | |
| 625 | 908 | await waitForMessage(ws); |
| 626 | 909 | |
| 627 | const response = await sendRequest(ws, "nonexistent.method"); | |
| 628 | expect(response.error).toBeDefined(); | |
| 629 | expect(response.error!.message).toContain("Unknown method"); | |
| 910 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 911 | threadId: "thread-any", | |
| 912 | fromTurnCount: 2, | |
| 913 | toTurnCount: 1, | |
| 914 | }); | |
| 915 | expect(response.result).toBeUndefined(); | |
| 916 | expect(response.error?.message).toContain( | |
| 917 | "fromTurnCount must be less than or equal to toTurnCount", | |
| 918 | ); | |
| 630 | 919 | }); |
| 631 | 920 | |
| 632 | it("responds to providers.listSessions with empty array", async () => { | |
| 633 | server = createTestServer({ cwd: "/test" }); | |
| 634 | await server.start(); | |
| 635 | const addr = server.httpServer.address(); | |
| 921 | it("returns error when requesting full thread diff for unknown thread", async () => { | |
| 922 | server = await createTestServer({ cwd: "/test" }); | |
| 923 | const addr = server.address(); | |
| 636 | 924 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 637 | 925 | |
| 638 | 926 | const ws = await connectWs(port); |
| 639 | 927 | connections.push(ws); |
| 640 | ||
| 641 | // Consume welcome | |
| 642 | 928 | await waitForMessage(ws); |
| 643 | 929 | |
| 644 | const response = await sendRequest(ws, WS_METHODS.providersListSessions); | |
| 645 | expect(response.error).toBeUndefined(); | |
| 646 | expect(response.result).toEqual([]); | |
| 930 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { | |
| 931 | threadId: "thread-missing", | |
| 932 | toTurnCount: 2, | |
| 933 | }); | |
| 934 | expect(response.result).toBeUndefined(); | |
| 935 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); | |
| 647 | 936 | }); |
| 648 | 937 | |
| 649 | it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { | |
| 650 | server = createTestServer({ cwd: "/test" }); | |
| 651 | await server.start(); | |
| 652 | const addr = server.httpServer.address(); | |
| 938 | it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { | |
| 939 | server = await createTestServer({ cwd: "/test" }); | |
| 940 | const addr = server.address(); | |
| 653 | 941 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 654 | 942 | |
| 655 | 943 | const ws = await connectWs(port); |
| 656 | 944 | connections.push(ws); |
| 657 | 945 | await waitForMessage(ws); |
| 658 | 946 | |
| 659 | const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, { | |
| 660 | sessionId: "missing-session", | |
| 947 | const createdAt = new Date().toISOString(); | |
| 948 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 949 | type: "project.create", | |
| 950 | commandId: "cmd-diff-project-create", | |
| 951 | projectId: "project-diff", | |
| 952 | title: "Diff Project", | |
| 953 | workspaceRoot: "/tmp/ws-diff-project", | |
| 954 | defaultModel: "gpt-5-codex", | |
| 955 | createdAt, | |
| 661 | 956 | }); |
| 662 | expect(listResponse.result).toBeUndefined(); | |
| 663 | expect(listResponse.error?.message).toContain("Unknown provider session"); | |
| 664 | ||
| 665 | const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, { | |
| 666 | sessionId: "missing-session", | |
| 667 | turnCount: 0, | |
| 957 | expect(createProjectResponse.error).toBeUndefined(); | |
| 958 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 959 | type: "thread.create", | |
| 960 | commandId: "cmd-diff-thread-create", | |
| 961 | threadId: "thread-diff", | |
| 962 | projectId: "project-diff", | |
| 963 | title: "Diff Thread", | |
| 964 | model: "gpt-5-codex", | |
| 965 | branch: null, | |
| 966 | worktreePath: null, | |
| 967 | createdAt, | |
| 668 | 968 | }); |
| 669 | expect(revertResponse.result).toBeUndefined(); | |
| 670 | expect(revertResponse.error?.message).toContain("Unknown provider session"); | |
| 969 | expect(createThreadResponse.error).toBeUndefined(); | |
| 671 | 970 | |
| 672 | const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, { | |
| 673 | sessionId: "missing-session", | |
| 971 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | |
| 972 | threadId: "thread-diff", | |
| 674 | 973 | fromTurnCount: 0, |
| 675 | 974 | toTurnCount: 1, |
| 676 | 975 | }); |
| 677 | expect(diffResponse.result).toBeUndefined(); | |
| 678 | expect(diffResponse.error?.message).toContain("Unknown provider session"); | |
| 976 | expect(response.result).toBeUndefined(); | |
| 977 | expect(response.error?.message).toContain("exceeds current turn count"); | |
| 978 | }); | |
| 979 | ||
| 980 | it("keeps orchestration domain push behavior for provider runtime events", async () => { | |
| 981 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| 982 | const sessionId = asProviderSessionId("sess-test"); | |
| 983 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | |
| 984 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | |
| 985 | }; | |
| 986 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | |
| 987 | const providerService: ProviderServiceShape = { | |
| 988 | startSession: () => | |
| 989 | Effect.succeed({ | |
| 990 | sessionId, | |
| 991 | provider: "codex", | |
| 992 | status: "ready", | |
| 993 | threadId: asProviderThreadId("provider-thread-1"), | |
| 994 | createdAt: new Date().toISOString(), | |
| 995 | updatedAt: new Date().toISOString(), | |
| 996 | }), | |
| 997 | sendTurn: () => | |
| 998 | Effect.succeed({ | |
| 999 | threadId: asProviderThreadId("provider-thread-1"), | |
| 1000 | turnId: asProviderTurnId("provider-turn-1"), | |
| 1001 | }), | |
| 1002 | interruptTurn: () => unsupported(), | |
| 1003 | respondToRequest: () => unsupported(), | |
| 1004 | stopSession: () => unsupported(), | |
| 1005 | listSessions: () => Effect.succeed([]), | |
| 1006 | rollbackConversation: () => unsupported(), | |
| 1007 | stopAll: () => Effect.void, | |
| 1008 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 1009 | }; | |
| 1010 | const providerLayer = Layer.succeed(ProviderService, providerService); | |
| 1011 | ||
| 1012 | server = await createTestServer({ | |
| 1013 | cwd: "/test", | |
| 1014 | providerLayer, | |
| 1015 | }); | |
| 1016 | const addr = server.address(); | |
| 1017 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1018 | ||
| 1019 | const ws = await connectWs(port); | |
| 1020 | connections.push(ws); | |
| 1021 | await waitForMessage(ws); | |
| 1022 | ||
| 1023 | const createdAt = new Date().toISOString(); | |
| 1024 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1025 | type: "project.create", | |
| 1026 | commandId: "cmd-ws-project-create", | |
| 1027 | projectId: "project-1", | |
| 1028 | title: "WS Project", | |
| 1029 | workspaceRoot: "/tmp/ws-project", | |
| 1030 | defaultModel: "gpt-5-codex", | |
| 1031 | createdAt, | |
| 1032 | }); | |
| 1033 | expect(createProjectResponse.error).toBeUndefined(); | |
| 1034 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1035 | type: "thread.create", | |
| 1036 | commandId: "cmd-ws-runtime-thread-create", | |
| 1037 | threadId: "thread-1", | |
| 1038 | projectId: "project-1", | |
| 1039 | title: "Thread 1", | |
| 1040 | model: "gpt-5-codex", | |
| 1041 | branch: null, | |
| 1042 | worktreePath: null, | |
| 1043 | createdAt, | |
| 1044 | }); | |
| 1045 | expect(createThreadResponse.error).toBeUndefined(); | |
| 1046 | ||
| 1047 | const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 1048 | type: "thread.turn.start", | |
| 1049 | commandId: "cmd-ws-runtime-turn-start", | |
| 1050 | threadId: "thread-1", | |
| 1051 | message: { | |
| 1052 | messageId: "msg-ws-runtime-1", | |
| 1053 | role: "user", | |
| 1054 | text: "hello", | |
| 1055 | attachments: [], | |
| 1056 | }, | |
| 1057 | assistantDeliveryMode: "streaming", | |
| 1058 | approvalPolicy: "on-request", | |
| 1059 | sandboxMode: "workspace-write", | |
| 1060 | createdAt, | |
| 1061 | }); | |
| 1062 | expect(startTurnResponse.error).toBeUndefined(); | |
| 1063 | ||
| 1064 | await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | |
| 1065 | const event = push.data as { type?: string }; | |
| 1066 | return event.type === "thread.session-set"; | |
| 1067 | }); | |
| 1068 | ||
| 1069 | emitRuntimeEvent({ | |
| 1070 | type: "message.delta", | |
| 1071 | eventId: asEventId("evt-ws-runtime-message-delta"), | |
| 1072 | provider: "codex", | |
| 1073 | sessionId, | |
| 1074 | createdAt: new Date().toISOString(), | |
| 1075 | turnId: asProviderTurnId("turn-1"), | |
| 1076 | itemId: asProviderItemId("item-1"), | |
| 1077 | delta: "hello from runtime", | |
| 1078 | }); | |
| 1079 | ||
| 1080 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | |
| 1081 | const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; | |
| 1082 | return ( | |
| 1083 | event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" | |
| 1084 | ); | |
| 1085 | }); | |
| 1086 | ||
| 1087 | const domainEvent = domainPush.data as { | |
| 1088 | type: string; | |
| 1089 | payload: { messageId: string; text: string }; | |
| 1090 | }; | |
| 1091 | expect(domainEvent.type).toBe("thread.message-sent"); | |
| 1092 | expect(domainEvent.payload.messageId).toBe("assistant:item-1"); | |
| 1093 | expect(domainEvent.payload.text).toBe("hello from runtime"); | |
| 679 | 1094 | }); |
| 680 | 1095 | |
| 681 | 1096 | it("routes terminal RPC methods and broadcasts terminal events", async () => { |
| 682 | 1097 | const cwd = makeTempDir("t3code-ws-terminal-cwd-"); |
| 683 | 1098 | const terminalManager = new MockTerminalManager(); |
| 684 | server = createTestServer({ | |
| 1099 | server = await createTestServer({ | |
| 685 | 1100 | cwd: "/test", |
| 686 | terminalManager: terminalManager as unknown as TerminalManager, | |
| 1101 | terminalManager, | |
| 687 | 1102 | }); |
| 688 | await server.start(); | |
| 689 | const addr = server.httpServer.address(); | |
| 1103 | const addr = server.address(); | |
| 690 | 1104 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 691 | 1105 | |
| 692 | 1106 | const ws = await connectWs(port); |
| ... | ... | @@ -742,7 +1156,7 @@ describe("WebSocket Server", () => { |
| 742 | 1156 | createdAt: new Date().toISOString(), |
| 743 | 1157 | data: "manual test output\n", |
| 744 | 1158 | }; |
| 745 | terminalManager.emit("event", manualEvent); | |
| 1159 | terminalManager.emitEvent(manualEvent); | |
| 746 | 1160 | |
| 747 | 1161 | const push = (await waitForMessage(ws)) as WsPush; |
| 748 | 1162 | expect(push.type).toBe("push"); |
| ... | ... | @@ -752,24 +1166,22 @@ describe("WebSocket Server", () => { |
| 752 | 1166 | |
| 753 | 1167 | it("detaches terminal event listener on stop for injected manager", async () => { |
| 754 | 1168 | const terminalManager = new MockTerminalManager(); |
| 755 | server = createTestServer({ | |
| 1169 | server = await createTestServer({ | |
| 756 | 1170 | cwd: "/test", |
| 757 | terminalManager: terminalManager as unknown as TerminalManager, | |
| 1171 | terminalManager, | |
| 758 | 1172 | }); |
| 759 | await server.start(); | |
| 760 | 1173 | |
| 761 | expect(terminalManager.listenerCount("event")).toBe(1); | |
| 1174 | expect(terminalManager.subscriptionCount()).toBe(1); | |
| 762 | 1175 | |
| 763 | await server.stop(); | |
| 1176 | await closeTestServer(); | |
| 764 | 1177 | server = null; |
| 765 | 1178 | |
| 766 | expect(terminalManager.listenerCount("event")).toBe(0); | |
| 1179 | expect(terminalManager.subscriptionCount()).toBe(0); | |
| 767 | 1180 | }); |
| 768 | 1181 | |
| 769 | 1182 | it("returns validation errors for invalid terminal open params", async () => { |
| 770 | server = createTestServer({ cwd: "/test" }); | |
| 771 | await server.start(); | |
| 772 | const addr = server.httpServer.address(); | |
| 1183 | server = await createTestServer({ cwd: "/test" }); | |
| 1184 | const addr = server.address(); | |
| 773 | 1185 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 774 | 1186 | |
| 775 | 1187 | const ws = await connectWs(port); |
| ... | ... | @@ -786,9 +1198,8 @@ describe("WebSocket Server", () => { |
| 786 | 1198 | }); |
| 787 | 1199 | |
| 788 | 1200 | it("handles invalid JSON gracefully", async () => { |
| 789 | server = createTestServer({ cwd: "/test" }); | |
| 790 | await server.start(); | |
| 791 | const addr = server.httpServer.address(); | |
| 1201 | server = await createTestServer({ cwd: "/test" }); | |
| 1202 | const addr = server.address(); | |
| 792 | 1203 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 793 | 1204 | |
| 794 | 1205 | const ws = await connectWs(port); |
| ... | ... | @@ -800,106 +1211,125 @@ describe("WebSocket Server", () => { |
| 800 | 1211 | // Send garbage |
| 801 | 1212 | ws.send("not json at all"); |
| 802 | 1213 | |
| 803 | const response = (await waitForMessage(ws)) as WsResponse; | |
| 804 | expect(response.error).toBeDefined(); | |
| 805 | expect(response.error!.message).toContain("Invalid request format"); | |
| 1214 | let response: WebSocketResponse | null = null; | |
| 1215 | for (let attempt = 0; attempt < 5; attempt += 1) { | |
| 1216 | const message = asWebSocketResponse(await waitForMessage(ws)); | |
| 1217 | if (!message) { | |
| 1218 | continue; | |
| 1219 | } | |
| 1220 | if (message.id === "unknown") { | |
| 1221 | response = message; | |
| 1222 | break; | |
| 1223 | } | |
| 1224 | if (message.error) { | |
| 1225 | response = message; | |
| 1226 | break; | |
| 1227 | } | |
| 1228 | } | |
| 1229 | expect(response).toBeDefined(); | |
| 1230 | expect(response!.error).toBeDefined(); | |
| 1231 | expect(response!.error!.message).toContain("Invalid request format"); | |
| 806 | 1232 | }); |
| 807 | 1233 | |
| 808 | it("supports projects list/add/dedupe/remove", async () => { | |
| 809 | const stateDir = makeTempDir("t3code-ws-projects-state-"); | |
| 810 | const firstProjectCwd = makeTempDir("t3code-ws-project-a-"); | |
| 1234 | it("catches websocket message handler rejections and keeps the socket usable", async () => { | |
| 1235 | const unhandledRejections: unknown[] = []; | |
| 1236 | const onUnhandledRejection = (reason: unknown) => { | |
| 1237 | unhandledRejections.push(reason); | |
| 1238 | }; | |
| 1239 | process.on("unhandledRejection", onUnhandledRejection); | |
| 811 | 1240 | |
| 812 | server = createTestServer({ stateDir, cwd: "/test" }); | |
| 813 | await server.start(); | |
| 814 | const addr = server.httpServer.address(); | |
| 1241 | const brokenOpenService: OpenShape = { | |
| 1242 | openBrowser: () => Effect.void, | |
| 1243 | openInEditor: () => | |
| 1244 | Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), | |
| 1245 | }; | |
| 1246 | ||
| 1247 | try { | |
| 1248 | server = await createTestServer({ cwd: "/test", open: brokenOpenService }); | |
| 1249 | const addr = server.address(); | |
| 1250 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1251 | ||
| 1252 | const ws = await connectWs(port); | |
| 1253 | connections.push(ws); | |
| 1254 | await waitForMessage(ws); | |
| 1255 | ||
| 1256 | ws.send( | |
| 1257 | JSON.stringify({ | |
| 1258 | id: "req-broken-open", | |
| 1259 | body: { | |
| 1260 | _tag: WS_METHODS.shellOpenInEditor, | |
| 1261 | cwd: "/tmp", | |
| 1262 | editor: "cursor", | |
| 1263 | }, | |
| 1264 | }), | |
| 1265 | ); | |
| 1266 | ||
| 1267 | await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 1268 | expect(unhandledRejections).toHaveLength(0); | |
| 1269 | ||
| 1270 | const workspace = makeTempDir("t3code-ws-handler-still-usable-"); | |
| 1271 | fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8"); | |
| 1272 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 1273 | cwd: workspace, | |
| 1274 | query: "file", | |
| 1275 | limit: 5, | |
| 1276 | }); | |
| 1277 | expect(response.error).toBeUndefined(); | |
| 1278 | expect(response.result).toEqual( | |
| 1279 | expect.objectContaining({ | |
| 1280 | entries: expect.arrayContaining([ | |
| 1281 | expect.objectContaining({ | |
| 1282 | path: "file.txt", | |
| 1283 | kind: "file", | |
| 1284 | }), | |
| 1285 | ]), | |
| 1286 | }), | |
| 1287 | ); | |
| 1288 | } finally { | |
| 1289 | process.off("unhandledRejection", onUnhandledRejection); | |
| 1290 | } | |
| 1291 | }); | |
| 1292 | ||
| 1293 | it("returns errors for removed projects CRUD methods", async () => { | |
| 1294 | server = await createTestServer({ cwd: "/test" }); | |
| 1295 | const addr = server.address(); | |
| 815 | 1296 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 816 | 1297 | |
| 817 | 1298 | const ws = await connectWs(port); |
| 818 | 1299 | connections.push(ws); |
| 819 | 1300 | await waitForMessage(ws); |
| 820 | 1301 | |
| 821 | const emptyList = await sendRequest(ws, WS_METHODS.projectsList); | |
| 822 | expect(emptyList.error).toBeUndefined(); | |
| 823 | expect(emptyList.result).toEqual([]); | |
| 824 | ||
| 825 | const created = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 826 | cwd: firstProjectCwd, | |
| 827 | }); | |
| 828 | expect(created.error).toBeUndefined(); | |
| 829 | expect((created.result as { created: boolean }).created).toBe(true); | |
| 830 | ||
| 831 | const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 832 | cwd: firstProjectCwd, | |
| 833 | }); | |
| 834 | expect(duplicate.error).toBeUndefined(); | |
| 835 | expect((duplicate.result as { created: boolean }).created).toBe(false); | |
| 836 | ||
| 837 | const listed = await sendRequest(ws, WS_METHODS.projectsList); | |
| 838 | expect(listed.error).toBeUndefined(); | |
| 839 | const listedProjects = listed.result as Array<{ id: string; cwd: string }>; | |
| 840 | expect(listedProjects).toHaveLength(1); | |
| 841 | ||
| 842 | const projectId = listedProjects[0]?.id; | |
| 843 | expect(projectId).toBeTruthy(); | |
| 844 | if (!projectId) return; | |
| 1302 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); | |
| 1303 | expect(listResponse.result).toBeUndefined(); | |
| 1304 | expect(listResponse.error?.message).toContain("Invalid request format"); | |
| 845 | 1305 | |
| 846 | const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { | |
| 847 | id: projectId, | |
| 848 | scripts: [ | |
| 849 | { | |
| 850 | id: "setup", | |
| 851 | name: "Setup", | |
| 852 | command: "bun install", | |
| 853 | icon: "configure", | |
| 854 | runOnWorktreeCreate: true, | |
| 855 | }, | |
| 856 | ], | |
| 1306 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 1307 | cwd: "/tmp/project-a", | |
| 857 | 1308 | }); |
| 858 | expect(updatedScripts.error).toBeUndefined(); | |
| 859 | const scriptPayload = ( | |
| 860 | updatedScripts.result as { | |
| 861 | project: { | |
| 862 | scripts: Array<{ | |
| 863 | id: string; | |
| 864 | name: string; | |
| 865 | command: string; | |
| 866 | icon: string; | |
| 867 | runOnWorktreeCreate: boolean; | |
| 868 | }>; | |
| 869 | }; | |
| 870 | } | |
| 871 | ).project.scripts; | |
| 872 | expect(scriptPayload).toEqual([ | |
| 873 | { | |
| 874 | id: "setup", | |
| 875 | name: "Setup", | |
| 876 | command: "bun install", | |
| 877 | icon: "configure", | |
| 878 | runOnWorktreeCreate: true, | |
| 879 | }, | |
| 880 | ]); | |
| 1309 | expect(addResponse.result).toBeUndefined(); | |
| 1310 | expect(addResponse.error?.message).toContain("Invalid request format"); | |
| 881 | 1311 | |
| 882 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 883 | id: projectId, | |
| 1312 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 1313 | id: "project-a", | |
| 884 | 1314 | }); |
| 885 | expect(removed.error).toBeUndefined(); | |
| 886 | ||
| 887 | const afterRemove = await sendRequest(ws, WS_METHODS.projectsList); | |
| 888 | expect(afterRemove.error).toBeUndefined(); | |
| 889 | expect(afterRemove.result).toEqual([]); | |
| 1315 | expect(removeResponse.result).toBeUndefined(); | |
| 1316 | expect(removeResponse.error?.message).toContain("Invalid request format"); | |
| 890 | 1317 | }); |
| 891 | 1318 | |
| 892 | 1319 | it("supports projects.searchEntries", async () => { |
| 893 | 1320 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); |
| 894 | 1321 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); |
| 895 | fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); | |
| 1322 | fs.writeFileSync( | |
| 1323 | path.join(workspace, "src", "components", "Composer.tsx"), | |
| 1324 | "export {};", | |
| 1325 | "utf8", | |
| 1326 | ); | |
| 896 | 1327 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); |
| 897 | 1328 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); |
| 898 | 1329 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); |
| 899 | 1330 | |
| 900 | server = createTestServer({ cwd: "/test" }); | |
| 901 | await server.start(); | |
| 902 | const addr = server.httpServer.address(); | |
| 1331 | server = await createTestServer({ cwd: "/test" }); | |
| 1332 | const addr = server.address(); | |
| 903 | 1333 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 904 | 1334 | |
| 905 | 1335 | const ws = await connectWs(port); |
| ... | ... | @@ -921,69 +1351,57 @@ describe("WebSocket Server", () => { |
| 921 | 1351 | }); |
| 922 | 1352 | }); |
| 923 | 1353 | |
| 924 | it("supports git methods over websocket", async () => { | |
| 925 | const repoCwd = makeTempDir("t3code-ws-git-project-"); | |
| 1354 | it("routes git core methods over websocket", async () => { | |
| 1355 | const listBranches = vi.fn(() => | |
| 1356 | Effect.succeed({ | |
| 1357 | branches: [], | |
| 1358 | isRepo: false, | |
| 1359 | }), | |
| 1360 | ); | |
| 1361 | const initRepo = vi.fn(() => Effect.void); | |
| 1362 | const pullCurrentBranch = vi.fn(() => | |
| 1363 | Effect.fail( | |
| 1364 | new GitCommandError({ | |
| 1365 | operation: "GitCore.test.pullCurrentBranch", | |
| 1366 | detail: "No upstream configured", | |
| 1367 | command: "git pull", | |
| 1368 | cwd: "/repo/path", | |
| 1369 | }), | |
| 1370 | ), | |
| 1371 | ); | |
| 926 | 1372 | |
| 927 | server = createTestServer({ cwd: "/test" }); | |
| 928 | await server.start(); | |
| 929 | const addr = server.httpServer.address(); | |
| 1373 | server = await createTestServer({ | |
| 1374 | cwd: "/test", | |
| 1375 | gitCore: { | |
| 1376 | listBranches, | |
| 1377 | initRepo, | |
| 1378 | pullCurrentBranch, | |
| 1379 | }, | |
| 1380 | }); | |
| 1381 | const addr = server.address(); | |
| 930 | 1382 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 931 | 1383 | |
| 932 | 1384 | const ws = await connectWs(port); |
| 933 | 1385 | connections.push(ws); |
| 934 | 1386 | await waitForMessage(ws); |
| 935 | 1387 | |
| 936 | const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); | |
| 937 | expect(beforeInit.error).toBeUndefined(); | |
| 938 | expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); | |
| 1388 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); | |
| 1389 | expect(listResponse.error).toBeUndefined(); | |
| 1390 | expect(listResponse.result).toEqual({ branches: [], isRepo: false }); | |
| 1391 | expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 939 | 1392 | |
| 940 | const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd }); | |
| 1393 | const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); | |
| 941 | 1394 | expect(initResponse.error).toBeUndefined(); |
| 1395 | expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); | |
| 942 | 1396 | |
| 943 | const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { | |
| 944 | cwd: repoCwd, | |
| 945 | }); | |
| 946 | expect(afterInit.error).toBeUndefined(); | |
| 947 | expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); | |
| 948 | ||
| 949 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: repoCwd }); | |
| 1397 | const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); | |
| 950 | 1398 | expect(pullResponse.result).toBeUndefined(); |
| 951 | expect(pullResponse.error?.message).toBeDefined(); | |
| 952 | expect(pullResponse.error?.message).not.toContain("Unknown method"); | |
| 1399 | expect(pullResponse.error?.message).toContain("No upstream configured"); | |
| 1400 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); | |
| 953 | 1401 | }); |
| 954 | 1402 | |
| 955 | it("responds to git.status via the git manager", async () => { | |
| 956 | const gitManager = { | |
| 957 | status: vi.fn().mockResolvedValue({ | |
| 958 | branch: "feature/test", | |
| 959 | hasWorkingTreeChanges: true, | |
| 960 | workingTree: { | |
| 961 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | |
| 962 | insertions: 7, | |
| 963 | deletions: 2, | |
| 964 | }, | |
| 965 | hasUpstream: false, | |
| 966 | aheadCount: 0, | |
| 967 | behindCount: 0, | |
| 968 | pr: null, | |
| 969 | }), | |
| 970 | runStackedAction: vi.fn(), | |
| 971 | }; | |
| 972 | ||
| 973 | server = createTestServer({ cwd: "/test", gitManager }); | |
| 974 | await server.start(); | |
| 975 | const addr = server.httpServer.address(); | |
| 976 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 977 | ||
| 978 | const ws = await connectWs(port); | |
| 979 | connections.push(ws); | |
| 980 | await waitForMessage(ws); | |
| 981 | ||
| 982 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | |
| 983 | cwd: "/test", | |
| 984 | }); | |
| 985 | expect(response.error).toBeUndefined(); | |
| 986 | expect(response.result).toEqual({ | |
| 1403 | it("supports git.status over websocket", async () => { | |
| 1404 | const statusResult = { | |
| 987 | 1405 | branch: "feature/test", |
| 988 | 1406 | hasWorkingTreeChanges: true, |
| 989 | 1407 | workingTree: { |
| ... | ... | @@ -995,91 +1413,65 @@ describe("WebSocket Server", () => { |
| 995 | 1413 | aheadCount: 0, |
| 996 | 1414 | behindCount: 0, |
| 997 | 1415 | pr: null, |
| 998 | }); | |
| 999 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1000 | }); | |
| 1001 | ||
| 1002 | it("returns errors from git.runStackedAction", async () => { | |
| 1003 | const gitManager = { | |
| 1004 | status: vi.fn(), | |
| 1005 | runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")), | |
| 1006 | 1416 | }; |
| 1007 | 1417 | |
| 1008 | server = createTestServer({ cwd: "/test", gitManager }); | |
| 1009 | await server.start(); | |
| 1010 | const addr = server.httpServer.address(); | |
| 1418 | const status = vi.fn(() => Effect.succeed(statusResult)); | |
| 1419 | const runStackedAction = vi.fn(() => Effect.void as any); | |
| 1420 | const gitManager: GitManagerShape = { status, runStackedAction }; | |
| 1421 | ||
| 1422 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1423 | const addr = server.address(); | |
| 1011 | 1424 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1012 | 1425 | |
| 1013 | 1426 | const ws = await connectWs(port); |
| 1014 | 1427 | connections.push(ws); |
| 1015 | 1428 | await waitForMessage(ws); |
| 1016 | 1429 | |
| 1017 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { | |
| 1018 | cwd: "/test", | |
| 1019 | action: "commit_push", | |
| 1020 | }); | |
| 1021 | expect(response.result).toBeUndefined(); | |
| 1022 | expect(response.error?.message).toContain("detached HEAD"); | |
| 1023 | expect(gitManager.runStackedAction).toHaveBeenCalledWith({ | |
| 1430 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | |
| 1024 | 1431 | cwd: "/test", |
| 1025 | action: "commit_push", | |
| 1026 | 1432 | }); |
| 1433 | expect(response.error).toBeUndefined(); | |
| 1434 | expect(response.result).toEqual(statusResult); | |
| 1435 | expect(status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1027 | 1436 | }); |
| 1028 | 1437 | |
| 1029 | it("prunes missing projects on startup", async () => { | |
| 1030 | const stateDir = makeTempDir("t3code-ws-prune-state-"); | |
| 1031 | const existing = makeTempDir("t3code-ws-existing-project-"); | |
| 1032 | const missing = path.join(stateDir, "definitely-missing"); | |
| 1033 | const now = new Date().toISOString(); | |
| 1034 | const projectsFile = path.join(stateDir, "projects.json"); | |
| 1035 | ||
| 1036 | fs.writeFileSync( | |
| 1037 | projectsFile, | |
| 1038 | JSON.stringify( | |
| 1039 | { | |
| 1040 | version: 1, | |
| 1041 | projects: [ | |
| 1042 | { | |
| 1043 | id: "project-existing", | |
| 1044 | cwd: existing, | |
| 1045 | name: "existing", | |
| 1046 | createdAt: now, | |
| 1047 | updatedAt: now, | |
| 1048 | }, | |
| 1049 | { | |
| 1050 | id: "project-missing", | |
| 1051 | cwd: missing, | |
| 1052 | name: "missing", | |
| 1053 | createdAt: now, | |
| 1054 | updatedAt: now, | |
| 1055 | }, | |
| 1056 | ], | |
| 1057 | }, | |
| 1058 | null, | |
| 1059 | 2, | |
| 1438 | it("returns errors from git.runStackedAction", async () => { | |
| 1439 | const runStackedAction = vi.fn(() => | |
| 1440 | Effect.fail( | |
| 1441 | new GitManagerError({ | |
| 1442 | operation: "GitManager.test.runStackedAction", | |
| 1443 | detail: "Cannot push from detached HEAD.", | |
| 1444 | }), | |
| 1060 | 1445 | ), |
| 1061 | 1446 | ); |
| 1447 | const gitManager: GitManagerShape = { | |
| 1448 | status: vi.fn(() => Effect.void as any), | |
| 1449 | runStackedAction, | |
| 1450 | }; | |
| 1062 | 1451 | |
| 1063 | server = createTestServer({ stateDir, cwd: "/test" }); | |
| 1064 | await server.start(); | |
| 1065 | const addr = server.httpServer.address(); | |
| 1452 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1453 | const addr = server.address(); | |
| 1066 | 1454 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1067 | 1455 | |
| 1068 | 1456 | const ws = await connectWs(port); |
| 1069 | 1457 | connections.push(ws); |
| 1070 | 1458 | await waitForMessage(ws); |
| 1071 | 1459 | |
| 1072 | const response = await sendRequest(ws, WS_METHODS.projectsList); | |
| 1073 | expect(response.error).toBeUndefined(); | |
| 1074 | const listed = response.result as Array<{ id: string }>; | |
| 1075 | expect(listed).toHaveLength(1); | |
| 1076 | expect(listed[0]?.id).toBe("project-existing"); | |
| 1460 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { | |
| 1461 | cwd: "/test", | |
| 1462 | action: "commit_push", | |
| 1463 | }); | |
| 1464 | expect(response.result).toBeUndefined(); | |
| 1465 | expect(response.error?.message).toContain("detached HEAD"); | |
| 1466 | expect(runStackedAction).toHaveBeenCalledWith({ | |
| 1467 | cwd: "/test", | |
| 1468 | action: "commit_push", | |
| 1469 | }); | |
| 1077 | 1470 | }); |
| 1078 | 1471 | |
| 1079 | 1472 | it("rejects websocket connections without a valid auth token", async () => { |
| 1080 | server = createTestServer({ cwd: "/test", authToken: "secret-token" }); | |
| 1081 | await server.start(); | |
| 1082 | const addr = server.httpServer.address(); | |
| 1473 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); | |
| 1474 | const addr = server.address(); | |
| 1083 | 1475 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1084 | 1476 | |
| 1085 | 1477 | await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); |
apps/server/src/wsServer.ts+517-329| ... | ... | @@ -1,38 +1,48 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 1 | /** | |
| 2 | * Server - HTTP/WebSocket server service interface. | |
| 3 | * | |
| 4 | * Owns startup and shutdown lifecycle of the HTTP server, static asset serving, | |
| 5 | * and WebSocket request routing. | |
| 6 | * | |
| 7 | * @module Server | |
| 8 | */ | |
| 2 | 9 | import fs from "node:fs"; |
| 3 | 10 | import http from "node:http"; |
| 4 | import os from "node:os"; | |
| 5 | 11 | import path from "node:path"; |
| 6 | 12 | import type { Duplex } from "node:stream"; |
| 7 | 13 | |
| 8 | 14 | import { |
| 9 | EDITORS, | |
| 15 | CommandId, | |
| 16 | ORCHESTRATION_WS_CHANNELS, | |
| 17 | ORCHESTRATION_WS_METHODS, | |
| 18 | ProjectId, | |
| 19 | ThreadId, | |
| 20 | TerminalEvent, | |
| 10 | 21 | WS_CHANNELS, |
| 11 | 22 | WS_METHODS, |
| 12 | type TerminalEvent, | |
| 13 | type WsPush, | |
| 14 | type WsRequest, | |
| 15 | type WsResponse, | |
| 16 | wsRequestSchema, | |
| 23 | WebSocketRequest, | |
| 24 | WsPush, | |
| 25 | WsResponse, | |
| 17 | 26 | } from "@t3tools/contracts"; |
| 27 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 28 | import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; | |
| 18 | 29 | import { WebSocketServer, type WebSocket } from "ws"; |
| 19 | 30 | |
| 20 | 31 | import { createLogger } from "./logger"; |
| 21 | import { ProjectRegistry } from "./projectRegistry"; | |
| 22 | import { ProviderManager } from "./providerManager"; | |
| 23 | import { GitManager } from "./gitManager"; | |
| 24 | import { | |
| 25 | checkoutGitBranch, | |
| 26 | createGitBranch, | |
| 27 | createGitWorktree, | |
| 28 | initGitRepo, | |
| 29 | listGitBranches, | |
| 30 | pullGitBranch, | |
| 31 | removeGitWorktree, | |
| 32 | } from "./git"; | |
| 33 | import { TerminalManager } from "./terminalManager"; | |
| 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | |
| 32 | import { GitManager } from "./git/Services/GitManager.ts"; | |
| 33 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | |
| 34 | import { Keybindings } from "./keybindings"; | |
| 35 | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; | |
| 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 38 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 39 | import { ProviderService } from "./provider/Services/ProviderService"; | |
| 40 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | |
| 41 | import { clamp } from "effect/Number"; | |
| 42 | import { Open } from "./open"; | |
| 43 | import { ServerConfig } from "./config"; | |
| 44 | import { GitCore } from "./git/Services/GitCore.ts"; | |
| 45 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | |
| 36 | 46 | |
| 37 | 47 | const MIME_TYPES: Record<string, string> = { |
| 38 | 48 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -49,50 +59,147 @@ const MIME_TYPES: Record<string, string> = { |
| 49 | 59 | ".map": "application/json", |
| 50 | 60 | }; |
| 51 | 61 | |
| 52 | export interface ServerOptions { | |
| 53 | port: number; | |
| 54 | host?: string | undefined; | |
| 55 | cwd: string; | |
| 56 | staticDir?: string | undefined; | |
| 57 | devUrl?: string | undefined; | |
| 58 | logWebSocketEvents?: boolean | undefined; | |
| 59 | projectRegistry?: ProjectRegistry | undefined; | |
| 60 | gitManager?: GitManager | undefined; | |
| 61 | terminalManager?: TerminalManager | undefined; | |
| 62 | authToken?: string | undefined; | |
| 62 | /** | |
| 63 | * ServerShape - Service API for server lifecycle control. | |
| 64 | */ | |
| 65 | export interface ServerShape { | |
| 66 | /** | |
| 67 | * Start HTTP and WebSocket listeners. | |
| 68 | */ | |
| 69 | readonly start: Effect.Effect< | |
| 70 | http.Server, | |
| 71 | ServerLifecycleError, | |
| 72 | Scope.Scope | ServerRuntimeServices | ServerConfig | |
| 73 | >; | |
| 74 | ||
| 75 | /** | |
| 76 | * Wait for process shutdown signals. | |
| 77 | */ | |
| 78 | readonly stopSignal: Effect.Effect<void, never>; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Server - Service tag for HTTP/WebSocket lifecycle management. | |
| 83 | */ | |
| 84 | export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {} | |
| 85 | ||
| 86 | const isServerNotRunningError = (error: unknown): boolean => { | |
| 87 | if (!(error instanceof Error)) return false; | |
| 88 | const maybeCode = (error as NodeJS.ErrnoException).code; | |
| 89 | return ( | |
| 90 | maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") | |
| 91 | ); | |
| 92 | }; | |
| 93 | ||
| 94 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | |
| 95 | socket.end( | |
| 96 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | |
| 97 | "Connection: close\r\n" + | |
| 98 | "Content-Type: text/plain\r\n" + | |
| 99 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | |
| 100 | "\r\n" + | |
| 101 | message, | |
| 102 | ); | |
| 103 | } | |
| 104 | ||
| 105 | function websocketRawToString(raw: unknown): string | null { | |
| 106 | if (typeof raw === "string") { | |
| 107 | return raw; | |
| 108 | } | |
| 109 | if (raw instanceof Uint8Array) { | |
| 110 | return Buffer.from(raw).toString("utf8"); | |
| 111 | } | |
| 112 | if (raw instanceof ArrayBuffer) { | |
| 113 | return Buffer.from(new Uint8Array(raw)).toString("utf8"); | |
| 114 | } | |
| 115 | if (Array.isArray(raw)) { | |
| 116 | const chunks: string[] = []; | |
| 117 | for (const chunk of raw) { | |
| 118 | if (typeof chunk === "string") { | |
| 119 | chunks.push(chunk); | |
| 120 | continue; | |
| 121 | } | |
| 122 | if (chunk instanceof Uint8Array) { | |
| 123 | chunks.push(Buffer.from(chunk).toString("utf8")); | |
| 124 | continue; | |
| 125 | } | |
| 126 | if (chunk instanceof ArrayBuffer) { | |
| 127 | chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8")); | |
| 128 | continue; | |
| 129 | } | |
| 130 | return null; | |
| 131 | } | |
| 132 | return chunks.join(""); | |
| 133 | } | |
| 134 | return null; | |
| 63 | 135 | } |
| 64 | 136 | |
| 65 | function parseBooleanEnv(value: string | undefined): boolean | undefined { | |
| 66 | if (value === undefined) return undefined; | |
| 67 | const normalized = value.trim().toLowerCase(); | |
| 68 | if (["1", "true", "yes", "on"].includes(normalized)) return true; | |
| 69 | if (["0", "false", "no", "off"].includes(normalized)) return false; | |
| 70 | return undefined; | |
| 137 | function stripRequestTag<T extends { _tag: string }>(body: T) { | |
| 138 | return Struct.omit(body, ["_tag"]); | |
| 71 | 139 | } |
| 72 | 140 | |
| 73 | export function createServer(options: ServerOptions) { | |
| 141 | export type ServerCoreRuntimeServices = | |
| 142 | | OrchestrationEngineService | |
| 143 | | ProjectionSnapshotQuery | |
| 144 | | CheckpointDiffQuery | |
| 145 | | OrchestrationReactor | |
| 146 | | ProviderService; | |
| 147 | ||
| 148 | export type ServerRuntimeServices = | |
| 149 | | ServerCoreRuntimeServices | |
| 150 | | GitManager | |
| 151 | | GitCore | |
| 152 | | TerminalManager | |
| 153 | | Keybindings | |
| 154 | | Open; | |
| 155 | ||
| 156 | export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()( | |
| 157 | "ServerLifecycleError", | |
| 158 | { | |
| 159 | operation: Schema.String, | |
| 160 | cause: Schema.optional(Schema.Defect), | |
| 161 | }, | |
| 162 | ) {} | |
| 163 | ||
| 164 | class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", { | |
| 165 | message: Schema.String, | |
| 166 | }) {} | |
| 167 | ||
| 168 | export const createServer = Effect.fn(function* (): Effect.fn.Return< | |
| 169 | http.Server, | |
| 170 | ServerLifecycleError, | |
| 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | |
| 172 | > { | |
| 173 | const serverConfig = yield* ServerConfig; | |
| 74 | 174 | const { |
| 75 | 175 | port, |
| 76 | host, | |
| 77 | 176 | cwd, |
| 177 | keybindingsConfigPath, | |
| 78 | 178 | staticDir, |
| 79 | 179 | devUrl, |
| 80 | logWebSocketEvents: explicitLogWsEvents, | |
| 81 | projectRegistry: providedRegistry, | |
| 82 | gitManager: providedGitManager, | |
| 83 | terminalManager: providedTerminalManager, | |
| 84 | 180 | authToken, |
| 85 | } = options; | |
| 86 | const providerManager = new ProviderManager(); | |
| 87 | const terminalManager = providedTerminalManager ?? new TerminalManager(); | |
| 88 | const projectRegistry = | |
| 89 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | |
| 90 | const gitManager = providedGitManager ?? new GitManager(); | |
| 91 | const clients = new Set<WebSocket>(); | |
| 181 | host, | |
| 182 | logWebSocketEvents, | |
| 183 | autoBootstrapProjectFromCwd, | |
| 184 | } = serverConfig; | |
| 185 | ||
| 186 | const gitManager = yield* GitManager; | |
| 187 | const terminalManager = yield* TerminalManager; | |
| 188 | const keybindingsManager = yield* Keybindings; | |
| 189 | const git = yield* GitCore; | |
| 190 | ||
| 191 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( | |
| 192 | Effect.catch((error) => | |
| 193 | Effect.logWarning("failed to sync keybindings defaults on startup", { | |
| 194 | path: error.configPath, | |
| 195 | detail: error.detail, | |
| 196 | cause: error.cause, | |
| 197 | }), | |
| 198 | ), | |
| 199 | ); | |
| 200 | ||
| 201 | const clients = yield* Ref.make(new Set<WebSocket>()); | |
| 92 | 202 | const logger = createLogger("ws"); |
| 93 | const logWebSocketEvents = | |
| 94 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); | |
| 95 | let keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 96 | 203 | |
| 97 | 204 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 98 | 205 | if (!logWebSocketEvents) return; |
| ... | ... | @@ -103,16 +210,11 @@ export function createServer(options: ServerOptions) { |
| 103 | 210 | }); |
| 104 | 211 | } |
| 105 | 212 | |
| 106 | // Forward provider events to all connected WebSocket clients | |
| 107 | providerManager.on("event", (event) => { | |
| 108 | const push: WsPush = { | |
| 109 | type: "push", | |
| 110 | channel: WS_CHANNELS.providerEvent, | |
| 111 | data: event, | |
| 112 | }; | |
| 113 | const message = JSON.stringify(push); | |
| 213 | const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush)); | |
| 214 | const broadcastPush = Effect.fnUntraced(function* (push: WsPush) { | |
| 215 | const message = yield* encodePush(push); | |
| 114 | 216 | let recipients = 0; |
| 115 | for (const client of clients) { | |
| 217 | for (const client of yield* Ref.get(clients)) { | |
| 116 | 218 | if (client.readyState === client.OPEN) { |
| 117 | 219 | client.send(message); |
| 118 | 220 | recipients += 1; |
| ... | ... | @@ -121,29 +223,25 @@ export function createServer(options: ServerOptions) { |
| 121 | 223 | logOutgoingPush(push, recipients); |
| 122 | 224 | }); |
| 123 | 225 | |
| 124 | const onTerminalEvent = (event: TerminalEvent) => { | |
| 125 | const push: WsPush = { | |
| 226 | const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) { | |
| 227 | yield* broadcastPush({ | |
| 126 | 228 | type: "push", |
| 127 | 229 | channel: WS_CHANNELS.terminalEvent, |
| 128 | 230 | data: event, |
| 129 | }; | |
| 130 | const message = JSON.stringify(push); | |
| 131 | let recipients = 0; | |
| 132 | for (const client of clients) { | |
| 133 | if (client.readyState === client.OPEN) { | |
| 134 | client.send(message); | |
| 135 | recipients += 1; | |
| 136 | } | |
| 137 | } | |
| 138 | logOutgoingPush(push, recipients); | |
| 139 | }; | |
| 140 | terminalManager.on("event", onTerminalEvent); | |
| 231 | }); | |
| 232 | }); | |
| 141 | 233 | |
| 142 | 234 | // HTTP server — serves static files or redirects to Vite dev server |
| 143 | 235 | const httpServer = http.createServer((req, res) => { |
| 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 237 | ||
| 238 | if (tryHandleProjectFaviconRequest(url, res)) { | |
| 239 | return; | |
| 240 | } | |
| 241 | ||
| 144 | 242 | // In dev mode, redirect to Vite dev server |
| 145 | 243 | if (devUrl) { |
| 146 | res.writeHead(302, { Location: devUrl }); | |
| 244 | res.writeHead(302, { Location: devUrl.href }); | |
| 147 | 245 | res.end(); |
| 148 | 246 | return; |
| 149 | 247 | } |
| ... | ... | @@ -155,7 +253,6 @@ export function createServer(options: ServerOptions) { |
| 155 | 253 | return; |
| 156 | 254 | } |
| 157 | 255 | |
| 158 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 159 | 256 | let filePath = path.join(staticDir, url.pathname); |
| 160 | 257 | |
| 161 | 258 | // SPA fallback: if no file extension and not found, serve index.html |
| ... | ... | @@ -198,312 +295,403 @@ export function createServer(options: ServerOptions) { |
| 198 | 295 | // WebSocket server — upgrades from the HTTP server |
| 199 | 296 | const wss = new WebSocketServer({ noServer: true }); |
| 200 | 297 | |
| 201 | function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { | |
| 202 | socket.write( | |
| 203 | `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + | |
| 204 | "Connection: close\r\n" + | |
| 205 | "Content-Type: text/plain\r\n" + | |
| 206 | `Content-Length: ${Buffer.byteLength(message)}\r\n` + | |
| 207 | "\r\n" + | |
| 208 | message, | |
| 209 | ); | |
| 210 | socket.destroy(); | |
| 211 | } | |
| 212 | ||
| 213 | httpServer.on("upgrade", (request, socket, head) => { | |
| 214 | if (authToken) { | |
| 215 | let providedToken: string | null = null; | |
| 216 | try { | |
| 217 | const url = new URL(request.url ?? "/", `http://localhost:${port}`); | |
| 218 | providedToken = url.searchParams.get("token"); | |
| 219 | } catch { | |
| 220 | rejectUpgrade(socket, 400, "Invalid WebSocket URL"); | |
| 221 | return; | |
| 298 | const closeWebSocketServer = Effect.callback<void, ServerLifecycleError>((resume) => { | |
| 299 | wss.close((error) => { | |
| 300 | if (error && !isServerNotRunningError(error)) { | |
| 301 | resume( | |
| 302 | Effect.fail( | |
| 303 | new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), | |
| 304 | ), | |
| 305 | ); | |
| 306 | } else { | |
| 307 | resume(Effect.void); | |
| 222 | 308 | } |
| 223 | ||
| 224 | if (providedToken !== authToken) { | |
| 225 | rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); | |
| 226 | return; | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | wss.handleUpgrade(request, socket, head, (ws) => { | |
| 231 | wss.emit("connection", ws, request); | |
| 232 | 309 | }); |
| 233 | 310 | }); |
| 234 | 311 | |
| 235 | wss.on("connection", (ws) => { | |
| 236 | clients.add(ws); | |
| 312 | const closeAllClients = Ref.get(clients).pipe( | |
| 313 | Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))), | |
| 314 | Effect.flatMap(() => Ref.set(clients, new Set())), | |
| 315 | ); | |
| 237 | 316 | |
| 238 | // Send welcome message with project info | |
| 239 | const segments = cwd.split(/[/\\]/).filter(Boolean); | |
| 240 | const projectName = segments[segments.length - 1] ?? "project"; | |
| 317 | const listenOptions = host ? { host, port } : { port }; | |
| 241 | 318 | |
| 242 | const welcome: WsPush = { | |
| 319 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 320 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; | |
| 321 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 322 | const liveProviderService = yield* ProviderService; | |
| 323 | const orchestrationReactor = yield* OrchestrationReactor; | |
| 324 | const { openInEditor } = yield* Open; | |
| 325 | ||
| 326 | const subscriptionsScope = yield* Scope.make("sequential"); | |
| 327 | yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); | |
| 328 | ||
| 329 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => | |
| 330 | broadcastPush({ | |
| 243 | 331 | type: "push", |
| 244 | channel: WS_CHANNELS.serverWelcome, | |
| 245 | data: { cwd, projectName }, | |
| 246 | }; | |
| 247 | logOutgoingPush(welcome, 1); | |
| 248 | ws.send(JSON.stringify(welcome)); | |
| 332 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 333 | data: event, | |
| 334 | }), | |
| 335 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 249 | 336 | |
| 250 | ws.on("message", (raw) => { | |
| 251 | void handleMessage(ws, raw); | |
| 252 | }); | |
| 337 | yield* Stream.runForEach(keybindingsManager.changes, (event) => | |
| 338 | broadcastPush({ | |
| 339 | type: "push", | |
| 340 | channel: WS_CHANNELS.serverConfigUpdated, | |
| 341 | data: event, | |
| 342 | }), | |
| 343 | ).pipe(Effect.forkIn(subscriptionsScope)); | |
| 253 | 344 | |
| 254 | ws.on("close", () => { | |
| 255 | clients.delete(ws); | |
| 256 | }); | |
| 345 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); | |
| 257 | 346 | |
| 258 | ws.on("error", () => { | |
| 259 | clients.delete(ws); | |
| 260 | }); | |
| 261 | }); | |
| 347 | let welcomeBootstrapProjectId: ProjectId | undefined; | |
| 348 | let welcomeBootstrapThreadId: ThreadId | undefined; | |
| 262 | 349 | |
| 263 | async function handleMessage(ws: WebSocket, raw: unknown) { | |
| 264 | let request: WsRequest; | |
| 265 | try { | |
| 266 | const parsed = JSON.parse(String(raw)); | |
| 267 | request = wsRequestSchema.parse(parsed); | |
| 268 | } catch { | |
| 269 | const errorResponse: WsResponse = { | |
| 270 | id: "unknown", | |
| 271 | error: { message: "Invalid request format" }, | |
| 272 | }; | |
| 273 | ws.send(JSON.stringify(errorResponse)); | |
| 274 | return; | |
| 275 | } | |
| 350 | if (autoBootstrapProjectFromCwd) { | |
| 351 | yield* Effect.gen(function* () { | |
| 352 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | |
| 353 | const existingProject = snapshot.projects.find( | |
| 354 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, | |
| 355 | ); | |
| 356 | let bootstrapProjectId: ProjectId; | |
| 357 | let bootstrapProjectDefaultModel: string; | |
| 358 | ||
| 359 | if (!existingProject) { | |
| 360 | const createdAt = new Date().toISOString(); | |
| 361 | bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 362 | const bootstrapProjectTitle = path.basename(cwd) || "project"; | |
| 363 | bootstrapProjectDefaultModel = "gpt-5-codex"; | |
| 364 | yield* orchestrationEngine.dispatch({ | |
| 365 | type: "project.create", | |
| 366 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 367 | projectId: bootstrapProjectId, | |
| 368 | title: bootstrapProjectTitle, | |
| 369 | workspaceRoot: cwd, | |
| 370 | defaultModel: bootstrapProjectDefaultModel, | |
| 371 | createdAt, | |
| 372 | }); | |
| 373 | } else { | |
| 374 | bootstrapProjectId = existingProject.id; | |
| 375 | bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; | |
| 376 | } | |
| 276 | 377 | |
| 277 | try { | |
| 278 | const result = await routeRequest(request); | |
| 279 | const response: WsResponse = { id: request.id, result }; | |
| 280 | ws.send(JSON.stringify(response)); | |
| 281 | } catch (err) { | |
| 282 | const message = err instanceof Error ? err.message : "Unknown server error"; | |
| 283 | const response: WsResponse = { | |
| 284 | id: request.id, | |
| 285 | error: { message }, | |
| 286 | }; | |
| 287 | ws.send(JSON.stringify(response)); | |
| 288 | } | |
| 378 | const existingThread = snapshot.threads.find( | |
| 379 | (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, | |
| 380 | ); | |
| 381 | if (!existingThread) { | |
| 382 | const createdAt = new Date().toISOString(); | |
| 383 | const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 384 | yield* orchestrationEngine.dispatch({ | |
| 385 | type: "thread.create", | |
| 386 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 387 | threadId, | |
| 388 | projectId: bootstrapProjectId, | |
| 389 | title: "New thread", | |
| 390 | model: bootstrapProjectDefaultModel, | |
| 391 | branch: null, | |
| 392 | worktreePath: null, | |
| 393 | createdAt, | |
| 394 | }); | |
| 395 | welcomeBootstrapProjectId = bootstrapProjectId; | |
| 396 | welcomeBootstrapThreadId = threadId; | |
| 397 | } else { | |
| 398 | welcomeBootstrapProjectId = bootstrapProjectId; | |
| 399 | welcomeBootstrapThreadId = existingThread.id; | |
| 400 | } | |
| 401 | }).pipe( | |
| 402 | Effect.mapError( | |
| 403 | (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), | |
| 404 | ), | |
| 405 | ); | |
| 289 | 406 | } |
| 290 | 407 | |
| 291 | async function routeRequest(request: WsRequest): Promise<unknown> { | |
| 292 | switch (request.method) { | |
| 293 | case WS_METHODS.providersStartSession: | |
| 294 | return providerManager.startSession(request.params as never); | |
| 408 | const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { | |
| 409 | switch (request.body._tag) { | |
| 410 | case ORCHESTRATION_WS_METHODS.getSnapshot: | |
| 411 | return yield* projectionReadModelQuery.getSnapshot(); | |
| 295 | 412 | |
| 296 | case WS_METHODS.providersSendTurn: | |
| 297 | return providerManager.sendTurn(request.params as never); | |
| 413 | case ORCHESTRATION_WS_METHODS.dispatchCommand: { | |
| 414 | const { command } = request.body; | |
| 415 | return yield* orchestrationEngine.dispatch(command); | |
| 416 | } | |
| 298 | 417 | |
| 299 | case WS_METHODS.providersInterruptTurn: | |
| 300 | return providerManager.interruptTurn(request.params as never); | |
| 418 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { | |
| 419 | const body = stripRequestTag(request.body); | |
| 420 | return yield* checkpointDiffQuery.getTurnDiff(body); | |
| 421 | } | |
| 301 | 422 | |
| 302 | case WS_METHODS.providersRespondToRequest: | |
| 303 | return providerManager.respondToRequest(request.params as never); | |
| 423 | case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { | |
| 424 | const body = stripRequestTag(request.body); | |
| 425 | return yield* checkpointDiffQuery.getFullThreadDiff(body); | |
| 426 | } | |
| 304 | 427 | |
| 305 | case WS_METHODS.providersStopSession: { | |
| 306 | providerManager.stopSession(request.params as never); | |
| 307 | return undefined; | |
| 428 | case ORCHESTRATION_WS_METHODS.replayEvents: { | |
| 429 | const { fromSequenceExclusive } = request.body; | |
| 430 | return yield* Stream.runCollect( | |
| 431 | orchestrationEngine.readEvents( | |
| 432 | clamp(fromSequenceExclusive, { | |
| 433 | maximum: Number.MAX_SAFE_INTEGER, | |
| 434 | minimum: 0, | |
| 435 | }), | |
| 436 | ), | |
| 437 | ).pipe(Effect.map((events) => Array.from(events))); | |
| 308 | 438 | } |
| 309 | 439 | |
| 310 | case WS_METHODS.providersListSessions: | |
| 311 | return providerManager.listSessions(); | |
| 440 | case WS_METHODS.projectsSearchEntries: { | |
| 441 | const body = stripRequestTag(request.body); | |
| 442 | return yield* Effect.tryPromise({ | |
| 443 | try: () => searchWorkspaceEntries(body), | |
| 444 | catch: (cause) => | |
| 445 | new RouteRequestError({ | |
| 446 | message: `Failed to search workspace entries: ${String(cause)}`, | |
| 447 | }), | |
| 448 | }); | |
| 449 | } | |
| 312 | 450 | |
| 313 | case WS_METHODS.providersListCheckpoints: | |
| 314 | return providerManager.listCheckpoints(request.params as never); | |
| 451 | case WS_METHODS.shellOpenInEditor: { | |
| 452 | const body = stripRequestTag(request.body); | |
| 453 | return yield* openInEditor(body); | |
| 454 | } | |
| 315 | 455 | |
| 316 | case WS_METHODS.providersGetCheckpointDiff: | |
| 317 | return providerManager.getCheckpointDiff(request.params as never); | |
| 456 | case WS_METHODS.gitStatus: { | |
| 457 | const body = stripRequestTag(request.body); | |
| 458 | return yield* gitManager.status(body); | |
| 459 | } | |
| 318 | 460 | |
| 319 | case WS_METHODS.providersRevertToCheckpoint: | |
| 320 | return providerManager.revertToCheckpoint(request.params as never); | |
| 461 | case WS_METHODS.gitPull: { | |
| 462 | const body = stripRequestTag(request.body); | |
| 463 | return yield* git.pullCurrentBranch(body.cwd); | |
| 464 | } | |
| 321 | 465 | |
| 322 | case WS_METHODS.projectsList: | |
| 323 | return projectRegistry.list(); | |
| 466 | case WS_METHODS.gitRunStackedAction: { | |
| 467 | const body = stripRequestTag(request.body); | |
| 468 | return yield* gitManager.runStackedAction(body); | |
| 469 | } | |
| 324 | 470 | |
| 325 | case WS_METHODS.projectsAdd: | |
| 326 | return projectRegistry.add(request.params as never); | |
| 471 | case WS_METHODS.gitListBranches: { | |
| 472 | const body = stripRequestTag(request.body); | |
| 473 | return yield* git.listBranches(body); | |
| 474 | } | |
| 327 | 475 | |
| 328 | case WS_METHODS.projectsRemove: | |
| 329 | projectRegistry.remove(request.params as never); | |
| 330 | return undefined; | |
| 476 | case WS_METHODS.gitCreateWorktree: { | |
| 477 | const body = stripRequestTag(request.body); | |
| 478 | return yield* git.createWorktree(body); | |
| 479 | } | |
| 331 | 480 | |
| 332 | case WS_METHODS.projectsSearchEntries: | |
| 333 | return searchWorkspaceEntries(request.params as never); | |
| 334 | case WS_METHODS.projectsUpdateScripts: | |
| 335 | return projectRegistry.updateScripts(request.params as never); | |
| 481 | case WS_METHODS.gitRemoveWorktree: { | |
| 482 | const body = stripRequestTag(request.body); | |
| 483 | return yield* git.removeWorktree(body); | |
| 484 | } | |
| 336 | 485 | |
| 337 | case WS_METHODS.shellOpenInEditor: { | |
| 338 | const params = request.params as { | |
| 339 | cwd: string; | |
| 340 | editor: string; | |
| 341 | }; | |
| 342 | if (!params?.cwd) throw new Error("cwd is required"); | |
| 343 | const editorDef = EDITORS.find((e) => e.id === params.editor); | |
| 344 | if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`); | |
| 345 | ||
| 346 | let command: string; | |
| 347 | let args: string[]; | |
| 348 | ||
| 349 | if (editorDef.command) { | |
| 350 | command = editorDef.command; | |
| 351 | args = [params.cwd]; | |
| 352 | } else if (editorDef.id === "file-manager") { | |
| 353 | // Use platform-specific file manager command | |
| 354 | switch (process.platform) { | |
| 355 | case "darwin": | |
| 356 | command = "open"; | |
| 357 | break; | |
| 358 | case "win32": | |
| 359 | command = "explorer"; | |
| 360 | break; | |
| 361 | default: | |
| 362 | command = "xdg-open"; | |
| 363 | break; | |
| 364 | } | |
| 365 | args = [params.cwd]; | |
| 366 | } else { | |
| 367 | return undefined; | |
| 368 | } | |
| 486 | case WS_METHODS.gitCreateBranch: { | |
| 487 | const body = stripRequestTag(request.body); | |
| 488 | return yield* git.createBranch(body); | |
| 489 | } | |
| 369 | 490 | |
| 370 | const child = spawn(command, args, { | |
| 371 | detached: true, | |
| 372 | stdio: "ignore", | |
| 373 | }); | |
| 374 | child.on("error", () => { | |
| 375 | /* ignore spawn failures for detached editors */ | |
| 376 | }); | |
| 377 | child.unref(); | |
| 378 | return undefined; | |
| 491 | case WS_METHODS.gitCheckout: { | |
| 492 | const body = stripRequestTag(request.body); | |
| 493 | return yield* git.checkoutBranch(body); | |
| 379 | 494 | } |
| 380 | 495 | |
| 381 | case WS_METHODS.gitStatus: | |
| 382 | return gitManager.status(request.params as never); | |
| 496 | case WS_METHODS.gitInit: { | |
| 497 | const body = stripRequestTag(request.body); | |
| 498 | return yield* git.initRepo(body); | |
| 499 | } | |
| 383 | 500 | |
| 384 | case WS_METHODS.gitPull: | |
| 385 | return pullGitBranch(request.params as never); | |
| 501 | case WS_METHODS.terminalOpen: { | |
| 502 | const body = stripRequestTag(request.body); | |
| 503 | return yield* terminalManager.open(body); | |
| 504 | } | |
| 386 | 505 | |
| 387 | case WS_METHODS.gitRunStackedAction: | |
| 388 | return gitManager.runStackedAction(request.params as never); | |
| 389 | case WS_METHODS.gitListBranches: | |
| 390 | return listGitBranches(request.params as never); | |
| 506 | case WS_METHODS.terminalWrite: { | |
| 507 | const body = stripRequestTag(request.body); | |
| 508 | return yield* terminalManager.write(body); | |
| 509 | } | |
| 391 | 510 | |
| 392 | case WS_METHODS.gitCreateWorktree: | |
| 393 | return createGitWorktree(request.params as never); | |
| 511 | case WS_METHODS.terminalResize: { | |
| 512 | const body = stripRequestTag(request.body); | |
| 513 | return yield* terminalManager.resize(body); | |
| 514 | } | |
| 394 | 515 | |
| 395 | case WS_METHODS.gitRemoveWorktree: | |
| 396 | return removeGitWorktree(request.params as never); | |
| 516 | case WS_METHODS.terminalClear: { | |
| 517 | const body = stripRequestTag(request.body); | |
| 518 | return yield* terminalManager.clear(body); | |
| 519 | } | |
| 397 | 520 | |
| 398 | case WS_METHODS.gitCreateBranch: | |
| 399 | return createGitBranch(request.params as never); | |
| 521 | case WS_METHODS.terminalRestart: { | |
| 522 | const body = stripRequestTag(request.body); | |
| 523 | return yield* terminalManager.restart(body); | |
| 524 | } | |
| 400 | 525 | |
| 401 | case WS_METHODS.gitCheckout: | |
| 402 | return checkoutGitBranch(request.params as never); | |
| 526 | case WS_METHODS.terminalClose: { | |
| 527 | const body = stripRequestTag(request.body); | |
| 528 | return yield* terminalManager.close(body); | |
| 529 | } | |
| 403 | 530 | |
| 404 | case WS_METHODS.gitInit: | |
| 405 | return initGitRepo(request.params as never); | |
| 531 | case WS_METHODS.serverGetConfig: | |
| 532 | const keybindingsConfig = yield* keybindingsManager.loadConfigState; | |
| 533 | return { | |
| 534 | cwd, | |
| 535 | keybindingsConfigPath, | |
| 536 | keybindings: keybindingsConfig.keybindings, | |
| 537 | issues: keybindingsConfig.issues, | |
| 538 | }; | |
| 406 | 539 | |
| 407 | case WS_METHODS.terminalOpen: | |
| 408 | return terminalManager.open(request.params as never); | |
| 540 | case WS_METHODS.serverUpsertKeybinding: { | |
| 541 | const body = stripRequestTag(request.body); | |
| 542 | const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); | |
| 543 | return { keybindings: keybindingsConfig, issues: [] }; | |
| 544 | } | |
| 409 | 545 | |
| 410 | case WS_METHODS.terminalWrite: | |
| 411 | await terminalManager.write(request.params as never); | |
| 412 | return undefined; | |
| 546 | default: { | |
| 547 | const _exhaustiveCheck: never = request.body; | |
| 548 | return yield* new RouteRequestError({ | |
| 549 | message: `Unknown method: ${String(_exhaustiveCheck)}`, | |
| 550 | }); | |
| 551 | } | |
| 552 | } | |
| 553 | }); | |
| 413 | 554 | |
| 414 | case WS_METHODS.terminalResize: | |
| 415 | await terminalManager.resize(request.params as never); | |
| 416 | return undefined; | |
| 555 | const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { | |
| 556 | const encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); | |
| 417 | 557 | |
| 418 | case WS_METHODS.terminalClear: | |
| 419 | await terminalManager.clear(request.params as never); | |
| 420 | return undefined; | |
| 558 | const messageText = websocketRawToString(raw); | |
| 559 | if (messageText === null) { | |
| 560 | const errorResponse = yield* encodeResponse({ | |
| 561 | id: "unknown", | |
| 562 | error: { message: "Invalid request format: Failed to read message" }, | |
| 563 | }); | |
| 564 | ws.send(errorResponse); | |
| 565 | return; | |
| 566 | } | |
| 421 | 567 | |
| 422 | case WS_METHODS.terminalRestart: | |
| 423 | return terminalManager.restart(request.params as never); | |
| 568 | const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); | |
| 569 | if (request._tag === "Failure") { | |
| 570 | const errorResponse = yield* encodeResponse({ | |
| 571 | id: "unknown", | |
| 572 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, | |
| 573 | }); | |
| 574 | ws.send(errorResponse); | |
| 575 | return; | |
| 576 | } | |
| 424 | 577 | |
| 425 | case WS_METHODS.terminalClose: | |
| 426 | await terminalManager.close(request.params as never); | |
| 427 | return undefined; | |
| 578 | const result = yield* Effect.exit(routeRequest(request.value)); | |
| 579 | if (result._tag === "Failure") { | |
| 580 | const errorResponse = yield* encodeResponse({ | |
| 581 | id: request.value.id, | |
| 582 | error: { message: Cause.pretty(result.cause) }, | |
| 583 | }); | |
| 584 | ws.send(errorResponse); | |
| 585 | return; | |
| 586 | } | |
| 428 | 587 | |
| 429 | case WS_METHODS.serverGetConfig: | |
| 430 | return { | |
| 431 | cwd, | |
| 432 | keybindings: keybindingsConfig, | |
| 433 | }; | |
| 588 | const response = yield* encodeResponse({ | |
| 589 | id: request.value.id, | |
| 590 | result: result.value, | |
| 591 | }); | |
| 592 | ws.send(response); | |
| 593 | }); | |
| 434 | 594 | |
| 435 | case WS_METHODS.serverUpsertKeybinding: | |
| 436 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | |
| 437 | return { | |
| 438 | keybindings: keybindingsConfig, | |
| 439 | }; | |
| 595 | httpServer.on("upgrade", (request, socket, head) => { | |
| 596 | socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake | |
| 440 | 597 | |
| 441 | default: | |
| 442 | throw new Error(`Unknown method: ${request.method}`); | |
| 443 | } | |
| 444 | } | |
| 598 | if (authToken) { | |
| 599 | let providedToken: string | null = null; | |
| 600 | try { | |
| 601 | const url = new URL(request.url ?? "/", `http://localhost:${port}`); | |
| 602 | providedToken = url.searchParams.get("token"); | |
| 603 | } catch { | |
| 604 | rejectUpgrade(socket, 400, "Invalid WebSocket URL"); | |
| 605 | return; | |
| 606 | } | |
| 445 | 607 | |
| 446 | function start() { | |
| 447 | return new Promise<void>((resolve, reject) => { | |
| 448 | const onError = (error: Error) => { | |
| 449 | httpServer.off("error", onError); | |
| 450 | reject(error); | |
| 451 | }; | |
| 452 | httpServer.once("error", onError); | |
| 453 | const onListening = () => { | |
| 454 | httpServer.off("error", onError); | |
| 455 | resolve(); | |
| 456 | }; | |
| 457 | if (host) { | |
| 458 | httpServer.listen(port, host, onListening); | |
| 608 | if (providedToken !== authToken) { | |
| 609 | rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); | |
| 459 | 610 | return; |
| 460 | 611 | } |
| 461 | httpServer.listen(port, onListening); | |
| 612 | } | |
| 613 | ||
| 614 | wss.handleUpgrade(request, socket, head, (ws) => { | |
| 615 | wss.emit("connection", ws, request); | |
| 462 | 616 | }); |
| 463 | } | |
| 617 | }); | |
| 464 | 618 | |
| 465 | async function stop(): Promise<void> { | |
| 466 | terminalManager.off("event", onTerminalEvent); | |
| 467 | providerManager.stopAll(); | |
| 468 | providerManager.dispose(); | |
| 469 | terminalManager.dispose(); | |
| 619 | wss.on("connection", (ws) => { | |
| 620 | void Effect.runPromise(Ref.update(clients, (clients) => clients.add(ws))); | |
| 470 | 621 | |
| 471 | for (const client of clients) { | |
| 472 | client.close(); | |
| 473 | } | |
| 474 | clients.clear(); | |
| 475 | ||
| 476 | const isServerNotRunningError = (error: unknown): boolean => { | |
| 477 | if (!(error instanceof Error)) return false; | |
| 478 | const maybeCode = (error as NodeJS.ErrnoException).code; | |
| 479 | return ( | |
| 480 | maybeCode === "ERR_SERVER_NOT_RUNNING" || | |
| 481 | error.message.toLowerCase().includes("not running") | |
| 482 | ); | |
| 622 | const segments = cwd.split(/[/\\]/).filter(Boolean); | |
| 623 | const projectName = segments[segments.length - 1] ?? "project"; | |
| 624 | ||
| 625 | const welcome: WsPush = { | |
| 626 | type: "push", | |
| 627 | channel: WS_CHANNELS.serverWelcome, | |
| 628 | data: { | |
| 629 | cwd, | |
| 630 | projectName, | |
| 631 | ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), | |
| 632 | ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), | |
| 633 | }, | |
| 483 | 634 | }; |
| 635 | logOutgoingPush(welcome, 1); | |
| 636 | ws.send(JSON.stringify(welcome)); | |
| 484 | 637 | |
| 485 | const closeWebSocketServer = new Promise<void>((resolve, reject) => { | |
| 486 | wss.close((error) => { | |
| 487 | if (error && !isServerNotRunningError(error)) { | |
| 488 | reject(error); | |
| 489 | return; | |
| 490 | } | |
| 491 | resolve(); | |
| 492 | }); | |
| 638 | ws.on("message", (raw) => { | |
| 639 | void Effect.runPromise( | |
| 640 | handleMessage(ws, raw).pipe( | |
| 641 | Effect.catch((error) => Effect.logError("Error handling message", error)), | |
| 642 | ), | |
| 643 | ); | |
| 493 | 644 | }); |
| 494 | 645 | |
| 495 | const closeHttpServer = new Promise<void>((resolve, reject) => { | |
| 496 | httpServer.close((error) => { | |
| 497 | if (error && !isServerNotRunningError(error)) { | |
| 498 | reject(error); | |
| 499 | return; | |
| 500 | } | |
| 501 | resolve(); | |
| 502 | }); | |
| 646 | ws.on("close", () => { | |
| 647 | void Effect.runPromise( | |
| 648 | Ref.update(clients, (clients) => { | |
| 649 | clients.delete(ws); | |
| 650 | return clients; | |
| 651 | }), | |
| 652 | ); | |
| 503 | 653 | }); |
| 504 | 654 | |
| 505 | await Promise.all([closeWebSocketServer, closeHttpServer]); | |
| 506 | } | |
| 655 | ws.on("error", () => { | |
| 656 | void Effect.runPromise( | |
| 657 | Ref.update(clients, (clients) => { | |
| 658 | clients.delete(ws); | |
| 659 | return clients; | |
| 660 | }), | |
| 661 | ); | |
| 662 | }); | |
| 663 | }); | |
| 507 | 664 | |
| 508 | return { start, stop, httpServer }; | |
| 509 | } | |
| 665 | yield* Effect.addFinalizer(() => | |
| 666 | Effect.catch(liveProviderService.stopAll(), (cause) => | |
| 667 | Effect.logWarning("failed to stop provider service", { cause }), | |
| 668 | ), | |
| 669 | ); | |
| 670 | ||
| 671 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( | |
| 672 | (event) => void Effect.runPromise(onTerminalEvent(event)), | |
| 673 | ); | |
| 674 | yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); | |
| 675 | ||
| 676 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( | |
| 677 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), | |
| 678 | ); | |
| 679 | ||
| 680 | yield* Effect.addFinalizer(() => | |
| 681 | Effect.all([ | |
| 682 | closeAllClients, | |
| 683 | closeWebSocketServer.pipe( | |
| 684 | Effect.catch((error) => | |
| 685 | Effect.logWarning("failed to close web socket server", { cause: error }), | |
| 686 | ), | |
| 687 | ), | |
| 688 | ]), | |
| 689 | ); | |
| 690 | ||
| 691 | return httpServer; | |
| 692 | }); | |
| 693 | ||
| 694 | export const ServerLive = Layer.succeed(Server, { | |
| 695 | start: createServer(), | |
| 696 | stopSignal: Effect.never, | |
| 697 | } satisfies ServerShape); |
apps/server/tsconfig.json+19-3| ... | ... | @@ -2,8 +2,24 @@ |
| 2 | 2 | "extends": "../../tsconfig.base.json", |
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | "types": ["node"], | |
| 6 | "lib": ["ES2023", "esnext.disposable"] | |
| 5 | "types": ["node", "bun"], | |
| 6 | "lib": ["ES2023", "esnext.disposable"], | |
| 7 | "noEmit": true, | |
| 8 | "allowImportingTsExtensions": true, | |
| 9 | "plugins": [ | |
| 10 | { | |
| 11 | "name": "@effect/language-service", | |
| 12 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 13 | "diagnosticSeverity": { | |
| 14 | "importFromBarrel": "error", | |
| 15 | "anyUnknownInErrorContext": "warning", | |
| 16 | "instanceOfSchema": "warning", | |
| 17 | "deterministicKeys": "warning", | |
| 18 | "preferSchemaOverJson": "off", | |
| 19 | "globalErrorInEffectFailure": "off" | |
| 20 | } | |
| 21 | } | |
| 22 | ] | |
| 7 | 23 | }, |
| 8 | "include": ["src", "tsdown.config.ts"] | |
| 24 | "include": ["src", "tsdown.config.ts", "scripts", "integration"] | |
| 9 | 25 | } |
apps/server/tsdown.config.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ export default defineConfig({ |
| 6 | 6 | outDir: "dist", |
| 7 | 7 | sourcemap: true, |
| 8 | 8 | clean: true, |
| 9 | noExternal: ["@t3tools/contracts", "zod"], | |
| 9 | noExternal: ["@t3tools/contracts"], | |
| 10 | 10 | inlineOnly: false, |
| 11 | 11 | banner: { |
| 12 | 12 | js: "#!/usr/bin/env node\n", |
apps/web/package.json+3-2| ... | ... | @@ -21,6 +21,7 @@ |
| 21 | 21 | "@xterm/addon-fit": "^0.11.0", |
| 22 | 22 | "@xterm/xterm": "^6.0.0", |
| 23 | 23 | "class-variance-authority": "^0.7.1", |
| 24 | "effect": "catalog:", | |
| 24 | 25 | "highlight.js": "^11.11.1", |
| 25 | 26 | "lucide-react": "^0.564.0", |
| 26 | 27 | "react": "^19.0.0", |
| ... | ... | @@ -28,10 +29,10 @@ |
| 28 | 29 | "react-markdown": "^10.1.0", |
| 29 | 30 | "rehype-highlight": "^7.0.2", |
| 30 | 31 | "remark-gfm": "^4.0.1", |
| 31 | "tailwind-merge": "^3.4.0", | |
| 32 | "zod": "^3.24.1" | |
| 32 | "tailwind-merge": "^3.4.0" | |
| 33 | 33 | }, |
| 34 | 34 | "devDependencies": { |
| 35 | "@effect/language-service": "catalog:", | |
| 35 | 36 | "@tailwindcss/vite": "^4.0.0", |
| 36 | 37 | "@tanstack/router-plugin": "^1.161.0", |
| 37 | 38 | "@types/react": "^19.0.0", |
apps/web/src/appSettings.ts+21-12| ... | ... | @@ -1,17 +1,23 @@ |
| 1 | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { z } from "zod"; | |
| 2 | import { Option, Schema } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 5 | 5 | |
| 6 | const appSettingsSchema = z.object({ | |
| 7 | codexBinaryPath: z.string().trim().max(4096).default(""), | |
| 8 | codexHomePath: z.string().trim().max(4096).default(""), | |
| 9 | confirmThreadDelete: z.boolean().default(true), | |
| 6 | const AppSettingsSchema = Schema.Struct({ | |
| 7 | codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( | |
| 8 | Schema.withConstructorDefault(() => Option.some("")), | |
| 9 | ), | |
| 10 | codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( | |
| 11 | Schema.withConstructorDefault(() => Option.some("")), | |
| 12 | ), | |
| 13 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), | |
| 14 | enableAssistantStreaming: Schema.Boolean.pipe( | |
| 15 | Schema.withConstructorDefault(() => Option.some(false)), | |
| 16 | ), | |
| 10 | 17 | }); |
| 18 | export type AppSettings = typeof AppSettingsSchema.Type; | |
| 11 | 19 | |
| 12 | export type AppSettings = z.infer<typeof appSettingsSchema>; | |
| 13 | ||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | |
| 20 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | |
| 15 | 21 | |
| 16 | 22 | let listeners: Array<() => void> = []; |
| 17 | 23 | let cachedRawSettings: string | null | undefined; |
| ... | ... | @@ -29,8 +35,7 @@ function parsePersistedSettings(value: string | null): AppSettings { |
| 29 | 35 | } |
| 30 | 36 | |
| 31 | 37 | try { |
| 32 | const parsed = JSON.parse(value) as unknown; | |
| 33 | return appSettingsSchema.parse(parsed); | |
| 38 | return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value); | |
| 34 | 39 | } catch { |
| 35 | 40 | return DEFAULT_APP_SETTINGS; |
| 36 | 41 | } |
| ... | ... | @@ -84,10 +89,14 @@ function subscribe(listener: () => void): () => void { |
| 84 | 89 | } |
| 85 | 90 | |
| 86 | 91 | export function useAppSettings() { |
| 87 | const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); | |
| 92 | const settings = useSyncExternalStore( | |
| 93 | subscribe, | |
| 94 | getAppSettingsSnapshot, | |
| 95 | () => DEFAULT_APP_SETTINGS, | |
| 96 | ); | |
| 88 | 97 | |
| 89 | 98 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { |
| 90 | const next = appSettingsSchema.parse({ | |
| 99 | const next = Schema.decodeSync(AppSettingsSchema)({ | |
| 91 | 100 | ...getAppSettingsSnapshot(), |
| 92 | 101 | ...patch, |
| 93 | 102 | }); |
apps/web/src/components/BranchToolbar.logic.test.ts+7-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 4 | 4 | |
| ... | ... | @@ -19,8 +19,9 @@ const branches: GitBranch[] = [ |
| 19 | 19 | |
| 20 | 20 | describe("deriveSyncedLocalBranch", () => { |
| 21 | 21 | it("syncs to git current branch in local mode", () => { |
| 22 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 22 | 23 | const result = deriveSyncedLocalBranch({ |
| 23 | activeThreadId: "thread-1", | |
| 24 | activeThreadId: threadId, | |
| 24 | 25 | activeWorktreePath: null, |
| 25 | 26 | envMode: "local", |
| 26 | 27 | activeThreadBranch: "feature/demo", |
| ... | ... | @@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 31 | 32 | }); |
| 32 | 33 | |
| 33 | 34 | it("does not sync when creating a new worktree", () => { |
| 35 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 34 | 36 | const result = deriveSyncedLocalBranch({ |
| 35 | activeThreadId: "thread-1", | |
| 37 | activeThreadId: threadId, | |
| 36 | 38 | activeWorktreePath: null, |
| 37 | 39 | envMode: "worktree", |
| 38 | 40 | activeThreadBranch: "feature/demo", |
| ... | ... | @@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 43 | 45 | }); |
| 44 | 46 | |
| 45 | 47 | it("does not sync when thread already targets a worktree path", () => { |
| 48 | const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 46 | 49 | const result = deriveSyncedLocalBranch({ |
| 47 | activeThreadId: "thread-1", | |
| 50 | activeThreadId: threadId, | |
| 48 | 51 | activeWorktreePath: "/tmp/repo/worktrees/feature-demo", |
| 49 | 52 | envMode: "local", |
| 50 | 53 | activeThreadBranch: "feature/demo", |
apps/web/src/components/BranchToolbar.logic.ts+3-3| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | interface DeriveSyncedLocalBranchInput { |
| 4 | activeThreadId: string | undefined; | |
| 4 | activeThreadId: ThreadId | undefined; | |
| 5 | 5 | activeWorktreePath: string | null; |
| 6 | 6 | envMode: "local" | "worktree"; |
| 7 | 7 | activeThreadBranch: string | null; |
| 8 | queryBranches: GitBranch[] | undefined; | |
| 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; | |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | export function deriveSyncedLocalBranch({ |
apps/web/src/components/BranchToolbar.tsx+38-11| ... | ... | @@ -1,13 +1,14 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 5 | import { newCommandId } from "../lib/utils"; | |
| 6 | 6 | import { |
| 7 | 7 | gitBranchesQueryOptions, |
| 8 | 8 | gitCheckoutMutationOptions, |
| 9 | 9 | gitCreateBranchAndCheckoutMutationOptions, |
| 10 | 10 | } from "../lib/gitReactQuery"; |
| 11 | import { readNativeApi } from "../nativeApi"; | |
| 11 | 12 | import { useStore } from "../store"; |
| 12 | 13 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 13 | 14 | import { Button } from "./ui/button"; |
| ... | ... | @@ -23,7 +24,7 @@ import { |
| 23 | 24 | import { ChevronDownIcon } from "lucide-react"; |
| 24 | 25 | |
| 25 | 26 | interface BranchToolbarProps { |
| 26 | threadId: string; | |
| 27 | threadId: ThreadId; | |
| 27 | 28 | envMode: "local" | "worktree"; |
| 28 | 29 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 29 | 30 | envLocked: boolean; |
| ... | ... | @@ -38,7 +39,6 @@ export default function BranchToolbar({ |
| 38 | 39 | onComposerFocusRequest, |
| 39 | 40 | }: BranchToolbarProps) { |
| 40 | 41 | const { state, dispatch } = useStore(); |
| 41 | const api = useNativeApi(); | |
| 42 | 42 | const queryClient = useQueryClient(); |
| 43 | 43 | |
| 44 | 44 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| ... | ... | @@ -53,7 +53,7 @@ export default function BranchToolbar({ |
| 53 | 53 | |
| 54 | 54 | // ── Queries ─────────────────────────────────────────────────────────── |
| 55 | 55 | |
| 56 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); | |
| 56 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 57 | 57 | |
| 58 | 58 | const branches = branchesQuery.data?.branches ?? []; |
| 59 | 59 | const branchNames = branches.map((branch) => branch.name); |
| ... | ... | @@ -70,12 +70,10 @@ export default function BranchToolbar({ |
| 70 | 70 | : branchNames; |
| 71 | 71 | // ── Mutations ───────────────────────────────────────────────────────── |
| 72 | 72 | |
| 73 | const checkoutMutation = useMutation( | |
| 74 | gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 75 | ); | |
| 73 | const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient })); | |
| 76 | 74 | |
| 77 | 75 | const createBranchMutation = useMutation( |
| 78 | gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 76 | gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }), | |
| 79 | 77 | ); |
| 80 | 78 | |
| 81 | 79 | // ── Effects ─────────────────────────────────────────────────────────── |
| ... | ... | @@ -91,7 +89,17 @@ export default function BranchToolbar({ |
| 91 | 89 | queryBranches, |
| 92 | 90 | }); |
| 93 | 91 | if (!activeThreadId || !syncedBranch) return; |
| 92 | const api = readNativeApi(); | |
| 94 | 93 | |
| 94 | if (api) { | |
| 95 | void api.orchestration.dispatchCommand({ | |
| 96 | type: "thread.meta.update", | |
| 97 | commandId: newCommandId(), | |
| 98 | threadId: activeThreadId, | |
| 99 | branch: syncedBranch, | |
| 100 | worktreePath: null, | |
| 101 | }); | |
| 102 | } | |
| 95 | 103 | dispatch({ |
| 96 | 104 | type: "SET_THREAD_BRANCH", |
| 97 | 105 | threadId: activeThreadId, |
| ... | ... | @@ -114,16 +122,34 @@ export default function BranchToolbar({ |
| 114 | 122 | |
| 115 | 123 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| 116 | 124 | if (!activeThreadId) return; |
| 125 | const api = readNativeApi(); | |
| 117 | 126 | // If the effective cwd is about to change, stop the running session so the |
| 118 | 127 | // next message creates a new one with the correct cwd. |
| 119 | 128 | const sessionId = activeThread?.session?.sessionId; |
| 120 | if (sessionId && worktreePath !== activeWorktreePath) { | |
| 121 | void api?.providers.stopSession({ sessionId }).catch(() => undefined); | |
| 129 | if (sessionId && worktreePath !== activeWorktreePath && api) { | |
| 130 | void api.orchestration | |
| 131 | .dispatchCommand({ | |
| 132 | type: "thread.session.stop", | |
| 133 | commandId: newCommandId(), | |
| 134 | threadId: activeThreadId, | |
| 135 | createdAt: new Date().toISOString(), | |
| 136 | }) | |
| 137 | .catch(() => undefined); | |
| 138 | } | |
| 139 | if (api) { | |
| 140 | void api.orchestration.dispatchCommand({ | |
| 141 | type: "thread.meta.update", | |
| 142 | commandId: newCommandId(), | |
| 143 | threadId: activeThreadId, | |
| 144 | branch, | |
| 145 | worktreePath, | |
| 146 | }); | |
| 122 | 147 | } |
| 123 | 148 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); |
| 124 | 149 | }; |
| 125 | 150 | |
| 126 | 151 | const selectBranch = (branch: GitBranch) => { |
| 152 | const api = readNativeApi(); | |
| 127 | 153 | if (!api || !activeThreadId || !branchCwd) return; |
| 128 | 154 | |
| 129 | 155 | // For new worktree mode, selecting a branch picks the base branch. |
| ... | ... | @@ -164,6 +190,7 @@ export default function BranchToolbar({ |
| 164 | 190 | |
| 165 | 191 | const createBranch = (rawName: string) => { |
| 166 | 192 | const name = rawName.trim(); |
| 193 | const api = readNativeApi(); | |
| 167 | 194 | if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return; |
| 168 | 195 | createBranchMutation.mutate(name, { |
| 169 | 196 | onSuccess: () => { |
apps/web/src/components/ChatMarkdown.tsx+35-8| ... | ... | @@ -1,22 +1,49 @@ |
| 1 | import { memo } from "react"; | |
| 1 | import { memo, useMemo } from "react"; | |
| 2 | 2 | import type { Components } from "react-markdown"; |
| 3 | 3 | import ReactMarkdown from "react-markdown"; |
| 4 | 4 | import rehypeHighlight from "rehype-highlight"; |
| 5 | 5 | import remarkGfm from "remark-gfm"; |
| 6 | import { readNativeApi } from "../nativeApi"; | |
| 7 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | |
| 8 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 6 | 9 | |
| 7 | 10 | interface ChatMarkdownProps { |
| 8 | 11 | text: string; |
| 12 | cwd: string | undefined; | |
| 9 | 13 | } |
| 10 | 14 | |
| 11 | const markdownComponents: Components = { | |
| 12 | a({ node: _node, ...props }) { | |
| 13 | return <a {...props} target="_blank" rel="noreferrer" />; | |
| 14 | }, | |
| 15 | }; | |
| 15 | function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { | |
| 16 | const markdownComponents = useMemo<Components>( | |
| 17 | () => ({ | |
| 18 | a({ node: _node, href, ...props }) { | |
| 19 | const targetPath = resolveMarkdownFileLinkTarget(href, cwd); | |
| 20 | if (!targetPath) { | |
| 21 | return <a {...props} href={href} target="_blank" rel="noreferrer" />; | |
| 22 | } | |
| 23 | ||
| 24 | return ( | |
| 25 | <a | |
| 26 | {...props} | |
| 27 | href={href} | |
| 28 | onClick={(event) => { | |
| 29 | event.preventDefault(); | |
| 30 | event.stopPropagation(); | |
| 31 | const api = readNativeApi(); | |
| 32 | if (api) { | |
| 33 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | |
| 34 | } else { | |
| 35 | console.warn("Native API not found. Unable to open file in editor."); | |
| 36 | } | |
| 37 | }} | |
| 38 | /> | |
| 39 | ); | |
| 40 | }, | |
| 41 | }), | |
| 42 | [cwd], | |
| 43 | ); | |
| 16 | 44 | |
| 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { | |
| 18 | 45 | return ( |
| 19 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> | |
| 46 | <div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80"> | |
| 20 | 47 | <ReactMarkdown |
| 21 | 48 | remarkPlugins={[remarkGfm]} |
| 22 | 49 | rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} |
apps/web/src/components/ChatView.tsx+584-794| ... | ... | @@ -1,66 +1,50 @@ |
| 1 | 1 | import { |
| 2 | type ApprovalRequestId, | |
| 3 | DEFAULT_MODEL, | |
| 4 | DEFAULT_REASONING, | |
| 2 | 5 | EDITORS, |
| 3 | 6 | type EditorId, |
| 4 | type NativeApi, | |
| 7 | type KeybindingCommand, | |
| 8 | type MessageId, | |
| 9 | MODEL_OPTIONS, | |
| 10 | type ProjectId, | |
| 5 | 11 | type ProjectEntry, |
| 6 | 12 | type ProjectScript, |
| 7 | 13 | ModelSlug, |
| 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 9 | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | 15 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 16 | REASONING_OPTIONS, | |
| 17 | type ReasoningEffort, | |
| 11 | 18 | type ResolvedKeybindingsConfig, |
| 12 | 19 | type ProviderApprovalDecision, |
| 13 | type ProviderSendTurnAttachmentInput, | |
| 20 | type ThreadId, | |
| 21 | type TurnId, | |
| 22 | resolveModelSlug, | |
| 14 | 23 | } from "@t3tools/contracts"; |
| 15 | import { | |
| 16 | type ClipboardEvent, | |
| 17 | type DragEvent, | |
| 18 | type FormEvent, | |
| 19 | type KeyboardEvent, | |
| 20 | memo, | |
| 21 | type RefObject, | |
| 22 | useCallback, | |
| 23 | useEffect, | |
| 24 | useLayoutEffect, | |
| 25 | useMemo, | |
| 26 | useRef, | |
| 27 | useState, | |
| 28 | } from "react"; | |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 24 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 25 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 30 | 26 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | 27 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 32 | 28 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 33 | 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 35 | 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 36 | 31 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 37 | 32 | |
| 38 | 33 | import { isElectron } from "../env"; |
| 39 | 34 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 40 | import { useAppSettings } from "../appSettings"; | |
| 41 | import { buildBootstrapInput } from "../historyBootstrap"; | |
| 42 | 35 | import { |
| 43 | 36 | type ComposerTriggerKind, |
| 44 | 37 | detectComposerTrigger, |
| 45 | 38 | replaceTextRange, |
| 46 | 39 | } from "../composer-logic"; |
| 47 | import { | |
| 48 | DEFAULT_MODEL, | |
| 49 | DEFAULT_REASONING, | |
| 50 | MODEL_OPTIONS, | |
| 51 | REASONING_OPTIONS, | |
| 52 | ReasoningEffort, | |
| 53 | resolveModelSlug, | |
| 54 | } from "../model-logic"; | |
| 55 | 40 | import { |
| 56 | 41 | derivePendingApprovals, |
| 57 | 42 | derivePhase, |
| 58 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 59 | 43 | deriveTimelineEntries, |
| 60 | type TurnDiffSummary, | |
| 61 | 44 | type PendingApproval, |
| 62 | 45 | deriveWorkLogEntries, |
| 63 | formatDuration, | |
| 46 | hasToolActivityForTurn, | |
| 47 | isLatestTurnSettled, | |
| 64 | 48 | formatElapsed, |
| 65 | 49 | formatTimestamp, |
| 66 | 50 | } from "../session-logic"; |
| ... | ... | @@ -70,8 +54,9 @@ import { truncateTitle } from "../truncateTitle"; |
| 70 | 54 | import { |
| 71 | 55 | DEFAULT_THREAD_TERMINAL_ID, |
| 72 | 56 | MAX_THREAD_TERMINAL_COUNT, |
| 57 | type ChatMessage, | |
| 73 | 58 | type ChatImageAttachment, |
| 74 | type TurnDiffFileChange, | |
| 59 | type TurnDiffSummary, | |
| 75 | 60 | } from "../types"; |
| 76 | 61 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 77 | 62 | import { useTheme } from "../hooks/useTheme"; |
| ... | ... | @@ -85,7 +70,6 @@ import { |
| 85 | 70 | } from "../keybindings"; |
| 86 | 71 | import ChatMarkdown from "./ChatMarkdown"; |
| 87 | 72 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 88 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 89 | 73 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 90 | 74 | import { |
| 91 | 75 | BotIcon, |
| ... | ... | @@ -100,6 +84,8 @@ import { |
| 100 | 84 | LockOpenIcon, |
| 101 | 85 | Undo2Icon, |
| 102 | 86 | XIcon, |
| 87 | CopyIcon, | |
| 88 | CheckIcon, | |
| 103 | 89 | } from "lucide-react"; |
| 104 | 90 | import { Button } from "./ui/button"; |
| 105 | 91 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; |
| ... | ... | @@ -109,7 +95,9 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 109 | 95 | import { CursorIcon, Icon } from "./Icons"; |
| 110 | 96 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 111 | 97 | import { Badge } from "./ui/badge"; |
| 98 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | |
| 112 | 99 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 100 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | |
| 113 | 101 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 114 | 102 | import { |
| 115 | 103 | commandForProjectScript, |
| ... | ... | @@ -119,6 +107,10 @@ import { |
| 119 | 107 | setupProjectScript, |
| 120 | 108 | } from "~/projectScripts"; |
| 121 | 109 | import { Toggle } from "./ui/toggle"; |
| 110 | import { SidebarTrigger } from "./ui/sidebar"; | |
| 111 | import { newCommandId, newMessageId } from "~/lib/utils"; | |
| 112 | import { readNativeApi } from "~/nativeApi"; | |
| 113 | import { useAppSettings } from "../appSettings"; | |
| 122 | 114 | |
| 123 | 115 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 124 | 116 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -168,14 +160,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 168 | 160 | return "text-muted-foreground/40"; |
| 169 | 161 | } |
| 170 | 162 | |
| 171 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | |
| 172 | ||
| 173 | interface EnsuredSessionInfo { | |
| 174 | sessionId: string; | |
| 175 | resolvedThreadId: string | null; | |
| 176 | continuityState: SessionContinuityState; | |
| 177 | } | |
| 178 | ||
| 179 | 163 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { |
| 180 | 164 | previewUrl: string; |
| 181 | 165 | file: File; |
| ... | ... | @@ -186,33 +170,6 @@ interface ExpandedImagePreview { |
| 186 | 170 | name: string; |
| 187 | 171 | } |
| 188 | 172 | |
| 189 | type TurnCheckpointFilesState = | |
| 190 | | { | |
| 191 | status: "pending" | "loading"; | |
| 192 | files: TurnDiffFileChange[]; | |
| 193 | errorMessage?: undefined; | |
| 194 | } | |
| 195 | | { | |
| 196 | status: "error"; | |
| 197 | files: TurnDiffFileChange[]; | |
| 198 | errorMessage: string; | |
| 199 | } | |
| 200 | | { | |
| 201 | status: "ready"; | |
| 202 | files: TurnDiffFileChange[]; | |
| 203 | errorMessage?: undefined; | |
| 204 | }; | |
| 205 | ||
| 206 | function checkpointErrorMessage(error: unknown): string { | |
| 207 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 208 | return error.message; | |
| 209 | } | |
| 210 | if (typeof error === "string" && error.trim().length > 0) { | |
| 211 | return error; | |
| 212 | } | |
| 213 | return "Checkpoint diff is unavailable."; | |
| 214 | } | |
| 215 | ||
| 216 | 173 | type ComposerCommandItem = |
| 217 | 174 | | { |
| 218 | 175 | id: string; |
| ... | ... | @@ -236,6 +193,8 @@ type ComposerCommandItem = |
| 236 | 193 | description: string; |
| 237 | 194 | }; |
| 238 | 195 | |
| 196 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | |
| 197 | ||
| 239 | 198 | function readFileAsDataUrl(file: File): Promise<string> { |
| 240 | 199 | return new Promise((resolve, reject) => { |
| 241 | 200 | const reader = new FileReader(); |
| ... | ... | @@ -332,7 +291,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 332 | 291 | triggerKind: ComposerTriggerKind | null; |
| 333 | 292 | onHighlightedItemChange: (itemId: string | null) => void; |
| 334 | 293 | onSelect: (item: ComposerCommandItem) => void; |
| 335 | commandInputRef: RefObject<HTMLInputElement | null>; | |
| 294 | commandInputRef: React.RefObject<HTMLInputElement | null>; | |
| 336 | 295 | }) { |
| 337 | 296 | return ( |
| 338 | 297 | <Command |
| ... | ... | @@ -372,32 +331,33 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 372 | 331 | }); |
| 373 | 332 | |
| 374 | 333 | interface ChatViewProps { |
| 375 | threadId: string; | |
| 334 | threadId: ThreadId; | |
| 376 | 335 | } |
| 377 | 336 | |
| 378 | 337 | export default function ChatView({ threadId }: ChatViewProps) { |
| 379 | 338 | const { state, dispatch } = useStore(); |
| 339 | const { settings } = useAppSettings(); | |
| 380 | 340 | const navigate = useNavigate(); |
| 381 | const rawSearch = useSearch({ strict: false }); | |
| 382 | const api = useNativeApi(); | |
| 341 | const rawSearch = useSearch({ | |
| 342 | strict: false, | |
| 343 | select: (params) => parseDiffRouteSearch(params), | |
| 344 | }); | |
| 383 | 345 | const { resolvedTheme } = useTheme(); |
| 384 | const { settings: appSettings } = useAppSettings(); | |
| 385 | 346 | const queryClient = useQueryClient(); |
| 386 | const createWorktreeMutation = useMutation( | |
| 387 | gitCreateWorktreeMutationOptions({ api, queryClient }), | |
| 388 | ); | |
| 347 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); | |
| 389 | 348 | const [prompt, setPrompt] = useState(""); |
| 390 | 349 | const promptRef = useRef(prompt); |
| 391 | 350 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 392 | 351 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 393 | 352 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 394 | const [isSending, setIsSending] = useState(false); | |
| 395 | const [isConnecting, setIsConnecting] = useState(false); | |
| 353 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); | |
| 354 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | |
| 355 | const [isConnecting, _setIsConnecting] = useState(false); | |
| 396 | 356 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 397 | 357 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 398 | 358 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 399 | 359 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 400 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]); | |
| 360 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); | |
| 401 | 361 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 402 | 362 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 403 | 363 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| ... | ... | @@ -411,10 +371,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 411 | 371 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 412 | 372 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| 413 | 373 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 374 | const sendInFlightRef = useRef(false); | |
| 414 | 375 | const dragDepthRef = useRef(0); |
| 415 | 376 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 416 | const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); | |
| 417 | const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); | |
| 418 | 377 | |
| 419 | 378 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 420 | 379 | const diffSearch = useMemo( |
| ... | ... | @@ -423,117 +382,79 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 423 | 382 | ); |
| 424 | 383 | const diffOpen = diffSearch.diff === "1"; |
| 425 | 384 | const activeThreadId = activeThread?.id ?? null; |
| 426 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 427 | const activeThreadRuntimeId = | |
| 428 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 385 | const activeLatestTurn = activeThread?.latestTurn ?? null; | |
| 386 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | |
| 429 | 387 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 388 | ||
| 389 | useEffect(() => { | |
| 390 | if (!activeThread?.id) return; | |
| 391 | if (!latestTurnSettled) return; | |
| 392 | if (!activeLatestTurn?.completedAt) return; | |
| 393 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | |
| 394 | if (Number.isNaN(turnCompletedAt)) return; | |
| 395 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; | |
| 396 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; | |
| 397 | ||
| 398 | dispatch({ | |
| 399 | type: "MARK_THREAD_VISITED", | |
| 400 | threadId: activeThread.id, | |
| 401 | }); | |
| 402 | }, [ | |
| 403 | activeThread?.id, | |
| 404 | activeThread?.lastVisitedAt, | |
| 405 | activeLatestTurn?.completedAt, | |
| 406 | latestTurnSettled, | |
| 407 | dispatch, | |
| 408 | ]); | |
| 409 | ||
| 430 | 410 | const selectedModel = resolveModelSlug( |
| 431 | 411 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 432 | 412 | ); |
| 433 | 413 | const phase = derivePhase(activeThread?.session ?? null); |
| 434 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; | |
| 414 | const isSendBusy = sendPhase !== "idle"; | |
| 415 | const isPreparingWorktree = sendPhase === "preparing-worktree"; | |
| 416 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; | |
| 435 | 417 | const nowIso = new Date(nowTick).toISOString(); |
| 418 | const threadActivities = activeThread?.activities ?? []; | |
| 436 | 419 | const workLogEntries = useMemo( |
| 437 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | |
| 438 | [activeThread?.events], | |
| 439 | ); | |
| 440 | const latestTurnWorkEntries = useMemo( | |
| 441 | () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), | |
| 442 | [activeThread?.events, activeThread?.latestTurnId], | |
| 420 | () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), | |
| 421 | [activeLatestTurn?.turnId, threadActivities], | |
| 443 | 422 | ); |
| 423 | const latestTurnHasToolActivity = useMemo(() => { | |
| 424 | return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId); | |
| 425 | }, [activeLatestTurn?.turnId, threadActivities]); | |
| 444 | 426 | const pendingApprovals = useMemo( |
| 445 | () => derivePendingApprovals(activeThread?.events ?? []), | |
| 446 | [activeThread?.events], | |
| 427 | () => derivePendingApprovals(threadActivities), | |
| 428 | [threadActivities], | |
| 447 | 429 | ); |
| 448 | const assistantCompletionByItemId = useMemo(() => { | |
| 449 | const map = new Map<string, string>(); | |
| 450 | const ordered = [...(activeThread?.events ?? [])].toReversed(); | |
| 451 | for (const event of ordered) { | |
| 452 | if (event.method !== "item/completed") continue; | |
| 453 | if (!event.itemId) continue; | |
| 454 | map.set(event.itemId, event.createdAt); | |
| 430 | const timelineMessages = useMemo(() => { | |
| 431 | const serverMessages = activeThread?.messages ?? []; | |
| 432 | if (optimisticUserMessages.length === 0) { | |
| 433 | return serverMessages; | |
| 455 | 434 | } |
| 456 | return map; | |
| 457 | }, [activeThread?.events]); | |
| 435 | const serverIds = new Set(serverMessages.map((message) => message.id)); | |
| 436 | const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); | |
| 437 | if (pendingMessages.length === 0) { | |
| 438 | return serverMessages; | |
| 439 | } | |
| 440 | return [...serverMessages, ...pendingMessages]; | |
| 441 | }, [activeThread?.messages, optimisticUserMessages]); | |
| 458 | 442 | const timelineEntries = useMemo( |
| 459 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | |
| 460 | [activeThread?.messages, workLogEntries], | |
| 443 | () => deriveTimelineEntries(timelineMessages, workLogEntries), | |
| 444 | [timelineMessages, workLogEntries], | |
| 461 | 445 | ); |
| 462 | 446 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 463 | 447 | useTurnDiffSummaries(activeThread); |
| 464 | 448 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 465 | const byMessageId = new Map<string, TurnDiffSummary>(); | |
| 449 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); | |
| 466 | 450 | for (const summary of turnDiffSummaries) { |
| 467 | 451 | if (!summary.assistantMessageId) continue; |
| 468 | 452 | byMessageId.set(summary.assistantMessageId, summary); |
| 469 | 453 | } |
| 470 | 454 | return byMessageId; |
| 471 | 455 | }, [turnDiffSummaries]); |
| 472 | const checkpointDiffInputsByTurn = useMemo( | |
| 473 | () => | |
| 474 | turnDiffSummaries | |
| 475 | .filter((summary) => summary.assistantMessageId) | |
| 476 | .map((summary) => { | |
| 477 | const checkpointTurnCount = | |
| 478 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 479 | return { | |
| 480 | turnId: summary.turnId, | |
| 481 | checkpointTurnCount: | |
| 482 | typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, | |
| 483 | }; | |
| 484 | }), | |
| 485 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], | |
| 486 | ); | |
| 487 | const checkpointDiffQueriesByTurn = useQueries({ | |
| 488 | queries: checkpointDiffInputsByTurn.map((input) => | |
| 489 | checkpointDiffQueryOptions(api, { | |
| 490 | sessionId: activeSessionId, | |
| 491 | threadRuntimeId: activeThreadRuntimeId, | |
| 492 | fromTurnCount: | |
| 493 | typeof input.checkpointTurnCount === "number" | |
| 494 | ? Math.max(0, input.checkpointTurnCount - 1) | |
| 495 | : null, | |
| 496 | toTurnCount: input.checkpointTurnCount, | |
| 497 | cacheScope: `turn:${input.turnId}`, | |
| 498 | }), | |
| 499 | ), | |
| 500 | }); | |
| 501 | const turnCheckpointFilesByTurnId = useMemo(() => { | |
| 502 | const byTurnId = new Map<string, TurnCheckpointFilesState>(); | |
| 503 | for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { | |
| 504 | const input = checkpointDiffInputsByTurn[index]; | |
| 505 | const query = checkpointDiffQueriesByTurn[index]; | |
| 506 | if (!input || !query) continue; | |
| 507 | ||
| 508 | if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { | |
| 509 | byTurnId.set(input.turnId, { status: "pending", files: [] }); | |
| 510 | continue; | |
| 511 | } | |
| 512 | ||
| 513 | const diff = query.data?.diff; | |
| 514 | if (typeof diff === "string") { | |
| 515 | byTurnId.set(input.turnId, { | |
| 516 | status: "ready", | |
| 517 | files: deriveTurnDiffFilesFromUnifiedDiff(diff), | |
| 518 | }); | |
| 519 | continue; | |
| 520 | } | |
| 521 | ||
| 522 | if (query.isError) { | |
| 523 | byTurnId.set(input.turnId, { | |
| 524 | status: "error", | |
| 525 | files: [], | |
| 526 | errorMessage: checkpointErrorMessage(query.error), | |
| 527 | }); | |
| 528 | continue; | |
| 529 | } | |
| 530 | ||
| 531 | byTurnId.set(input.turnId, { status: "loading", files: [] }); | |
| 532 | } | |
| 533 | return byTurnId; | |
| 534 | }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); | |
| 535 | 456 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 536 | const byUserMessageId = new Map<string, number>(); | |
| 457 | const byUserMessageId = new Map<MessageId, number>(); | |
| 537 | 458 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| 538 | 459 | const entry = timelineEntries[index]; |
| 539 | 460 | if (!entry || entry.kind !== "message" || entry.message.role !== "user") { |
| ... | ... | @@ -565,69 +486,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 565 | 486 | return byUserMessageId; |
| 566 | 487 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 567 | 488 | |
| 568 | useEffect(() => { | |
| 569 | if (!activeThreadId || turnDiffSummaries.length === 0) { | |
| 570 | return; | |
| 571 | } | |
| 572 | ||
| 573 | const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>( | |
| 574 | (acc, summary) => { | |
| 575 | if (typeof summary.checkpointTurnCount === "number") { | |
| 576 | return acc; | |
| 577 | } | |
| 578 | const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 579 | if (typeof inferredTurnCount !== "number") { | |
| 580 | return acc; | |
| 581 | } | |
| 582 | acc[summary.turnId] = inferredTurnCount; | |
| 583 | return acc; | |
| 584 | }, | |
| 585 | {}, | |
| 586 | ); | |
| 587 | if (Object.keys(inferredMissingTurnCounts).length === 0) { | |
| 588 | return; | |
| 589 | } | |
| 590 | ||
| 591 | dispatch({ | |
| 592 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 593 | threadId: activeThreadId, | |
| 594 | checkpointTurnCountByTurnId: inferredMissingTurnCounts, | |
| 595 | }); | |
| 596 | }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 597 | ||
| 598 | 489 | const completionSummary = useMemo(() => { |
| 599 | if (!activeThread?.latestTurnStartedAt) return null; | |
| 600 | if (!activeThread.latestTurnCompletedAt) return null; | |
| 601 | if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { | |
| 602 | return null; | |
| 603 | } | |
| 604 | ||
| 605 | if ( | |
| 606 | typeof activeThread.latestTurnDurationMs === "number" && | |
| 607 | Number.isFinite(activeThread.latestTurnDurationMs) && | |
| 608 | activeThread.latestTurnDurationMs >= 0 | |
| 609 | ) { | |
| 610 | return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; | |
| 611 | } | |
| 490 | if (!latestTurnSettled) return null; | |
| 491 | if (!activeLatestTurn?.startedAt) return null; | |
| 492 | if (!activeLatestTurn.completedAt) return null; | |
| 493 | if (!latestTurnHasToolActivity) return null; | |
| 612 | 494 | |
| 613 | const elapsed = formatElapsed( | |
| 614 | activeThread.latestTurnStartedAt, | |
| 615 | activeThread.latestTurnCompletedAt, | |
| 616 | ); | |
| 495 | const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); | |
| 617 | 496 | return elapsed ? `Worked for ${elapsed}` : null; |
| 618 | 497 | }, [ |
| 619 | activeThread?.latestTurnStartedAt, | |
| 620 | activeThread?.latestTurnCompletedAt, | |
| 621 | activeThread?.latestTurnDurationMs, | |
| 622 | latestTurnWorkEntries, | |
| 498 | activeLatestTurn?.completedAt, | |
| 499 | activeLatestTurn?.startedAt, | |
| 500 | latestTurnHasToolActivity, | |
| 501 | latestTurnSettled, | |
| 623 | 502 | ]); |
| 624 | 503 | const completionDividerBeforeEntryId = useMemo(() => { |
| 625 | if (!activeThread?.latestTurnStartedAt) return null; | |
| 626 | if (!activeThread.latestTurnCompletedAt) return null; | |
| 504 | if (!latestTurnSettled) return null; | |
| 505 | if (!activeLatestTurn?.startedAt) return null; | |
| 506 | if (!activeLatestTurn.completedAt) return null; | |
| 627 | 507 | if (!completionSummary) return null; |
| 628 | 508 | |
| 629 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); | |
| 630 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | |
| 509 | const turnStartedAt = Date.parse(activeLatestTurn.startedAt); | |
| 510 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | |
| 631 | 511 | if (Number.isNaN(turnStartedAt)) return null; |
| 632 | 512 | if (Number.isNaN(turnCompletedAt)) return null; |
| 633 | 513 | |
| ... | ... | @@ -645,16 +525,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 645 | 525 | } |
| 646 | 526 | return inRangeMatch ?? fallbackMatch; |
| 647 | 527 | }, [ |
| 648 | activeThread?.latestTurnCompletedAt, | |
| 649 | activeThread?.latestTurnStartedAt, | |
| 528 | activeLatestTurn?.completedAt, | |
| 529 | activeLatestTurn?.startedAt, | |
| 650 | 530 | completionSummary, |
| 531 | latestTurnSettled, | |
| 651 | 532 | timelineEntries, |
| 652 | 533 | ]); |
| 653 | const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 654 | const runtimeSandboxMode = | |
| 655 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 656 | const codexBinaryPath = appSettings.codexBinaryPath.trim(); | |
| 657 | const codexHomePath = appSettings.codexHomePath.trim(); | |
| 658 | 534 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 659 | 535 | const composerTrigger = useMemo( |
| 660 | 536 | () => detectComposerTrigger(prompt, composerCursor), |
| ... | ... | @@ -669,14 +545,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 669 | 545 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 670 | 546 | ); |
| 671 | 547 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 672 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 548 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 673 | 549 | const keybindingsQuery = useQuery({ |
| 674 | ...serverConfigQueryOptions(api), | |
| 550 | ...serverConfigQueryOptions(), | |
| 675 | 551 | select: (config) => config.keybindings, |
| 676 | 552 | }); |
| 677 | 553 | const workspaceEntriesQuery = useQuery( |
| 678 | 554 | projectSearchEntriesQueryOptions({ |
| 679 | api, | |
| 680 | 555 | cwd: gitCwd, |
| 681 | 556 | query: effectivePathQuery, |
| 682 | 557 | enabled: isPathTrigger, |
| ... | ... | @@ -755,6 +630,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 755 | 630 | () => shortcutLabelForCommand(keybindings, "terminal.close"), |
| 756 | 631 | [keybindings], |
| 757 | 632 | ); |
| 633 | const diffPanelShortcutLabel = useMemo( | |
| 634 | () => shortcutLabelForCommand(keybindings, "diff.toggle"), | |
| 635 | [keybindings], | |
| 636 | ); | |
| 637 | const onToggleDiff = useCallback(() => { | |
| 638 | void navigate({ | |
| 639 | to: "/$threadId", | |
| 640 | params: { threadId }, | |
| 641 | replace: true, | |
| 642 | search: (previous) => { | |
| 643 | const rest = stripDiffSearchParams(previous); | |
| 644 | return diffOpen ? rest : { ...rest, diff: "1" }; | |
| 645 | }, | |
| 646 | }); | |
| 647 | }, [diffOpen, navigate, threadId]); | |
| 758 | 648 | |
| 759 | 649 | const envLocked = Boolean( |
| 760 | 650 | activeThread && |
| ... | ... | @@ -822,6 +712,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 822 | 712 | ); |
| 823 | 713 | const closeTerminal = useCallback( |
| 824 | 714 | (terminalId: string) => { |
| 715 | const api = readNativeApi(); | |
| 825 | 716 | if (!activeThreadId || !api) return; |
| 826 | 717 | const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; |
| 827 | 718 | const fallbackExitWrite = () => |
| ... | ... | @@ -847,7 +738,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 847 | 738 | }); |
| 848 | 739 | setTerminalFocusRequestId((value) => value + 1); |
| 849 | 740 | }, |
| 850 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], | |
| 741 | [activeThread?.terminalIds.length, activeThreadId, dispatch], | |
| 851 | 742 | ); |
| 852 | 743 | const runProjectScript = useCallback( |
| 853 | 744 | async ( |
| ... | ... | @@ -860,6 +751,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 860 | 751 | rememberAsLastInvoked?: boolean; |
| 861 | 752 | }, |
| 862 | 753 | ) => { |
| 754 | const api = readNativeApi(); | |
| 863 | 755 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 864 | 756 | if (options?.rememberAsLastInvoked !== false) { |
| 865 | 757 | setLastInvokedScriptByProjectId((current) => { |
| ... | ... | @@ -932,73 +824,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 932 | 824 | }); |
| 933 | 825 | } |
| 934 | 826 | }, |
| 935 | [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], | |
| 827 | [activeProject, activeThread, activeThreadId, dispatch, gitCwd], | |
| 936 | 828 | ); |
| 937 | 829 | const persistProjectScripts = useCallback( |
| 938 | 830 | async (input: { |
| 939 | projectId: string; | |
| 831 | projectId: ProjectId; | |
| 940 | 832 | projectCwd: string; |
| 941 | 833 | previousScripts: ProjectScript[]; |
| 942 | 834 | nextScripts: ProjectScript[]; |
| 943 | 835 | keybinding?: string | null; |
| 944 | keybindingCommand: string; | |
| 836 | keybindingCommand: KeybindingCommand; | |
| 945 | 837 | }) => { |
| 946 | dispatch({ | |
| 947 | type: "SET_PROJECT_SCRIPTS", | |
| 838 | const api = readNativeApi(); | |
| 839 | if (!api) return; | |
| 840 | ||
| 841 | await api.orchestration.dispatchCommand({ | |
| 842 | type: "project.meta.update", | |
| 843 | commandId: newCommandId(), | |
| 948 | 844 | projectId: input.projectId, |
| 949 | 845 | scripts: input.nextScripts, |
| 950 | 846 | }); |
| 951 | 847 | |
| 952 | if (!isElectron || !api) return; | |
| 953 | ||
| 954 | let scriptsPersisted = false; | |
| 955 | try { | |
| 956 | const updated = await api.projects.updateScripts({ | |
| 957 | id: input.projectId, | |
| 958 | scripts: input.nextScripts, | |
| 959 | }); | |
| 960 | scriptsPersisted = true; | |
| 961 | ||
| 962 | if (input.keybinding) { | |
| 963 | const keybindingUpdate = await api.server.upsertKeybinding({ | |
| 964 | key: input.keybinding, | |
| 965 | command: input.keybindingCommand, | |
| 966 | }); | |
| 967 | queryClient.setQueryData( | |
| 968 | serverQueryKeys.config(), | |
| 969 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | |
| 970 | current | |
| 971 | ? { ...current, keybindings: keybindingUpdate.keybindings } | |
| 972 | : { | |
| 973 | cwd: input.projectCwd, | |
| 974 | keybindings: keybindingUpdate.keybindings, | |
| 975 | }, | |
| 976 | ); | |
| 977 | } | |
| 848 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 849 | keybinding: input.keybinding, | |
| 850 | command: input.keybindingCommand, | |
| 851 | }); | |
| 978 | 852 | |
| 979 | dispatch({ | |
| 980 | type: "SET_PROJECT_SCRIPTS", | |
| 981 | projectId: updated.project.id, | |
| 982 | scripts: updated.project.scripts, | |
| 983 | }); | |
| 984 | } catch (error) { | |
| 985 | if (scriptsPersisted) { | |
| 986 | await api.projects | |
| 987 | .updateScripts({ | |
| 988 | id: input.projectId, | |
| 989 | scripts: input.previousScripts, | |
| 990 | }) | |
| 991 | .catch(() => undefined); | |
| 992 | } | |
| 993 | dispatch({ | |
| 994 | type: "SET_PROJECT_SCRIPTS", | |
| 995 | projectId: input.projectId, | |
| 996 | scripts: input.previousScripts, | |
| 997 | }); | |
| 998 | throw error; | |
| 853 | if (isElectron && keybindingRule) { | |
| 854 | await api.server.upsertKeybinding(keybindingRule); | |
| 855 | await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); | |
| 999 | 856 | } |
| 1000 | 857 | }, |
| 1001 | [api, dispatch, queryClient], | |
| 858 | [queryClient], | |
| 1002 | 859 | ); |
| 1003 | 860 | const saveProjectScript = useCallback( |
| 1004 | 861 | async (input: NewProjectScriptInput) => { |
| ... | ... | @@ -1073,19 +930,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1073 | 930 | if (mode === state.runtimeMode) return; |
| 1074 | 931 | dispatch({ type: "SET_RUNTIME_MODE", mode }); |
| 1075 | 932 | scheduleComposerFocus(); |
| 933 | const api = readNativeApi(); | |
| 1076 | 934 | if (!api) return; |
| 1077 | 935 | |
| 1078 | const sessionIds = state.threads | |
| 1079 | .map((t) => t.session) | |
| 1080 | .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed") | |
| 1081 | .map((s) => s.sessionId); | |
| 936 | const runningThreadIds = state.threads | |
| 937 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") | |
| 938 | .map((thread) => thread.id); | |
| 1082 | 939 | |
| 1083 | if (sessionIds.length === 0) return; | |
| 940 | if (runningThreadIds.length === 0) return; | |
| 1084 | 941 | |
| 1085 | 942 | setIsSwitchingRuntimeMode(true); |
| 1086 | 943 | try { |
| 1087 | 944 | await Promise.all( |
| 1088 | sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), | |
| 945 | runningThreadIds.map((threadId) => | |
| 946 | api.orchestration | |
| 947 | .dispatchCommand({ | |
| 948 | type: "thread.session.stop", | |
| 949 | commandId: newCommandId(), | |
| 950 | threadId, | |
| 951 | createdAt: new Date().toISOString(), | |
| 952 | }) | |
| 953 | .catch(() => undefined), | |
| 954 | ), | |
| 1089 | 955 | ); |
| 1090 | 956 | } finally { |
| 1091 | 957 | setIsSwitchingRuntimeMode(false); |
| ... | ... | @@ -1108,7 +974,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1108 | 974 | }, [lastInvokedScriptByProjectId]); |
| 1109 | 975 | |
| 1110 | 976 | // Auto-scroll on new messages |
| 1111 | const messageCount = activeThread?.messages.length ?? 0; | |
| 977 | const messageCount = timelineMessages.length; | |
| 1112 | 978 | const workLogCount = workLogEntries.length; |
| 1113 | 979 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { |
| 1114 | 980 | const scrollContainer = messagesScrollRef.current; |
| ... | ... | @@ -1167,14 +1033,30 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1167 | 1033 | composerImagesRef.current = composerImages; |
| 1168 | 1034 | }, [composerImages]); |
| 1169 | 1035 | |
| 1036 | useEffect(() => { | |
| 1037 | if (!activeThread?.id) { | |
| 1038 | setOptimisticUserMessages([]); | |
| 1039 | return; | |
| 1040 | } | |
| 1041 | if (activeThread.messages.length === 0) { | |
| 1042 | return; | |
| 1043 | } | |
| 1044 | const serverIds = new Set(activeThread.messages.map((message) => message.id)); | |
| 1045 | setOptimisticUserMessages((existing) => { | |
| 1046 | const next = existing.filter((message) => !serverIds.has(message.id)); | |
| 1047 | return next.length === existing.length ? existing : next; | |
| 1048 | }); | |
| 1049 | }, [activeThread?.id, activeThread?.messages]); | |
| 1050 | ||
| 1170 | 1051 | useEffect(() => { |
| 1171 | 1052 | setComposerImages((existing) => { |
| 1172 | 1053 | revokePreviewUrls(existing); |
| 1173 | 1054 | return []; |
| 1174 | 1055 | }); |
| 1056 | setOptimisticUserMessages([]); | |
| 1175 | 1057 | setPrompt(""); |
| 1176 | 1058 | promptRef.current = ""; |
| 1177 | setIsSending(false); | |
| 1059 | setSendPhase("idle"); | |
| 1178 | 1060 | setComposerCursor(0); |
| 1179 | 1061 | setComposerHighlightedItemId(null); |
| 1180 | 1062 | dragDepthRef.current = 0; |
| ... | ... | @@ -1311,6 +1193,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1311 | 1193 | return; |
| 1312 | 1194 | } |
| 1313 | 1195 | |
| 1196 | if (command === "diff.toggle") { | |
| 1197 | event.preventDefault(); | |
| 1198 | event.stopPropagation(); | |
| 1199 | onToggleDiff(); | |
| 1200 | return; | |
| 1201 | } | |
| 1202 | ||
| 1314 | 1203 | const scriptId = projectScriptIdFromCommand(command); |
| 1315 | 1204 | if (!scriptId || !activeProject) return; |
| 1316 | 1205 | const script = activeProject.scripts.find((entry) => entry.id === scriptId); |
| ... | ... | @@ -1332,11 +1221,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1332 | 1221 | runProjectScript, |
| 1333 | 1222 | splitTerminal, |
| 1334 | 1223 | keybindings, |
| 1224 | onToggleDiff, | |
| 1335 | 1225 | toggleTerminalVisibility, |
| 1336 | 1226 | ]); |
| 1337 | 1227 | |
| 1338 | 1228 | const setThreadError = useCallback( |
| 1339 | (threadId: string | null, error: string | null) => { | |
| 1229 | (threadId: ThreadId | null, error: string | null) => { | |
| 1340 | 1230 | if (!threadId) return; |
| 1341 | 1231 | dispatch({ |
| 1342 | 1232 | type: "SET_ERROR", |
| ... | ... | @@ -1392,7 +1282,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1392 | 1282 | }); |
| 1393 | 1283 | }; |
| 1394 | 1284 | |
| 1395 | const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => { | |
| 1285 | const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => { | |
| 1396 | 1286 | const files = Array.from(event.clipboardData.files); |
| 1397 | 1287 | if (files.length === 0) { |
| 1398 | 1288 | return; |
| ... | ... | @@ -1405,7 +1295,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1405 | 1295 | addComposerImages(imageFiles); |
| 1406 | 1296 | }; |
| 1407 | 1297 | |
| 1408 | const onComposerDragEnter = (event: DragEvent<HTMLDivElement>) => { | |
| 1298 | const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1409 | 1299 | if (!event.dataTransfer.types.includes("Files")) { |
| 1410 | 1300 | return; |
| 1411 | 1301 | } |
| ... | ... | @@ -1414,7 +1304,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1414 | 1304 | setIsDragOverComposer(true); |
| 1415 | 1305 | }; |
| 1416 | 1306 | |
| 1417 | const onComposerDragOver = (event: DragEvent<HTMLDivElement>) => { | |
| 1307 | const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1418 | 1308 | if (!event.dataTransfer.types.includes("Files")) { |
| 1419 | 1309 | return; |
| 1420 | 1310 | } |
| ... | ... | @@ -1423,7 +1313,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1423 | 1313 | setIsDragOverComposer(true); |
| 1424 | 1314 | }; |
| 1425 | 1315 | |
| 1426 | const onComposerDragLeave = (event: DragEvent<HTMLDivElement>) => { | |
| 1316 | const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1427 | 1317 | if (!event.dataTransfer.types.includes("Files")) { |
| 1428 | 1318 | return; |
| 1429 | 1319 | } |
| ... | ... | @@ -1438,7 +1328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1438 | 1328 | } |
| 1439 | 1329 | }; |
| 1440 | 1330 | |
| 1441 | const onComposerDrop = (event: DragEvent<HTMLDivElement>) => { | |
| 1331 | const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => { | |
| 1442 | 1332 | if (!event.dataTransfer.types.includes("Files")) { |
| 1443 | 1333 | return; |
| 1444 | 1334 | } |
| ... | ... | @@ -1450,177 +1340,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1450 | 1340 | focusComposer(); |
| 1451 | 1341 | }; |
| 1452 | 1342 | |
| 1453 | const syncCheckpointTurnCounts = useCallback( | |
| 1454 | async (input: { | |
| 1455 | threadId: string; | |
| 1456 | sessionId: string; | |
| 1457 | threadRuntimeId: string | null; | |
| 1458 | }): Promise<void> => { | |
| 1459 | if (!api || turnDiffSummaries.length === 0) { | |
| 1460 | return; | |
| 1461 | } | |
| 1462 | ||
| 1463 | const turnIds = turnDiffSummaries.map((summary) => summary.turnId); | |
| 1464 | if (turnIds.length === 0) { | |
| 1465 | return; | |
| 1466 | } | |
| 1467 | ||
| 1468 | const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; | |
| 1469 | const turnFingerprint = turnIds.join(","); | |
| 1470 | if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { | |
| 1471 | return; | |
| 1472 | } | |
| 1473 | checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); | |
| 1474 | ||
| 1475 | try { | |
| 1476 | const result = await api.providers.listCheckpoints({ | |
| 1477 | sessionId: input.sessionId, | |
| 1478 | }); | |
| 1479 | const turnIdSet = new Set(turnIds); | |
| 1480 | const checkpointTurnCountByTurnId = Object.fromEntries( | |
| 1481 | result.checkpoints | |
| 1482 | .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) | |
| 1483 | .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), | |
| 1484 | ); | |
| 1485 | if (Object.keys(checkpointTurnCountByTurnId).length === 0) { | |
| 1486 | return; | |
| 1487 | } | |
| 1488 | dispatch({ | |
| 1489 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 1490 | threadId: input.threadId, | |
| 1491 | checkpointTurnCountByTurnId, | |
| 1492 | }); | |
| 1493 | } catch { | |
| 1494 | checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); | |
| 1495 | } | |
| 1496 | }, | |
| 1497 | [api, dispatch, turnDiffSummaries], | |
| 1498 | ); | |
| 1499 | ||
| 1500 | const ensureSession = useCallback( | |
| 1501 | async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 1502 | if (!api || !activeThread || !activeProject) return null; | |
| 1503 | if (activeThread.session && activeThread.session.status !== "closed") { | |
| 1504 | const sessionThreadId = activeThread.session.threadId ?? null; | |
| 1505 | const continuityState: SessionContinuityState = | |
| 1506 | activeThread.codexThreadId === null | |
| 1507 | ? "new" | |
| 1508 | : sessionThreadId === activeThread.codexThreadId | |
| 1509 | ? "resumed" | |
| 1510 | : "fallback_new"; | |
| 1511 | await syncCheckpointTurnCounts({ | |
| 1512 | threadId: activeThread.id, | |
| 1513 | sessionId: activeThread.session.sessionId, | |
| 1514 | threadRuntimeId: sessionThreadId, | |
| 1515 | }); | |
| 1516 | return { | |
| 1517 | sessionId: activeThread.session.sessionId, | |
| 1518 | resolvedThreadId: sessionThreadId, | |
| 1519 | continuityState, | |
| 1520 | } satisfies EnsuredSessionInfo; | |
| 1521 | } | |
| 1522 | ||
| 1523 | const priorCodexThreadId = activeThread.codexThreadId; | |
| 1524 | setIsConnecting(true); | |
| 1525 | try { | |
| 1526 | const session = await api.providers.startSession({ | |
| 1527 | provider: "codex", | |
| 1528 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 1529 | model: selectedModel || undefined, | |
| 1530 | resumeThreadId: priorCodexThreadId ?? undefined, | |
| 1531 | ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), | |
| 1532 | ...(codexHomePath.length > 0 ? { codexHomePath } : {}), | |
| 1533 | approvalPolicy: runtimeApprovalPolicy, | |
| 1534 | sandboxMode: runtimeSandboxMode, | |
| 1535 | }); | |
| 1536 | dispatch({ | |
| 1537 | type: "UPDATE_SESSION", | |
| 1538 | threadId: activeThread.id, | |
| 1539 | session, | |
| 1540 | }); | |
| 1541 | const resolvedThreadId = session.threadId ?? null; | |
| 1542 | const continuityState: SessionContinuityState = | |
| 1543 | priorCodexThreadId === null | |
| 1544 | ? "new" | |
| 1545 | : resolvedThreadId === priorCodexThreadId | |
| 1546 | ? "resumed" | |
| 1547 | : "fallback_new"; | |
| 1548 | await syncCheckpointTurnCounts({ | |
| 1549 | threadId: activeThread.id, | |
| 1550 | sessionId: session.sessionId, | |
| 1551 | threadRuntimeId: resolvedThreadId, | |
| 1552 | }); | |
| 1553 | return { | |
| 1554 | sessionId: session.sessionId, | |
| 1555 | resolvedThreadId, | |
| 1556 | continuityState, | |
| 1557 | }; | |
| 1558 | } catch (err) { | |
| 1559 | dispatch({ | |
| 1560 | type: "SET_ERROR", | |
| 1561 | threadId: activeThread.id, | |
| 1562 | error: err instanceof Error ? err.message : "Failed to connect.", | |
| 1563 | }); | |
| 1564 | return null; | |
| 1565 | } finally { | |
| 1566 | setIsConnecting(false); | |
| 1567 | } | |
| 1568 | }, | |
| 1569 | [ | |
| 1570 | activeProject, | |
| 1571 | activeThread, | |
| 1572 | api, | |
| 1573 | codexBinaryPath, | |
| 1574 | codexHomePath, | |
| 1575 | dispatch, | |
| 1576 | runtimeApprovalPolicy, | |
| 1577 | runtimeSandboxMode, | |
| 1578 | selectedModel, | |
| 1579 | syncCheckpointTurnCounts, | |
| 1580 | ], | |
| 1581 | ); | |
| 1582 | ||
| 1583 | useEffect(() => { | |
| 1584 | if (!api || !activeThreadId || activeSessionId) return; | |
| 1585 | if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; | |
| 1586 | const hasThreadIdentity = Boolean(activeThreadRuntimeId); | |
| 1587 | if (!hasThreadIdentity) return; | |
| 1588 | if (turnDiffSummaries.length === 0) return; | |
| 1589 | ||
| 1590 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; | |
| 1591 | if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { | |
| 1592 | return; | |
| 1593 | } | |
| 1594 | checkpointHydrationSessionRequestRef.current.add(requestKey); | |
| 1595 | setThreadError(activeThreadId, null); | |
| 1596 | void ensureSession().then( | |
| 1597 | () => { | |
| 1598 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | |
| 1599 | }, | |
| 1600 | () => { | |
| 1601 | // Keep the key on failure so this effect does not re-enter in a tight loop. | |
| 1602 | }, | |
| 1603 | ); | |
| 1604 | }, [ | |
| 1605 | activeThreadId, | |
| 1606 | activeThreadRuntimeId, | |
| 1607 | activeSessionId, | |
| 1608 | api, | |
| 1609 | ensureSession, | |
| 1610 | isConnecting, | |
| 1611 | isRevertingCheckpoint, | |
| 1612 | isSending, | |
| 1613 | phase, | |
| 1614 | setThreadError, | |
| 1615 | turnDiffSummaries, | |
| 1616 | ]); | |
| 1617 | ||
| 1618 | 1343 | const onRevertToTurnCount = useCallback( |
| 1619 | 1344 | async (turnCount: number) => { |
| 1620 | if (!api || !activeThread || isRevertingCheckpoint) { | |
| 1621 | return; | |
| 1622 | } | |
| 1623 | if (phase === "running" || isSending || isConnecting) { | |
| 1345 | const api = readNativeApi(); | |
| 1346 | if (!api || !activeThread || isRevertingCheckpoint) return; | |
| 1347 | ||
| 1348 | if (phase === "running" || isSendBusy || isConnecting) { | |
| 1624 | 1349 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 1625 | 1350 | return; |
| 1626 | 1351 | } |
| ... | ... | @@ -1638,30 +1363,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1638 | 1363 | setIsRevertingCheckpoint(true); |
| 1639 | 1364 | setThreadError(activeThread.id, null); |
| 1640 | 1365 | try { |
| 1641 | const sessionInfo = await ensureSession(); | |
| 1642 | if (!sessionInfo) { | |
| 1643 | return; | |
| 1644 | } | |
| 1645 | const result = await api.providers.revertToCheckpoint({ | |
| 1646 | sessionId: sessionInfo.sessionId, | |
| 1647 | turnCount, | |
| 1648 | }); | |
| 1649 | dispatch({ | |
| 1650 | type: "REVERT_TO_CHECKPOINT", | |
| 1651 | threadId: activeThread.id, | |
| 1652 | sessionId: sessionInfo.sessionId, | |
| 1653 | threadRuntimeId: result.threadId, | |
| 1654 | turnCount: result.turnCount, | |
| 1655 | messageCount: result.messageCount, | |
| 1656 | }); | |
| 1657 | dispatch({ | |
| 1658 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 1366 | await api.orchestration.dispatchCommand({ | |
| 1367 | type: "thread.checkpoint.revert", | |
| 1368 | commandId: newCommandId(), | |
| 1659 | 1369 | threadId: activeThread.id, |
| 1660 | checkpointTurnCountByTurnId: Object.fromEntries( | |
| 1661 | result.checkpoints | |
| 1662 | .filter((entry) => entry.turnCount > 0) | |
| 1663 | .map((entry) => [entry.id, entry.turnCount] as const), | |
| 1664 | ), | |
| 1370 | turnCount, | |
| 1371 | createdAt: new Date().toISOString(), | |
| 1665 | 1372 | }); |
| 1666 | 1373 | } catch (err) { |
| 1667 | 1374 | setThreadError( |
| ... | ... | @@ -1672,113 +1379,133 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1672 | 1379 | setIsRevertingCheckpoint(false); |
| 1673 | 1380 | } |
| 1674 | 1381 | }, |
| 1675 | [ | |
| 1676 | activeThread, | |
| 1677 | api, | |
| 1678 | dispatch, | |
| 1679 | ensureSession, | |
| 1680 | isConnecting, | |
| 1681 | isRevertingCheckpoint, | |
| 1682 | isSending, | |
| 1683 | phase, | |
| 1684 | setThreadError, | |
| 1685 | ], | |
| 1382 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], | |
| 1686 | 1383 | ); |
| 1687 | 1384 | |
| 1688 | const onSend = async (e: FormEvent) => { | |
| 1385 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { | |
| 1689 | 1386 | e.preventDefault(); |
| 1690 | if (!api || !activeThread || isSending || isConnecting) return; | |
| 1387 | const api = readNativeApi(); | |
| 1388 | if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; | |
| 1691 | 1389 | const trimmed = prompt.trim(); |
| 1692 | 1390 | if (!trimmed && composerImages.length === 0) return; |
| 1693 | 1391 | if (!activeProject) return; |
| 1392 | const threadIdForSend = activeThread.id; | |
| 1393 | const isFirstMessage = activeThread.messages.length === 0; | |
| 1394 | const baseBranchForWorktree = | |
| 1395 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath | |
| 1396 | ? activeThread.branch | |
| 1397 | : null; | |
| 1398 | ||
| 1399 | // In worktree mode, require an explicit base branch so we don't silently | |
| 1400 | // fall back to local execution when branch selection is missing. | |
| 1401 | const shouldCreateWorktree = | |
| 1402 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; | |
| 1403 | if (shouldCreateWorktree && !activeThread.branch) { | |
| 1404 | dispatch({ | |
| 1405 | type: "SET_ERROR", | |
| 1406 | threadId: threadIdForSend, | |
| 1407 | error: "Select a base branch before sending in New worktree mode.", | |
| 1408 | }); | |
| 1409 | return; | |
| 1410 | } | |
| 1411 | ||
| 1412 | sendInFlightRef.current = true; | |
| 1413 | setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); | |
| 1414 | ||
| 1694 | 1415 | const composerImagesSnapshot = [...composerImages]; |
| 1416 | const messageIdForSend = newMessageId(); | |
| 1417 | const messageCreatedAt = new Date().toISOString(); | |
| 1418 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ | |
| 1419 | type: "image" as const, | |
| 1420 | id: image.id, | |
| 1421 | name: image.name, | |
| 1422 | mimeType: image.mimeType, | |
| 1423 | sizeBytes: image.sizeBytes, | |
| 1424 | previewUrl: image.previewUrl, | |
| 1425 | })); | |
| 1426 | setOptimisticUserMessages((existing) => [ | |
| 1427 | ...existing, | |
| 1428 | { | |
| 1429 | id: messageIdForSend, | |
| 1430 | role: "user", | |
| 1431 | text: trimmed, | |
| 1432 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), | |
| 1433 | createdAt: messageCreatedAt, | |
| 1434 | streaming: false, | |
| 1435 | }, | |
| 1436 | ]); | |
| 1695 | 1437 | |
| 1696 | // On first message: lock in branch + create worktree if needed. | |
| 1697 | let sessionCwd: string | undefined; | |
| 1698 | if ( | |
| 1699 | activeThread.messages.length === 0 && | |
| 1700 | activeThread.branch && | |
| 1701 | envMode === "worktree" && | |
| 1702 | !activeThread.worktreePath | |
| 1703 | ) { | |
| 1704 | try { | |
| 1438 | setThreadError(threadIdForSend, null); | |
| 1439 | promptRef.current = ""; | |
| 1440 | setPrompt(""); | |
| 1441 | setComposerImages([]); | |
| 1442 | setComposerCursor(0); | |
| 1443 | setComposerHighlightedItemId(null); | |
| 1444 | ||
| 1445 | let attemptedTurnStart = false; | |
| 1446 | try { | |
| 1447 | // On first message: lock in branch + create worktree if needed. | |
| 1448 | if (baseBranchForWorktree) { | |
| 1449 | setSendPhase("preparing-worktree"); | |
| 1705 | 1450 | const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; |
| 1706 | 1451 | const result = await createWorktreeMutation.mutateAsync({ |
| 1707 | 1452 | cwd: activeProject.cwd, |
| 1708 | branch: activeThread.branch, | |
| 1453 | branch: baseBranchForWorktree, | |
| 1709 | 1454 | newBranch, |
| 1710 | 1455 | }); |
| 1711 | sessionCwd = result.worktree.path; | |
| 1456 | await api.orchestration.dispatchCommand({ | |
| 1457 | type: "thread.meta.update", | |
| 1458 | commandId: newCommandId(), | |
| 1459 | threadId: threadIdForSend, | |
| 1460 | branch: result.worktree.branch, | |
| 1461 | worktreePath: result.worktree.path, | |
| 1462 | }); | |
| 1463 | // Keep local thread state in sync immediately so terminal drawer opens | |
| 1464 | // with the worktree cwd/env instead of briefly using the project root. | |
| 1712 | 1465 | dispatch({ |
| 1713 | 1466 | type: "SET_THREAD_BRANCH", |
| 1714 | threadId: activeThread.id, | |
| 1467 | threadId: threadIdForSend, | |
| 1715 | 1468 | branch: result.worktree.branch, |
| 1716 | 1469 | worktreePath: result.worktree.path, |
| 1717 | 1470 | }); |
| 1718 | 1471 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1719 | 1472 | if (setupScript) { |
| 1720 | void runProjectScript(setupScript, { | |
| 1473 | await runProjectScript(setupScript, { | |
| 1721 | 1474 | cwd: result.worktree.path, |
| 1722 | 1475 | worktreePath: result.worktree.path, |
| 1723 | 1476 | rememberAsLastInvoked: false, |
| 1724 | 1477 | }); |
| 1725 | 1478 | } |
| 1726 | } catch (err) { | |
| 1727 | dispatch({ | |
| 1728 | type: "SET_ERROR", | |
| 1729 | threadId: activeThread.id, | |
| 1730 | error: err instanceof Error ? err.message : "Failed to create worktree", | |
| 1731 | }); | |
| 1732 | return; | |
| 1733 | 1479 | } |
| 1734 | } | |
| 1735 | ||
| 1736 | // Auto-title from first message | |
| 1737 | if (activeThread.messages.length === 0) { | |
| 1738 | const titleSeed = | |
| 1739 | trimmed || | |
| 1740 | (composerImagesSnapshot.length > 0 | |
| 1741 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` | |
| 1742 | : "New thread"); | |
| 1743 | const title = truncateTitle(titleSeed); | |
| 1744 | dispatch({ | |
| 1745 | type: "SET_THREAD_TITLE", | |
| 1746 | threadId: activeThread.id, | |
| 1747 | title, | |
| 1748 | }); | |
| 1749 | } | |
| 1750 | ||
| 1751 | setThreadError(activeThread.id, null); | |
| 1752 | const messageAttachments: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({ | |
| 1753 | type: "image", | |
| 1754 | id: image.id, | |
| 1755 | name: image.name, | |
| 1756 | mimeType: image.mimeType, | |
| 1757 | sizeBytes: image.sizeBytes, | |
| 1758 | previewUrl: image.previewUrl, | |
| 1759 | })); | |
| 1760 | dispatch({ | |
| 1761 | type: "PUSH_USER_MESSAGE", | |
| 1762 | threadId: activeThread.id, | |
| 1763 | id: crypto.randomUUID(), | |
| 1764 | text: trimmed, | |
| 1765 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), | |
| 1766 | }); | |
| 1767 | const previousMessages = activeThread.messages; | |
| 1768 | promptRef.current = ""; | |
| 1769 | setPrompt(""); | |
| 1770 | setComposerImages([]); | |
| 1771 | setComposerCursor(0); | |
| 1772 | setComposerHighlightedItemId(null); | |
| 1773 | 1480 | |
| 1774 | const sessionInfo = await ensureSession(sessionCwd); | |
| 1775 | if (!sessionInfo) return; | |
| 1481 | // Auto-title from first message | |
| 1482 | if (isFirstMessage) { | |
| 1483 | const titleSeed = | |
| 1484 | trimmed || | |
| 1485 | (composerImagesSnapshot.length > 0 | |
| 1486 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` | |
| 1487 | : "New thread"); | |
| 1488 | const title = truncateTitle(titleSeed); | |
| 1489 | await api.orchestration.dispatchCommand({ | |
| 1490 | type: "thread.meta.update", | |
| 1491 | commandId: newCommandId(), | |
| 1492 | threadId: threadIdForSend, | |
| 1493 | title, | |
| 1494 | }); | |
| 1495 | } | |
| 1776 | 1496 | |
| 1777 | setIsSending(true); | |
| 1778 | try { | |
| 1497 | setSendPhase("sending-turn"); | |
| 1779 | 1498 | const turnAttachments = await Promise.all( |
| 1780 | 1499 | composerImagesSnapshot.map( |
| 1781 | async (image): Promise<ProviderSendTurnAttachmentInput> => ({ | |
| 1500 | async ( | |
| 1501 | image, | |
| 1502 | ): Promise<{ | |
| 1503 | type: "image"; | |
| 1504 | name: string; | |
| 1505 | mimeType: string; | |
| 1506 | sizeBytes: number; | |
| 1507 | dataUrl: string; | |
| 1508 | }> => ({ | |
| 1782 | 1509 | type: "image", |
| 1783 | 1510 | name: image.name, |
| 1784 | 1511 | mimeType: image.mimeType, |
| ... | ... | @@ -1787,54 +1514,78 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1787 | 1514 | }), |
| 1788 | 1515 | ), |
| 1789 | 1516 | ); |
| 1790 | const shouldBootstrap = | |
| 1791 | previousMessages.length > 0 && | |
| 1792 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); | |
| 1793 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | |
| 1794 | const input = shouldBootstrap | |
| 1795 | ? buildBootstrapInput( | |
| 1796 | previousMessages, | |
| 1797 | latestPromptForBootstrap, | |
| 1798 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 1799 | ).text | |
| 1800 | : trimmed || undefined; | |
| 1801 | await api.providers.sendTurn({ | |
| 1802 | sessionId: sessionInfo.sessionId, | |
| 1803 | ...(input ? { input } : {}), | |
| 1804 | ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), | |
| 1517 | attemptedTurnStart = true; | |
| 1518 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 1519 | const sandboxMode = | |
| 1520 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 1521 | await api.orchestration.dispatchCommand({ | |
| 1522 | type: "thread.turn.start", | |
| 1523 | commandId: newCommandId(), | |
| 1524 | threadId: threadIdForSend, | |
| 1525 | message: { | |
| 1526 | messageId: messageIdForSend, | |
| 1527 | role: "user", | |
| 1528 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 1529 | attachments: turnAttachments, | |
| 1530 | }, | |
| 1805 | 1531 | model: selectedModel || undefined, |
| 1806 | 1532 | effort: selectedEffort || undefined, |
| 1533 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| 1534 | approvalPolicy, | |
| 1535 | sandboxMode, | |
| 1536 | createdAt: messageCreatedAt, | |
| 1807 | 1537 | }); |
| 1808 | 1538 | } catch (err) { |
| 1539 | if ( | |
| 1540 | !attemptedTurnStart && | |
| 1541 | promptRef.current.length === 0 && | |
| 1542 | composerImagesRef.current.length === 0 | |
| 1543 | ) { | |
| 1544 | setOptimisticUserMessages((existing) => | |
| 1545 | existing.filter((message) => message.id !== messageIdForSend), | |
| 1546 | ); | |
| 1547 | promptRef.current = trimmed; | |
| 1548 | setPrompt(trimmed); | |
| 1549 | setComposerImages(composerImagesSnapshot); | |
| 1550 | setComposerCursor(trimmed.length); | |
| 1551 | } | |
| 1809 | 1552 | setThreadError( |
| 1810 | activeThread.id, | |
| 1553 | threadIdForSend, | |
| 1811 | 1554 | err instanceof Error ? err.message : "Failed to send message.", |
| 1812 | 1555 | ); |
| 1813 | 1556 | } finally { |
| 1814 | setIsSending(false); | |
| 1557 | sendInFlightRef.current = false; | |
| 1558 | setSendPhase("idle"); | |
| 1815 | 1559 | } |
| 1816 | 1560 | }; |
| 1817 | 1561 | |
| 1818 | 1562 | const onInterrupt = async () => { |
| 1819 | if (!api || !activeThread?.session) return; | |
| 1820 | await api.providers.interruptTurn({ | |
| 1821 | sessionId: activeThread.session.sessionId, | |
| 1822 | turnId: activeThread.session.activeTurnId, | |
| 1563 | const api = readNativeApi(); | |
| 1564 | if (!api || !activeThread) return; | |
| 1565 | await api.orchestration.dispatchCommand({ | |
| 1566 | type: "thread.turn.interrupt", | |
| 1567 | commandId: newCommandId(), | |
| 1568 | threadId: activeThread.id, | |
| 1569 | createdAt: new Date().toISOString(), | |
| 1823 | 1570 | }); |
| 1824 | 1571 | }; |
| 1825 | 1572 | |
| 1826 | 1573 | const onRespondToApproval = useCallback( |
| 1827 | async (requestId: string, decision: ProviderApprovalDecision) => { | |
| 1828 | if (!api || !activeSessionId || !activeThreadId) return; | |
| 1574 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | |
| 1575 | const api = readNativeApi(); | |
| 1576 | if (!api || !activeThreadId) return; | |
| 1829 | 1577 | |
| 1830 | 1578 | setRespondingRequestIds((existing) => |
| 1831 | 1579 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 1832 | 1580 | ); |
| 1833 | 1581 | try { |
| 1834 | await api.providers.respondToRequest({ | |
| 1835 | sessionId: activeSessionId, | |
| 1582 | await api.orchestration.dispatchCommand({ | |
| 1583 | type: "thread.approval.respond", | |
| 1584 | commandId: newCommandId(), | |
| 1585 | threadId: activeThreadId, | |
| 1836 | 1586 | requestId, |
| 1837 | 1587 | decision, |
| 1588 | createdAt: new Date().toISOString(), | |
| 1838 | 1589 | }); |
| 1839 | 1590 | } catch (err) { |
| 1840 | 1591 | dispatch({ |
| ... | ... | @@ -1846,20 +1597,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1846 | 1597 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 1847 | 1598 | } |
| 1848 | 1599 | }, |
| 1849 | [activeSessionId, activeThreadId, api, dispatch], | |
| 1600 | [activeThreadId, dispatch], | |
| 1850 | 1601 | ); |
| 1851 | 1602 | |
| 1852 | 1603 | const onModelSelect = useCallback( |
| 1853 | 1604 | (model: ModelSlug) => { |
| 1605 | const api = readNativeApi(); | |
| 1854 | 1606 | if (!activeThread) return; |
| 1855 | dispatch({ | |
| 1856 | type: "SET_THREAD_MODEL", | |
| 1857 | threadId: activeThread.id, | |
| 1858 | model: resolveModelSlug(model), | |
| 1859 | }); | |
| 1607 | if (api) { | |
| 1608 | void api.orchestration.dispatchCommand({ | |
| 1609 | type: "thread.meta.update", | |
| 1610 | commandId: newCommandId(), | |
| 1611 | threadId: activeThread.id, | |
| 1612 | model: resolveModelSlug(model), | |
| 1613 | }); | |
| 1614 | } | |
| 1860 | 1615 | scheduleComposerFocus(); |
| 1861 | 1616 | }, |
| 1862 | [activeThread, dispatch, scheduleComposerFocus], | |
| 1617 | [activeThread, scheduleComposerFocus], | |
| 1863 | 1618 | ); |
| 1864 | 1619 | const onEffortSelect = useCallback( |
| 1865 | 1620 | (effort: ReasoningEffort) => { |
| ... | ... | @@ -1934,7 +1689,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1934 | 1689 | setComposerCursor(nextCursor); |
| 1935 | 1690 | }, []); |
| 1936 | 1691 | |
| 1937 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | |
| 1692 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| 1938 | 1693 | if (composerMenuOpen && composerMenuItems.length > 0) { |
| 1939 | 1694 | if (e.key === "ArrowDown") { |
| 1940 | 1695 | e.preventDefault(); |
| ... | ... | @@ -1958,7 +1713,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1958 | 1713 | |
| 1959 | 1714 | if (e.key === "Enter" && !e.shiftKey) { |
| 1960 | 1715 | e.preventDefault(); |
| 1961 | void onSend(e as unknown as FormEvent); | |
| 1716 | void onSend(e); | |
| 1962 | 1717 | } |
| 1963 | 1718 | }; |
| 1964 | 1719 | const onToggleWorkGroup = useCallback((groupId: string) => { |
| ... | ... | @@ -1970,18 +1725,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1970 | 1725 | const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { |
| 1971 | 1726 | setExpandedImage(image); |
| 1972 | 1727 | }, []); |
| 1973 | const onToggleDiff = useCallback(() => { | |
| 1974 | void navigate({ | |
| 1975 | to: "/$threadId", | |
| 1976 | params: { threadId }, | |
| 1977 | search: (previous) => { | |
| 1978 | const rest = stripDiffSearchParams(previous); | |
| 1979 | return diffOpen ? rest : { ...rest, diff: "1" }; | |
| 1980 | }, | |
| 1981 | }); | |
| 1982 | }, [diffOpen, navigate, threadId]); | |
| 1983 | 1728 | const onOpenTurnDiff = useCallback( |
| 1984 | (turnId: string, filePath?: string) => { | |
| 1729 | (turnId: TurnId, filePath?: string) => { | |
| 1985 | 1730 | void navigate({ |
| 1986 | 1731 | to: "/$threadId", |
| 1987 | 1732 | params: { threadId }, |
| ... | ... | @@ -1996,7 +1741,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1996 | 1741 | [navigate, threadId], |
| 1997 | 1742 | ); |
| 1998 | 1743 | const onRevertUserMessage = useCallback( |
| 1999 | (messageId: string) => { | |
| 1744 | (messageId: MessageId) => { | |
| 2000 | 1745 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); |
| 2001 | 1746 | if (typeof targetTurnCount !== "number") { |
| 2002 | 1747 | return; |
| ... | ... | @@ -2010,6 +1755,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2010 | 1755 | if (!activeThread) { |
| 2011 | 1756 | return ( |
| 2012 | 1757 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> |
| 1758 | {!isElectron && ( | |
| 1759 | <header className="border-b border-border px-3 py-2 md:hidden"> | |
| 1760 | <div className="flex items-center gap-2"> | |
| 1761 | <SidebarTrigger className="size-7 shrink-0" /> | |
| 1762 | <span className="text-sm font-medium text-foreground">Threads</span> | |
| 1763 | </div> | |
| 1764 | </header> | |
| 1765 | )} | |
| 2013 | 1766 | {isElectron && ( |
| 2014 | 1767 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> |
| 2015 | 1768 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
| ... | ... | @@ -2025,10 +1778,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2025 | 1778 | } |
| 2026 | 1779 | |
| 2027 | 1780 | return ( |
| 2028 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> | |
| 1781 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | |
| 2029 | 1782 | {/* Top bar */} |
| 2030 | 1783 | <header |
| 2031 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} | |
| 1784 | className={cn( | |
| 1785 | "border-b border-border px-3 sm:px-5", | |
| 1786 | isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3", | |
| 1787 | )} | |
| 2032 | 1788 | > |
| 2033 | 1789 | <ChatHeader |
| 2034 | 1790 | activeThreadId={activeThread.id} |
| ... | ... | @@ -2039,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2039 | 1795 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| 2040 | 1796 | } |
| 2041 | 1797 | keybindings={keybindings} |
| 2042 | api={api} | |
| 1798 | diffToggleShortcutLabel={diffPanelShortcutLabel} | |
| 2043 | 1799 | gitCwd={gitCwd} |
| 2044 | 1800 | diffOpen={diffOpen} |
| 2045 | 1801 | onRunProjectScript={(script) => { |
| ... | ... | @@ -2062,19 +1818,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2062 | 1818 | {/* Messages */} |
| 2063 | 1819 | <div |
| 2064 | 1820 | ref={messagesScrollRef} |
| 2065 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" | |
| 1821 | className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" | |
| 2066 | 1822 | onScroll={onMessagesScroll} |
| 2067 | 1823 | > |
| 2068 | 1824 | <MessagesTimeline |
| 2069 | hasMessages={activeThread.messages.length > 0} | |
| 1825 | hasMessages={timelineMessages.length > 0} | |
| 2070 | 1826 | isWorking={isWorking} |
| 2071 | 1827 | scrollContainerRef={messagesScrollRef} |
| 2072 | 1828 | timelineEntries={timelineEntries} |
| 2073 | 1829 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 2074 | 1830 | completionSummary={completionSummary} |
| 2075 | assistantCompletionByItemId={assistantCompletionByItemId} | |
| 2076 | 1831 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 2077 | turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} | |
| 2078 | 1832 | nowIso={nowIso} |
| 2079 | 1833 | expandedWorkGroups={expandedWorkGroups} |
| 2080 | 1834 | onToggleWorkGroup={onToggleWorkGroup} |
| ... | ... | @@ -2083,12 +1837,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2083 | 1837 | onRevertUserMessage={onRevertUserMessage} |
| 2084 | 1838 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 2085 | 1839 | onImageExpand={onExpandTimelineImage} |
| 1840 | markdownCwd={gitCwd ?? undefined} | |
| 2086 | 1841 | /> |
| 2087 | 1842 | </div> |
| 2088 | 1843 | |
| 2089 | 1844 | {/* Input bar */} |
| 2090 | <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}> | |
| 2091 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | |
| 1845 | <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}> | |
| 1846 | <form | |
| 1847 | onSubmit={onSend} | |
| 1848 | className="mx-auto w-full min-w-fit max-w-3xl" | |
| 1849 | data-chat-composer-form="true" | |
| 1850 | > | |
| 2092 | 1851 | <div |
| 2093 | 1852 | className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${ |
| 2094 | 1853 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border" |
| ... | ... | @@ -2099,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2099 | 1858 | onDrop={onComposerDrop} |
| 2100 | 1859 | > |
| 2101 | 1860 | {/* Textarea area */} |
| 2102 | <div className="relative px-4 pt-4 pb-2"> | |
| 1861 | <div className="relative px-3 pt-3.5 pb-2 sm:px-4 sm:pt-4"> | |
| 2103 | 1862 | {composerMenuOpen && ( |
| 2104 | 1863 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 2105 | 1864 | <ComposerCommandMenu |
| ... | ... | @@ -2181,29 +1940,29 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2181 | 1940 | ? "Ask for follow-up changes or attach images" |
| 2182 | 1941 | : "Ask anything, @tag files/folders, or use /model" |
| 2183 | 1942 | } |
| 2184 | disabled={isSending || isConnecting} | |
| 1943 | disabled={isConnecting} | |
| 2185 | 1944 | /> |
| 2186 | 1945 | </div> |
| 2187 | 1946 | |
| 2188 | 1947 | {/* Bottom toolbar */} |
| 2189 | <div className="flex items-center justify-between px-3 pb-3"> | |
| 2190 | <div className="flex items-center gap-1"> | |
| 1948 | <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3"> | |
| 1949 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible"> | |
| 2191 | 1950 | {/* Model picker */} |
| 2192 | 1951 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> |
| 2193 | 1952 | |
| 2194 | 1953 | {/* Divider */} |
| 2195 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | |
| 1954 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2196 | 1955 | |
| 2197 | 1956 | {/* Reasoning effort */} |
| 2198 | 1957 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} /> |
| 2199 | 1958 | |
| 2200 | 1959 | {/* Divider */} |
| 2201 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | |
| 1960 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 2202 | 1961 | |
| 2203 | 1962 | {/* Runtime mode toggle */} |
| 2204 | 1963 | <Button |
| 2205 | 1964 | variant="ghost" |
| 2206 | className="text-muted-foreground/70 hover:text-foreground/80" | |
| 1965 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 2207 | 1966 | size="sm" |
| 2208 | 1967 | type="button" |
| 2209 | 1968 | disabled={isSwitchingRuntimeMode} |
| ... | ... | @@ -2219,16 +1978,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2219 | 1978 | } |
| 2220 | 1979 | > |
| 2221 | 1980 | {state.runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />} |
| 2222 | {state.runtimeMode === "full-access" ? "Full access" : "Supervised"} | |
| 1981 | <span className="sr-only sm:not-sr-only"> | |
| 1982 | {state.runtimeMode === "full-access" ? "Full access" : "Supervised"} | |
| 1983 | </span> | |
| 2223 | 1984 | </Button> |
| 2224 | 1985 | </div> |
| 2225 | 1986 | |
| 2226 | 1987 | {/* Right side: send / stop button */} |
| 2227 | <div className="flex items-center gap-2"> | |
| 1988 | <div className="flex shrink-0 items-center gap-2"> | |
| 1989 | {isPreparingWorktree ? ( | |
| 1990 | <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> | |
| 1991 | ) : null} | |
| 2228 | 1992 | {phase === "running" ? ( |
| 2229 | 1993 | <button |
| 2230 | 1994 | type="button" |
| 2231 | className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105" | |
| 1995 | className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" | |
| 2232 | 1996 | onClick={() => void onInterrupt()} |
| 2233 | 1997 | aria-label="Stop generation" |
| 2234 | 1998 | > |
| ... | ... | @@ -2245,15 +2009,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2245 | 2009 | ) : ( |
| 2246 | 2010 | <button |
| 2247 | 2011 | type="submit" |
| 2248 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" | |
| 2012 | className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" | |
| 2249 | 2013 | disabled={ |
| 2250 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) | |
| 2014 | isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0) | |
| 2251 | 2015 | } |
| 2252 | 2016 | aria-label={ |
| 2253 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | |
| 2017 | isConnecting | |
| 2018 | ? "Connecting" | |
| 2019 | : isPreparingWorktree | |
| 2020 | ? "Preparing worktree" | |
| 2021 | : isSendBusy | |
| 2022 | ? "Sending" | |
| 2023 | : "Send message" | |
| 2254 | 2024 | } |
| 2255 | 2025 | > |
| 2256 | {isConnecting || isSending ? ( | |
| 2026 | {isConnecting || isSendBusy ? ( | |
| 2257 | 2027 | <svg |
| 2258 | 2028 | width="14" |
| 2259 | 2029 | height="14" |
| ... | ... | @@ -2307,35 +2077,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2307 | 2077 | /> |
| 2308 | 2078 | )} |
| 2309 | 2079 | |
| 2310 | {activeThread.terminalOpen && api && activeProject && ( | |
| 2311 | <ThreadTerminalDrawer | |
| 2312 | key={activeThread.id} | |
| 2313 | api={api} | |
| 2314 | threadId={activeThread.id} | |
| 2315 | cwd={gitCwd ?? activeProject.cwd} | |
| 2316 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 2317 | height={activeThread.terminalHeight} | |
| 2318 | terminalIds={activeThread.terminalIds} | |
| 2319 | activeTerminalId={activeThread.activeTerminalId} | |
| 2320 | terminalGroups={activeThread.terminalGroups} | |
| 2321 | activeTerminalGroupId={activeThread.activeTerminalGroupId} | |
| 2322 | focusRequestId={terminalFocusRequestId} | |
| 2323 | onSplitTerminal={splitTerminal} | |
| 2324 | onNewTerminal={createNewTerminal} | |
| 2325 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 2326 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 2327 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 2328 | onActiveTerminalChange={activateTerminal} | |
| 2329 | onCloseTerminal={closeTerminal} | |
| 2330 | onHeightChange={(height) => | |
| 2331 | dispatch({ | |
| 2332 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 2333 | threadId: activeThread.id, | |
| 2334 | height, | |
| 2335 | }) | |
| 2336 | } | |
| 2337 | /> | |
| 2338 | )} | |
| 2080 | {(() => { | |
| 2081 | if (!activeThread.terminalOpen || !activeProject) { | |
| 2082 | return null; | |
| 2083 | } | |
| 2084 | return ( | |
| 2085 | <ThreadTerminalDrawer | |
| 2086 | key={activeThread.id} | |
| 2087 | threadId={activeThread.id} | |
| 2088 | cwd={gitCwd ?? activeProject.cwd} | |
| 2089 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 2090 | height={activeThread.terminalHeight} | |
| 2091 | terminalIds={activeThread.terminalIds} | |
| 2092 | activeTerminalId={activeThread.activeTerminalId} | |
| 2093 | terminalGroups={activeThread.terminalGroups} | |
| 2094 | activeTerminalGroupId={activeThread.activeTerminalGroupId} | |
| 2095 | focusRequestId={terminalFocusRequestId} | |
| 2096 | onSplitTerminal={splitTerminal} | |
| 2097 | onNewTerminal={createNewTerminal} | |
| 2098 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 2099 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 2100 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 2101 | onActiveTerminalChange={activateTerminal} | |
| 2102 | onCloseTerminal={closeTerminal} | |
| 2103 | onHeightChange={(height) => | |
| 2104 | dispatch({ | |
| 2105 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 2106 | threadId: activeThread.id, | |
| 2107 | height, | |
| 2108 | }) | |
| 2109 | } | |
| 2110 | /> | |
| 2111 | ); | |
| 2112 | })()} | |
| 2339 | 2113 | |
| 2340 | 2114 | {expandedImage && ( |
| 2341 | 2115 | <div |
| ... | ... | @@ -2376,13 +2150,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2376 | 2150 | } |
| 2377 | 2151 | |
| 2378 | 2152 | interface ChatHeaderProps { |
| 2379 | activeThreadId: string; | |
| 2153 | activeThreadId: ThreadId; | |
| 2380 | 2154 | activeThreadTitle: string; |
| 2381 | 2155 | activeProjectName: string | undefined; |
| 2382 | 2156 | activeProjectScripts: ProjectScript[] | undefined; |
| 2383 | 2157 | preferredScriptId: string | null; |
| 2384 | 2158 | keybindings: ResolvedKeybindingsConfig; |
| 2385 | api: NativeApi | undefined; | |
| 2159 | diffToggleShortcutLabel: string | null; | |
| 2386 | 2160 | gitCwd: string | null; |
| 2387 | 2161 | diffOpen: boolean; |
| 2388 | 2162 | onRunProjectScript: (script: ProjectScript) => void; |
| ... | ... | @@ -2398,7 +2172,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 2398 | 2172 | activeProjectScripts, |
| 2399 | 2173 | preferredScriptId, |
| 2400 | 2174 | keybindings, |
| 2401 | api, | |
| 2175 | diffToggleShortcutLabel, | |
| 2402 | 2176 | gitCwd, |
| 2403 | 2177 | diffOpen, |
| 2404 | 2178 | onRunProjectScript, |
| ... | ... | @@ -2407,14 +2181,22 @@ const ChatHeader = memo(function ChatHeader({ |
| 2407 | 2181 | onToggleDiff, |
| 2408 | 2182 | }: ChatHeaderProps) { |
| 2409 | 2183 | return ( |
| 2410 | <> | |
| 2411 | <div className="flex min-w-0 flex-1 items-center gap-3"> | |
| 2412 | <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}> | |
| 2184 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 2185 | <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3"> | |
| 2186 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 2187 | <h2 | |
| 2188 | className="min-w-0 shrink truncate text-sm font-medium text-foreground" | |
| 2189 | title={activeThreadTitle} | |
| 2190 | > | |
| 2413 | 2191 | {activeThreadTitle} |
| 2414 | 2192 | </h2> |
| 2415 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | |
| 2193 | {activeProjectName && ( | |
| 2194 | <Badge variant="outline" className="max-w-28 shrink-0 truncate"> | |
| 2195 | {activeProjectName} | |
| 2196 | </Badge> | |
| 2197 | )} | |
| 2416 | 2198 | </div> |
| 2417 | <div className="shrink-0 flex items-center gap-3"> | |
| 2199 | <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3"> | |
| 2418 | 2200 | {activeProjectScripts && ( |
| 2419 | 2201 | <ProjectScriptsControl |
| 2420 | 2202 | scripts={activeProjectScripts} |
| ... | ... | @@ -2428,20 +2210,30 @@ const ChatHeader = memo(function ChatHeader({ |
| 2428 | 2210 | {activeProjectName && ( |
| 2429 | 2211 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> |
| 2430 | 2212 | )} |
| 2431 | {activeProjectName && ( | |
| 2432 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | |
| 2433 | )} | |
| 2434 | <Toggle | |
| 2435 | pressed={diffOpen} | |
| 2436 | onPressedChange={onToggleDiff} | |
| 2437 | aria-label="Toggle diff panel" | |
| 2438 | variant="outline" | |
| 2439 | size="xs" | |
| 2440 | > | |
| 2441 | <DiffIcon className="size-3" /> | |
| 2442 | </Toggle> | |
| 2213 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} | |
| 2214 | <Tooltip> | |
| 2215 | <TooltipTrigger | |
| 2216 | render={ | |
| 2217 | <Toggle | |
| 2218 | className="shrink-0" | |
| 2219 | pressed={diffOpen} | |
| 2220 | onPressedChange={onToggleDiff} | |
| 2221 | aria-label="Toggle diff panel" | |
| 2222 | variant="outline" | |
| 2223 | size="xs" | |
| 2224 | > | |
| 2225 | <DiffIcon className="size-3" /> | |
| 2226 | </Toggle> | |
| 2227 | } | |
| 2228 | /> | |
| 2229 | <TooltipPopup side="bottom"> | |
| 2230 | {diffToggleShortcutLabel | |
| 2231 | ? `Toggle diff panel (${diffToggleShortcutLabel})` | |
| 2232 | : "Toggle diff panel"} | |
| 2233 | </TooltipPopup> | |
| 2234 | </Tooltip> | |
| 2443 | 2235 | </div> |
| 2444 | </> | |
| 2236 | </div> | |
| 2445 | 2237 | ); |
| 2446 | 2238 | }); |
| 2447 | 2239 | |
| ... | ... | @@ -2461,8 +2253,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st |
| 2461 | 2253 | |
| 2462 | 2254 | interface PendingApprovalsPanelProps { |
| 2463 | 2255 | pendingApprovals: PendingApproval[]; |
| 2464 | respondingRequestIds: string[]; | |
| 2465 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; | |
| 2256 | respondingRequestIds: ApprovalRequestId[]; | |
| 2257 | onRespondToApproval: ( | |
| 2258 | requestId: ApprovalRequestId, | |
| 2259 | decision: ProviderApprovalDecision, | |
| 2260 | ) => Promise<void>; | |
| 2466 | 2261 | } |
| 2467 | 2262 | |
| 2468 | 2263 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| ... | ... | @@ -2531,24 +2326,45 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 2531 | 2326 | ); |
| 2532 | 2327 | }); |
| 2533 | 2328 | |
| 2329 | const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { | |
| 2330 | const [copied, setCopied] = useState(false); | |
| 2331 | ||
| 2332 | const handleCopy = useCallback(() => { | |
| 2333 | void navigator.clipboard.writeText(text); | |
| 2334 | setCopied(true); | |
| 2335 | setTimeout(() => setCopied(false), 2000); | |
| 2336 | }, [text]); | |
| 2337 | ||
| 2338 | return ( | |
| 2339 | <Button | |
| 2340 | type="button" | |
| 2341 | size="xs" | |
| 2342 | variant="outline" | |
| 2343 | onClick={handleCopy} | |
| 2344 | title="Copy message" | |
| 2345 | > | |
| 2346 | {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | |
| 2347 | </Button> | |
| 2348 | ); | |
| 2349 | }); | |
| 2350 | ||
| 2534 | 2351 | interface MessagesTimelineProps { |
| 2535 | 2352 | hasMessages: boolean; |
| 2536 | 2353 | isWorking: boolean; |
| 2537 | scrollContainerRef: RefObject<HTMLDivElement | null>; | |
| 2354 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; | |
| 2538 | 2355 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2539 | 2356 | completionDividerBeforeEntryId: string | null; |
| 2540 | 2357 | completionSummary: string | null; |
| 2541 | assistantCompletionByItemId: Map<string, string>; | |
| 2542 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; | |
| 2543 | turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>; | |
| 2358 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | |
| 2544 | 2359 | nowIso: string; |
| 2545 | 2360 | expandedWorkGroups: Record<string, boolean>; |
| 2546 | 2361 | onToggleWorkGroup: (groupId: string) => void; |
| 2547 | onOpenTurnDiff: (turnId: string, filePath?: string) => void; | |
| 2548 | revertTurnCountByUserMessageId: Map<string, number>; | |
| 2549 | onRevertUserMessage: (messageId: string) => void; | |
| 2362 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | |
| 2363 | revertTurnCountByUserMessageId: Map<MessageId, number>; | |
| 2364 | onRevertUserMessage: (messageId: MessageId) => void; | |
| 2550 | 2365 | isRevertingCheckpoint: boolean; |
| 2551 | 2366 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2367 | markdownCwd: string | undefined; | |
| 2552 | 2368 | } |
| 2553 | 2369 | |
| 2554 | 2370 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| ... | ... | @@ -2575,9 +2391,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2575 | 2391 | timelineEntries, |
| 2576 | 2392 | completionDividerBeforeEntryId, |
| 2577 | 2393 | completionSummary, |
| 2578 | assistantCompletionByItemId, | |
| 2579 | 2394 | turnDiffSummaryByAssistantMessageId, |
| 2580 | turnCheckpointFilesByTurnId, | |
| 2581 | 2395 | nowIso, |
| 2582 | 2396 | expandedWorkGroups, |
| 2583 | 2397 | onToggleWorkGroup, |
| ... | ... | @@ -2586,6 +2400,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2586 | 2400 | onRevertUserMessage, |
| 2587 | 2401 | isRevertingCheckpoint, |
| 2588 | 2402 | onImageExpand, |
| 2403 | markdownCwd, | |
| 2589 | 2404 | }: MessagesTimelineProps) { |
| 2590 | 2405 | const rows = useMemo<TimelineRow[]>(() => { |
| 2591 | 2406 | const nextRows: TimelineRow[] = []; |
| ... | ... | @@ -2659,7 +2474,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2659 | 2474 | |
| 2660 | 2475 | return ( |
| 2661 | 2476 | <div |
| 2662 | className="relative mx-auto max-w-3xl" | |
| 2477 | className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" | |
| 2663 | 2478 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} |
| 2664 | 2479 | > |
| 2665 | 2480 | {virtualRows.map((virtualRow: VirtualItem) => { |
| ... | ... | @@ -2749,7 +2564,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2749 | 2564 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); |
| 2750 | 2565 | return ( |
| 2751 | 2566 | <div className="flex justify-end"> |
| 2752 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 2567 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 2753 | 2568 | {userImages.length > 0 && ( |
| 2754 | 2569 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> |
| 2755 | 2570 | {userImages.map( |
| ... | ... | @@ -2783,17 +2598,21 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2783 | 2598 | </pre> |
| 2784 | 2599 | )} |
| 2785 | 2600 | <div className="mt-1.5 flex items-center justify-end gap-2"> |
| 2786 | {canRevertAgentWork && ( | |
| 2787 | <Button | |
| 2788 | type="button" | |
| 2789 | size="xs" | |
| 2790 | variant="outline" | |
| 2791 | disabled={isRevertingCheckpoint || isWorking} | |
| 2792 | onClick={() => onRevertUserMessage(row.message.id)} | |
| 2793 | > | |
| 2794 | <Undo2Icon className="size-3" /> | |
| 2795 | </Button> | |
| 2796 | )} | |
| 2601 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | |
| 2602 | {row.message.text && <MessageCopyButton text={row.message.text} />} | |
| 2603 | {canRevertAgentWork && ( | |
| 2604 | <Button | |
| 2605 | type="button" | |
| 2606 | size="xs" | |
| 2607 | variant="outline" | |
| 2608 | disabled={isRevertingCheckpoint || isWorking} | |
| 2609 | onClick={() => onRevertUserMessage(row.message.id)} | |
| 2610 | title="Revert to this message" | |
| 2611 | > | |
| 2612 | <Undo2Icon className="size-3" /> | |
| 2613 | </Button> | |
| 2614 | )} | |
| 2615 | </div> | |
| 2797 | 2616 | <p className="text-right text-[10px] text-muted-foreground/30"> |
| 2798 | 2617 | {formatTimestamp(row.message.createdAt)} |
| 2799 | 2618 | </p> |
| ... | ... | @@ -2819,17 +2638,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2819 | 2638 | <span className="h-px flex-1 bg-border" /> |
| 2820 | 2639 | </div> |
| 2821 | 2640 | )} |
| 2822 | <div className="px-1 py-0.5"> | |
| 2823 | <ChatMarkdown text={messageText} /> | |
| 2641 | <div className="min-w-0 px-1 py-0.5"> | |
| 2642 | <ChatMarkdown text={messageText} cwd={markdownCwd} /> | |
| 2824 | 2643 | {(() => { |
| 2825 | 2644 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( |
| 2826 | 2645 | row.message.id, |
| 2827 | 2646 | ); |
| 2828 | 2647 | if (!turnSummary) return null; |
| 2829 | const checkpointFilesState = turnCheckpointFilesByTurnId.get( | |
| 2830 | turnSummary.turnId, | |
| 2831 | ); | |
| 2832 | const checkpointFiles = checkpointFilesState?.files ?? []; | |
| 2648 | const checkpointFiles = turnSummary.files; | |
| 2649 | if (checkpointFiles.length === 0) return null; | |
| 2833 | 2650 | const summaryStat = checkpointFiles.reduce( |
| 2834 | 2651 | (acc, file) => { |
| 2835 | 2652 | if ( |
| ... | ... | @@ -2845,10 +2662,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2845 | 2662 | }, |
| 2846 | 2663 | { additions: 0, deletions: 0 }, |
| 2847 | 2664 | ); |
| 2848 | const changedFileCountLabel = | |
| 2849 | checkpointFilesState?.status === "ready" | |
| 2850 | ? String(checkpointFiles.length) | |
| 2851 | : "..."; | |
| 2665 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 2852 | 2666 | return ( |
| 2853 | 2667 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2854 | 2668 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| ... | ... | @@ -2876,66 +2690,42 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2876 | 2690 | View diff |
| 2877 | 2691 | </Button> |
| 2878 | 2692 | </div> |
| 2879 | {!checkpointFilesState || | |
| 2880 | checkpointFilesState.status === "pending" ? ( | |
| 2881 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2882 | Waiting for checkpoint metadata... | |
| 2883 | </p> | |
| 2884 | ) : checkpointFilesState?.status === "loading" ? ( | |
| 2885 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2886 | Loading checkpoint diff... | |
| 2887 | </p> | |
| 2888 | ) : checkpointFilesState?.status === "error" ? ( | |
| 2889 | <p | |
| 2890 | className="truncate text-[11px] text-muted-foreground/70" | |
| 2891 | title={checkpointFilesState.errorMessage} | |
| 2892 | > | |
| 2893 | {checkpointFilesState.errorMessage} | |
| 2894 | </p> | |
| 2895 | ) : checkpointFiles.length > 0 ? ( | |
| 2896 | <div className="flex flex-wrap gap-1.5"> | |
| 2897 | {checkpointFiles.map((file) => ( | |
| 2898 | <button | |
| 2899 | key={`${turnSummary.turnId}:${file.path}`} | |
| 2900 | type="button" | |
| 2901 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | |
| 2902 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2903 | > | |
| 2904 | {(() => { | |
| 2905 | const stat = | |
| 2906 | typeof file.additions === "number" && | |
| 2907 | typeof file.deletions === "number" | |
| 2908 | ? { | |
| 2909 | additions: file.additions, | |
| 2910 | deletions: file.deletions, | |
| 2911 | } | |
| 2912 | : null; | |
| 2913 | if (!stat) { | |
| 2914 | return file.path; | |
| 2915 | } | |
| 2916 | return ( | |
| 2917 | <> | |
| 2918 | <span>{file.path}</span> | |
| 2919 | <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2920 | <span className="text-success">+{stat.additions}</span> | |
| 2921 | <span className="mx-0.5 text-muted-foreground/70"> | |
| 2922 | / | |
| 2923 | </span> | |
| 2924 | <span className="text-destructive"> | |
| 2925 | -{stat.deletions} | |
| 2926 | </span> | |
| 2927 | <span className="text-muted-foreground/70">)</span> | |
| 2928 | </> | |
| 2929 | ); | |
| 2930 | })()} | |
| 2931 | </button> | |
| 2932 | ))} | |
| 2933 | </div> | |
| 2934 | ) : ( | |
| 2935 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2936 | No changed files. | |
| 2937 | </p> | |
| 2938 | )} | |
| 2693 | <div className="flex flex-wrap gap-1.5"> | |
| 2694 | {checkpointFiles.map((file) => ( | |
| 2695 | <button | |
| 2696 | key={`${turnSummary.turnId}:${file.path}`} | |
| 2697 | type="button" | |
| 2698 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | |
| 2699 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2700 | > | |
| 2701 | {(() => { | |
| 2702 | const stat = | |
| 2703 | typeof file.additions === "number" && | |
| 2704 | typeof file.deletions === "number" | |
| 2705 | ? { | |
| 2706 | additions: file.additions, | |
| 2707 | deletions: file.deletions, | |
| 2708 | } | |
| 2709 | : null; | |
| 2710 | if (!stat) { | |
| 2711 | return file.path; | |
| 2712 | } | |
| 2713 | return ( | |
| 2714 | <> | |
| 2715 | <span>{file.path}</span> | |
| 2716 | <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2717 | <span className="text-success">+{stat.additions}</span> | |
| 2718 | <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 2719 | <span className="text-destructive"> | |
| 2720 | -{stat.deletions} | |
| 2721 | </span> | |
| 2722 | <span className="text-muted-foreground/70">)</span> | |
| 2723 | </> | |
| 2724 | ); | |
| 2725 | })()} | |
| 2726 | </button> | |
| 2727 | ))} | |
| 2728 | </div> | |
| 2939 | 2729 | </div> |
| 2940 | 2730 | ); |
| 2941 | 2731 | })()} |
| ... | ... | @@ -2944,10 +2734,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2944 | 2734 | row.message.createdAt, |
| 2945 | 2735 | row.message.streaming |
| 2946 | 2736 | ? formatElapsed(row.message.createdAt, nowIso) |
| 2947 | : formatElapsed( | |
| 2948 | row.message.createdAt, | |
| 2949 | assistantCompletionByItemId.get(row.message.id), | |
| 2950 | ), | |
| 2737 | : formatElapsed(row.message.createdAt, row.message.completedAt), | |
| 2951 | 2738 | )} |
| 2952 | 2739 | </p> |
| 2953 | 2740 | </div> |
| ... | ... | @@ -3028,7 +2815,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3028 | 2815 | activeThreadId, |
| 3029 | 2816 | }: { |
| 3030 | 2817 | keybindings: ResolvedKeybindingsConfig; |
| 3031 | activeThreadId: string | null; | |
| 2818 | activeThreadId: ThreadId | null; | |
| 3032 | 2819 | }) { |
| 3033 | 2820 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 3034 | 2821 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | ... | @@ -3053,13 +2840,13 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3053 | 2840 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 3054 | 2841 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 3055 | 2842 | |
| 3056 | const api = useNativeApi(); | |
| 3057 | 2843 | const { state } = useStore(); |
| 3058 | 2844 | const activeThread = state.threads.find((t) => t.id === activeThreadId); |
| 3059 | 2845 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 3060 | 2846 | |
| 3061 | 2847 | const openInEditor = useCallback( |
| 3062 | 2848 | (editorId: EditorId | null) => { |
| 2849 | const api = readNativeApi(); | |
| 3063 | 2850 | if (!api || !activeProject) return; |
| 3064 | 2851 | const editor = editorId ?? lastEditor; |
| 3065 | 2852 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; |
| ... | ... | @@ -3067,7 +2854,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3067 | 2854 | localStorage.setItem(LAST_EDITOR_KEY, editor); |
| 3068 | 2855 | setLastEditor(editor); |
| 3069 | 2856 | }, |
| 3070 | [api, activeProject, activeThread, lastEditor, setLastEditor], | |
| 2857 | [activeProject, activeThread, lastEditor, setLastEditor], | |
| 3071 | 2858 | ); |
| 3072 | 2859 | |
| 3073 | 2860 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | ... | @@ -3077,6 +2864,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3077 | 2864 | |
| 3078 | 2865 | useEffect(() => { |
| 3079 | 2866 | const handler = (e: globalThis.KeyboardEvent) => { |
| 2867 | const api = readNativeApi(); | |
| 3080 | 2868 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 3081 | 2869 | if (!api || !activeProject) return; |
| 3082 | 2870 | |
| ... | ... | @@ -3086,15 +2874,17 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3086 | 2874 | }; |
| 3087 | 2875 | window.addEventListener("keydown", handler); |
| 3088 | 2876 | return () => window.removeEventListener("keydown", handler); |
| 3089 | }, [api, activeProject, activeThread, keybindings, lastEditor]); | |
| 2877 | }, [activeProject, activeThread, keybindings, lastEditor]); | |
| 3090 | 2878 | |
| 3091 | 2879 | return ( |
| 3092 | 2880 | <Group aria-label="Subscription actions"> |
| 3093 | 2881 | <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}> |
| 3094 | 2882 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} |
| 3095 | <span className="ml-0.5">Open</span> | |
| 2883 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 2884 | Open | |
| 2885 | </span> | |
| 3096 | 2886 | </Button> |
| 3097 | <GroupSeparator /> | |
| 2887 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 3098 | 2888 | <Menu> |
| 3099 | 2889 | <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}> |
| 3100 | 2890 | <ChevronDownIcon aria-hidden="true" className="size-4" /> |
apps/web/src/components/DiffPanel.tsx+169-135| ... | ... | @@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; | |
| 5 | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 7 | 8 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 8 | 9 | import { cn } from "~/lib/utils"; |
| 9 | 10 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 10 | 11 | import { isElectron } from "../env"; |
| 11 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 12 | 12 | import { useTheme } from "../hooks/useTheme"; |
| 13 | 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 14 | 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -16,7 +16,11 @@ import { useStore } from "../store"; |
| 16 | 16 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 17 | 17 | |
| 18 | 18 | type DiffRenderMode = "stacked" | "split"; |
| 19 | type DiffThemeType = "light" | "dark" | "system"; | |
| 19 | type DiffThemeType = "light" | "dark"; | |
| 20 | ||
| 21 | function resolveDiffThemeName(theme: "light" | "dark") { | |
| 22 | return theme === "dark" ? "pierre-dark" : "pierre-light"; | |
| 23 | } | |
| 20 | 24 | |
| 21 | 25 | type RenderablePatch = |
| 22 | 26 | | { |
| ... | ... | @@ -29,13 +33,19 @@ type RenderablePatch = |
| 29 | 33 | reason: string; |
| 30 | 34 | }; |
| 31 | 35 | |
| 32 | function getRenderablePatch(patch: string | undefined): RenderablePatch | null { | |
| 36 | function getRenderablePatch( | |
| 37 | patch: string | undefined, | |
| 38 | cacheScope = "diff-panel", | |
| 39 | ): RenderablePatch | null { | |
| 33 | 40 | if (!patch) return null; |
| 34 | 41 | const normalizedPatch = patch.trim(); |
| 35 | 42 | if (normalizedPatch.length === 0) return null; |
| 36 | 43 | |
| 37 | 44 | try { |
| 38 | const parsedPatches = parsePatchFiles(normalizedPatch, buildPatchCacheKey(normalizedPatch)); | |
| 45 | const parsedPatches = parsePatchFiles( | |
| 46 | normalizedPatch, | |
| 47 | buildPatchCacheKey(normalizedPatch, cacheScope), | |
| 48 | ); | |
| 39 | 49 | const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); |
| 40 | 50 | if (files.length > 0) { |
| 41 | 51 | return { kind: "files", files }; |
| ... | ... | @@ -75,13 +85,12 @@ function formatTurnChipTimestamp(isoDate: string): string { |
| 75 | 85 | } |
| 76 | 86 | |
| 77 | 87 | interface DiffPanelProps { |
| 78 | mode?: "inline" | "sheet"; | |
| 88 | mode?: "inline" | "sheet" | "sidebar"; | |
| 79 | 89 | } |
| 80 | 90 | |
| 81 | 91 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 82 | 92 | |
| 83 | 93 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 84 | const api = useNativeApi(); | |
| 85 | 94 | const navigate = useNavigate(); |
| 86 | 95 | const { resolvedTheme } = useTheme(); |
| 87 | 96 | const { state } = useStore(); |
| ... | ... | @@ -90,28 +99,37 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 90 | 99 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 91 | 100 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 92 | 101 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 93 | const params = useParams({ strict: false }); | |
| 94 | const rawSearch = useSearch({ strict: false }); | |
| 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 96 | const diffSearch = useMemo( | |
| 97 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 98 | [rawSearch], | |
| 99 | ); | |
| 102 | const routeThreadId = useParams({ | |
| 103 | strict: false, | |
| 104 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 105 | }); | |
| 106 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | |
| 100 | 107 | const activeThreadId = routeThreadId; |
| 101 | 108 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); |
| 102 | const activeThreadRuntimeId = | |
| 103 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 104 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 105 | 109 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 106 | 110 | useTurnDiffSummaries(activeThread); |
| 111 | const orderedTurnDiffSummaries = useMemo( | |
| 112 | () => | |
| 113 | [...turnDiffSummaries].toSorted((left, right) => { | |
| 114 | const leftTurnCount = | |
| 115 | left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; | |
| 116 | const rightTurnCount = | |
| 117 | right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; | |
| 118 | if (leftTurnCount !== rightTurnCount) { | |
| 119 | return rightTurnCount - leftTurnCount; | |
| 120 | } | |
| 121 | return right.completedAt.localeCompare(left.completedAt); | |
| 122 | }), | |
| 123 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], | |
| 124 | ); | |
| 107 | 125 | |
| 108 | 126 | const selectedTurnId = diffSearch.diffTurnId ?? null; |
| 109 | 127 | const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; |
| 110 | 128 | const selectedTurn = |
| 111 | 129 | selectedTurnId === null |
| 112 | 130 | ? undefined |
| 113 | : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 114 | turnDiffSummaries[0]); | |
| 131 | : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 132 | orderedTurnDiffSummaries[0]); | |
| 115 | 133 | const selectedCheckpointTurnCount = |
| 116 | 134 | selectedTurn && |
| 117 | 135 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); |
| ... | ... | @@ -126,7 +144,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 126 | 144 | [selectedCheckpointTurnCount], |
| 127 | 145 | ); |
| 128 | 146 | const conversationCheckpointTurnCount = useMemo(() => { |
| 129 | const turnCounts = turnDiffSummaries | |
| 147 | const turnCounts = orderedTurnDiffSummaries | |
| 130 | 148 | .map( |
| 131 | 149 | (summary) => |
| 132 | 150 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], |
| ... | ... | @@ -137,7 +155,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 137 | 155 | } |
| 138 | 156 | const latest = Math.max(...turnCounts); |
| 139 | 157 | return latest > 0 ? latest : undefined; |
| 140 | }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 158 | }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); | |
| 141 | 159 | const conversationCheckpointRange = useMemo( |
| 142 | 160 | () => |
| 143 | 161 | !selectedTurn && typeof conversationCheckpointTurnCount === "number" |
| ... | ... | @@ -152,15 +170,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 152 | 170 | ? selectedCheckpointRange |
| 153 | 171 | : conversationCheckpointRange; |
| 154 | 172 | const conversationCacheScope = useMemo(() => { |
| 155 | if (selectedTurn || turnDiffSummaries.length === 0) { | |
| 173 | if (selectedTurn || orderedTurnDiffSummaries.length === 0) { | |
| 156 | 174 | return null; |
| 157 | 175 | } |
| 158 | return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | |
| 159 | }, [selectedTurn, turnDiffSummaries]); | |
| 176 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | |
| 177 | }, [orderedTurnDiffSummaries, selectedTurn]); | |
| 160 | 178 | const activeCheckpointDiffQuery = useQuery( |
| 161 | checkpointDiffQueryOptions(api, { | |
| 162 | sessionId: activeSessionId, | |
| 163 | threadRuntimeId: activeThreadRuntimeId, | |
| 179 | checkpointDiffQueryOptions({ | |
| 180 | threadId: activeThreadId, | |
| 164 | 181 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 165 | 182 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 166 | 183 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
| ... | ... | @@ -181,7 +198,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 181 | 198 | : null; |
| 182 | 199 | |
| 183 | 200 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; |
| 184 | const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); | |
| 201 | const hasResolvedPatch = typeof selectedPatch === "string"; | |
| 202 | const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; | |
| 203 | const renderablePatch = useMemo( | |
| 204 | () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), | |
| 205 | [resolvedTheme, selectedPatch], | |
| 206 | ); | |
| 185 | 207 | const renderableFiles = useMemo(() => { |
| 186 | 208 | if (!renderablePatch || renderablePatch.kind !== "files") { |
| 187 | 209 | return []; |
| ... | ... | @@ -204,7 +226,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 204 | 226 | target?.scrollIntoView({ block: "nearest" }); |
| 205 | 227 | }, [selectedFilePath, renderableFiles]); |
| 206 | 228 | |
| 207 | const selectTurn = (turnId: string) => { | |
| 229 | const selectTurn = (turnId: TurnId) => { | |
| 208 | 230 | if (!activeThread) return; |
| 209 | 231 | void navigate({ |
| 210 | 232 | to: "/$threadId", |
| ... | ... | @@ -259,7 +281,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 259 | 281 | |
| 260 | 282 | updateTurnStripScrollState(); |
| 261 | 283 | const onScroll = () => updateTurnStripScrollState(); |
| 262 | ||
| 284 | ||
| 263 | 285 | element.addEventListener("scroll", onScroll, { passive: true }); |
| 264 | 286 | element.addEventListener("wheel", onTurnStripWheel, { passive: false }); |
| 265 | 287 | |
| ... | ... | @@ -275,7 +297,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 275 | 297 | |
| 276 | 298 | useEffect(() => { |
| 277 | 299 | updateTurnStripScrollState(); |
| 278 | }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | |
| 300 | }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | |
| 279 | 301 | |
| 280 | 302 | useEffect(() => { |
| 281 | 303 | const element = turnStripRef.current; |
| ... | ... | @@ -285,135 +307,143 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 285 | 307 | selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); |
| 286 | 308 | }, [selectedTurn?.turnId, selectedTurnId]); |
| 287 | 309 | |
| 288 | const shouldUseDragRegion = isElectron && mode === "inline"; | |
| 289 | ||
| 290 | return ( | |
| 291 | <aside | |
| 292 | className={cn( | |
| 293 | "flex h-full min-w-0 flex-col bg-card", | |
| 294 | mode === "inline" | |
| 295 | ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border" | |
| 296 | : "w-full", | |
| 297 | )} | |
| 298 | > | |
| 299 | <div | |
| 300 | className={cn( | |
| 301 | "flex items-center justify-between gap-2 border-b border-border px-4", | |
| 302 | shouldUseDragRegion ? "drag-region h-[52px]" : "py-3", | |
| 310 | const shouldUseDragRegion = isElectron && mode !== "sheet"; | |
| 311 | const headerRow = ( | |
| 312 | <> | |
| 313 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> | |
| 314 | {canScrollTurnStripLeft && ( | |
| 315 | <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" /> | |
| 303 | 316 | )} |
| 304 | > | |
| 305 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> | |
| 306 | {canScrollTurnStripLeft && ( | |
| 307 | <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" /> | |
| 317 | {canScrollTurnStripRight && ( | |
| 318 | <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" /> | |
| 319 | )} | |
| 320 | <button | |
| 321 | type="button" | |
| 322 | className={cn( | |
| 323 | "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 324 | canScrollTurnStripLeft | |
| 325 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 326 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 308 | 327 | )} |
| 309 | {canScrollTurnStripRight && ( | |
| 310 | <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" /> | |
| 328 | onClick={() => scrollTurnStripBy(-180)} | |
| 329 | disabled={!canScrollTurnStripLeft} | |
| 330 | aria-label="Scroll turn list left" | |
| 331 | > | |
| 332 | <ChevronLeftIcon className="size-3.5" /> | |
| 333 | </button> | |
| 334 | <button | |
| 335 | type="button" | |
| 336 | className={cn( | |
| 337 | "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 338 | canScrollTurnStripRight | |
| 339 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 340 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 311 | 341 | )} |
| 342 | onClick={() => scrollTurnStripBy(180)} | |
| 343 | disabled={!canScrollTurnStripRight} | |
| 344 | aria-label="Scroll turn list right" | |
| 345 | > | |
| 346 | <ChevronRightIcon className="size-3.5" /> | |
| 347 | </button> | |
| 348 | <div ref={turnStripRef} className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"> | |
| 312 | 349 | <button |
| 313 | 350 | type="button" |
| 314 | className={cn( | |
| 315 | "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 316 | canScrollTurnStripLeft | |
| 317 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 318 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 319 | )} | |
| 320 | onClick={() => scrollTurnStripBy(-180)} | |
| 321 | disabled={!canScrollTurnStripLeft} | |
| 322 | aria-label="Scroll turn list left" | |
| 323 | > | |
| 324 | <ChevronLeftIcon className="size-3.5" /> | |
| 325 | </button> | |
| 326 | <button | |
| 327 | type="button" | |
| 328 | className={cn( | |
| 329 | "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 330 | canScrollTurnStripRight | |
| 331 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 332 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 333 | )} | |
| 334 | onClick={() => scrollTurnStripBy(180)} | |
| 335 | disabled={!canScrollTurnStripRight} | |
| 336 | aria-label="Scroll turn list right" | |
| 351 | className="shrink-0 rounded-md" | |
| 352 | onClick={selectWholeConversation} | |
| 353 | data-turn-chip-selected={selectedTurnId === null} | |
| 337 | 354 | > |
| 338 | <ChevronRightIcon className="size-3.5" /> | |
| 355 | <div | |
| 356 | className={cn( | |
| 357 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 358 | selectedTurnId === null | |
| 359 | ? "border-border bg-accent text-accent-foreground" | |
| 360 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 361 | )} | |
| 362 | > | |
| 363 | <div className="text-[10px] leading-tight font-medium">All turns</div> | |
| 364 | </div> | |
| 339 | 365 | </button> |
| 340 | <div | |
| 341 | ref={turnStripRef} | |
| 342 | className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5" | |
| 343 | > | |
| 366 | {orderedTurnDiffSummaries.map((summary) => ( | |
| 344 | 367 | <button |
| 368 | key={summary.turnId} | |
| 345 | 369 | type="button" |
| 346 | 370 | className="shrink-0 rounded-md" |
| 347 | onClick={selectWholeConversation} | |
| 348 | data-turn-chip-selected={selectedTurnId === null} | |
| 371 | onClick={() => selectTurn(summary.turnId)} | |
| 372 | title={summary.turnId} | |
| 373 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 349 | 374 | > |
| 350 | 375 | <div |
| 351 | 376 | className={cn( |
| 352 | 377 | "rounded-md border px-2 py-1 text-left transition-colors", |
| 353 | selectedTurnId === null | |
| 378 | summary.turnId === selectedTurn?.turnId | |
| 354 | 379 | ? "border-border bg-accent text-accent-foreground" |
| 355 | 380 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", |
| 356 | 381 | )} |
| 357 | 382 | > |
| 358 | <div className="text-[10px] leading-tight font-medium">All turns</div> | |
| 383 | <div className="flex items-center gap-1"> | |
| 384 | <span className="text-[10px] leading-tight font-medium"> | |
| 385 | Turn{" "} | |
| 386 | {summary.checkpointTurnCount ?? | |
| 387 | inferredCheckpointTurnCountByTurnId[summary.turnId] ?? | |
| 388 | "?"} | |
| 389 | </span> | |
| 390 | <span className="text-[9px] leading-tight opacity-70"> | |
| 391 | {formatTurnChipTimestamp(summary.completedAt)} | |
| 392 | </span> | |
| 393 | </div> | |
| 359 | 394 | </div> |
| 360 | 395 | </button> |
| 361 | {turnDiffSummaries.map((summary, index) => ( | |
| 362 | <button | |
| 363 | key={summary.turnId} | |
| 364 | type="button" | |
| 365 | className="shrink-0 rounded-md" | |
| 366 | onClick={() => selectTurn(summary.turnId)} | |
| 367 | title={summary.turnId} | |
| 368 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 369 | > | |
| 370 | <div | |
| 371 | className={cn( | |
| 372 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 373 | summary.turnId === selectedTurn?.turnId | |
| 374 | ? "border-border bg-accent text-accent-foreground" | |
| 375 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 376 | )} | |
| 377 | > | |
| 378 | <div className="flex items-center gap-1"> | |
| 379 | <span className="text-[10px] leading-tight font-medium"> | |
| 380 | Turn {turnDiffSummaries.length - index} | |
| 381 | </span> | |
| 382 | <span className="text-[9px] leading-tight opacity-70"> | |
| 383 | {formatTurnChipTimestamp(summary.completedAt)} | |
| 384 | </span> | |
| 385 | </div> | |
| 386 | </div> | |
| 387 | </button> | |
| 388 | ))} | |
| 389 | </div> | |
| 396 | ))} | |
| 390 | 397 | </div> |
| 391 | <ToggleGroup | |
| 392 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 393 | variant="outline" | |
| 394 | size="xs" | |
| 395 | value={[diffRenderMode]} | |
| 396 | onValueChange={(value) => { | |
| 397 | const next = value[0]; | |
| 398 | if (next === "stacked" || next === "split") { | |
| 399 | setDiffRenderMode(next); | |
| 400 | } | |
| 401 | }} | |
| 402 | > | |
| 403 | <Toggle aria-label="Stacked diff view" value="stacked"> | |
| 404 | <Rows3Icon className="size-3" /> | |
| 405 | </Toggle> | |
| 406 | <Toggle aria-label="Split diff view" value="split"> | |
| 407 | <Columns2Icon className="size-3" /> | |
| 408 | </Toggle> | |
| 409 | </ToggleGroup> | |
| 410 | 398 | </div> |
| 399 | <ToggleGroup | |
| 400 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 401 | variant="outline" | |
| 402 | size="xs" | |
| 403 | value={[diffRenderMode]} | |
| 404 | onValueChange={(value) => { | |
| 405 | const next = value[0]; | |
| 406 | if (next === "stacked" || next === "split") { | |
| 407 | setDiffRenderMode(next); | |
| 408 | } | |
| 409 | }} | |
| 410 | > | |
| 411 | <Toggle aria-label="Stacked diff view" value="stacked"> | |
| 412 | <Rows3Icon className="size-3" /> | |
| 413 | </Toggle> | |
| 414 | <Toggle aria-label="Split diff view" value="split"> | |
| 415 | <Columns2Icon className="size-3" /> | |
| 416 | </Toggle> | |
| 417 | </ToggleGroup> | |
| 418 | </> | |
| 419 | ); | |
| 420 | const headerRowClassName = cn( | |
| 421 | "flex items-center justify-between gap-2 px-4", | |
| 422 | shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", | |
| 423 | ); | |
| 424 | ||
| 425 | return ( | |
| 426 | <div | |
| 427 | className={cn( | |
| 428 | "flex h-full min-w-0 flex-col bg-card", | |
| 429 | mode === "inline" | |
| 430 | ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border" | |
| 431 | : "w-full", | |
| 432 | )} | |
| 433 | > | |
| 434 | {shouldUseDragRegion ? ( | |
| 435 | <div className={headerRowClassName}>{headerRow}</div> | |
| 436 | ) : ( | |
| 437 | <div className="border-b border-border"> | |
| 438 | <div className={headerRowClassName}>{headerRow}</div> | |
| 439 | </div> | |
| 440 | )} | |
| 411 | 441 | |
| 412 | 442 | {!activeThread ? ( |
| 413 | 443 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 414 | 444 | Select a thread to inspect turn diffs. |
| 415 | 445 | </div> |
| 416 | ) : turnDiffSummaries.length === 0 ? ( | |
| 446 | ) : orderedTurnDiffSummaries.length === 0 ? ( | |
| 417 | 447 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 418 | 448 | No completed turns yet. |
| 419 | 449 | </div> |
| ... | ... | @@ -430,7 +460,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 430 | 460 | <p> |
| 431 | 461 | {isLoadingCheckpointDiff |
| 432 | 462 | ? "Loading checkpoint diff..." |
| 433 | : "No patch available for this selection."} | |
| 463 | : hasNoNetChanges | |
| 464 | ? "No net changes in this selection." | |
| 465 | : "No patch available for this selection."} | |
| 434 | 466 | </p> |
| 435 | 467 | </div> |
| 436 | 468 | ) : renderablePatch.kind === "files" ? ( |
| ... | ... | @@ -444,13 +476,15 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 444 | 476 | {renderableFiles.map((fileDiff) => { |
| 445 | 477 | const filePath = resolveFileDiffPath(fileDiff); |
| 446 | 478 | const fileKey = buildFileDiffRenderKey(fileDiff); |
| 479 | const themedFileKey = `${fileKey}:${resolvedTheme}`; | |
| 447 | 480 | return ( |
| 448 | <div key={fileKey} data-diff-file-path={filePath} className="rounded-md"> | |
| 481 | <div key={themedFileKey} data-diff-file-path={filePath} className="rounded-md"> | |
| 449 | 482 | <FileDiff |
| 450 | 483 | fileDiff={fileDiff} |
| 451 | 484 | options={{ |
| 452 | 485 | diffStyle: diffRenderMode === "split" ? "split" : "unified", |
| 453 | 486 | lineDiffType: "none", |
| 487 | theme: resolveDiffThemeName(resolvedTheme), | |
| 454 | 488 | themeType: resolvedTheme as DiffThemeType, |
| 455 | 489 | }} |
| 456 | 490 | /> |
| ... | ... | @@ -471,6 +505,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 471 | 505 | </div> |
| 472 | 506 | </> |
| 473 | 507 | )} |
| 474 | </aside> | |
| 508 | </div> | |
| 475 | 509 | ); |
| 476 | 510 | } |
apps/web/src/components/DiffWorkerPoolProvider.tsx+39-6| ... | ... | @@ -1,26 +1,59 @@ |
| 1 | import { WorkerPoolContextProvider } from "@pierre/diffs/react"; | |
| 2 | import { useMemo, type ReactNode } from "react"; | |
| 1 | import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; | |
| 2 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; | |
| 3 | import { useEffect, useMemo, type ReactNode } from "react"; | |
| 4 | import { useTheme } from "../hooks/useTheme"; | |
| 5 | ||
| 6 | function resolveDiffThemeName(theme: "light" | "dark") { | |
| 7 | return theme === "dark" ? "pierre-dark" : "pierre-light"; | |
| 8 | } | |
| 9 | ||
| 10 | function DiffWorkerThemeSync({ themeName }: { themeName: "pierre-light" | "pierre-dark" }) { | |
| 11 | const workerPool = useWorkerPool(); | |
| 12 | ||
| 13 | useEffect(() => { | |
| 14 | if (!workerPool) { | |
| 15 | return; | |
| 16 | } | |
| 17 | ||
| 18 | const current = workerPool.getDiffRenderOptions(); | |
| 19 | if (current.theme === themeName) { | |
| 20 | return; | |
| 21 | } | |
| 22 | ||
| 23 | void workerPool | |
| 24 | .setRenderOptions({ | |
| 25 | ...current, | |
| 26 | theme: themeName, | |
| 27 | }) | |
| 28 | .catch(() => undefined); | |
| 29 | }, [themeName, workerPool]); | |
| 30 | ||
| 31 | return null; | |
| 32 | } | |
| 3 | 33 | |
| 4 | 34 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { |
| 35 | const { resolvedTheme } = useTheme(); | |
| 36 | const diffThemeName = resolveDiffThemeName(resolvedTheme); | |
| 5 | 37 | const workerPoolSize = useMemo(() => { |
| 6 | const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | |
| 38 | const cores = | |
| 39 | typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | |
| 7 | 40 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); |
| 8 | 41 | }, []); |
| 9 | 42 | |
| 10 | 43 | return ( |
| 11 | 44 | <WorkerPoolContextProvider |
| 12 | 45 | poolOptions={{ |
| 13 | workerFactory: () => | |
| 14 | new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), | |
| 46 | workerFactory: () => new DiffsWorker(), | |
| 15 | 47 | poolSize: workerPoolSize, |
| 16 | 48 | totalASTLRUCacheSize: 240, |
| 17 | 49 | }} |
| 18 | 50 | highlighterOptions={{ |
| 51 | theme: diffThemeName, | |
| 19 | 52 | tokenizeMaxLineLength: 1_000, |
| 20 | 53 | }} |
| 21 | 54 | > |
| 55 | <DiffWorkerThemeSync themeName={diffThemeName} /> | |
| 22 | 56 | {children} |
| 23 | 57 | </WorkerPoolContextProvider> |
| 24 | 58 | ); |
| 25 | 59 | } |
| 26 |
apps/web/src/components/GitActionsControl.tsx+32-26| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; | |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 1 | import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts"; | |
| 2 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 5 | 5 | import { GitHubIcon } from "./Icons"; |
| ... | ... | @@ -33,17 +33,18 @@ import { toastManager } from "~/components/ui/toast"; |
| 33 | 33 | import { |
| 34 | 34 | gitBranchesQueryOptions, |
| 35 | 35 | gitInitMutationOptions, |
| 36 | gitMutationKeys, | |
| 36 | 37 | gitPullMutationOptions, |
| 37 | 38 | gitRunStackedActionMutationOptions, |
| 38 | 39 | gitStatusQueryOptions, |
| 39 | 40 | invalidateGitQueries, |
| 40 | 41 | } from "~/lib/gitReactQuery"; |
| 41 | 42 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; |
| 43 | import { readNativeApi } from "~/nativeApi"; | |
| 42 | 44 | |
| 43 | 45 | interface GitActionsControlProps { |
| 44 | api: NativeApi | undefined; | |
| 45 | 46 | gitCwd: string | null; |
| 46 | activeThreadId: string | null; | |
| 47 | activeThreadId: ThreadId | null; | |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | 50 | function getMenuActionDisabledReason( |
| ... | ... | @@ -136,11 +137,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 136 | 137 | return <InfoIcon className={iconClassName} />; |
| 137 | 138 | } |
| 138 | 139 | |
| 139 | export default function GitActionsControl({ | |
| 140 | api, | |
| 141 | gitCwd, | |
| 142 | activeThreadId, | |
| 143 | }: GitActionsControlProps) { | |
| 140 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { | |
| 144 | 141 | const threadToastData = useMemo( |
| 145 | 142 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 146 | 143 | [activeThreadId], |
| ... | ... | @@ -149,11 +146,9 @@ export default function GitActionsControl({ |
| 149 | 146 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); |
| 150 | 147 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 151 | 148 | |
| 152 | const { data: gitStatus = null, error: gitStatusError } = useQuery( | |
| 153 | gitStatusQueryOptions(api, gitCwd), | |
| 154 | ); | |
| 149 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | |
| 155 | 150 | |
| 156 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); | |
| 151 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 157 | 152 | // Default to true while loading so we don't flash init controls. |
| 158 | 153 | const isRepo = branchList?.isRepo ?? true; |
| 159 | 154 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; |
| ... | ... | @@ -167,14 +162,17 @@ export default function GitActionsControl({ |
| 167 | 162 | |
| 168 | 163 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 169 | 164 | |
| 170 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 165 | const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); | |
| 171 | 166 | |
| 172 | 167 | const runImmediateGitActionMutation = useMutation( |
| 173 | gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), | |
| 168 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), | |
| 174 | 169 | ); |
| 175 | const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); | |
| 170 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | |
| 176 | 171 | |
| 177 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; | |
| 172 | const isRunStackedActionRunning = | |
| 173 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | |
| 174 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | |
| 175 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | |
| 178 | 176 | const isDefaultBranch = useMemo(() => { |
| 179 | 177 | const branchName = gitStatusForActions?.branch; |
| 180 | 178 | if (!branchName) return false; |
| ... | ... | @@ -196,6 +194,7 @@ export default function GitActionsControl({ |
| 196 | 194 | |
| 197 | 195 | const maybeConfirmPushToDefaultBranch = useCallback( |
| 198 | 196 | async (action: GitStackedAction): Promise<boolean> => { |
| 197 | const api = readNativeApi(); | |
| 199 | 198 | if (!api) return false; |
| 200 | 199 | if ( |
| 201 | 200 | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || |
| ... | ... | @@ -207,10 +206,11 @@ export default function GitActionsControl({ |
| 207 | 206 | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, |
| 208 | 207 | ); |
| 209 | 208 | }, |
| 210 | [api, gitStatusForActions?.branch, isDefaultBranch], | |
| 209 | [gitStatusForActions?.branch, isDefaultBranch], | |
| 211 | 210 | ); |
| 212 | 211 | |
| 213 | 212 | const openExistingPr = useCallback(async () => { |
| 213 | const api = readNativeApi(); | |
| 214 | 214 | if (!api) { |
| 215 | 215 | toastManager.add({ |
| 216 | 216 | type: "error", |
| ... | ... | @@ -236,8 +236,7 @@ export default function GitActionsControl({ |
| 236 | 236 | data: threadToastData, |
| 237 | 237 | }); |
| 238 | 238 | }); |
| 239 | }, [api, gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | |
| 240 | ||
| 239 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | |
| 241 | 240 | const runGitActionWithToast = useCallback( |
| 242 | 241 | async ({ |
| 243 | 242 | action, |
| ... | ... | @@ -335,8 +334,10 @@ export default function GitActionsControl({ |
| 335 | 334 | actionProps: { |
| 336 | 335 | children: "Open PR", |
| 337 | 336 | onClick: () => { |
| 337 | const api = readNativeApi(); | |
| 338 | if (!api) return; | |
| 338 | 339 | closeResultToast(); |
| 339 | void api?.shell.openExternal(prUrl); | |
| 340 | void api.shell.openExternal(prUrl); | |
| 340 | 341 | }, |
| 341 | 342 | }, |
| 342 | 343 | } |
| ... | ... | @@ -362,8 +363,8 @@ export default function GitActionsControl({ |
| 362 | 363 | }); |
| 363 | 364 | } |
| 364 | 365 | }, |
| 366 | ||
| 365 | 367 | [ |
| 366 | api, | |
| 367 | 368 | gitStatusForActions?.branch, |
| 368 | 369 | gitStatusForActions?.hasWorkingTreeChanges, |
| 369 | 370 | gitStatusForActions?.pr, |
| ... | ... | @@ -453,6 +454,7 @@ export default function GitActionsControl({ |
| 453 | 454 | |
| 454 | 455 | const openChangedFileInEditor = useCallback( |
| 455 | 456 | (filePath: string) => { |
| 457 | const api = readNativeApi(); | |
| 456 | 458 | if (!api || !gitCwd) { |
| 457 | 459 | toastManager.add({ |
| 458 | 460 | type: "error", |
| ... | ... | @@ -471,7 +473,7 @@ export default function GitActionsControl({ |
| 471 | 473 | }); |
| 472 | 474 | }); |
| 473 | 475 | }, |
| 474 | [api, gitCwd, threadToastData], | |
| 476 | [gitCwd, threadToastData], | |
| 475 | 477 | ); |
| 476 | 478 | |
| 477 | 479 | if (!gitCwd) return null; |
| ... | ... | @@ -503,7 +505,9 @@ export default function GitActionsControl({ |
| 503 | 505 | } |
| 504 | 506 | > |
| 505 | 507 | <GitQuickActionIcon quickAction={quickAction} /> |
| 506 | <span className="ml-0.5">{quickAction.label}</span> | |
| 508 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 509 | {quickAction.label} | |
| 510 | </span> | |
| 507 | 511 | </PopoverTrigger> |
| 508 | 512 | <PopoverPopup tooltipStyle side="bottom" align="start"> |
| 509 | 513 | {quickActionDisabledReason} |
| ... | ... | @@ -517,10 +521,12 @@ export default function GitActionsControl({ |
| 517 | 521 | onClick={runQuickAction} |
| 518 | 522 | > |
| 519 | 523 | <GitQuickActionIcon quickAction={quickAction} /> |
| 520 | <span className="ml-0.5">{quickAction.label}</span> | |
| 524 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 525 | {quickAction.label} | |
| 526 | </span> | |
| 521 | 527 | </Button> |
| 522 | 528 | )} |
| 523 | <GroupSeparator /> | |
| 529 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 524 | 530 | <Menu |
| 525 | 531 | onOpenChange={(open) => { |
| 526 | 532 | if (open) void invalidateGitQueries(queryClient); |
apps/web/src/components/ProjectScriptsControl.tsx+28-32| ... | ... | @@ -16,7 +16,15 @@ import { |
| 16 | 16 | } from "lucide-react"; |
| 17 | 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; |
| 18 | 18 | |
| 19 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; | |
| 19 | import { | |
| 20 | keybindingValueForCommand, | |
| 21 | decodeProjectScriptKeybindingRule, | |
| 22 | } from "~/lib/projectScriptKeybindings"; | |
| 23 | import { | |
| 24 | commandForProjectScript, | |
| 25 | nextProjectScriptId, | |
| 26 | primaryProjectScript, | |
| 27 | } from "~/projectScripts"; | |
| 20 | 28 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 21 | 29 | import { isMacPlatform } from "~/lib/utils"; |
| 22 | 30 | import { Button } from "./ui/button"; |
| ... | ... | @@ -129,32 +137,6 @@ function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | n |
| 129 | 137 | return parts.join("+"); |
| 130 | 138 | } |
| 131 | 139 | |
| 132 | function keybindingValueForCommand( | |
| 133 | keybindings: ResolvedKeybindingsConfig, | |
| 134 | command: string, | |
| 135 | ): string | null { | |
| 136 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 137 | const binding = keybindings[index]; | |
| 138 | if (!binding || binding.command !== command) continue; | |
| 139 | ||
| 140 | const parts: string[] = []; | |
| 141 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 142 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 143 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 144 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 145 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 146 | const keyToken = | |
| 147 | binding.shortcut.key === " " | |
| 148 | ? "space" | |
| 149 | : binding.shortcut.key === "escape" | |
| 150 | ? "esc" | |
| 151 | : binding.shortcut.key; | |
| 152 | parts.push(keyToken); | |
| 153 | return parts.join("+"); | |
| 154 | } | |
| 155 | return null; | |
| 156 | } | |
| 157 | ||
| 158 | 140 | export default function ProjectScriptsControl({ |
| 159 | 141 | scripts, |
| 160 | 142 | keybindings, |
| ... | ... | @@ -212,12 +194,22 @@ export default function ProjectScriptsControl({ |
| 212 | 194 | |
| 213 | 195 | setValidationError(null); |
| 214 | 196 | try { |
| 197 | const scriptIdForValidation = | |
| 198 | editingScriptId ?? | |
| 199 | nextProjectScriptId( | |
| 200 | trimmedName, | |
| 201 | scripts.map((script) => script.id), | |
| 202 | ); | |
| 203 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 204 | keybinding, | |
| 205 | command: commandForProjectScript(scriptIdForValidation), | |
| 206 | }); | |
| 215 | 207 | const payload = { |
| 216 | 208 | name: trimmedName, |
| 217 | 209 | command: trimmedCommand, |
| 218 | 210 | icon, |
| 219 | 211 | runOnWorktreeCreate, |
| 220 | keybinding: keybinding.trim() || null, | |
| 212 | keybinding: keybindingRule?.key ?? null, | |
| 221 | 213 | } satisfies NewProjectScriptInput; |
| 222 | 214 | if (editingScriptId) { |
| 223 | 215 | await onUpdateScript(editingScriptId, payload); |
| ... | ... | @@ -266,9 +258,11 @@ export default function ProjectScriptsControl({ |
| 266 | 258 | title={`Run ${primaryScript.name}`} |
| 267 | 259 | > |
| 268 | 260 | <ScriptIcon icon={primaryScript.icon} /> |
| 269 | <span className="ml-0.5">{primaryScript.name}</span> | |
| 261 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 262 | {primaryScript.name} | |
| 263 | </span> | |
| 270 | 264 | </Button> |
| 271 | <GroupSeparator /> | |
| 265 | <GroupSeparator className="hidden @sm/header-actions:block" /> | |
| 272 | 266 | <Menu highlightItemOnHover={false}> |
| 273 | 267 | <MenuTrigger |
| 274 | 268 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| ... | ... | @@ -327,9 +321,11 @@ export default function ProjectScriptsControl({ |
| 327 | 321 | </Menu> |
| 328 | 322 | </Group> |
| 329 | 323 | ) : ( |
| 330 | <Button size="xs" variant="outline" onClick={openAddDialog}> | |
| 324 | <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action"> | |
| 331 | 325 | <PlusIcon className="size-3.5" /> |
| 332 | Add action | |
| 326 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | |
| 327 | Add action | |
| 328 | </span> | |
| 333 | 329 | </Button> |
| 334 | 330 | )} |
| 335 | 331 |
apps/web/src/components/Sidebar.tsx+484-370| ... | ... | @@ -1,29 +1,43 @@ |
| 1 | import { | |
| 2 | GitPullRequestIcon, | |
| 3 | MonitorIcon, | |
| 4 | MoonIcon, | |
| 5 | SunIcon, | |
| 6 | TerminalIcon, | |
| 7 | } from "lucide-react"; | |
| 1 | import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; | |
| 8 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 9 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | DEFAULT_MODEL, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | type GitStatusResult, | |
| 8 | type ResolvedKeybindingsConfig, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 11 | import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; | |
| 12 | 11 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 13 | 12 | import { useAppSettings } from "../appSettings"; |
| 14 | 13 | import { isElectron } from "../env"; |
| 15 | 14 | import { APP_STAGE_LABEL } from "../branding"; |
| 16 | import { DEFAULT_MODEL } from "../model-logic"; | |
| 17 | import { derivePendingApprovals } from "../session-logic"; | |
| 15 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | |
| 18 | 16 | import { useStore } from "../store"; |
| 19 | 17 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 20 | import { type Project, type Thread } from "../types"; | |
| 21 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 18 | import { type Thread } from "../types"; | |
| 19 | import { derivePendingApprovals } from "../session-logic"; | |
| 22 | 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 23 | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | |
| 24 | 23 | import { toastManager } from "./ui/toast"; |
| 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | |
| 25 | import { | |
| 26 | SidebarContent, | |
| 27 | SidebarFooter, | |
| 28 | SidebarGroup, | |
| 29 | SidebarHeader, | |
| 30 | SidebarMenu, | |
| 31 | SidebarMenuButton, | |
| 32 | SidebarMenuItem, | |
| 33 | SidebarMenuSub, | |
| 34 | SidebarMenuSubButton, | |
| 35 | SidebarMenuSubItem, | |
| 36 | SidebarSeparator, | |
| 37 | SidebarTrigger, | |
| 38 | } from "./ui/sidebar"; | |
| 25 | 39 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 26 | import { createThread } from "../threadFactory"; | |
| 40 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | |
| 27 | 41 | |
| 28 | 42 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 29 | 43 | |
| ... | ... | @@ -37,15 +51,8 @@ function formatRelativeTime(iso: string): string { |
| 37 | 51 | return `${Math.floor(hours / 24)}d ago`; |
| 38 | 52 | } |
| 39 | 53 | |
| 40 | function inferProjectName(cwd: string): string { | |
| 41 | const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 42 | const parts = normalized.split("/"); | |
| 43 | const last = parts[parts.length - 1]; | |
| 44 | return last && last.length > 0 ? last : "project"; | |
| 45 | } | |
| 46 | ||
| 47 | 54 | interface ThreadStatusPill { |
| 48 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | |
| 55 | label: "Working" | "Connecting" | "Completed" | "Pending Approval"; | |
| 49 | 56 | colorClass: string; |
| 50 | 57 | dotClass: string; |
| 51 | 58 | pulse: boolean; |
| ... | ... | @@ -65,8 +72,8 @@ interface PrStatusIndicator { |
| 65 | 72 | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; |
| 66 | 73 | |
| 67 | 74 | function hasUnseenCompletion(thread: Thread): boolean { |
| 68 | if (!thread.latestTurnCompletedAt) return false; | |
| 69 | const completedAt = Date.parse(thread.latestTurnCompletedAt); | |
| 75 | if (!thread.latestTurn?.completedAt) return false; | |
| 76 | const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 70 | 77 | if (Number.isNaN(completedAt)) return false; |
| 71 | 78 | if (!thread.lastVisitedAt) return true; |
| 72 | 79 | |
| ... | ... | @@ -75,13 +82,10 @@ function hasUnseenCompletion(thread: Thread): boolean { |
| 75 | 82 | return completedAt > lastVisitedAt; |
| 76 | 83 | } |
| 77 | 84 | |
| 78 | function threadStatusPill( | |
| 79 | thread: Thread, | |
| 80 | hasPendingApprovals: boolean, | |
| 81 | ): ThreadStatusPill | null { | |
| 85 | function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { | |
| 82 | 86 | if (hasPendingApprovals) { |
| 83 | 87 | return { |
| 84 | label: "Awaiting response", | |
| 88 | label: "Pending Approval", | |
| 85 | 89 | colorClass: "text-amber-600 dark:text-amber-300/90", |
| 86 | 90 | dotClass: "bg-amber-500 dark:bg-amber-300/90", |
| 87 | 91 | pulse: false, |
| ... | ... | @@ -118,6 +122,17 @@ function threadStatusPill( |
| 118 | 122 | return null; |
| 119 | 123 | } |
| 120 | 124 | |
| 125 | function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { | |
| 126 | if (thread.runningTerminalIds.length === 0) { | |
| 127 | return null; | |
| 128 | } | |
| 129 | return { | |
| 130 | label: "Terminal process running", | |
| 131 | colorClass: "text-teal-600 dark:text-teal-300/90", | |
| 132 | pulse: true, | |
| 133 | }; | |
| 134 | } | |
| 135 | ||
| 121 | 136 | function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { |
| 122 | 137 | if (prState === "open") { |
| 123 | 138 | return { |
| ... | ... | @@ -140,40 +155,88 @@ function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { |
| 140 | 155 | return null; |
| 141 | 156 | } |
| 142 | 157 | |
| 143 | function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { | |
| 144 | if (thread.runningTerminalIds.length === 0) { | |
| 145 | return null; | |
| 158 | function T3Wordmark() { | |
| 159 | return ( | |
| 160 | <svg | |
| 161 | aria-label="T3" | |
| 162 | className="h-3 w-auto shrink-0 text-foreground" | |
| 163 | viewBox="15.5309 37 94.3941 56.96" | |
| 164 | xmlns="http://www.w3.org/2000/svg" | |
| 165 | > | |
| 166 | <path | |
| 167 | d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" | |
| 168 | fill="currentColor" | |
| 169 | /> | |
| 170 | </svg> | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Derives the server's HTTP origin (scheme + host + port) from the same | |
| 176 | * sources WsTransport uses, converting ws(s) to http(s). | |
| 177 | */ | |
| 178 | function getServerHttpOrigin(): string { | |
| 179 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | |
| 180 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 181 | const wsUrl = | |
| 182 | bridgeUrl && bridgeUrl.length > 0 | |
| 183 | ? bridgeUrl | |
| 184 | : envUrl && envUrl.length > 0 | |
| 185 | ? envUrl | |
| 186 | : `ws://${window.location.hostname}:${window.location.port}`; | |
| 187 | // Parse to extract just the origin, dropping path/query (e.g. ?token=…) | |
| 188 | const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); | |
| 189 | try { | |
| 190 | return new URL(httpUrl).origin; | |
| 191 | } catch { | |
| 192 | return httpUrl; | |
| 146 | 193 | } |
| 147 | return { | |
| 148 | label: "Terminal process running", | |
| 149 | colorClass: "text-teal-600 dark:text-teal-300/90", | |
| 150 | pulse: true, | |
| 151 | }; | |
| 194 | } | |
| 195 | ||
| 196 | const serverHttpOrigin = getServerHttpOrigin(); | |
| 197 | ||
| 198 | function ProjectFavicon({ cwd }: { cwd: string }) { | |
| 199 | const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); | |
| 200 | ||
| 201 | const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; | |
| 202 | ||
| 203 | if (status === "error") { | |
| 204 | return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />; | |
| 205 | } | |
| 206 | ||
| 207 | return ( | |
| 208 | <img | |
| 209 | src={src} | |
| 210 | alt="" | |
| 211 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`} | |
| 212 | onLoad={() => setStatus("loaded")} | |
| 213 | onError={() => setStatus("error")} | |
| 214 | /> | |
| 215 | ); | |
| 152 | 216 | } |
| 153 | 217 | |
| 154 | 218 | export default function Sidebar() { |
| 155 | 219 | const { state, dispatch } = useStore(); |
| 156 | const api = useNativeApi(); | |
| 157 | 220 | const navigate = useNavigate(); |
| 158 | 221 | const { settings: appSettings } = useAppSettings(); |
| 159 | const params = useParams({ strict: false }); | |
| 160 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 222 | const routeThreadId = useParams({ | |
| 223 | strict: false, | |
| 224 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | |
| 225 | }); | |
| 161 | 226 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| 162 | ...serverConfigQueryOptions(api), | |
| 227 | ...serverConfigQueryOptions(), | |
| 163 | 228 | select: (config) => config.keybindings, |
| 164 | 229 | }); |
| 165 | 230 | const queryClient = useQueryClient(); |
| 166 | const removeWorktreeMutation = useMutation( | |
| 167 | gitRemoveWorktreeMutationOptions({ api, queryClient }), | |
| 168 | ); | |
| 231 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | |
| 169 | 232 | const [addingProject, setAddingProject] = useState(false); |
| 170 | 233 | const [newCwd, setNewCwd] = useState(""); |
| 171 | 234 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 172 | 235 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 173 | 236 | const pendingApprovalByThreadId = useMemo(() => { |
| 174 | const map = new Map<string, boolean>(); | |
| 237 | const map = new Map<ThreadId, boolean>(); | |
| 175 | 238 | for (const thread of state.threads) { |
| 176 | map.set(thread.id, derivePendingApprovals(thread.events).length > 0); | |
| 239 | map.set(thread.id, derivePendingApprovals(thread.activities).length > 0); | |
| 177 | 240 | } |
| 178 | 241 | return map; |
| 179 | 242 | }, [state.threads]); |
| ... | ... | @@ -203,28 +266,24 @@ export default function Sidebar() { |
| 203 | 266 | [threadGitTargets], |
| 204 | 267 | ); |
| 205 | 268 | const threadGitStatusQueries = useQueries({ |
| 206 | queries: threadGitStatusCwds.map((cwd) => { | |
| 207 | const base = gitStatusQueryOptions(api, cwd); | |
| 208 | return { | |
| 209 | ...base, | |
| 210 | enabled: Boolean(base.enabled), | |
| 211 | staleTime: 30_000, | |
| 212 | refetchInterval: 60_000, | |
| 213 | }; | |
| 214 | }), | |
| 269 | queries: threadGitStatusCwds.map((cwd) => ({ | |
| 270 | ...gitStatusQueryOptions(cwd), | |
| 271 | staleTime: 30_000, | |
| 272 | refetchInterval: 60_000, | |
| 273 | })), | |
| 215 | 274 | }); |
| 216 | 275 | const prStateByThreadId = useMemo(() => { |
| 217 | 276 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 218 | 277 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 219 | 278 | const cwd = threadGitStatusCwds[index]; |
| 220 | 279 | if (!cwd) continue; |
| 221 | const parsed = gitStatusResultSchema.safeParse(threadGitStatusQueries[index]?.data); | |
| 222 | if (parsed.success) { | |
| 223 | statusByCwd.set(cwd, parsed.data); | |
| 280 | const status = threadGitStatusQueries[index]?.data; | |
| 281 | if (status) { | |
| 282 | statusByCwd.set(cwd, status); | |
| 224 | 283 | } |
| 225 | 284 | } |
| 226 | 285 | |
| 227 | const map = new Map<string, ThreadPrState>(); | |
| 286 | const map = new Map<ThreadId, ThreadPrState>(); | |
| 228 | 287 | for (const target of threadGitTargets) { |
| 229 | 288 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; |
| 230 | 289 | const branchMatches = |
| ... | ... | @@ -236,31 +295,50 @@ export default function Sidebar() { |
| 236 | 295 | |
| 237 | 296 | const handleNewThread = useCallback( |
| 238 | 297 | ( |
| 239 | projectId: string, | |
| 298 | projectId: ProjectId, | |
| 240 | 299 | options?: { |
| 241 | 300 | branch?: string | null; |
| 242 | 301 | worktreePath?: string | null; |
| 243 | 302 | }, |
| 244 | ) => { | |
| 245 | const thread = createThread(projectId, { | |
| 246 | model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, | |
| 247 | branch: options?.branch ?? null, | |
| 248 | worktreePath: options?.worktreePath ?? null, | |
| 249 | }); | |
| 250 | dispatch({ | |
| 251 | type: "ADD_THREAD", | |
| 252 | thread, | |
| 253 | }); | |
| 254 | void navigate({ | |
| 255 | to: "/$threadId", | |
| 256 | params: { threadId: thread.id }, | |
| 257 | }); | |
| 303 | ): Promise<void> => { | |
| 304 | const api = readNativeApi(); | |
| 305 | if (!api) return Promise.resolve(); | |
| 306 | const threadId = newThreadId(); | |
| 307 | const createdAt = new Date().toISOString(); | |
| 308 | const model = | |
| 309 | state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; | |
| 310 | return (async () => { | |
| 311 | await api.orchestration.dispatchCommand({ | |
| 312 | type: "thread.create", | |
| 313 | commandId: newCommandId(), | |
| 314 | threadId, | |
| 315 | projectId, | |
| 316 | title: "New thread", | |
| 317 | model, | |
| 318 | branch: options?.branch ?? null, | |
| 319 | worktreePath: options?.worktreePath ?? null, | |
| 320 | createdAt, | |
| 321 | }); | |
| 322 | ||
| 323 | // Ensure route guards can see the new thread before navigating. | |
| 324 | try { | |
| 325 | const snapshot = await api.orchestration.getSnapshot(); | |
| 326 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); | |
| 327 | } catch { | |
| 328 | // Event stream can still hydrate the thread shortly after dispatch. | |
| 329 | } | |
| 330 | ||
| 331 | await navigate({ | |
| 332 | to: "/$threadId", | |
| 333 | params: { threadId }, | |
| 334 | }); | |
| 335 | })(); | |
| 258 | 336 | }, |
| 259 | 337 | [dispatch, navigate, state.projects], |
| 260 | 338 | ); |
| 261 | 339 | |
| 262 | 340 | const focusMostRecentThreadForProject = useCallback( |
| 263 | (projectId: string) => { | |
| 341 | (projectId: ProjectId) => { | |
| 264 | 342 | const latestThread = state.threads |
| 265 | 343 | .filter((thread) => thread.projectId === projectId) |
| 266 | 344 | .toSorted((a, b) => { |
| ... | ... | @@ -282,64 +360,37 @@ export default function Sidebar() { |
| 282 | 360 | async (rawCwd: string) => { |
| 283 | 361 | const cwd = rawCwd.trim(); |
| 284 | 362 | if (!cwd || isAddingProject) return; |
| 363 | const api = readNativeApi(); | |
| 285 | 364 | |
| 286 | 365 | setIsAddingProject(true); |
| 287 | 366 | try { |
| 288 | if (isElectron && api) { | |
| 289 | const result = await api.projects.add({ cwd }); | |
| 290 | const project: Project = { | |
| 291 | id: result.project.id, | |
| 292 | name: result.project.name, | |
| 293 | cwd: result.project.cwd, | |
| 294 | model: DEFAULT_MODEL, | |
| 295 | expanded: true, | |
| 296 | scripts: result.project.scripts, | |
| 297 | }; | |
| 298 | const existingById = state.projects.find((p) => p.id === project.id); | |
| 299 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); | |
| 300 | if (!existingById && !existingByCwd) { | |
| 301 | dispatch({ type: "ADD_PROJECT", project }); | |
| 302 | } | |
| 303 | const resolvedProjectId = existingByCwd?.id ?? project.id; | |
| 304 | ||
| 305 | if (result.created) { | |
| 306 | handleNewThread(resolvedProjectId); | |
| 307 | } else { | |
| 308 | focusMostRecentThreadForProject(resolvedProjectId); | |
| 309 | } | |
| 310 | } else { | |
| 311 | const existing = state.projects.find((project) => project.cwd === cwd); | |
| 312 | if (existing) { | |
| 313 | focusMostRecentThreadForProject(existing.id); | |
| 314 | return; | |
| 315 | } | |
| 316 | ||
| 317 | const name = inferProjectName(cwd); | |
| 318 | const project: Project = { | |
| 319 | id: crypto.randomUUID(), | |
| 320 | name, | |
| 321 | cwd, | |
| 322 | model: DEFAULT_MODEL, | |
| 323 | expanded: true, | |
| 324 | scripts: [], | |
| 325 | }; | |
| 326 | dispatch({ type: "ADD_PROJECT", project }); | |
| 327 | handleNewThread(project.id); | |
| 367 | if (!api) return; | |
| 368 | const existing = state.projects.find((project) => project.cwd === cwd); | |
| 369 | if (existing) { | |
| 370 | focusMostRecentThreadForProject(existing.id); | |
| 371 | return; | |
| 328 | 372 | } |
| 373 | ||
| 374 | const projectId = newProjectId(); | |
| 375 | const createdAt = new Date().toISOString(); | |
| 376 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; | |
| 377 | await api.orchestration.dispatchCommand({ | |
| 378 | type: "project.create", | |
| 379 | commandId: newCommandId(), | |
| 380 | projectId, | |
| 381 | title, | |
| 382 | workspaceRoot: cwd, | |
| 383 | defaultModel: DEFAULT_MODEL, | |
| 384 | createdAt, | |
| 385 | }); | |
| 386 | await handleNewThread(projectId); | |
| 329 | 387 | } finally { |
| 330 | 388 | setIsAddingProject(false); |
| 331 | 389 | setNewCwd(""); |
| 332 | 390 | setAddingProject(false); |
| 333 | 391 | } |
| 334 | 392 | }, |
| 335 | [ | |
| 336 | api, | |
| 337 | dispatch, | |
| 338 | focusMostRecentThreadForProject, | |
| 339 | handleNewThread, | |
| 340 | isAddingProject, | |
| 341 | state.projects, | |
| 342 | ], | |
| 393 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects], | |
| 343 | 394 | ); |
| 344 | 395 | |
| 345 | 396 | const handleAddProject = () => { |
| ... | ... | @@ -347,6 +398,7 @@ export default function Sidebar() { |
| 347 | 398 | }; |
| 348 | 399 | |
| 349 | 400 | const handlePickFolder = async () => { |
| 401 | const api = readNativeApi(); | |
| 350 | 402 | if (!api || isPickingFolder) return; |
| 351 | 403 | setIsPickingFolder(true); |
| 352 | 404 | try { |
| ... | ... | @@ -359,9 +411,20 @@ export default function Sidebar() { |
| 359 | 411 | }; |
| 360 | 412 | |
| 361 | 413 | const handleThreadContextMenu = useCallback( |
| 362 | async (threadId: string, position: { x: number; y: number }) => { | |
| 414 | async (threadId: ThreadId, position: { x: number; y: number }) => { | |
| 415 | const api = readNativeApi(); | |
| 363 | 416 | if (!api) return; |
| 364 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | |
| 417 | const clicked = await api.contextMenu.show( | |
| 418 | [ | |
| 419 | { id: "mark-unread", label: "Mark unread" }, | |
| 420 | { id: "delete", label: "Delete" }, | |
| 421 | ], | |
| 422 | position, | |
| 423 | ); | |
| 424 | if (clicked === "mark-unread") { | |
| 425 | dispatch({ type: "MARK_THREAD_UNREAD", threadId }); | |
| 426 | return; | |
| 427 | } | |
| 365 | 428 | if (clicked !== "delete") return; |
| 366 | 429 | |
| 367 | 430 | const thread = state.threads.find((t) => t.id === threadId); |
| ... | ... | @@ -369,7 +432,7 @@ export default function Sidebar() { |
| 369 | 432 | if (appSettings.confirmThreadDelete) { |
| 370 | 433 | const confirmed = await api.dialogs.confirm( |
| 371 | 434 | [ |
| 372 | `Delete thread \"${thread.title}\"?`, | |
| 435 | `Delete thread "${thread.title}"?`, | |
| 373 | 436 | "This permanently clears conversation history for this thread.", |
| 374 | 437 | ].join("\n"), |
| 375 | 438 | ); |
| ... | ... | @@ -394,15 +457,15 @@ export default function Sidebar() { |
| 394 | 457 | ].join("\n"), |
| 395 | 458 | )); |
| 396 | 459 | |
| 397 | // Stop active session if running | |
| 398 | if (thread.session?.sessionId) { | |
| 399 | try { | |
| 400 | await api.providers.stopSession({ | |
| 401 | sessionId: thread.session.sessionId, | |
| 402 | }); | |
| 403 | } catch { | |
| 404 | // Session may already be stopped | |
| 405 | } | |
| 460 | if (thread.session && thread.session.status !== "closed") { | |
| 461 | await api.orchestration | |
| 462 | .dispatchCommand({ | |
| 463 | type: "thread.session.stop", | |
| 464 | commandId: newCommandId(), | |
| 465 | threadId, | |
| 466 | createdAt: new Date().toISOString(), | |
| 467 | }) | |
| 468 | .catch(() => undefined); | |
| 406 | 469 | } |
| 407 | 470 | |
| 408 | 471 | try { |
| ... | ... | @@ -416,7 +479,11 @@ export default function Sidebar() { |
| 416 | 479 | |
| 417 | 480 | const shouldNavigateToFallback = routeThreadId === threadId; |
| 418 | 481 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; |
| 419 | dispatch({ type: "DELETE_THREAD", threadId }); | |
| 482 | await api.orchestration.dispatchCommand({ | |
| 483 | type: "thread.delete", | |
| 484 | commandId: newCommandId(), | |
| 485 | threadId, | |
| 486 | }); | |
| 420 | 487 | if (shouldNavigateToFallback) { |
| 421 | 488 | if (fallbackThreadId) { |
| 422 | 489 | void navigate({ |
| ... | ... | @@ -455,7 +522,6 @@ export default function Sidebar() { |
| 455 | 522 | } |
| 456 | 523 | }, |
| 457 | 524 | [ |
| 458 | api, | |
| 459 | 525 | appSettings.confirmThreadDelete, |
| 460 | 526 | dispatch, |
| 461 | 527 | navigate, |
| ... | ... | @@ -467,7 +533,8 @@ export default function Sidebar() { |
| 467 | 533 | ); |
| 468 | 534 | |
| 469 | 535 | const handleProjectContextMenu = useCallback( |
| 470 | async (projectId: string, position: { x: number; y: number }) => { | |
| 536 | async (projectId: ProjectId, position: { x: number; y: number }) => { | |
| 537 | const api = readNativeApi(); | |
| 471 | 538 | if (!api) return; |
| 472 | 539 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 473 | 540 | if (clicked !== "delete") return; |
| ... | ... | @@ -490,25 +557,23 @@ export default function Sidebar() { |
| 490 | 557 | ); |
| 491 | 558 | if (!confirmed) return; |
| 492 | 559 | |
| 493 | if (isElectron) { | |
| 494 | try { | |
| 495 | await api.projects.remove({ id: projectId }); | |
| 496 | } catch (error) { | |
| 497 | const message = | |
| 498 | error instanceof Error ? error.message : "Unknown error deleting project."; | |
| 499 | console.error("Failed to remove project", { projectId, error }); | |
| 500 | toastManager.add({ | |
| 501 | type: "error", | |
| 502 | title: `Failed to delete "${project.name}"`, | |
| 503 | description: message, | |
| 504 | }); | |
| 505 | return; | |
| 506 | } | |
| 560 | try { | |
| 561 | await api.orchestration.dispatchCommand({ | |
| 562 | type: "project.delete", | |
| 563 | commandId: newCommandId(), | |
| 564 | projectId, | |
| 565 | }); | |
| 566 | } catch (error) { | |
| 567 | const message = error instanceof Error ? error.message : "Unknown error deleting project."; | |
| 568 | console.error("Failed to remove project", { projectId, error }); | |
| 569 | toastManager.add({ | |
| 570 | type: "error", | |
| 571 | title: `Failed to delete "${project.name}"`, | |
| 572 | description: message, | |
| 573 | }); | |
| 507 | 574 | } |
| 508 | ||
| 509 | dispatch({ type: "DELETE_PROJECT", projectId }); | |
| 510 | 575 | }, |
| 511 | [api, dispatch, state.projects, state.threads], | |
| 576 | [state.projects, state.threads], | |
| 512 | 577 | ); |
| 513 | 578 | |
| 514 | 579 | useEffect(() => { |
| ... | ... | @@ -520,7 +585,7 @@ export default function Sidebar() { |
| 520 | 585 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 521 | 586 | if (!projectId) return; |
| 522 | 587 | event.preventDefault(); |
| 523 | handleNewThread(projectId); | |
| 588 | void handleNewThread(projectId); | |
| 524 | 589 | return; |
| 525 | 590 | } |
| 526 | 591 | |
| ... | ... | @@ -528,7 +593,7 @@ export default function Sidebar() { |
| 528 | 593 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 529 | 594 | if (!projectId) return; |
| 530 | 595 | event.preventDefault(); |
| 531 | handleNewThread(projectId, { | |
| 596 | void handleNewThread(projectId, { | |
| 532 | 597 | branch: activeThread?.branch ?? null, |
| 533 | 598 | worktreePath: activeThread?.worktreePath ?? null, |
| 534 | 599 | }); |
| ... | ... | @@ -540,224 +605,273 @@ export default function Sidebar() { |
| 540 | 605 | }; |
| 541 | 606 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); |
| 542 | 607 | |
| 543 | return ( | |
| 544 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | |
| 545 | {/* Branding */} | |
| 546 | <div | |
| 547 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} | |
| 548 | > | |
| 549 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 550 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> | |
| 551 | T3 <span className="font-normal text-muted-foreground">Code</span> | |
| 552 | </span> | |
| 553 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | |
| 554 | {APP_STAGE_LABEL} | |
| 555 | </span> | |
| 556 | </div> | |
| 557 | </div> | |
| 608 | const onCreateThreadClick = () => { | |
| 609 | if (state.projects.length === 0) { | |
| 610 | setAddingProject(true); | |
| 611 | return; | |
| 612 | } | |
| 613 | const firstProject = state.projects[0]; | |
| 614 | if (firstProject) void handleNewThread(firstProject.id); | |
| 615 | }; | |
| 558 | 616 | |
| 559 | {/* New thread (global) */} | |
| 560 | <div className="px-3 pb-3"> | |
| 561 | <button | |
| 562 | type="button" | |
| 563 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" | |
| 564 | onClick={() => { | |
| 565 | if (state.projects.length === 0) { | |
| 566 | setAddingProject(true); | |
| 567 | return; | |
| 568 | } | |
| 569 | const firstProject = state.projects[0]; | |
| 570 | if (firstProject) handleNewThread(firstProject.id); | |
| 571 | }} | |
| 572 | > | |
| 573 | <span className="text-foreground">+</span> | |
| 574 | New thread | |
| 575 | </button> | |
| 617 | const wordmark = ( | |
| 618 | <div className={`flex items-center gap-2 ${isElectron ? "-translate-y-px" : ""}`}> | |
| 619 | <SidebarTrigger className="shrink-0 md:hidden" /> | |
| 620 | <div className="flex min-w-0 flex-1 items-center gap-1"> | |
| 621 | <T3Wordmark /> | |
| 622 | <span className="truncate text-base font-medium tracking-tight text-muted-foreground"> | |
| 623 | Code | |
| 624 | </span> | |
| 625 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | |
| 626 | {APP_STAGE_LABEL} | |
| 627 | </span> | |
| 576 | 628 | </div> |
| 629 | </div> | |
| 630 | ); | |
| 577 | 631 | |
| 578 | {/* Project list */} | |
| 579 | <nav className="flex-1 overflow-y-auto px-2"> | |
| 580 | {state.projects.map((project) => { | |
| 581 | const threads = state.threads | |
| 582 | .filter((t) => t.projectId === project.id) | |
| 583 | .toSorted((a, b) => { | |
| 584 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | |
| 585 | if (byDate !== 0) return byDate; | |
| 586 | return b.id.localeCompare(a.id); | |
| 587 | }); | |
| 588 | return ( | |
| 589 | <div key={project.id} className="mb-1"> | |
| 590 | {/* Project header */} | |
| 591 | <button | |
| 592 | type="button" | |
| 593 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" | |
| 594 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} | |
| 595 | onContextMenu={(e) => { | |
| 596 | e.preventDefault(); | |
| 597 | void handleProjectContextMenu(project.id, { | |
| 598 | x: e.clientX, | |
| 599 | y: e.clientY, | |
| 600 | }); | |
| 601 | }} | |
| 602 | > | |
| 603 | <span className="text-[10px] text-muted-foreground/70"> | |
| 604 | {project.expanded ? "â–¼" : "â–¶"} | |
| 605 | </span> | |
| 606 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | |
| 607 | {project.name} | |
| 608 | </span> | |
| 609 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | |
| 610 | </button> | |
| 632 | const newThreadButton = ( | |
| 633 | <button | |
| 634 | type="button" | |
| 635 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" | |
| 636 | onClick={onCreateThreadClick} | |
| 637 | > | |
| 638 | <span className="text-foreground">+</span> | |
| 639 | New thread | |
| 640 | </button> | |
| 641 | ); | |
| 611 | 642 | |
| 612 | {/* Threads */} | |
| 613 | {project.expanded && ( | |
| 614 | <div className="ml-2 border-l border-border/80 pl-2"> | |
| 615 | {threads.map((thread) => { | |
| 616 | const isActive = routeThreadId === thread.id; | |
| 617 | const threadStatus = threadStatusPill( | |
| 618 | thread, | |
| 619 | pendingApprovalByThreadId.get(thread.id) === true, | |
| 620 | ); | |
| 621 | const terminalStatus = terminalStatusIndicator(thread); | |
| 622 | const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); | |
| 623 | return ( | |
| 624 | <button | |
| 625 | key={thread.id} | |
| 626 | type="button" | |
| 627 | className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ | |
| 628 | isActive | |
| 629 | ? "bg-accent text-foreground" | |
| 630 | : "text-muted-foreground hover:bg-secondary" | |
| 643 | return ( | |
| 644 | <> | |
| 645 | {isElectron ? ( | |
| 646 | <> | |
| 647 | <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[88px]"> | |
| 648 | {wordmark} | |
| 649 | </SidebarHeader> | |
| 650 | <div className="px-4 pb-2">{newThreadButton}</div> | |
| 651 | </> | |
| 652 | ) : ( | |
| 653 | <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"> | |
| 654 | {wordmark} | |
| 655 | {newThreadButton} | |
| 656 | </SidebarHeader> | |
| 657 | )} | |
| 658 | ||
| 659 | <SidebarContent className="gap-0"> | |
| 660 | <SidebarGroup className="px-2 py-2"> | |
| 661 | <SidebarMenu> | |
| 662 | {state.projects.map((project) => { | |
| 663 | const threads = state.threads | |
| 664 | .filter((thread) => thread.projectId === project.id) | |
| 665 | .toSorted((a, b) => { | |
| 666 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | |
| 667 | if (byDate !== 0) return byDate; | |
| 668 | return b.id.localeCompare(a.id); | |
| 669 | }); | |
| 670 | ||
| 671 | return ( | |
| 672 | <Collapsible | |
| 673 | key={project.id} | |
| 674 | className="group/collapsible" | |
| 675 | open={project.expanded} | |
| 676 | onOpenChange={(open) => { | |
| 677 | if (open === project.expanded) return; | |
| 678 | dispatch({ type: "TOGGLE_PROJECT", projectId: project.id }); | |
| 679 | }} | |
| 680 | > | |
| 681 | <SidebarMenuItem> | |
| 682 | <CollapsibleTrigger | |
| 683 | render={ | |
| 684 | <SidebarMenuButton | |
| 685 | size="sm" | |
| 686 | className="gap-2 px-2 py-1.5 text-left hover:bg-accent" | |
| 687 | /> | |
| 688 | } | |
| 689 | onContextMenu={(event) => { | |
| 690 | event.preventDefault(); | |
| 691 | void handleProjectContextMenu(project.id, { | |
| 692 | x: event.clientX, | |
| 693 | y: event.clientY, | |
| 694 | }); | |
| 695 | }} | |
| 696 | > | |
| 697 | <ChevronRightIcon | |
| 698 | className={`-ml-0.5 size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 ${ | |
| 699 | project.expanded ? "rotate-90" : "" | |
| 631 | 700 | }`} |
| 632 | onClick={() => { | |
| 633 | void navigate({ | |
| 634 | to: "/$threadId", | |
| 635 | params: { threadId: thread.id }, | |
| 636 | }); | |
| 637 | }} | |
| 638 | onContextMenu={(e) => { | |
| 639 | e.preventDefault(); | |
| 640 | void handleThreadContextMenu(thread.id, { | |
| 641 | x: e.clientX, | |
| 642 | y: e.clientY, | |
| 643 | }); | |
| 644 | }} | |
| 645 | > | |
| 646 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | |
| 647 | {threadStatus && ( | |
| 648 | <span | |
| 649 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} | |
| 650 | > | |
| 651 | <span | |
| 652 | className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ | |
| 653 | threadStatus.pulse ? "animate-pulse" : "" | |
| 701 | /> | |
| 702 | <ProjectFavicon cwd={project.cwd} /> | |
| 703 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | |
| 704 | {project.name} | |
| 705 | </span> | |
| 706 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | |
| 707 | </CollapsibleTrigger> | |
| 708 | ||
| 709 | <CollapsibleContent> | |
| 710 | <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0"> | |
| 711 | {threads.map((thread) => { | |
| 712 | const isActive = routeThreadId === thread.id; | |
| 713 | const threadStatus = threadStatusPill( | |
| 714 | thread, | |
| 715 | pendingApprovalByThreadId.get(thread.id) === true, | |
| 716 | ); | |
| 717 | const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); | |
| 718 | const terminalStatus = terminalStatusIndicator(thread); | |
| 719 | ||
| 720 | return ( | |
| 721 | <SidebarMenuSubItem key={thread.id} className="w-full"> | |
| 722 | <SidebarMenuSubButton | |
| 723 | render={<button type="button" />} | |
| 724 | size="sm" | |
| 725 | isActive={isActive} | |
| 726 | className={`h-7 w-full translate-x-0 justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ | |
| 727 | isActive | |
| 728 | ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" | |
| 729 | : "text-muted-foreground" | |
| 654 | 730 | }`} |
| 655 | /> | |
| 656 | <span className="hidden md:inline">{threadStatus.label}</span> | |
| 657 | </span> | |
| 658 | )} | |
| 659 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | |
| 660 | </div> | |
| 661 | <div className="ml-2 flex shrink-0 items-center gap-1.5"> | |
| 662 | {prStatus && ( | |
| 663 | <span | |
| 664 | role="img" | |
| 665 | aria-label={prStatus.label} | |
| 666 | title={prStatus.label} | |
| 667 | className={`inline-flex items-center justify-center ${prStatus.colorClass}`} | |
| 668 | > | |
| 669 | <GitPullRequestIcon className="size-3" /> | |
| 670 | </span> | |
| 671 | )} | |
| 672 | {terminalStatus && ( | |
| 673 | <span | |
| 674 | role="img" | |
| 675 | aria-label={terminalStatus.label} | |
| 676 | title={terminalStatus.label} | |
| 677 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 678 | > | |
| 679 | <TerminalIcon | |
| 680 | className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} | |
| 681 | /> | |
| 682 | </span> | |
| 683 | )} | |
| 684 | <span className="text-[10px] text-muted-foreground/40"> | |
| 685 | {formatRelativeTime(thread.createdAt)} | |
| 686 | </span> | |
| 687 | </div> | |
| 688 | </button> | |
| 689 | ); | |
| 690 | })} | |
| 691 | ||
| 692 | {/* New thread within project */} | |
| 693 | <button | |
| 694 | type="button" | |
| 695 | className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 696 | onClick={() => handleNewThread(project.id)} | |
| 697 | > | |
| 698 | <span>+</span> New thread | |
| 699 | </button> | |
| 700 | </div> | |
| 701 | )} | |
| 731 | onClick={() => { | |
| 732 | void navigate({ | |
| 733 | to: "/$threadId", | |
| 734 | params: { threadId: thread.id }, | |
| 735 | }); | |
| 736 | }} | |
| 737 | onContextMenu={(event) => { | |
| 738 | event.preventDefault(); | |
| 739 | void handleThreadContextMenu(thread.id, { | |
| 740 | x: event.clientX, | |
| 741 | y: event.clientY, | |
| 742 | }); | |
| 743 | }} | |
| 744 | > | |
| 745 | <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> | |
| 746 | {threadStatus && ( | |
| 747 | <span | |
| 748 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} | |
| 749 | > | |
| 750 | <span | |
| 751 | className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ | |
| 752 | threadStatus.pulse ? "animate-pulse" : "" | |
| 753 | }`} | |
| 754 | /> | |
| 755 | <span className="hidden md:inline">{threadStatus.label}</span> | |
| 756 | </span> | |
| 757 | )} | |
| 758 | <span className="min-w-0 flex-1 truncate text-xs"> | |
| 759 | {thread.title} | |
| 760 | </span> | |
| 761 | </div> | |
| 762 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | |
| 763 | {prStatus && ( | |
| 764 | <span | |
| 765 | role="img" | |
| 766 | aria-label={prStatus.label} | |
| 767 | title={prStatus.label} | |
| 768 | className={`inline-flex items-center justify-center ${prStatus.colorClass}`} | |
| 769 | > | |
| 770 | <GitPullRequestIcon className="size-3" /> | |
| 771 | </span> | |
| 772 | )} | |
| 773 | {terminalStatus && ( | |
| 774 | <span | |
| 775 | role="img" | |
| 776 | aria-label={terminalStatus.label} | |
| 777 | title={terminalStatus.label} | |
| 778 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | |
| 779 | > | |
| 780 | <TerminalIcon | |
| 781 | className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} | |
| 782 | /> | |
| 783 | </span> | |
| 784 | )} | |
| 785 | <span | |
| 786 | className={`text-[10px] ${ | |
| 787 | isActive ? "text-foreground/65" : "text-muted-foreground/40" | |
| 788 | }`} | |
| 789 | > | |
| 790 | {formatRelativeTime(thread.createdAt)} | |
| 791 | </span> | |
| 792 | </div> | |
| 793 | </SidebarMenuSubButton> | |
| 794 | </SidebarMenuSubItem> | |
| 795 | ); | |
| 796 | })} | |
| 797 | ||
| 798 | <SidebarMenuSubItem className="w-full"> | |
| 799 | <SidebarMenuSubButton | |
| 800 | render={<button type="button" />} | |
| 801 | size="sm" | |
| 802 | className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" | |
| 803 | onClick={() => { | |
| 804 | void handleNewThread(project.id); | |
| 805 | }} | |
| 806 | > | |
| 807 | <span>+</span> | |
| 808 | <span>New thread</span> | |
| 809 | </SidebarMenuSubButton> | |
| 810 | </SidebarMenuSubItem> | |
| 811 | </SidebarMenuSub> | |
| 812 | </CollapsibleContent> | |
| 813 | </SidebarMenuItem> | |
| 814 | </Collapsible> | |
| 815 | ); | |
| 816 | })} | |
| 817 | </SidebarMenu> | |
| 818 | ||
| 819 | {state.projects.length === 0 && !addingProject && ( | |
| 820 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 821 | No projects yet. | |
| 822 | <br /> | |
| 823 | Add one to get started. | |
| 702 | 824 | </div> |
| 703 | ); | |
| 704 | })} | |
| 705 | ||
| 706 | {state.projects.length === 0 && !addingProject && ( | |
| 707 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | |
| 708 | No projects yet. | |
| 709 | <br /> | |
| 710 | Add one to get started. | |
| 711 | </div> | |
| 712 | )} | |
| 713 | </nav> | |
| 714 | ||
| 715 | {/* Add project form */} | |
| 716 | {addingProject ? ( | |
| 717 | <div className="border-t border-border p-3"> | |
| 718 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 719 | Add project | |
| 720 | </p> | |
| 721 | <input | |
| 722 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | |
| 723 | placeholder="/path/to/project" | |
| 724 | value={newCwd} | |
| 725 | onChange={(e) => setNewCwd(e.target.value)} | |
| 726 | onKeyDown={(e) => { | |
| 727 | if (e.key === "Enter") handleAddProject(); | |
| 728 | if (e.key === "Escape") setAddingProject(false); | |
| 729 | }} | |
| 730 | /> | |
| 731 | {isElectron && api && ( | |
| 732 | <button | |
| 733 | type="button" | |
| 734 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | |
| 735 | onClick={() => void handlePickFolder()} | |
| 736 | disabled={isPickingFolder || isAddingProject} | |
| 737 | > | |
| 738 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 739 | </button> | |
| 740 | 825 | )} |
| 741 | <div className="flex gap-2"> | |
| 742 | <button | |
| 743 | type="button" | |
| 744 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | |
| 745 | onClick={handleAddProject} | |
| 746 | disabled={isAddingProject} | |
| 747 | > | |
| 748 | {isAddingProject ? "Adding..." : "Add"} | |
| 749 | </button> | |
| 750 | <button | |
| 751 | type="button" | |
| 752 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | |
| 753 | onClick={() => setAddingProject(false)} | |
| 754 | > | |
| 755 | Cancel | |
| 756 | </button> | |
| 757 | </div> | |
| 758 | </div> | |
| 759 | ) : ( | |
| 760 | <div className="border-t border-border p-3"> | |
| 826 | </SidebarGroup> | |
| 827 | </SidebarContent> | |
| 828 | ||
| 829 | <SidebarSeparator /> | |
| 830 | <SidebarFooter className="gap-0 p-3"> | |
| 831 | {addingProject ? ( | |
| 832 | <> | |
| 833 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | |
| 834 | Add project | |
| 835 | </p> | |
| 836 | <input | |
| 837 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | |
| 838 | placeholder="/path/to/project" | |
| 839 | value={newCwd} | |
| 840 | onChange={(event) => setNewCwd(event.target.value)} | |
| 841 | onKeyDown={(event) => { | |
| 842 | if (event.key === "Enter") handleAddProject(); | |
| 843 | if (event.key === "Escape") setAddingProject(false); | |
| 844 | }} | |
| 845 | /> | |
| 846 | {isElectron && ( | |
| 847 | <button | |
| 848 | type="button" | |
| 849 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | |
| 850 | onClick={() => void handlePickFolder()} | |
| 851 | disabled={isPickingFolder || isAddingProject} | |
| 852 | > | |
| 853 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | |
| 854 | </button> | |
| 855 | )} | |
| 856 | <div className="flex gap-2"> | |
| 857 | <button | |
| 858 | type="button" | |
| 859 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | |
| 860 | onClick={handleAddProject} | |
| 861 | disabled={isAddingProject} | |
| 862 | > | |
| 863 | {isAddingProject ? "Adding..." : "Add"} | |
| 864 | </button> | |
| 865 | <button | |
| 866 | type="button" | |
| 867 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | |
| 868 | onClick={() => setAddingProject(false)} | |
| 869 | > | |
| 870 | Cancel | |
| 871 | </button> | |
| 872 | </div> | |
| 873 | </> | |
| 874 | ) : ( | |
| 761 | 875 | <button |
| 762 | 876 | type="button" |
| 763 | 877 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" |
| ... | ... | @@ -765,8 +879,8 @@ export default function Sidebar() { |
| 765 | 879 | > |
| 766 | 880 | + Add project |
| 767 | 881 | </button> |
| 768 | </div> | |
| 769 | )} | |
| 770 | </aside> | |
| 882 | )} | |
| 883 | </SidebarFooter> | |
| 884 | </> | |
| 771 | 885 | ); |
| 772 | 886 | } |
apps/web/src/components/ThreadTerminalDrawer.tsx+41-22| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | import { type NativeApi } from "@t3tools/contracts"; | |
| 3 | import { type ThreadId } from "@t3tools/contracts"; | |
| 4 | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | 5 | import { |
| 6 | 6 | type PointerEvent as ReactPointerEvent, |
| ... | ... | @@ -25,6 +25,7 @@ import { |
| 25 | 25 | MAX_THREAD_TERMINAL_COUNT, |
| 26 | 26 | type ThreadTerminalGroup, |
| 27 | 27 | } from "../types"; |
| 28 | import { readNativeApi } from "~/nativeApi"; | |
| 28 | 29 | |
| 29 | 30 | const MIN_DRAWER_HEIGHT = 180; |
| 30 | 31 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | ... | @@ -107,11 +108,11 @@ function terminalThemeFromApp(): ITheme { |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | 110 | interface TerminalViewportProps { |
| 110 | api: NativeApi; | |
| 111 | threadId: string; | |
| 111 | threadId: ThreadId; | |
| 112 | 112 | terminalId: string; |
| 113 | 113 | cwd: string; |
| 114 | 114 | runtimeEnv?: Record<string, string>; |
| 115 | onSessionExited: () => void; | |
| 115 | 116 | focusRequestId: number; |
| 116 | 117 | autoFocus: boolean; |
| 117 | 118 | resizeEpoch: number; |
| ... | ... | @@ -119,11 +120,11 @@ interface TerminalViewportProps { |
| 119 | 120 | } |
| 120 | 121 | |
| 121 | 122 | function TerminalViewport({ |
| 122 | api, | |
| 123 | 123 | threadId, |
| 124 | 124 | terminalId, |
| 125 | 125 | cwd, |
| 126 | 126 | runtimeEnv, |
| 127 | onSessionExited, | |
| 127 | 128 | focusRequestId, |
| 128 | 129 | autoFocus, |
| 129 | 130 | resizeEpoch, |
| ... | ... | @@ -132,6 +133,12 @@ function TerminalViewport({ |
| 132 | 133 | const containerRef = useRef<HTMLDivElement>(null); |
| 133 | 134 | const terminalRef = useRef<Terminal | null>(null); |
| 134 | 135 | const fitAddonRef = useRef<FitAddon | null>(null); |
| 136 | const onSessionExitedRef = useRef(onSessionExited); | |
| 137 | const hasHandledExitRef = useRef(false); | |
| 138 | ||
| 139 | useEffect(() => { | |
| 140 | onSessionExitedRef.current = onSessionExited; | |
| 141 | }, [onSessionExited]); | |
| 135 | 142 | |
| 136 | 143 | useEffect(() => { |
| 137 | 144 | const mount = containerRef.current; |
| ... | ... | @@ -155,6 +162,9 @@ function TerminalViewport({ |
| 155 | 162 | terminalRef.current = terminal; |
| 156 | 163 | fitAddonRef.current = fitAddon; |
| 157 | 164 | |
| 165 | const api = readNativeApi(); | |
| 166 | if (!api) return; | |
| 167 | ||
| 158 | 168 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 159 | 169 | const activeTerminal = terminalRef.current; |
| 160 | 170 | if (!activeTerminal) return; |
| ... | ... | @@ -210,14 +220,10 @@ function TerminalViewport({ |
| 210 | 220 | end: { x: match.end, y: bufferLineNumber }, |
| 211 | 221 | }, |
| 212 | 222 | activate: (event: MouseEvent) => { |
| 213 | if (!isTerminalLinkActivation(event)) { | |
| 214 | return; | |
| 215 | } | |
| 223 | if (!isTerminalLinkActivation(event)) return; | |
| 216 | 224 | |
| 217 | 225 | const latestTerminal = terminalRef.current; |
| 218 | if (!latestTerminal) { | |
| 219 | return; | |
| 220 | } | |
| 226 | if (!latestTerminal) return; | |
| 221 | 227 | |
| 222 | 228 | if (match.kind === "url") { |
| 223 | 229 | void api.shell.openExternal(match.text).catch((error) => { |
| ... | ... | @@ -297,7 +303,7 @@ function TerminalViewport({ |
| 297 | 303 | } |
| 298 | 304 | }; |
| 299 | 305 | |
| 300 | const unsubscribe = api.terminal.onEvent((event) => { | |
| 306 | const unsubscribe = api?.terminal.onEvent((event) => { | |
| 301 | 307 | if (event.threadId !== threadId || event.terminalId !== terminalId) return; |
| 302 | 308 | const activeTerminal = terminalRef.current; |
| 303 | 309 | if (!activeTerminal) return; |
| ... | ... | @@ -308,6 +314,7 @@ function TerminalViewport({ |
| 308 | 314 | } |
| 309 | 315 | |
| 310 | 316 | if (event.type === "started" || event.type === "restarted") { |
| 317 | hasHandledExitRef.current = false; | |
| 311 | 318 | activeTerminal.write("\u001bc"); |
| 312 | 319 | if (event.snapshot.history.length > 0) { |
| 313 | 320 | activeTerminal.write(event.snapshot.history); |
| ... | ... | @@ -337,6 +344,16 @@ function TerminalViewport({ |
| 337 | 344 | activeTerminal, |
| 338 | 345 | details.length > 0 ? `Process exited (${details})` : "Process exited", |
| 339 | 346 | ); |
| 347 | if (hasHandledExitRef.current) { | |
| 348 | return; | |
| 349 | } | |
| 350 | hasHandledExitRef.current = true; | |
| 351 | window.setTimeout(() => { | |
| 352 | if (!hasHandledExitRef.current) { | |
| 353 | return; | |
| 354 | } | |
| 355 | onSessionExitedRef.current(); | |
| 356 | }, 0); | |
| 340 | 357 | } |
| 341 | 358 | }); |
| 342 | 359 | |
| ... | ... | @@ -372,7 +389,7 @@ function TerminalViewport({ |
| 372 | 389 | fitAddonRef.current = null; |
| 373 | 390 | terminal.dispose(); |
| 374 | 391 | }; |
| 375 | }, [api, cwd, runtimeEnv, terminalId, threadId]); | |
| 392 | }, [cwd, runtimeEnv, terminalId, threadId]); | |
| 376 | 393 | |
| 377 | 394 | useEffect(() => { |
| 378 | 395 | if (!autoFocus) return; |
| ... | ... | @@ -387,9 +404,10 @@ function TerminalViewport({ |
| 387 | 404 | }, [autoFocus, focusRequestId]); |
| 388 | 405 | |
| 389 | 406 | useEffect(() => { |
| 407 | const api = readNativeApi(); | |
| 390 | 408 | const terminal = terminalRef.current; |
| 391 | 409 | const fitAddon = fitAddonRef.current; |
| 392 | if (!terminal || !fitAddon) return; | |
| 410 | if (!api || !terminal || !fitAddon) return; | |
| 393 | 411 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; |
| 394 | 412 | const frame = window.requestAnimationFrame(() => { |
| 395 | 413 | fitAddon.fit(); |
| ... | ... | @@ -408,14 +426,12 @@ function TerminalViewport({ |
| 408 | 426 | return () => { |
| 409 | 427 | window.cancelAnimationFrame(frame); |
| 410 | 428 | }; |
| 411 | }, [api, drawerHeight, resizeEpoch, terminalId, threadId]); | |
| 412 | ||
| 429 | }, [drawerHeight, resizeEpoch, terminalId, threadId]); | |
| 413 | 430 | return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; |
| 414 | 431 | } |
| 415 | 432 | |
| 416 | 433 | interface ThreadTerminalDrawerProps { |
| 417 | api: NativeApi; | |
| 418 | threadId: string; | |
| 434 | threadId: ThreadId; | |
| 419 | 435 | cwd: string; |
| 420 | 436 | runtimeEnv?: Record<string, string>; |
| 421 | 437 | height: number; |
| ... | ... | @@ -464,7 +480,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA |
| 464 | 480 | } |
| 465 | 481 | |
| 466 | 482 | export default function ThreadTerminalDrawer({ |
| 467 | api, | |
| 468 | 483 | threadId, |
| 469 | 484 | cwd, |
| 470 | 485 | runtimeEnv, |
| ... | ... | @@ -597,10 +612,14 @@ export default function ThreadTerminalDrawer({ |
| 597 | 612 | ); |
| 598 | 613 | const splitTerminalActionLabel = hasReachedTerminalLimit |
| 599 | 614 | ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` |
| 600 | : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"); | |
| 615 | : splitShortcutLabel | |
| 616 | ? `Split Terminal (${splitShortcutLabel})` | |
| 617 | : "Split Terminal"; | |
| 601 | 618 | const newTerminalActionLabel = hasReachedTerminalLimit |
| 602 | 619 | ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` |
| 603 | : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"); | |
| 620 | : newShortcutLabel | |
| 621 | ? `New Terminal (${newShortcutLabel})` | |
| 622 | : "New Terminal"; | |
| 604 | 623 | const closeTerminalActionLabel = closeShortcutLabel |
| 605 | 624 | ? `Close Terminal (${closeShortcutLabel})` |
| 606 | 625 | : "Close Terminal"; |
| ... | ... | @@ -779,11 +798,11 @@ export default function ThreadTerminalDrawer({ |
| 779 | 798 | > |
| 780 | 799 | <div className="h-full p-1"> |
| 781 | 800 | <TerminalViewport |
| 782 | api={api} | |
| 783 | 801 | threadId={threadId} |
| 784 | 802 | terminalId={terminalId} |
| 785 | 803 | cwd={cwd} |
| 786 | 804 | {...(runtimeEnv ? { runtimeEnv } : {})} |
| 805 | onSessionExited={() => onCloseTerminal(terminalId)} | |
| 787 | 806 | focusRequestId={focusRequestId} |
| 788 | 807 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 789 | 808 | resizeEpoch={resizeEpoch} |
| ... | ... | @@ -797,11 +816,11 @@ export default function ThreadTerminalDrawer({ |
| 797 | 816 | <div className="h-full p-1"> |
| 798 | 817 | <TerminalViewport |
| 799 | 818 | key={resolvedActiveTerminalId} |
| 800 | api={api} | |
| 801 | 819 | threadId={threadId} |
| 802 | 820 | terminalId={resolvedActiveTerminalId} |
| 803 | 821 | cwd={cwd} |
| 804 | 822 | {...(runtimeEnv ? { runtimeEnv } : {})} |
| 823 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} | |
| 805 | 824 | focusRequestId={focusRequestId} |
| 806 | 825 | autoFocus |
| 807 | 826 | resizeEpoch={resizeEpoch} |
apps/web/src/components/ui/collapsible.tsx created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; | |
| 4 | ||
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { | |
| 8 | return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; | |
| 9 | } | |
| 10 | ||
| 11 | function CollapsibleTrigger({ className, ...props }: CollapsiblePrimitive.Trigger.Props) { | |
| 12 | return ( | |
| 13 | <CollapsiblePrimitive.Trigger | |
| 14 | className={cn("cursor-pointer", className)} | |
| 15 | data-slot="collapsible-trigger" | |
| 16 | {...props} | |
| 17 | /> | |
| 18 | ); | |
| 19 | } | |
| 20 | ||
| 21 | function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Props) { | |
| 22 | return ( | |
| 23 | <CollapsiblePrimitive.Panel | |
| 24 | className={cn( | |
| 25 | "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0", | |
| 26 | className, | |
| 27 | )} | |
| 28 | data-slot="collapsible-panel" | |
| 29 | {...props} | |
| 30 | /> | |
| 31 | ); | |
| 32 | } | |
| 33 | ||
| 34 | export { | |
| 35 | Collapsible, | |
| 36 | CollapsibleTrigger, | |
| 37 | CollapsiblePanel, | |
| 38 | CollapsiblePanel as CollapsibleContent, | |
| 39 | }; |
apps/web/src/components/ui/combobox.tsx+3-1| ... | ... | @@ -21,7 +21,9 @@ function Combobox<Value, Multiple extends boolean | undefined = false>( |
| 21 | 21 | ) { |
| 22 | 22 | const chipsRef = React.useRef<Element | null>(null); |
| 23 | 23 | return ( |
| 24 | <ComboboxContext.Provider value={{ chipsRef, multiple: !!props.multiple }}> | |
| 24 | <ComboboxContext.Provider | |
| 25 | value={React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple])} | |
| 26 | > | |
| 25 | 27 | <ComboboxPrimitive.Root {...props} /> |
| 26 | 28 | </ComboboxContext.Provider> |
| 27 | 29 | ); |
apps/web/src/components/ui/command.tsx+4-1| ... | ... | @@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc |
| 133 | 133 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { |
| 134 | 134 | return ( |
| 135 | 135 | <div |
| 136 | className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2" | |
| 136 | className={cn( | |
| 137 | "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2", | |
| 138 | className, | |
| 139 | )} | |
| 137 | 140 | {...props} |
| 138 | 141 | /> |
| 139 | 142 | ); |
apps/web/src/components/ui/sheet.tsx+1| ... | ... | @@ -49,6 +49,7 @@ function SheetViewport({ |
| 49 | 49 | side === "left" && "flex justify-start", |
| 50 | 50 | side === "right" && "flex justify-end", |
| 51 | 51 | variant === "inset" && "sm:p-4", |
| 52 | className, | |
| 52 | 53 | )} |
| 53 | 54 | data-slot="sheet-viewport" |
| 54 | 55 | {...props} |
apps/web/src/components/ui/sidebar.tsx created+991| ... | ... | @@ -0,0 +1,991 @@ |
| 1 | import { mergeProps } from "@base-ui/react/merge-props"; | |
| 2 | import { useRender } from "@base-ui/react/use-render"; | |
| 3 | import { cva, type VariantProps } from "class-variance-authority"; | |
| 4 | import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react"; | |
| 5 | import * as React from "react"; | |
| 6 | import { cn } from "~/lib/utils"; | |
| 7 | import { Button } from "~/components/ui/button"; | |
| 8 | import { Input } from "~/components/ui/input"; | |
| 9 | import { ScrollArea } from "~/components/ui/scroll-area"; | |
| 10 | import { Separator } from "~/components/ui/separator"; | |
| 11 | import { | |
| 12 | Sheet, | |
| 13 | SheetDescription, | |
| 14 | SheetHeader, | |
| 15 | SheetPopup, | |
| 16 | SheetTitle, | |
| 17 | } from "~/components/ui/sheet"; | |
| 18 | import { Skeleton } from "~/components/ui/skeleton"; | |
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 20 | import { useMediaQuery } from "~/hooks/useMediaQuery"; | |
| 21 | ||
| 22 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; | |
| 23 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; | |
| 24 | const SIDEBAR_WIDTH = "16rem"; | |
| 25 | const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; | |
| 26 | const SIDEBAR_WIDTH_ICON = "3rem"; | |
| 27 | const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; | |
| 28 | ||
| 29 | type SidebarContextProps = { | |
| 30 | state: "expanded" | "collapsed"; | |
| 31 | open: boolean; | |
| 32 | setOpen: (open: boolean) => void; | |
| 33 | openMobile: boolean; | |
| 34 | setOpenMobile: (open: boolean) => void; | |
| 35 | isMobile: boolean; | |
| 36 | toggleSidebar: () => void; | |
| 37 | }; | |
| 38 | ||
| 39 | type SidebarResizableOptions = { | |
| 40 | maxWidth?: number; | |
| 41 | minWidth?: number; | |
| 42 | onResize?: (width: number) => void; | |
| 43 | shouldAcceptWidth?: (context: { | |
| 44 | currentWidth: number; | |
| 45 | nextWidth: number; | |
| 46 | rail: HTMLButtonElement; | |
| 47 | side: "left" | "right"; | |
| 48 | sidebarRoot: HTMLElement; | |
| 49 | wrapper: HTMLElement; | |
| 50 | }) => boolean; | |
| 51 | storageKey?: string; | |
| 52 | }; | |
| 53 | ||
| 54 | type SidebarResolvedResizableOptions = { | |
| 55 | maxWidth: number; | |
| 56 | minWidth: number; | |
| 57 | onResize?: (width: number) => void; | |
| 58 | shouldAcceptWidth?: (context: { | |
| 59 | currentWidth: number; | |
| 60 | nextWidth: number; | |
| 61 | rail: HTMLButtonElement; | |
| 62 | side: "left" | "right"; | |
| 63 | sidebarRoot: HTMLElement; | |
| 64 | wrapper: HTMLElement; | |
| 65 | }) => boolean; | |
| 66 | storageKey: string | null; | |
| 67 | }; | |
| 68 | ||
| 69 | type SidebarInstanceContextProps = { | |
| 70 | resizable: SidebarResolvedResizableOptions | null; | |
| 71 | side: "left" | "right"; | |
| 72 | }; | |
| 73 | ||
| 74 | const SidebarContext = React.createContext<SidebarContextProps | null>(null); | |
| 75 | const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null); | |
| 76 | ||
| 77 | function useSidebar() { | |
| 78 | const context = React.useContext(SidebarContext); | |
| 79 | if (!context) { | |
| 80 | throw new Error("useSidebar must be used within a SidebarProvider."); | |
| 81 | } | |
| 82 | ||
| 83 | return context; | |
| 84 | } | |
| 85 | ||
| 86 | function SidebarProvider({ | |
| 87 | defaultOpen = true, | |
| 88 | open: openProp, | |
| 89 | onOpenChange: setOpenProp, | |
| 90 | className, | |
| 91 | style, | |
| 92 | children, | |
| 93 | ...props | |
| 94 | }: React.ComponentProps<"div"> & { | |
| 95 | defaultOpen?: boolean; | |
| 96 | open?: boolean; | |
| 97 | onOpenChange?: (open: boolean) => void; | |
| 98 | }) { | |
| 99 | const isMobile = useMediaQuery("(max-width: 767px)"); | |
| 100 | const [openMobile, setOpenMobile] = React.useState(false); | |
| 101 | ||
| 102 | // This is the internal state of the sidebar. | |
| 103 | // We use openProp and setOpenProp for control from outside the component. | |
| 104 | const [_open, _setOpen] = React.useState(defaultOpen); | |
| 105 | const open = openProp ?? _open; | |
| 106 | const setOpen = React.useCallback( | |
| 107 | async (value: boolean | ((value: boolean) => boolean)) => { | |
| 108 | const openState = typeof value === "function" ? value(open) : value; | |
| 109 | if (setOpenProp) { | |
| 110 | setOpenProp(openState); | |
| 111 | } else { | |
| 112 | _setOpen(openState); | |
| 113 | } | |
| 114 | ||
| 115 | // This sets the cookie to keep the sidebar state. | |
| 116 | await cookieStore.set({ | |
| 117 | expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000, | |
| 118 | name: SIDEBAR_COOKIE_NAME, | |
| 119 | path: "/", | |
| 120 | value: String(openState), | |
| 121 | }); | |
| 122 | }, | |
| 123 | [setOpenProp, open], | |
| 124 | ); | |
| 125 | ||
| 126 | // Helper to toggle the sidebar. | |
| 127 | const toggleSidebar = React.useCallback(() => { | |
| 128 | return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); | |
| 129 | }, [isMobile, setOpen]); | |
| 130 | ||
| 131 | // We add a state so that we can do data-state="expanded" or "collapsed". | |
| 132 | // This makes it easier to style the sidebar with Tailwind classes. | |
| 133 | const state = open ? "expanded" : "collapsed"; | |
| 134 | ||
| 135 | const contextValue = React.useMemo<SidebarContextProps>( | |
| 136 | () => ({ | |
| 137 | isMobile, | |
| 138 | open, | |
| 139 | openMobile, | |
| 140 | setOpen, | |
| 141 | setOpenMobile, | |
| 142 | state, | |
| 143 | toggleSidebar, | |
| 144 | }), | |
| 145 | [state, open, setOpen, isMobile, openMobile, toggleSidebar], | |
| 146 | ); | |
| 147 | ||
| 148 | return ( | |
| 149 | <SidebarContext.Provider value={contextValue}> | |
| 150 | <div | |
| 151 | className={cn( | |
| 152 | "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", | |
| 153 | className, | |
| 154 | )} | |
| 155 | data-slot="sidebar-wrapper" | |
| 156 | style={ | |
| 157 | { | |
| 158 | "--sidebar-width": SIDEBAR_WIDTH, | |
| 159 | "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, | |
| 160 | ...style, | |
| 161 | } as React.CSSProperties | |
| 162 | } | |
| 163 | {...props} | |
| 164 | > | |
| 165 | {children} | |
| 166 | </div> | |
| 167 | </SidebarContext.Provider> | |
| 168 | ); | |
| 169 | } | |
| 170 | ||
| 171 | function Sidebar({ | |
| 172 | side = "left", | |
| 173 | variant = "sidebar", | |
| 174 | collapsible = "offcanvas", | |
| 175 | resizable = false, | |
| 176 | className, | |
| 177 | children, | |
| 178 | ...props | |
| 179 | }: React.ComponentProps<"div"> & { | |
| 180 | side?: "left" | "right"; | |
| 181 | variant?: "sidebar" | "floating" | "inset"; | |
| 182 | collapsible?: "offcanvas" | "icon" | "none"; | |
| 183 | resizable?: boolean | SidebarResizableOptions; | |
| 184 | }) { | |
| 185 | const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | |
| 186 | const resolvedResizable = React.useMemo<SidebarResolvedResizableOptions | null>(() => { | |
| 187 | if (isMobile || collapsible === "none" || !resizable) { | |
| 188 | return null; | |
| 189 | } | |
| 190 | ||
| 191 | const options = typeof resizable === "boolean" ? {} : resizable; | |
| 192 | return { | |
| 193 | maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY, | |
| 194 | minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH, | |
| 195 | storageKey: options.storageKey ?? null, | |
| 196 | ...(options.onResize ? { onResize: options.onResize } : {}), | |
| 197 | ...(options.shouldAcceptWidth ? { shouldAcceptWidth: options.shouldAcceptWidth } : {}), | |
| 198 | }; | |
| 199 | }, [collapsible, isMobile, resizable]); | |
| 200 | const instanceContextValue = React.useMemo<SidebarInstanceContextProps>( | |
| 201 | () => ({ side, resizable: resolvedResizable }), | |
| 202 | [resolvedResizable, side], | |
| 203 | ); | |
| 204 | ||
| 205 | if (collapsible === "none") { | |
| 206 | return ( | |
| 207 | <SidebarInstanceContext.Provider value={instanceContextValue}> | |
| 208 | <div | |
| 209 | className={cn( | |
| 210 | "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", | |
| 211 | className, | |
| 212 | )} | |
| 213 | data-slot="sidebar" | |
| 214 | {...props} | |
| 215 | > | |
| 216 | {children} | |
| 217 | </div> | |
| 218 | </SidebarInstanceContext.Provider> | |
| 219 | ); | |
| 220 | } | |
| 221 | ||
| 222 | if (isMobile) { | |
| 223 | return ( | |
| 224 | <SidebarInstanceContext.Provider value={instanceContextValue}> | |
| 225 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> | |
| 226 | <SheetPopup | |
| 227 | className={cn( | |
| 228 | "w-(--sidebar-width) max-w-none bg-sidebar p-0 text-sidebar-foreground", | |
| 229 | className, | |
| 230 | )} | |
| 231 | data-mobile="true" | |
| 232 | data-sidebar="sidebar" | |
| 233 | data-slot="sidebar" | |
| 234 | showCloseButton={false} | |
| 235 | side={side} | |
| 236 | style={ | |
| 237 | { | |
| 238 | "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | |
| 239 | } as React.CSSProperties | |
| 240 | } | |
| 241 | > | |
| 242 | <SheetHeader className="sr-only"> | |
| 243 | <SheetTitle>Sidebar</SheetTitle> | |
| 244 | <SheetDescription>Displays the mobile sidebar.</SheetDescription> | |
| 245 | </SheetHeader> | |
| 246 | <div className="flex h-full w-full flex-col">{children}</div> | |
| 247 | </SheetPopup> | |
| 248 | </Sheet> | |
| 249 | </SidebarInstanceContext.Provider> | |
| 250 | ); | |
| 251 | } | |
| 252 | ||
| 253 | return ( | |
| 254 | <SidebarInstanceContext.Provider value={instanceContextValue}> | |
| 255 | <div | |
| 256 | className="group peer hidden text-sidebar-foreground md:block" | |
| 257 | data-collapsible={state === "collapsed" ? collapsible : ""} | |
| 258 | data-side={side} | |
| 259 | data-slot="sidebar" | |
| 260 | data-state={state} | |
| 261 | data-variant={variant} | |
| 262 | > | |
| 263 | {/* This is what handles the sidebar gap on desktop */} | |
| 264 | <div | |
| 265 | className={cn( | |
| 266 | "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", | |
| 267 | "group-data-[collapsible=offcanvas]:w-0", | |
| 268 | "group-data-[side=right]:rotate-180", | |
| 269 | variant === "floating" || variant === "inset" | |
| 270 | ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" | |
| 271 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", | |
| 272 | )} | |
| 273 | data-slot="sidebar-gap" | |
| 274 | /> | |
| 275 | <div | |
| 276 | className={cn( | |
| 277 | "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", | |
| 278 | side === "left" | |
| 279 | ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" | |
| 280 | : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", | |
| 281 | // Adjust the padding for floating and inset variants. | |
| 282 | variant === "floating" || variant === "inset" | |
| 283 | ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" | |
| 284 | : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", | |
| 285 | className, | |
| 286 | )} | |
| 287 | data-slot="sidebar-container" | |
| 288 | {...props} | |
| 289 | > | |
| 290 | <div | |
| 291 | className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5" | |
| 292 | data-sidebar="sidebar" | |
| 293 | data-slot="sidebar-inner" | |
| 294 | > | |
| 295 | {children} | |
| 296 | </div> | |
| 297 | </div> | |
| 298 | </div> | |
| 299 | </SidebarInstanceContext.Provider> | |
| 300 | ); | |
| 301 | } | |
| 302 | ||
| 303 | function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { | |
| 304 | const { toggleSidebar, openMobile } = useSidebar(); | |
| 305 | ||
| 306 | return ( | |
| 307 | <Button | |
| 308 | className={cn("size-7", className)} | |
| 309 | data-sidebar="trigger" | |
| 310 | data-slot="sidebar-trigger" | |
| 311 | onClick={(event) => { | |
| 312 | onClick?.(event); | |
| 313 | toggleSidebar(); | |
| 314 | }} | |
| 315 | size="icon" | |
| 316 | variant="ghost" | |
| 317 | {...props} | |
| 318 | > | |
| 319 | {openMobile ? <PanelLeftCloseIcon /> : <PanelLeftIcon />} | |
| 320 | <span className="sr-only">Toggle Sidebar</span> | |
| 321 | </Button> | |
| 322 | ); | |
| 323 | } | |
| 324 | ||
| 325 | function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number { | |
| 326 | return Math.max(options.minWidth, Math.min(width, options.maxWidth)); | |
| 327 | } | |
| 328 | ||
| 329 | function SidebarRail({ | |
| 330 | className, | |
| 331 | onClick, | |
| 332 | onPointerCancel, | |
| 333 | onPointerDown, | |
| 334 | onPointerMove, | |
| 335 | onPointerUp, | |
| 336 | ...props | |
| 337 | }: React.ComponentProps<"button">) { | |
| 338 | const { open, toggleSidebar } = useSidebar(); | |
| 339 | const sidebarInstance = React.useContext(SidebarInstanceContext); | |
| 340 | const railRef = React.useRef<HTMLButtonElement | null>(null); | |
| 341 | const suppressClickRef = React.useRef(false); | |
| 342 | const resizeStateRef = React.useRef<{ | |
| 343 | moved: boolean; | |
| 344 | pointerId: number; | |
| 345 | pendingWidth: number; | |
| 346 | rail: HTMLButtonElement; | |
| 347 | rafId: number | null; | |
| 348 | sidebarRoot: HTMLElement; | |
| 349 | side: "left" | "right"; | |
| 350 | startWidth: number; | |
| 351 | startX: number; | |
| 352 | transitionTargets: HTMLElement[]; | |
| 353 | width: number; | |
| 354 | wrapper: HTMLElement; | |
| 355 | } | null>(null); | |
| 356 | const resolvedResizable = sidebarInstance?.resizable ?? null; | |
| 357 | const canResize = resolvedResizable !== null && open; | |
| 358 | const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; | |
| 359 | const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; | |
| 360 | ||
| 361 | const stopResize = React.useCallback( | |
| 362 | (pointerId: number) => { | |
| 363 | const resizeState = resizeStateRef.current; | |
| 364 | if (!resizeState) { | |
| 365 | return; | |
| 366 | } | |
| 367 | if (resizeState.rafId !== null) { | |
| 368 | window.cancelAnimationFrame(resizeState.rafId); | |
| 369 | } | |
| 370 | resizeState.transitionTargets.forEach((element) => { | |
| 371 | element.style.removeProperty("transition-duration"); | |
| 372 | }); | |
| 373 | if (resolvedResizable?.storageKey && typeof window !== "undefined") { | |
| 374 | window.localStorage.setItem(resolvedResizable.storageKey, String(resizeState.width)); | |
| 375 | } | |
| 376 | resolvedResizable?.onResize?.(resizeState.width); | |
| 377 | resizeStateRef.current = null; | |
| 378 | if (resizeState.rail.hasPointerCapture(pointerId)) { | |
| 379 | resizeState.rail.releasePointerCapture(pointerId); | |
| 380 | } | |
| 381 | document.body.style.removeProperty("cursor"); | |
| 382 | document.body.style.removeProperty("user-select"); | |
| 383 | }, | |
| 384 | [resolvedResizable], | |
| 385 | ); | |
| 386 | ||
| 387 | const handlePointerDown = React.useCallback( | |
| 388 | (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 389 | onPointerDown?.(event); | |
| 390 | if (event.defaultPrevented) return; | |
| 391 | if (!resolvedResizable || !open || event.button !== 0) return; | |
| 392 | ||
| 393 | const wrapper = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); | |
| 394 | const sidebarRoot = event.currentTarget.closest<HTMLElement>("[data-slot='sidebar']"); | |
| 395 | if (!wrapper || !sidebarRoot) { | |
| 396 | return; | |
| 397 | } | |
| 398 | ||
| 399 | const sidebarContainer = sidebarRoot.querySelector<HTMLElement>( | |
| 400 | "[data-slot='sidebar-container']", | |
| 401 | ); | |
| 402 | if (!sidebarContainer) { | |
| 403 | return; | |
| 404 | } | |
| 405 | ||
| 406 | const startWidth = sidebarContainer.getBoundingClientRect().width; | |
| 407 | const initialWidth = clampSidebarWidth(startWidth, resolvedResizable); | |
| 408 | const transitionTargets = [ | |
| 409 | sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-gap']"), | |
| 410 | sidebarRoot.querySelector<HTMLElement>("[data-slot='sidebar-container']"), | |
| 411 | ].filter((element): element is HTMLElement => element !== null); | |
| 412 | transitionTargets.forEach((element) => { | |
| 413 | element.style.setProperty("transition-duration", "0ms"); | |
| 414 | }); | |
| 415 | ||
| 416 | event.preventDefault(); | |
| 417 | event.stopPropagation(); | |
| 418 | resizeStateRef.current = { | |
| 419 | moved: false, | |
| 420 | pointerId: event.pointerId, | |
| 421 | pendingWidth: initialWidth, | |
| 422 | rail: event.currentTarget, | |
| 423 | rafId: null, | |
| 424 | sidebarRoot, | |
| 425 | side: sidebarInstance?.side ?? "left", | |
| 426 | startWidth: initialWidth, | |
| 427 | startX: event.clientX, | |
| 428 | transitionTargets, | |
| 429 | width: initialWidth, | |
| 430 | wrapper, | |
| 431 | }; | |
| 432 | wrapper.style.setProperty("--sidebar-width", `${initialWidth}px`); | |
| 433 | event.currentTarget.setPointerCapture(event.pointerId); | |
| 434 | document.body.style.cursor = "col-resize"; | |
| 435 | document.body.style.userSelect = "none"; | |
| 436 | }, | |
| 437 | [onPointerDown, open, resolvedResizable, sidebarInstance?.side], | |
| 438 | ); | |
| 439 | ||
| 440 | const handlePointerMove = React.useCallback( | |
| 441 | (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 442 | onPointerMove?.(event); | |
| 443 | if (event.defaultPrevented) return; | |
| 444 | const resizeState = resizeStateRef.current; | |
| 445 | if (!resizeState || resizeState.pointerId !== event.pointerId || !resolvedResizable) return; | |
| 446 | ||
| 447 | event.preventDefault(); | |
| 448 | const delta = | |
| 449 | resizeState.side === "right" | |
| 450 | ? resizeState.startX - event.clientX | |
| 451 | : event.clientX - resizeState.startX; | |
| 452 | if (Math.abs(delta) > 2) { | |
| 453 | resizeState.moved = true; | |
| 454 | } | |
| 455 | resizeState.pendingWidth = clampSidebarWidth( | |
| 456 | resizeState.startWidth + delta, | |
| 457 | resolvedResizable, | |
| 458 | ); | |
| 459 | if (resizeState.rafId !== null) { | |
| 460 | return; | |
| 461 | } | |
| 462 | ||
| 463 | resizeState.rafId = window.requestAnimationFrame(() => { | |
| 464 | const activeResizeState = resizeStateRef.current; | |
| 465 | if (!activeResizeState || !resolvedResizable) return; | |
| 466 | ||
| 467 | activeResizeState.rafId = null; | |
| 468 | const nextWidth = activeResizeState.pendingWidth; | |
| 469 | const accepted = | |
| 470 | resolvedResizable.shouldAcceptWidth?.({ | |
| 471 | currentWidth: activeResizeState.width, | |
| 472 | nextWidth, | |
| 473 | rail: activeResizeState.rail, | |
| 474 | side: activeResizeState.side, | |
| 475 | sidebarRoot: activeResizeState.sidebarRoot, | |
| 476 | wrapper: activeResizeState.wrapper, | |
| 477 | }) ?? true; | |
| 478 | if (!accepted) { | |
| 479 | return; | |
| 480 | } | |
| 481 | ||
| 482 | activeResizeState.wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 483 | activeResizeState.width = nextWidth; | |
| 484 | }); | |
| 485 | }, | |
| 486 | [onPointerMove, resolvedResizable], | |
| 487 | ); | |
| 488 | ||
| 489 | const endResizeInteraction = React.useCallback( | |
| 490 | (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 491 | const resizeState = resizeStateRef.current; | |
| 492 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | |
| 493 | ||
| 494 | event.preventDefault(); | |
| 495 | suppressClickRef.current = resizeState.moved; | |
| 496 | stopResize(event.pointerId); | |
| 497 | }, | |
| 498 | [stopResize], | |
| 499 | ); | |
| 500 | ||
| 501 | const handlePointerUp = React.useCallback( | |
| 502 | (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 503 | onPointerUp?.(event); | |
| 504 | if (event.defaultPrevented) return; | |
| 505 | endResizeInteraction(event); | |
| 506 | }, | |
| 507 | [endResizeInteraction, onPointerUp], | |
| 508 | ); | |
| 509 | ||
| 510 | const handlePointerCancel = React.useCallback( | |
| 511 | (event: React.PointerEvent<HTMLButtonElement>) => { | |
| 512 | onPointerCancel?.(event); | |
| 513 | if (event.defaultPrevented) return; | |
| 514 | endResizeInteraction(event); | |
| 515 | }, | |
| 516 | [endResizeInteraction, onPointerCancel], | |
| 517 | ); | |
| 518 | ||
| 519 | const handleClick = React.useCallback( | |
| 520 | (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 521 | onClick?.(event); | |
| 522 | if (event.defaultPrevented) return; | |
| 523 | if (suppressClickRef.current) { | |
| 524 | suppressClickRef.current = false; | |
| 525 | event.preventDefault(); | |
| 526 | return; | |
| 527 | } | |
| 528 | if (resolvedResizable && open) { | |
| 529 | event.preventDefault(); | |
| 530 | return; | |
| 531 | } | |
| 532 | toggleSidebar(); | |
| 533 | }, | |
| 534 | [onClick, open, resolvedResizable, toggleSidebar], | |
| 535 | ); | |
| 536 | ||
| 537 | React.useEffect(() => { | |
| 538 | if (!resolvedResizable?.storageKey || typeof window === "undefined") return; | |
| 539 | const rail = railRef.current; | |
| 540 | if (!rail) return; | |
| 541 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); | |
| 542 | if (!wrapper) return; | |
| 543 | ||
| 544 | const storedWidth = Number(window.localStorage.getItem(resolvedResizable.storageKey)); | |
| 545 | if (!Number.isFinite(storedWidth)) return; | |
| 546 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); | |
| 547 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); | |
| 548 | resolvedResizable.onResize?.(clampedWidth); | |
| 549 | }, [resolvedResizable]); | |
| 550 | ||
| 551 | React.useEffect(() => { | |
| 552 | return () => { | |
| 553 | const resizeState = resizeStateRef.current; | |
| 554 | if (resizeState?.rafId != null) { | |
| 555 | window.cancelAnimationFrame(resizeState.rafId); | |
| 556 | } | |
| 557 | resizeState?.transitionTargets.forEach((element) => { | |
| 558 | element.style.removeProperty("transition-duration"); | |
| 559 | }); | |
| 560 | document.body.style.removeProperty("cursor"); | |
| 561 | document.body.style.removeProperty("user-select"); | |
| 562 | }; | |
| 563 | }, []); | |
| 564 | ||
| 565 | return ( | |
| 566 | <button | |
| 567 | aria-label={railLabel} | |
| 568 | className={cn( | |
| 569 | "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex", | |
| 570 | "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | |
| 571 | "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | |
| 572 | "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", | |
| 573 | "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | |
| 574 | "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | |
| 575 | className, | |
| 576 | )} | |
| 577 | data-sidebar="rail" | |
| 578 | data-slot="sidebar-rail" | |
| 579 | onClick={handleClick} | |
| 580 | onPointerCancel={handlePointerCancel} | |
| 581 | onPointerDown={handlePointerDown} | |
| 582 | onPointerMove={handlePointerMove} | |
| 583 | onPointerUp={handlePointerUp} | |
| 584 | ref={railRef} | |
| 585 | tabIndex={-1} | |
| 586 | title={railTitle} | |
| 587 | type="button" | |
| 588 | {...props} | |
| 589 | /> | |
| 590 | ); | |
| 591 | } | |
| 592 | ||
| 593 | function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { | |
| 594 | return ( | |
| 595 | <main | |
| 596 | className={cn( | |
| 597 | "relative flex w-full flex-1 flex-col bg-background", | |
| 598 | "md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm/5", | |
| 599 | className, | |
| 600 | )} | |
| 601 | data-slot="sidebar-inset" | |
| 602 | {...props} | |
| 603 | /> | |
| 604 | ); | |
| 605 | } | |
| 606 | ||
| 607 | function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { | |
| 608 | return ( | |
| 609 | <Input | |
| 610 | className={cn("h-8 w-full bg-background shadow-none", className)} | |
| 611 | data-sidebar="input" | |
| 612 | data-slot="sidebar-input" | |
| 613 | {...props} | |
| 614 | /> | |
| 615 | ); | |
| 616 | } | |
| 617 | ||
| 618 | function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { | |
| 619 | return ( | |
| 620 | <div | |
| 621 | className={cn("flex flex-col gap-2 p-2", className)} | |
| 622 | data-sidebar="header" | |
| 623 | data-slot="sidebar-header" | |
| 624 | {...props} | |
| 625 | /> | |
| 626 | ); | |
| 627 | } | |
| 628 | ||
| 629 | function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { | |
| 630 | return ( | |
| 631 | <div | |
| 632 | className={cn("flex flex-col gap-2 p-2", className)} | |
| 633 | data-sidebar="footer" | |
| 634 | data-slot="sidebar-footer" | |
| 635 | {...props} | |
| 636 | /> | |
| 637 | ); | |
| 638 | } | |
| 639 | ||
| 640 | function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { | |
| 641 | return ( | |
| 642 | <Separator | |
| 643 | className={cn("mx-2 w-auto bg-sidebar-border", className)} | |
| 644 | data-sidebar="separator" | |
| 645 | data-slot="sidebar-separator" | |
| 646 | {...props} | |
| 647 | /> | |
| 648 | ); | |
| 649 | } | |
| 650 | ||
| 651 | function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { | |
| 652 | return ( | |
| 653 | <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade> | |
| 654 | <div | |
| 655 | className={cn( | |
| 656 | "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", | |
| 657 | className, | |
| 658 | )} | |
| 659 | data-sidebar="content" | |
| 660 | data-slot="sidebar-content" | |
| 661 | {...props} | |
| 662 | /> | |
| 663 | </ScrollArea> | |
| 664 | ); | |
| 665 | } | |
| 666 | ||
| 667 | function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { | |
| 668 | return ( | |
| 669 | <div | |
| 670 | className={cn("relative flex w-full min-w-0 flex-col p-2", className)} | |
| 671 | data-sidebar="group" | |
| 672 | data-slot="sidebar-group" | |
| 673 | {...props} | |
| 674 | /> | |
| 675 | ); | |
| 676 | } | |
| 677 | ||
| 678 | function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) { | |
| 679 | const defaultProps = { | |
| 680 | className: cn( | |
| 681 | "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | |
| 682 | "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", | |
| 683 | className, | |
| 684 | ), | |
| 685 | "data-sidebar": "group-label", | |
| 686 | "data-slot": "sidebar-group-label", | |
| 687 | }; | |
| 688 | ||
| 689 | return useRender({ | |
| 690 | defaultTagName: "div", | |
| 691 | props: mergeProps(defaultProps, props), | |
| 692 | render, | |
| 693 | }); | |
| 694 | } | |
| 695 | ||
| 696 | function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) { | |
| 697 | const defaultProps = { | |
| 698 | className: cn( | |
| 699 | "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | |
| 700 | // Increases the hit area of the button on mobile. | |
| 701 | "after:-inset-2 after:absolute md:after:hidden", | |
| 702 | "group-data-[collapsible=icon]:hidden", | |
| 703 | className, | |
| 704 | ), | |
| 705 | "data-sidebar": "group-action", | |
| 706 | "data-slot": "sidebar-group-action", | |
| 707 | }; | |
| 708 | ||
| 709 | return useRender({ | |
| 710 | defaultTagName: "button", | |
| 711 | props: mergeProps(defaultProps, props), | |
| 712 | render, | |
| 713 | }); | |
| 714 | } | |
| 715 | ||
| 716 | function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { | |
| 717 | return ( | |
| 718 | <div | |
| 719 | className={cn("w-full text-sm", className)} | |
| 720 | data-sidebar="group-content" | |
| 721 | data-slot="sidebar-group-content" | |
| 722 | {...props} | |
| 723 | /> | |
| 724 | ); | |
| 725 | } | |
| 726 | ||
| 727 | function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { | |
| 728 | return ( | |
| 729 | <ul | |
| 730 | className={cn("flex w-full min-w-0 flex-col gap-1", className)} | |
| 731 | data-sidebar="menu" | |
| 732 | data-slot="sidebar-menu" | |
| 733 | {...props} | |
| 734 | /> | |
| 735 | ); | |
| 736 | } | |
| 737 | ||
| 738 | function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { | |
| 739 | return ( | |
| 740 | <li | |
| 741 | className={cn("group/menu-item relative", className)} | |
| 742 | data-sidebar="menu-item" | |
| 743 | data-slot="sidebar-menu-item" | |
| 744 | {...props} | |
| 745 | /> | |
| 746 | ); | |
| 747 | } | |
| 748 | ||
| 749 | const sidebarMenuButtonVariants = cva( | |
| 750 | "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | |
| 751 | { | |
| 752 | defaultVariants: { | |
| 753 | size: "default", | |
| 754 | variant: "default", | |
| 755 | }, | |
| 756 | variants: { | |
| 757 | size: { | |
| 758 | default: "h-8 text-sm", | |
| 759 | lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", | |
| 760 | sm: "h-7 text-xs", | |
| 761 | }, | |
| 762 | variant: { | |
| 763 | default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", | |
| 764 | outline: | |
| 765 | "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", | |
| 766 | }, | |
| 767 | }, | |
| 768 | }, | |
| 769 | ); | |
| 770 | ||
| 771 | function SidebarMenuButton({ | |
| 772 | isActive = false, | |
| 773 | variant = "default", | |
| 774 | size = "default", | |
| 775 | tooltip, | |
| 776 | className, | |
| 777 | render, | |
| 778 | ...props | |
| 779 | }: useRender.ComponentProps<"button"> & { | |
| 780 | isActive?: boolean; | |
| 781 | tooltip?: string | React.ComponentProps<typeof TooltipPopup>; | |
| 782 | } & VariantProps<typeof sidebarMenuButtonVariants>) { | |
| 783 | const { isMobile, state } = useSidebar(); | |
| 784 | ||
| 785 | const defaultProps = { | |
| 786 | className: cn(sidebarMenuButtonVariants({ size, variant }), className), | |
| 787 | "data-active": isActive, | |
| 788 | "data-sidebar": "menu-button", | |
| 789 | "data-size": size, | |
| 790 | "data-slot": "sidebar-menu-button", | |
| 791 | }; | |
| 792 | ||
| 793 | const buttonProps = mergeProps<"button">(defaultProps, props); | |
| 794 | ||
| 795 | const buttonElement = useRender({ | |
| 796 | defaultTagName: "button", | |
| 797 | props: buttonProps, | |
| 798 | render, | |
| 799 | }); | |
| 800 | ||
| 801 | if (!tooltip) { | |
| 802 | return buttonElement; | |
| 803 | } | |
| 804 | ||
| 805 | if (typeof tooltip === "string") { | |
| 806 | tooltip = { | |
| 807 | children: tooltip, | |
| 808 | }; | |
| 809 | } | |
| 810 | ||
| 811 | return ( | |
| 812 | <Tooltip> | |
| 813 | <TooltipTrigger render={buttonElement as React.ReactElement<Record<string, unknown>>} /> | |
| 814 | <TooltipPopup | |
| 815 | align="center" | |
| 816 | hidden={state !== "collapsed" || isMobile} | |
| 817 | side="right" | |
| 818 | {...tooltip} | |
| 819 | /> | |
| 820 | </Tooltip> | |
| 821 | ); | |
| 822 | } | |
| 823 | ||
| 824 | function SidebarMenuAction({ | |
| 825 | className, | |
| 826 | showOnHover = false, | |
| 827 | render, | |
| 828 | ...props | |
| 829 | }: useRender.ComponentProps<"button"> & { | |
| 830 | showOnHover?: boolean; | |
| 831 | }) { | |
| 832 | const defaultProps = { | |
| 833 | className: cn( | |
| 834 | "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", | |
| 835 | // Increases the hit area of the button on mobile. | |
| 836 | "after:-inset-2 after:absolute md:after:hidden", | |
| 837 | "peer-data-[size=sm]/menu-button:top-1", | |
| 838 | "peer-data-[size=default]/menu-button:top-1.5", | |
| 839 | "peer-data-[size=lg]/menu-button:top-2.5", | |
| 840 | "group-data-[collapsible=icon]:hidden", | |
| 841 | showOnHover && | |
| 842 | "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", | |
| 843 | className, | |
| 844 | ), | |
| 845 | "data-sidebar": "menu-action", | |
| 846 | "data-slot": "sidebar-menu-action", | |
| 847 | }; | |
| 848 | ||
| 849 | return useRender({ | |
| 850 | defaultTagName: "button", | |
| 851 | props: mergeProps<"button">(defaultProps, props), | |
| 852 | render, | |
| 853 | }); | |
| 854 | } | |
| 855 | ||
| 856 | function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { | |
| 857 | return ( | |
| 858 | <div | |
| 859 | className={cn( | |
| 860 | "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-lg px-1 font-medium text-sidebar-foreground text-xs tabular-nums", | |
| 861 | "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", | |
| 862 | "peer-data-[size=sm]/menu-button:top-1", | |
| 863 | "peer-data-[size=default]/menu-button:top-1.5", | |
| 864 | "peer-data-[size=lg]/menu-button:top-2.5", | |
| 865 | "group-data-[collapsible=icon]:hidden", | |
| 866 | className, | |
| 867 | )} | |
| 868 | data-sidebar="menu-badge" | |
| 869 | data-slot="sidebar-menu-badge" | |
| 870 | {...props} | |
| 871 | /> | |
| 872 | ); | |
| 873 | } | |
| 874 | ||
| 875 | function SidebarMenuSkeleton({ | |
| 876 | className, | |
| 877 | showIcon = false, | |
| 878 | ...props | |
| 879 | }: React.ComponentProps<"div"> & { | |
| 880 | showIcon?: boolean; | |
| 881 | }) { | |
| 882 | // Random width between 50 to 90%. | |
| 883 | const width = React.useMemo(() => { | |
| 884 | return `${Math.floor(Math.random() * 40) + 50}%`; | |
| 885 | }, []); | |
| 886 | ||
| 887 | return ( | |
| 888 | <div | |
| 889 | className={cn("flex h-8 items-center gap-2 rounded-lg px-2", className)} | |
| 890 | data-sidebar="menu-skeleton" | |
| 891 | data-slot="sidebar-menu-skeleton" | |
| 892 | {...props} | |
| 893 | > | |
| 894 | {showIcon && <Skeleton className="size-4 rounded-lg" data-sidebar="menu-skeleton-icon" />} | |
| 895 | <Skeleton | |
| 896 | className="h-4 max-w-(--skeleton-width) flex-1" | |
| 897 | data-sidebar="menu-skeleton-text" | |
| 898 | style={ | |
| 899 | { | |
| 900 | "--skeleton-width": width, | |
| 901 | } as React.CSSProperties | |
| 902 | } | |
| 903 | /> | |
| 904 | </div> | |
| 905 | ); | |
| 906 | } | |
| 907 | ||
| 908 | function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { | |
| 909 | return ( | |
| 910 | <ul | |
| 911 | className={cn( | |
| 912 | "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5", | |
| 913 | "group-data-[collapsible=icon]:hidden", | |
| 914 | className, | |
| 915 | )} | |
| 916 | data-sidebar="menu-sub" | |
| 917 | data-slot="sidebar-menu-sub" | |
| 918 | {...props} | |
| 919 | /> | |
| 920 | ); | |
| 921 | } | |
| 922 | ||
| 923 | function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { | |
| 924 | return ( | |
| 925 | <li | |
| 926 | className={cn("group/menu-sub-item relative", className)} | |
| 927 | data-sidebar="menu-sub-item" | |
| 928 | data-slot="sidebar-menu-sub-item" | |
| 929 | {...props} | |
| 930 | /> | |
| 931 | ); | |
| 932 | } | |
| 933 | ||
| 934 | function SidebarMenuSubButton({ | |
| 935 | size = "md", | |
| 936 | isActive = false, | |
| 937 | className, | |
| 938 | render, | |
| 939 | ...props | |
| 940 | }: useRender.ComponentProps<"a"> & { | |
| 941 | size?: "sm" | "md"; | |
| 942 | isActive?: boolean; | |
| 943 | }) { | |
| 944 | const defaultProps = { | |
| 945 | className: cn( | |
| 946 | "-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", | |
| 947 | "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", | |
| 948 | size === "sm" && "text-xs", | |
| 949 | size === "md" && "text-sm", | |
| 950 | "group-data-[collapsible=icon]:hidden", | |
| 951 | className, | |
| 952 | ), | |
| 953 | "data-active": isActive, | |
| 954 | "data-sidebar": "menu-sub-button", | |
| 955 | "data-size": size, | |
| 956 | "data-slot": "sidebar-menu-sub-button", | |
| 957 | }; | |
| 958 | ||
| 959 | return useRender({ | |
| 960 | defaultTagName: "a", | |
| 961 | props: mergeProps<"a">(defaultProps, props), | |
| 962 | render, | |
| 963 | }); | |
| 964 | } | |
| 965 | ||
| 966 | export { | |
| 967 | Sidebar, | |
| 968 | SidebarContent, | |
| 969 | SidebarFooter, | |
| 970 | SidebarGroup, | |
| 971 | SidebarGroupAction, | |
| 972 | SidebarGroupContent, | |
| 973 | SidebarGroupLabel, | |
| 974 | SidebarHeader, | |
| 975 | SidebarInput, | |
| 976 | SidebarInset, | |
| 977 | SidebarMenu, | |
| 978 | SidebarMenuAction, | |
| 979 | SidebarMenuBadge, | |
| 980 | SidebarMenuButton, | |
| 981 | SidebarMenuItem, | |
| 982 | SidebarMenuSkeleton, | |
| 983 | SidebarMenuSub, | |
| 984 | SidebarMenuSubButton, | |
| 985 | SidebarMenuSubItem, | |
| 986 | SidebarProvider, | |
| 987 | SidebarRail, | |
| 988 | SidebarSeparator, | |
| 989 | SidebarTrigger, | |
| 990 | useSidebar, | |
| 991 | }; |
apps/web/src/components/ui/toast.tsx+6-4| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | |
| 6 | 7 | import { |
| 7 | 8 | CircleAlertIcon, |
| 8 | 9 | CircleCheckIcon, |
| ... | ... | @@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button"; |
| 16 | 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 18 | |
| 18 | 19 | type ThreadToastData = { |
| 19 | threadId?: string | null; | |
| 20 | threadId?: ThreadId | null; | |
| 20 | 21 | tooltipStyle?: boolean; |
| 21 | 22 | dismissAfterVisibleMs?: number; |
| 22 | 23 | }; |
| ... | ... | @@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props { |
| 48 | 49 | |
| 49 | 50 | function shouldRenderForActiveThread( |
| 50 | 51 | data: ThreadToastData | undefined, |
| 51 | activeThreadId: string | null, | |
| 52 | activeThreadId: ThreadId | null, | |
| 52 | 53 | ): boolean { |
| 53 | 54 | const toastThreadId = data?.threadId; |
| 54 | 55 | if (!toastThreadId) return true; |
| 55 | 56 | return toastThreadId === activeThreadId; |
| 56 | 57 | } |
| 57 | 58 | |
| 58 | function useActiveThreadIdFromRoute(): string | null { | |
| 59 | function useActiveThreadIdFromRoute(): ThreadId | null { | |
| 59 | 60 | return useParams({ |
| 60 | 61 | strict: false, |
| 61 | select: (params) => params.threadId ?? null, | |
| 62 | select: (params) => | |
| 63 | typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, | |
| 62 | 64 | }); |
| 63 | 65 | } |
| 64 | 66 |
apps/web/src/components/ui/toggle-group.tsx+3-1| ... | ... | @@ -42,7 +42,9 @@ function ToggleGroup({ |
| 42 | 42 | orientation={orientation} |
| 43 | 43 | {...props} |
| 44 | 44 | > |
| 45 | <ToggleGroupContext.Provider value={{ size, variant }}> | |
| 45 | <ToggleGroupContext.Provider | |
| 46 | value={React.useMemo(() => ({ size, variant }), [size, variant])} | |
| 47 | > | |
| 46 | 48 | {children} |
| 47 | 49 | </ToggleGroupContext.Provider> |
| 48 | 50 | </ToggleGroupPrimitive> |
apps/web/src/components/ui/tooltip.tsx created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | const TooltipCreateHandle = TooltipPrimitive.createHandle; | |
| 6 | ||
| 7 | const TooltipProvider = TooltipPrimitive.Provider; | |
| 8 | ||
| 9 | const Tooltip = TooltipPrimitive.Root; | |
| 10 | ||
| 11 | function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { | |
| 12 | return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; | |
| 13 | } | |
| 14 | ||
| 15 | function TooltipPopup({ | |
| 16 | className, | |
| 17 | align = "center", | |
| 18 | sideOffset = 4, | |
| 19 | side = "top", | |
| 20 | anchor, | |
| 21 | children, | |
| 22 | ...props | |
| 23 | }: TooltipPrimitive.Popup.Props & { | |
| 24 | align?: TooltipPrimitive.Positioner.Props["align"]; | |
| 25 | side?: TooltipPrimitive.Positioner.Props["side"]; | |
| 26 | sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; | |
| 27 | anchor?: TooltipPrimitive.Positioner.Props["anchor"]; | |
| 28 | }) { | |
| 29 | return ( | |
| 30 | <TooltipPrimitive.Portal> | |
| 31 | <TooltipPrimitive.Positioner | |
| 32 | align={align} | |
| 33 | anchor={anchor} | |
| 34 | className="z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom,transform] data-instant:transition-none" | |
| 35 | data-slot="tooltip-positioner" | |
| 36 | side={side} | |
| 37 | sideOffset={sideOffset} | |
| 38 | > | |
| 39 | <TooltipPrimitive.Popup | |
| 40 | className={cn( | |
| 41 | "relative flex h-(--popup-height,auto) w-(--popup-width,auto) origin-(--transform-origin) text-balance rounded-md border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs shadow-md/5 transition-[width,height,scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | |
| 42 | className, | |
| 43 | )} | |
| 44 | data-slot="tooltip-popup" | |
| 45 | {...props} | |
| 46 | > | |
| 47 | <TooltipPrimitive.Viewport | |
| 48 | className="relative size-full overflow-clip px-(--viewport-inline-padding) py-1 [--viewport-inline-padding:--spacing(2)] data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:truncate **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity" | |
| 49 | data-slot="tooltip-viewport" | |
| 50 | > | |
| 51 | {children} | |
| 52 | </TooltipPrimitive.Viewport> | |
| 53 | </TooltipPrimitive.Popup> | |
| 54 | </TooltipPrimitive.Positioner> | |
| 55 | </TooltipPrimitive.Portal> | |
| 56 | ); | |
| 57 | } | |
| 58 | ||
| 59 | export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; |
apps/web/src/contextMenuFallback.ts+4-2| ... | ... | @@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>( |
| 41 | 41 | const btn = document.createElement("button"); |
| 42 | 42 | btn.type = "button"; |
| 43 | 43 | btn.textContent = item.label; |
| 44 | btn.className = | |
| 45 | "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default"; | |
| 44 | const isDeleteAction = item.id === "delete"; | |
| 45 | btn.className = isDeleteAction | |
| 46 | ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" | |
| 47 | : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; | |
| 46 | 48 | btn.addEventListener("click", () => cleanup(item.id)); |
| 47 | 49 | menu.appendChild(btn); |
| 48 | 50 | } |
apps/web/src/diffRouteSearch.test.ts+22-1| ... | ... | @@ -17,6 +17,28 @@ describe("parseDiffRouteSearch", () => { |
| 17 | 17 | }); |
| 18 | 18 | }); |
| 19 | 19 | |
| 20 | it("treats numeric and boolean diff toggles as open", () => { | |
| 21 | expect( | |
| 22 | parseDiffRouteSearch({ | |
| 23 | diff: 1, | |
| 24 | diffTurnId: "turn-1", | |
| 25 | }), | |
| 26 | ).toEqual({ | |
| 27 | diff: "1", | |
| 28 | diffTurnId: "turn-1", | |
| 29 | }); | |
| 30 | ||
| 31 | expect( | |
| 32 | parseDiffRouteSearch({ | |
| 33 | diff: true, | |
| 34 | diffTurnId: "turn-1", | |
| 35 | }), | |
| 36 | ).toEqual({ | |
| 37 | diff: "1", | |
| 38 | diffTurnId: "turn-1", | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 20 | 42 | it("drops turn and file values when diff is closed", () => { |
| 21 | 43 | const parsed = parseDiffRouteSearch({ |
| 22 | 44 | diff: "0", |
| ... | ... | @@ -50,4 +72,3 @@ describe("parseDiffRouteSearch", () => { |
| 50 | 72 | }); |
| 51 | 73 | }); |
| 52 | 74 | }); |
| 53 |
apps/web/src/diffRouteSearch.ts+10-3| ... | ... | @@ -1,9 +1,15 @@ |
| 1 | import { TurnId } from "@t3tools/contracts"; | |
| 2 | ||
| 1 | 3 | export interface DiffRouteSearch { |
| 2 | 4 | diff?: "1"; |
| 3 | diffTurnId?: string; | |
| 5 | diffTurnId?: TurnId; | |
| 4 | 6 | diffFilePath?: string; |
| 5 | 7 | } |
| 6 | 8 | |
| 9 | function isDiffOpenValue(value: unknown): boolean { | |
| 10 | return value === "1" || value === 1 || value === true; | |
| 11 | } | |
| 12 | ||
| 7 | 13 | function normalizeSearchString(value: unknown): string | undefined { |
| 8 | 14 | if (typeof value !== "string") { |
| 9 | 15 | return undefined; |
| ... | ... | @@ -20,8 +26,9 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>( |
| 20 | 26 | } |
| 21 | 27 | |
| 22 | 28 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { |
| 23 | const diff = search.diff === "1" ? "1" : undefined; | |
| 24 | const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 29 | const diff = isDiffOpenValue(search.diff) ? "1" : undefined; | |
| 30 | const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 31 | const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; | |
| 25 | 32 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; |
| 26 | 33 | |
| 27 | 34 | return { |
apps/web/src/historyBootstrap.test.ts+10-7| ... | ... | @@ -1,20 +1,23 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { buildBootstrapInput } from "./historyBootstrap"; |
| 4 | 5 | |
| 6 | const messageId = (value: string) => MessageId.makeUnsafe(value); | |
| 7 | ||
| 5 | 8 | describe("buildBootstrapInput", () => { |
| 6 | 9 | it("includes full transcript when under budget", () => { |
| 7 | 10 | const result = buildBootstrapInput( |
| 8 | 11 | [ |
| 9 | 12 | { |
| 10 | id: "u-1", | |
| 13 | id: messageId("u-1"), | |
| 11 | 14 | role: "user", |
| 12 | 15 | text: "hello", |
| 13 | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 14 | 17 | streaming: false, |
| 15 | 18 | }, |
| 16 | 19 | { |
| 17 | id: "a-1", | |
| 20 | id: messageId("a-1"), | |
| 18 | 21 | role: "assistant", |
| 19 | 22 | text: "world", |
| 20 | 23 | createdAt: "2026-02-09T00:00:01.000Z", |
| ... | ... | @@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => { |
| 38 | 41 | const result = buildBootstrapInput( |
| 39 | 42 | [ |
| 40 | 43 | { |
| 41 | id: "u-1", | |
| 44 | id: messageId("u-1"), | |
| 42 | 45 | role: "user", |
| 43 | 46 | text: "first question with details", |
| 44 | 47 | createdAt: "2026-02-09T00:00:00.000Z", |
| 45 | 48 | streaming: false, |
| 46 | 49 | }, |
| 47 | 50 | { |
| 48 | id: "a-1", | |
| 51 | id: messageId("a-1"), | |
| 49 | 52 | role: "assistant", |
| 50 | 53 | text: "first answer with details", |
| 51 | 54 | createdAt: "2026-02-09T00:00:01.000Z", |
| 52 | 55 | streaming: false, |
| 53 | 56 | }, |
| 54 | 57 | { |
| 55 | id: "u-2", | |
| 58 | id: messageId("u-2"), | |
| 56 | 59 | role: "user", |
| 57 | 60 | text: "second question with details", |
| 58 | 61 | createdAt: "2026-02-09T00:00:02.000Z", |
| ... | ... | @@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => { |
| 75 | 78 | const result = buildBootstrapInput( |
| 76 | 79 | [ |
| 77 | 80 | { |
| 78 | id: "u-1", | |
| 81 | id: messageId("u-1"), | |
| 79 | 82 | role: "user", |
| 80 | 83 | text: "old context", |
| 81 | 84 | createdAt: "2026-02-09T00:00:00.000Z", |
| ... | ... | @@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => { |
| 96 | 99 | const result = buildBootstrapInput( |
| 97 | 100 | [ |
| 98 | 101 | { |
| 99 | id: "u-image", | |
| 102 | id: messageId("u-image"), | |
| 100 | 103 | role: "user", |
| 101 | 104 | text: "", |
| 102 | 105 | attachments: [ |
apps/web/src/hooks/useNativeApi.ts deleted-6| ... | ... | @@ -1,6 +0,0 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { readNativeApi } from "../session-logic"; | |
| 3 | ||
| 4 | export function useNativeApi() { | |
| 5 | return useMemo(() => readNativeApi(), []); | |
| 6 | } |
apps/web/src/hooks/useTheme.ts+18-4| ... | ... | @@ -1,11 +1,16 @@ |
| 1 | 1 | import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 2 | 2 | |
| 3 | 3 | type Theme = "light" | "dark" | "system"; |
| 4 | type ThemeSnapshot = { | |
| 5 | theme: Theme; | |
| 6 | systemDark: boolean; | |
| 7 | }; | |
| 4 | 8 | |
| 5 | 9 | const STORAGE_KEY = "t3code:theme"; |
| 6 | 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 7 | 11 | |
| 8 | 12 | let listeners: Array<() => void> = []; |
| 13 | let lastSnapshot: ThemeSnapshot | null = null; | |
| 9 | 14 | function emitChange() { |
| 10 | 15 | for (const listener of listeners) listener(); |
| 11 | 16 | } |
| ... | ... | @@ -39,8 +44,16 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 39 | 44 | // Apply immediately on module load to prevent flash |
| 40 | 45 | applyTheme(getStored()); |
| 41 | 46 | |
| 42 | function getSnapshot(): Theme { | |
| 43 | return getStored(); | |
| 47 | function getSnapshot(): ThemeSnapshot { | |
| 48 | const theme = getStored(); | |
| 49 | const systemDark = theme === "system" ? getSystemDark() : false; | |
| 50 | ||
| 51 | if (lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark) { | |
| 52 | return lastSnapshot; | |
| 53 | } | |
| 54 | ||
| 55 | lastSnapshot = { theme, systemDark }; | |
| 56 | return lastSnapshot; | |
| 44 | 57 | } |
| 45 | 58 | |
| 46 | 59 | function subscribe(listener: () => void): () => void { |
| ... | ... | @@ -71,10 +84,11 @@ function subscribe(listener: () => void): () => void { |
| 71 | 84 | } |
| 72 | 85 | |
| 73 | 86 | export function useTheme() { |
| 74 | const theme = useSyncExternalStore(subscribe, getSnapshot); | |
| 87 | const snapshot = useSyncExternalStore(subscribe, getSnapshot); | |
| 88 | const theme = snapshot.theme; | |
| 75 | 89 | |
| 76 | 90 | const resolvedTheme: "light" | "dark" = |
| 77 | theme === "system" ? (getSystemDark() ? "dark" : "light") : theme; | |
| 91 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; | |
| 78 | 92 | |
| 79 | 93 | const setTheme = useCallback((next: Theme) => { |
| 80 | 94 | localStorage.setItem(STORAGE_KEY, next); |
apps/web/src/hooks/useTurnDiffSummaries.ts+3-19| ... | ... | @@ -1,29 +1,13 @@ |
| 1 | import type { ProviderEvent } from "@t3tools/contracts"; | |
| 2 | 1 | import { useMemo } from "react"; |
| 3 | import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; | |
| 4 | import type { Thread, TurnDiffSummary } from "../types"; | |
| 5 | ||
| 6 | const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>(); | |
| 7 | ||
| 8 | function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { | |
| 9 | const cached = derivedTurnDiffSummaryCache.get(events); | |
| 10 | if (cached) { | |
| 11 | return cached; | |
| 12 | } | |
| 13 | const derived = deriveTurnDiffSummaries(events); | |
| 14 | derivedTurnDiffSummaryCache.set(events, derived); | |
| 15 | return derived; | |
| 16 | } | |
| 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; | |
| 3 | import type { Thread } from "../types"; | |
| 17 | 4 | |
| 18 | 5 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { |
| 19 | 6 | const turnDiffSummaries = useMemo(() => { |
| 20 | 7 | if (!activeThread) { |
| 21 | 8 | return []; |
| 22 | 9 | } |
| 23 | if (activeThread.turnDiffSummaries.length > 0) { | |
| 24 | return activeThread.turnDiffSummaries; | |
| 25 | } | |
| 26 | return deriveTurnDiffSummariesCached(activeThread.events); | |
| 10 | return activeThread.turnDiffSummaries; | |
| 27 | 11 | }, [activeThread]); |
| 28 | 12 | |
| 29 | 13 | const inferredCheckpointTurnCountByTurnId = useMemo( |
apps/web/src/index.css+17| ... | ... | @@ -133,6 +133,18 @@ body { |
| 133 | 133 | min-height: 100vh; |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | html, | |
| 137 | body, | |
| 138 | #root { | |
| 139 | height: 100%; | |
| 140 | width: 100%; | |
| 141 | max-width: 100%; | |
| 142 | overflow-y: hidden; | |
| 143 | overflow-x: hidden; | |
| 144 | overflow-x: clip; | |
| 145 | overscroll-behavior-y: none; | |
| 146 | } | |
| 147 | ||
| 136 | 148 | body::after { |
| 137 | 149 | content: ""; |
| 138 | 150 | position: fixed; |
| ... | ... | @@ -229,7 +241,9 @@ label:has(> select#reasoning-effort) select { |
| 229 | 241 | |
| 230 | 242 | /* Chat markdown rendering */ |
| 231 | 243 | .chat-markdown { |
| 244 | min-width: 0; | |
| 232 | 245 | overflow-wrap: anywhere; |
| 246 | word-break: break-word; | |
| 233 | 247 | } |
| 234 | 248 | |
| 235 | 249 | .chat-markdown > :first-child { |
| ... | ... | @@ -280,9 +294,11 @@ label:has(> select#reasoning-effort) select { |
| 280 | 294 | background: var(--muted); |
| 281 | 295 | padding: 0.1rem 0.35rem; |
| 282 | 296 | color: var(--foreground); |
| 297 | font-size: 0.75rem; | |
| 283 | 298 | } |
| 284 | 299 | |
| 285 | 300 | .chat-markdown pre { |
| 301 | max-width: 100%; | |
| 286 | 302 | overflow-x: auto; |
| 287 | 303 | border: 1px solid var(--border); |
| 288 | 304 | border-radius: 0.75rem; |
| ... | ... | @@ -295,6 +311,7 @@ label:has(> select#reasoning-effort) select { |
| 295 | 311 | background: transparent; |
| 296 | 312 | padding: 0; |
| 297 | 313 | line-height: 1.5; |
| 314 | font-size: 0.875rem; | |
| 298 | 315 | } |
| 299 | 316 | |
| 300 | 317 | .chat-markdown table { |
apps/web/src/keybindings.test.ts+23-3| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | formatShortcutLabel, |
| 11 | 11 | isChatNewShortcut, |
| 12 | 12 | isChatNewLocalShortcut, |
| 13 | isDiffToggleShortcut, | |
| 13 | 14 | isOpenFavoriteEditorShortcut, |
| 14 | 15 | isTerminalClearShortcut, |
| 15 | 16 | isTerminalCloseShortcut, |
| ... | ... | @@ -91,6 +92,11 @@ const DEFAULT_BINDINGS = compile([ |
| 91 | 92 | command: "terminal.close", |
| 92 | 93 | whenAst: whenIdentifier("terminalFocus"), |
| 93 | 94 | }, |
| 95 | { | |
| 96 | shortcut: modShortcut("d"), | |
| 97 | command: "diff.toggle", | |
| 98 | whenAst: whenNot(whenIdentifier("terminalFocus")), | |
| 99 | }, | |
| 94 | 100 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 95 | 101 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, |
| 96 | 102 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| ... | ... | @@ -230,6 +236,7 @@ describe("shortcutLabelForCommand", () => { |
| 230 | 236 | |
| 231 | 237 | it("returns labels for non-terminal commands", () => { |
| 232 | 238 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 239 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); | |
| 233 | 240 | assert.strictEqual( |
| 234 | 241 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), |
| 235 | 242 | "Ctrl+O", |
| ... | ... | @@ -276,6 +283,21 @@ describe("chat/editor shortcuts", () => { |
| 276 | 283 | }), |
| 277 | 284 | ); |
| 278 | 285 | }); |
| 286 | ||
| 287 | it("matches diff.toggle shortcut outside terminal focus", () => { | |
| 288 | assert.isTrue( | |
| 289 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | |
| 290 | platform: "MacIntel", | |
| 291 | context: { terminalFocus: false }, | |
| 292 | }), | |
| 293 | ); | |
| 294 | assert.isFalse( | |
| 295 | isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | |
| 296 | platform: "MacIntel", | |
| 297 | context: { terminalFocus: true }, | |
| 298 | }), | |
| 299 | ); | |
| 300 | }); | |
| 279 | 301 | }); |
| 280 | 302 | |
| 281 | 303 | describe("cross-command precedence", () => { |
| ... | ... | @@ -342,9 +364,7 @@ describe("cross-command precedence", () => { |
| 342 | 364 | |
| 343 | 365 | describe("resolveShortcutCommand", () => { |
| 344 | 366 | it("returns dynamic script commands", () => { |
| 345 | const keybindings = compile([ | |
| 346 | { shortcut: modShortcut("r"), command: "script.setup.run" }, | |
| 347 | ]); | |
| 367 | const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); | |
| 348 | 368 | |
| 349 | 369 | assert.strictEqual( |
| 350 | 370 | resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, { |
apps/web/src/keybindings.ts+8| ... | ... | @@ -198,6 +198,14 @@ export function isTerminalCloseShortcut( |
| 198 | 198 | return matchesCommandShortcut(event, keybindings, "terminal.close", options); |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | export function isDiffToggleShortcut( | |
| 202 | event: ShortcutEventLike, | |
| 203 | keybindings: ResolvedKeybindingsConfig, | |
| 204 | options?: ShortcutMatchOptions, | |
| 205 | ): boolean { | |
| 206 | return matchesCommandShortcut(event, keybindings, "diff.toggle", options); | |
| 207 | } | |
| 208 | ||
| 201 | 209 | export function isChatNewShortcut( |
| 202 | 210 | event: ShortcutEventLike, |
| 203 | 211 | keybindings: ResolvedKeybindingsConfig, |
apps/web/src/lib/diffRendering.test.ts+8| ... | ... | @@ -20,4 +20,12 @@ describe("buildPatchCacheKey", () => { |
| 20 | 20 | |
| 21 | 21 | expect(buildPatchCacheKey(before)).not.toBe(buildPatchCacheKey(after)); |
| 22 | 22 | }); |
| 23 | ||
| 24 | it("changes when cache scope changes", () => { | |
| 25 | const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; | |
| 26 | ||
| 27 | expect(buildPatchCacheKey(patch, "diff-panel:light")).not.toBe( | |
| 28 | buildPatchCacheKey(patch, "diff-panel:dark"), | |
| 29 | ); | |
| 30 | }); | |
| 23 | 31 | }); |
apps/web/src/lib/gitReactQuery.test.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { QueryClient } from "@tanstack/react-query"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | import { | |
| 4 | gitMutationKeys, | |
| 5 | gitPullMutationOptions, | |
| 6 | gitRunStackedActionMutationOptions, | |
| 7 | } from "./gitReactQuery"; | |
| 8 | ||
| 9 | describe("gitMutationKeys", () => { | |
| 10 | it("scopes stacked action keys by cwd", () => { | |
| 11 | expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( | |
| 12 | gitMutationKeys.runStackedAction("/repo/b"), | |
| 13 | ); | |
| 14 | }); | |
| 15 | ||
| 16 | it("scopes pull keys by cwd", () => { | |
| 17 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | |
| 18 | }); | |
| 19 | }); | |
| 20 | ||
| 21 | describe("git mutation options", () => { | |
| 22 | const queryClient = new QueryClient(); | |
| 23 | ||
| 24 | it("attaches cwd-scoped mutation key for runStackedAction", () => { | |
| 25 | const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); | |
| 26 | expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); | |
| 27 | }); | |
| 28 | ||
| 29 | it("attaches cwd-scoped mutation key for pull", () => { | |
| 30 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | |
| 31 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | |
| 32 | }); | |
| 33 | }); |
apps/web/src/lib/gitReactQuery.ts+54-53| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; | |
| 1 | import type { GitStackedAction } from "@t3tools/contracts"; | |
| 2 | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "../nativeApi"; | |
| 3 | 4 | |
| 4 | 5 | const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 5 | 6 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| ... | ... | @@ -12,20 +13,28 @@ export const gitQueryKeys = { |
| 12 | 13 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, |
| 13 | 14 | }; |
| 14 | 15 | |
| 16 | export const gitMutationKeys = { | |
| 17 | init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, | |
| 18 | checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, | |
| 19 | createBranchAndCheckout: (cwd: string | null) => | |
| 20 | ["git", "mutation", "create-branch-and-checkout", cwd] as const, | |
| 21 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | |
| 22 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | |
| 23 | }; | |
| 24 | ||
| 15 | 25 | export function invalidateGitQueries(queryClient: QueryClient) { |
| 16 | 26 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); |
| 17 | 27 | } |
| 18 | 28 | |
| 19 | export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 29 | export function gitStatusQueryOptions(cwd: string | null) { | |
| 20 | 30 | return queryOptions({ |
| 21 | 31 | queryKey: gitQueryKeys.status(cwd), |
| 22 | 32 | queryFn: async () => { |
| 23 | if (!api || !cwd) { | |
| 24 | throw new Error("Git status is unavailable."); | |
| 25 | } | |
| 33 | const api = ensureNativeApi(); | |
| 34 | if (!cwd) throw new Error("Git status is unavailable."); | |
| 26 | 35 | return api.git.status({ cwd }); |
| 27 | 36 | }, |
| 28 | enabled: !!api && !!cwd, | |
| 37 | enabled: cwd !== null, | |
| 29 | 38 | staleTime: GIT_STATUS_STALE_TIME_MS, |
| 30 | 39 | refetchOnWindowFocus: "always", |
| 31 | 40 | refetchOnReconnect: "always", |
| ... | ... | @@ -33,16 +42,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | |
| 33 | 42 | }); |
| 34 | 43 | } |
| 35 | 44 | |
| 36 | export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) { | |
| 45 | export function gitBranchesQueryOptions(cwd: string | null) { | |
| 37 | 46 | return queryOptions({ |
| 38 | 47 | queryKey: gitQueryKeys.branches(cwd), |
| 39 | 48 | queryFn: async () => { |
| 40 | if (!api || !cwd) { | |
| 41 | throw new Error("Git branches are unavailable."); | |
| 42 | } | |
| 49 | const api = ensureNativeApi(); | |
| 50 | if (!cwd) throw new Error("Git branches are unavailable."); | |
| 43 | 51 | return api.git.listBranches({ cwd }); |
| 44 | 52 | }, |
| 45 | enabled: !!api && !!cwd, | |
| 53 | enabled: cwd !== null, | |
| 46 | 54 | staleTime: GIT_BRANCHES_STALE_TIME_MS, |
| 47 | 55 | refetchOnWindowFocus: true, |
| 48 | 56 | refetchOnReconnect: true, |
| ... | ... | @@ -50,15 +58,13 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string |
| 50 | 58 | }); |
| 51 | 59 | } |
| 52 | 60 | |
| 53 | export function gitInitMutationOptions(input: { | |
| 54 | api: NativeApi | undefined; | |
| 55 | cwd: string | null; | |
| 56 | queryClient: QueryClient; | |
| 57 | }) { | |
| 61 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 58 | 62 | return mutationOptions({ |
| 63 | mutationKey: gitMutationKeys.init(input.cwd), | |
| 59 | 64 | mutationFn: async () => { |
| 60 | if (!input.api || !input.cwd) throw new Error("Git init is unavailable."); | |
| 61 | return input.api.git.init({ cwd: input.cwd }); | |
| 65 | const api = ensureNativeApi(); | |
| 66 | if (!input.cwd) throw new Error("Git init is unavailable."); | |
| 67 | return api.git.init({ cwd: input.cwd }); | |
| 62 | 68 | }, |
| 63 | 69 | onSuccess: async () => { |
| 64 | 70 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -67,14 +73,15 @@ export function gitInitMutationOptions(input: { |
| 67 | 73 | } |
| 68 | 74 | |
| 69 | 75 | export function gitCheckoutMutationOptions(input: { |
| 70 | api: NativeApi | undefined; | |
| 71 | 76 | cwd: string | null; |
| 72 | 77 | queryClient: QueryClient; |
| 73 | 78 | }) { |
| 74 | 79 | return mutationOptions({ |
| 80 | mutationKey: gitMutationKeys.checkout(input.cwd), | |
| 75 | 81 | mutationFn: async (branch: string) => { |
| 76 | if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable."); | |
| 77 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 82 | const api = ensureNativeApi(); | |
| 83 | if (!input.cwd) throw new Error("Git checkout is unavailable."); | |
| 84 | return api.git.checkout({ cwd: input.cwd, branch }); | |
| 78 | 85 | }, |
| 79 | 86 | onSuccess: async () => { |
| 80 | 87 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -83,15 +90,16 @@ export function gitCheckoutMutationOptions(input: { |
| 83 | 90 | } |
| 84 | 91 | |
| 85 | 92 | export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 86 | api: NativeApi | undefined; | |
| 87 | 93 | cwd: string | null; |
| 88 | 94 | queryClient: QueryClient; |
| 89 | 95 | }) { |
| 90 | 96 | return mutationOptions({ |
| 97 | mutationKey: gitMutationKeys.createBranchAndCheckout(input.cwd), | |
| 91 | 98 | mutationFn: async (branch: string) => { |
| 92 | if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable."); | |
| 93 | await input.api.git.createBranch({ cwd: input.cwd, branch }); | |
| 94 | return input.api.git.checkout({ cwd: input.cwd, branch }); | |
| 99 | const api = ensureNativeApi(); | |
| 100 | if (!input.cwd) throw new Error("Git branch creation is unavailable."); | |
| 101 | await api.git.createBranch({ cwd: input.cwd, branch }); | |
| 102 | return api.git.checkout({ cwd: input.cwd, branch }); | |
| 95 | 103 | }, |
| 96 | 104 | onSuccess: async () => { |
| 97 | 105 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -100,11 +108,11 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 100 | 108 | } |
| 101 | 109 | |
| 102 | 110 | export function gitRunStackedActionMutationOptions(input: { |
| 103 | api: NativeApi | undefined; | |
| 104 | 111 | cwd: string | null; |
| 105 | 112 | queryClient: QueryClient; |
| 106 | 113 | }) { |
| 107 | 114 | return mutationOptions({ |
| 115 | mutationKey: gitMutationKeys.runStackedAction(input.cwd), | |
| 108 | 116 | mutationFn: async ({ |
| 109 | 117 | action, |
| 110 | 118 | commitMessage, |
| ... | ... | @@ -112,10 +120,9 @@ export function gitRunStackedActionMutationOptions(input: { |
| 112 | 120 | action: GitStackedAction; |
| 113 | 121 | commitMessage?: string; |
| 114 | 122 | }) => { |
| 115 | if (!input.api || !input.cwd) { | |
| 116 | throw new Error("Git action is unavailable."); | |
| 117 | } | |
| 118 | return input.api.git.runStackedAction({ | |
| 123 | const api = ensureNativeApi(); | |
| 124 | if (!input.cwd) throw new Error("Git action is unavailable."); | |
| 125 | return api.git.runStackedAction({ | |
| 119 | 126 | cwd: input.cwd, |
| 120 | 127 | action, |
| 121 | 128 | ...(commitMessage ? { commitMessage } : {}), |
| ... | ... | @@ -127,15 +134,13 @@ export function gitRunStackedActionMutationOptions(input: { |
| 127 | 134 | }); |
| 128 | 135 | } |
| 129 | 136 | |
| 130 | export function gitPullMutationOptions(input: { | |
| 131 | api: NativeApi | undefined; | |
| 132 | cwd: string | null; | |
| 133 | queryClient: QueryClient; | |
| 134 | }) { | |
| 137 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 135 | 138 | return mutationOptions({ |
| 139 | mutationKey: gitMutationKeys.pull(input.cwd), | |
| 136 | 140 | mutationFn: async () => { |
| 137 | if (!input.api || !input.cwd) throw new Error("Git pull is unavailable."); | |
| 138 | return input.api.git.pull({ cwd: input.cwd }); | |
| 141 | const api = ensureNativeApi(); | |
| 142 | if (!input.cwd) throw new Error("Git pull is unavailable."); | |
| 143 | return api.git.pull({ cwd: input.cwd }); | |
| 139 | 144 | }, |
| 140 | 145 | onSettled: async () => { |
| 141 | 146 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -143,42 +148,38 @@ export function gitPullMutationOptions(input: { |
| 143 | 148 | }); |
| 144 | 149 | } |
| 145 | 150 | |
| 146 | export function gitCreateWorktreeMutationOptions(input: { | |
| 147 | api: NativeApi | undefined; | |
| 148 | queryClient: QueryClient; | |
| 149 | }) { | |
| 151 | export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 150 | 152 | return mutationOptions({ |
| 151 | 153 | mutationFn: async ({ |
| 152 | 154 | cwd, |
| 153 | 155 | branch, |
| 154 | 156 | newBranch, |
| 157 | path, | |
| 155 | 158 | }: { |
| 156 | 159 | cwd: string; |
| 157 | 160 | branch: string; |
| 158 | 161 | newBranch: string; |
| 162 | path?: string | null; | |
| 159 | 163 | }) => { |
| 160 | if (!input.api) { | |
| 161 | throw new Error("Git worktree creation is unavailable."); | |
| 162 | } | |
| 163 | return input.api.git.createWorktree({ cwd, branch, newBranch }); | |
| 164 | const api = ensureNativeApi(); | |
| 165 | if (!cwd) throw new Error("Git worktree creation is unavailable."); | |
| 166 | return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | |
| 164 | 167 | }, |
| 168 | mutationKey: ["git", "mutation", "create-worktree"] as const, | |
| 165 | 169 | onSettled: async () => { |
| 166 | 170 | await invalidateGitQueries(input.queryClient); |
| 167 | 171 | }, |
| 168 | 172 | }); |
| 169 | 173 | } |
| 170 | 174 | |
| 171 | export function gitRemoveWorktreeMutationOptions(input: { | |
| 172 | api: NativeApi | undefined; | |
| 173 | queryClient: QueryClient; | |
| 174 | }) { | |
| 175 | export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 175 | 176 | return mutationOptions({ |
| 176 | 177 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { |
| 177 | if (!input.api) { | |
| 178 | throw new Error("Git worktree removal is unavailable."); | |
| 179 | } | |
| 180 | return input.api.git.removeWorktree({ cwd, path, force }); | |
| 178 | const api = ensureNativeApi(); | |
| 179 | if (!cwd) throw new Error("Git worktree removal is unavailable."); | |
| 180 | return api.git.removeWorktree({ cwd, path, force }); | |
| 181 | 181 | }, |
| 182 | mutationKey: ["git", "mutation", "remove-worktree"] as const, | |
| 182 | 183 | onSettled: async () => { |
| 183 | 184 | await invalidateGitQueries(input.queryClient); |
| 184 | 185 | }, |
apps/web/src/lib/projectReactQuery.ts+6-5| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 1 | import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | 4 | |
| 4 | 5 | export const projectQueryKeys = { |
| 5 | 6 | all: ["projects"] as const, |
| ... | ... | @@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { |
| 15 | 16 | }; |
| 16 | 17 | |
| 17 | 18 | export function projectSearchEntriesQueryOptions(input: { |
| 18 | api: NativeApi | undefined; | |
| 19 | 19 | cwd: string | null; |
| 20 | 20 | query: string; |
| 21 | 21 | enabled?: boolean; |
| ... | ... | @@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: { |
| 26 | 26 | return queryOptions({ |
| 27 | 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), |
| 28 | 28 | queryFn: async () => { |
| 29 | if (!input.api || !input.cwd) { | |
| 29 | const api = ensureNativeApi(); | |
| 30 | if (!input.cwd) { | |
| 30 | 31 | throw new Error("Workspace entry search is unavailable."); |
| 31 | 32 | } |
| 32 | return input.api.projects.searchEntries({ | |
| 33 | return api.projects.searchEntries({ | |
| 33 | 34 | cwd: input.cwd, |
| 34 | 35 | query: input.query, |
| 35 | 36 | limit, |
| 36 | 37 | }); |
| 37 | 38 | }, |
| 38 | enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd), | |
| 39 | enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, | |
| 39 | 40 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, |
| 40 | 41 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, |
| 41 | 42 | }); |
apps/web/src/lib/projectScriptKeybindings.test.ts created+83| ... | ... | @@ -0,0 +1,83 @@ |
| 1 | import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { commandForProjectScript } from "../projectScripts"; | |
| 5 | import { | |
| 6 | decodeProjectScriptKeybindingRule, | |
| 7 | keybindingValueForCommand, | |
| 8 | PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE, | |
| 9 | } from "./projectScriptKeybindings"; | |
| 10 | ||
| 11 | describe("projectScriptKeybindings", () => { | |
| 12 | it("decodes and trims valid keybinding rules", () => { | |
| 13 | const rule = decodeProjectScriptKeybindingRule({ | |
| 14 | keybinding: " mod+k ", | |
| 15 | command: commandForProjectScript("lint"), | |
| 16 | }); | |
| 17 | ||
| 18 | expect(rule).toEqual({ | |
| 19 | key: "mod+k", | |
| 20 | command: "script.lint.run", | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("returns null when keybinding is empty", () => { | |
| 25 | expect( | |
| 26 | decodeProjectScriptKeybindingRule({ | |
| 27 | keybinding: " ", | |
| 28 | command: commandForProjectScript("lint"), | |
| 29 | }), | |
| 30 | ).toBeNull(); | |
| 31 | }); | |
| 32 | ||
| 33 | it("rejects invalid keybinding values", () => { | |
| 34 | expect(() => | |
| 35 | decodeProjectScriptKeybindingRule({ | |
| 36 | keybinding: "k".repeat(MAX_KEYBINDING_VALUE_LENGTH + 1), | |
| 37 | command: commandForProjectScript("lint"), | |
| 38 | }), | |
| 39 | ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 40 | }); | |
| 41 | ||
| 42 | it("rejects invalid commands", () => { | |
| 43 | expect(() => | |
| 44 | decodeProjectScriptKeybindingRule({ | |
| 45 | keybinding: "mod+k", | |
| 46 | command: "script.BAD.run" as KeybindingCommand, | |
| 47 | }), | |
| 48 | ).toThrowError(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 49 | }); | |
| 50 | ||
| 51 | it("reads latest matching keybinding value for a command", () => { | |
| 52 | const command = commandForProjectScript("test"); | |
| 53 | const value = keybindingValueForCommand( | |
| 54 | [ | |
| 55 | { | |
| 56 | command, | |
| 57 | shortcut: { | |
| 58 | key: "escape", | |
| 59 | metaKey: false, | |
| 60 | ctrlKey: false, | |
| 61 | shiftKey: false, | |
| 62 | altKey: false, | |
| 63 | modKey: true, | |
| 64 | }, | |
| 65 | }, | |
| 66 | { | |
| 67 | command, | |
| 68 | shortcut: { | |
| 69 | key: "k", | |
| 70 | metaKey: false, | |
| 71 | ctrlKey: false, | |
| 72 | shiftKey: true, | |
| 73 | altKey: false, | |
| 74 | modKey: true, | |
| 75 | }, | |
| 76 | }, | |
| 77 | ], | |
| 78 | command, | |
| 79 | ); | |
| 80 | ||
| 81 | expect(value).toBe("mod+shift+k"); | |
| 82 | }); | |
| 83 | }); |
apps/web/src/lib/projectScriptKeybindings.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { | |
| 2 | KeybindingRule as KeybindingRuleSchema, | |
| 3 | type KeybindingCommand, | |
| 4 | type KeybindingRule, | |
| 5 | type ResolvedKeybindingsConfig, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Schema } from "effect"; | |
| 8 | ||
| 9 | export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding."; | |
| 10 | ||
| 11 | function normalizeProjectScriptKeybindingInput( | |
| 12 | keybinding: string | null | undefined, | |
| 13 | ): string | null { | |
| 14 | const trimmed = keybinding?.trim() ?? ""; | |
| 15 | return trimmed.length > 0 ? trimmed : null; | |
| 16 | } | |
| 17 | ||
| 18 | export function decodeProjectScriptKeybindingRule(input: { | |
| 19 | keybinding: string | null | undefined; | |
| 20 | command: KeybindingCommand; | |
| 21 | }): KeybindingRule | null { | |
| 22 | const normalizedKey = normalizeProjectScriptKeybindingInput(input.keybinding); | |
| 23 | if (!normalizedKey) return null; | |
| 24 | ||
| 25 | const decoded = Schema.decodeUnknownOption(KeybindingRuleSchema)({ | |
| 26 | key: normalizedKey, | |
| 27 | command: input.command, | |
| 28 | }); | |
| 29 | if (decoded._tag === "None") { | |
| 30 | throw new Error(PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE); | |
| 31 | } | |
| 32 | return decoded.value; | |
| 33 | } | |
| 34 | ||
| 35 | export function keybindingValueForCommand( | |
| 36 | keybindings: ResolvedKeybindingsConfig, | |
| 37 | command: KeybindingCommand, | |
| 38 | ): string | null { | |
| 39 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 40 | const binding = keybindings[index]; | |
| 41 | if (!binding || binding.command !== command) continue; | |
| 42 | ||
| 43 | const parts: string[] = []; | |
| 44 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 45 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 46 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 47 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 48 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 49 | const keyToken = | |
| 50 | binding.shortcut.key === " " | |
| 51 | ? "space" | |
| 52 | : binding.shortcut.key === "escape" | |
| 53 | ? "esc" | |
| 54 | : binding.shortcut.key; | |
| 55 | parts.push(keyToken); | |
| 56 | return parts.join("+"); | |
| 57 | } | |
| 58 | return null; | |
| 59 | } |
apps/web/src/lib/providerReactQuery.test.ts+80-30| ... | ... | @@ -1,13 +1,31 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 1 | import { ThreadId, type NativeApi } from "@t3tools/contracts"; | |
| 2 | 2 | import { QueryClient } from "@tanstack/react-query"; |
| 3 | import { describe, expect, it, vi } from "vitest"; | |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 4 | 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; |
| 5 | import * as nativeApi from "../nativeApi"; | |
| 6 | ||
| 7 | const threadId = ThreadId.makeUnsafe("thread-id"); | |
| 8 | ||
| 9 | function mockNativeApi(input: { | |
| 10 | getTurnDiff: ReturnType<typeof vi.fn>; | |
| 11 | getFullThreadDiff: ReturnType<typeof vi.fn>; | |
| 12 | }) { | |
| 13 | vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ | |
| 14 | orchestration: { | |
| 15 | getTurnDiff: input.getTurnDiff, | |
| 16 | getFullThreadDiff: input.getFullThreadDiff, | |
| 17 | }, | |
| 18 | } as unknown as NativeApi); | |
| 19 | } | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | vi.restoreAllMocks(); | |
| 23 | }); | |
| 5 | 24 | |
| 6 | 25 | describe("providerQueryKeys.checkpointDiff", () => { |
| 7 | 26 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 8 | 27 | const baseInput = { |
| 9 | sessionId: "session-id", | |
| 10 | threadRuntimeId: "thread-id", | |
| 28 | threadId, | |
| 11 | 29 | fromTurnCount: 1, |
| 12 | 30 | toTurnCount: 2, |
| 13 | 31 | } as const; |
| ... | ... | @@ -28,16 +46,12 @@ describe("providerQueryKeys.checkpointDiff", () => { |
| 28 | 46 | |
| 29 | 47 | describe("checkpointDiffQueryOptions", () => { |
| 30 | 48 | it("forwards checkpoint range to the provider API", async () => { |
| 31 | const getCheckpointDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 32 | const api = { | |
| 33 | providers: { | |
| 34 | getCheckpointDiff, | |
| 35 | }, | |
| 36 | } as unknown as NativeApi; | |
| 37 | ||
| 38 | const options = checkpointDiffQueryOptions(api, { | |
| 39 | sessionId: "session-id", | |
| 40 | threadRuntimeId: "thread-id", | |
| 49 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 50 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 51 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 52 | ||
| 53 | const options = checkpointDiffQueryOptions({ | |
| 54 | threadId, | |
| 41 | 55 | fromTurnCount: 3, |
| 42 | 56 | toTurnCount: 4, |
| 43 | 57 | cacheScope: "turn:abc", |
| ... | ... | @@ -46,17 +60,60 @@ describe("checkpointDiffQueryOptions", () => { |
| 46 | 60 | const queryClient = new QueryClient(); |
| 47 | 61 | await queryClient.fetchQuery(options); |
| 48 | 62 | |
| 49 | expect(getCheckpointDiff).toHaveBeenCalledWith({ | |
| 50 | sessionId: "session-id", | |
| 63 | expect(getTurnDiff).toHaveBeenCalledWith({ | |
| 64 | threadId, | |
| 51 | 65 | fromTurnCount: 3, |
| 52 | 66 | toTurnCount: 4, |
| 53 | 67 | }); |
| 68 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | |
| 69 | }); | |
| 70 | ||
| 71 | it("uses explicit full thread diff API when range starts from zero", async () => { | |
| 72 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 73 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 74 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 75 | ||
| 76 | const options = checkpointDiffQueryOptions({ | |
| 77 | threadId, | |
| 78 | fromTurnCount: 0, | |
| 79 | toTurnCount: 2, | |
| 80 | cacheScope: "thread:all", | |
| 81 | }); | |
| 82 | ||
| 83 | const queryClient = new QueryClient(); | |
| 84 | await queryClient.fetchQuery(options); | |
| 85 | ||
| 86 | expect(getFullThreadDiff).toHaveBeenCalledWith({ | |
| 87 | threadId, | |
| 88 | toTurnCount: 2, | |
| 89 | }); | |
| 90 | expect(getTurnDiff).not.toHaveBeenCalled(); | |
| 91 | }); | |
| 92 | ||
| 93 | it("fails fast on invalid range and does not call provider RPC", async () => { | |
| 94 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 95 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 96 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | |
| 97 | ||
| 98 | const options = checkpointDiffQueryOptions({ | |
| 99 | threadId, | |
| 100 | fromTurnCount: 4, | |
| 101 | toTurnCount: 3, | |
| 102 | cacheScope: "turn:invalid", | |
| 103 | }); | |
| 104 | ||
| 105 | const queryClient = new QueryClient(); | |
| 106 | ||
| 107 | await expect(queryClient.fetchQuery(options)).rejects.toThrow( | |
| 108 | "Checkpoint diff is unavailable.", | |
| 109 | ); | |
| 110 | expect(getTurnDiff).not.toHaveBeenCalled(); | |
| 111 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | |
| 54 | 112 | }); |
| 55 | 113 | |
| 56 | 114 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 57 | const options = checkpointDiffQueryOptions(undefined, { | |
| 58 | sessionId: "session-id", | |
| 59 | threadRuntimeId: "thread-id", | |
| 115 | const options = checkpointDiffQueryOptions({ | |
| 116 | threadId, | |
| 60 | 117 | fromTurnCount: 1, |
| 61 | 118 | toTurnCount: 2, |
| 62 | 119 | cacheScope: "turn:abc", |
| ... | ... | @@ -69,25 +126,18 @@ describe("checkpointDiffQueryOptions", () => { |
| 69 | 126 | |
| 70 | 127 | expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true); |
| 71 | 128 | expect( |
| 72 | retry( | |
| 73 | 11, | |
| 74 | new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), | |
| 75 | ), | |
| 129 | retry(11, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), | |
| 76 | 130 | ).toBe(true); |
| 77 | 131 | expect( |
| 78 | retry( | |
| 79 | 12, | |
| 80 | new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), | |
| 81 | ), | |
| 132 | retry(12, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), | |
| 82 | 133 | ).toBe(false); |
| 83 | 134 | expect(retry(2, new Error("Something else failed."))).toBe(true); |
| 84 | 135 | expect(retry(3, new Error("Something else failed."))).toBe(false); |
| 85 | 136 | }); |
| 86 | 137 | |
| 87 | 138 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 88 | const options = checkpointDiffQueryOptions(undefined, { | |
| 89 | sessionId: "session-id", | |
| 90 | threadRuntimeId: "thread-id", | |
| 139 | const options = checkpointDiffQueryOptions({ | |
| 140 | threadId, | |
| 91 | 141 | fromTurnCount: 1, |
| 92 | 142 | toTurnCount: 2, |
| 93 | 143 | cacheScope: "turn:abc", |
apps/web/src/lib/providerReactQuery.ts+33-28| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | OrchestrationGetFullThreadDiffInput, | |
| 3 | OrchestrationGetTurnDiffInput, | |
| 4 | ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { queryOptions } from "@tanstack/react-query"; |
| 7 | import { Option, Schema } from "effect"; | |
| 8 | import { ensureNativeApi } from "../nativeApi"; | |
| 3 | 9 | |
| 4 | export interface CheckpointDiffQueryInput { | |
| 5 | sessionId: string | null; | |
| 6 | threadRuntimeId: string | null; | |
| 10 | interface CheckpointDiffQueryInput { | |
| 11 | threadId: ThreadId | null; | |
| 7 | 12 | fromTurnCount: number | null; |
| 8 | 13 | toTurnCount: number | null; |
| 9 | 14 | cacheScope?: string | null; |
| ... | ... | @@ -15,14 +20,28 @@ export const providerQueryKeys = { |
| 15 | 20 | [ |
| 16 | 21 | "providers", |
| 17 | 22 | "checkpointDiff", |
| 18 | input.sessionId, | |
| 19 | input.threadRuntimeId, | |
| 23 | input.threadId, | |
| 20 | 24 | input.fromTurnCount, |
| 21 | 25 | input.toTurnCount, |
| 22 | 26 | input.cacheScope ?? null, |
| 23 | 27 | ] as const, |
| 24 | 28 | }; |
| 25 | 29 | |
| 30 | function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { | |
| 31 | if (input.fromTurnCount === 0) { | |
| 32 | return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({ | |
| 33 | threadId: input.threadId, | |
| 34 | toTurnCount: input.toTurnCount, | |
| 35 | }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields }))); | |
| 36 | } | |
| 37 | ||
| 38 | return Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput)({ | |
| 39 | threadId: input.threadId, | |
| 40 | fromTurnCount: input.fromTurnCount, | |
| 41 | toTurnCount: input.toTurnCount, | |
| 42 | }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields }))); | |
| 43 | } | |
| 44 | ||
| 26 | 45 | function asCheckpointErrorMessage(error: unknown): string { |
| 27 | 46 | if (error instanceof Error) { |
| 28 | 47 | return error.message; |
| ... | ... | @@ -42,36 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean { |
| 42 | 61 | ); |
| 43 | 62 | } |
| 44 | 63 | |
| 45 | export function checkpointDiffQueryOptions( | |
| 46 | api: NativeApi | undefined, | |
| 47 | input: CheckpointDiffQueryInput, | |
| 48 | ) { | |
| 49 | const hasValidRange = | |
| 50 | typeof input.fromTurnCount === "number" && | |
| 51 | typeof input.toTurnCount === "number" && | |
| 52 | Number.isInteger(input.fromTurnCount) && | |
| 53 | Number.isInteger(input.toTurnCount) && | |
| 54 | input.fromTurnCount >= 0 && | |
| 55 | input.toTurnCount >= 0 && | |
| 56 | input.fromTurnCount <= input.toTurnCount; | |
| 64 | export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { | |
| 65 | const decodedRequest = decodeCheckpointDiffRequest(input); | |
| 57 | 66 | |
| 58 | 67 | return queryOptions({ |
| 59 | 68 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 60 | 69 | queryFn: async () => { |
| 61 | if (!api || !input.sessionId || !hasValidRange) { | |
| 70 | const api = ensureNativeApi(); | |
| 71 | if (!input.threadId || decodedRequest._tag === "None") { | |
| 62 | 72 | throw new Error("Checkpoint diff is unavailable."); |
| 63 | 73 | } |
| 64 | const { fromTurnCount, toTurnCount } = input; | |
| 65 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | |
| 66 | throw new Error("Checkpoint diff range is invalid."); | |
| 74 | if (decodedRequest.value.kind === "fullThreadDiff") { | |
| 75 | return api.orchestration.getFullThreadDiff(decodedRequest.value.input); | |
| 67 | 76 | } |
| 68 | return api.providers.getCheckpointDiff({ | |
| 69 | sessionId: input.sessionId, | |
| 70 | fromTurnCount, | |
| 71 | toTurnCount, | |
| 72 | }); | |
| 77 | return api.orchestration.getTurnDiff(decodedRequest.value.input); | |
| 73 | 78 | }, |
| 74 | enabled: !!api && !!input.sessionId && hasValidRange, | |
| 79 | enabled: !!input.threadId && decodedRequest._tag === "Some", | |
| 75 | 80 | staleTime: Infinity, |
| 76 | 81 | retry: (failureCount, error) => { |
| 77 | 82 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/lib/serverReactQuery.ts+3-6| ... | ... | @@ -1,21 +1,18 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | 1 | import { queryOptions } from "@tanstack/react-query"; |
| 2 | import { ensureNativeApi } from "~/nativeApi"; | |
| 3 | 3 | |
| 4 | 4 | export const serverQueryKeys = { |
| 5 | 5 | all: ["server"] as const, |
| 6 | 6 | config: () => ["server", "config"] as const, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | export function serverConfigQueryOptions(api: NativeApi | undefined) { | |
| 9 | export function serverConfigQueryOptions() { | |
| 10 | 10 | return queryOptions({ |
| 11 | 11 | queryKey: serverQueryKeys.config(), |
| 12 | 12 | queryFn: async () => { |
| 13 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | |
| 14 | throw new Error("Server config is unavailable."); | |
| 15 | } | |
| 13 | const api = ensureNativeApi(); | |
| 16 | 14 | return api.server.getConfig(); |
| 17 | 15 | }, |
| 18 | enabled: !!api && !!api.server && typeof api.server.getConfig === "function", | |
| 19 | 16 | staleTime: Infinity, |
| 20 | 17 | }); |
| 21 | 18 | } |
apps/web/src/lib/utils.ts+9| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { type CxOptions, cx } from "class-variance-authority"; |
| 2 | 3 | import { twMerge } from "tailwind-merge"; |
| 3 | 4 | |
| ... | ... | @@ -12,3 +13,11 @@ export function isMacPlatform(platform: string): boolean { |
| 12 | 13 | export function isWindowsPlatform(platform: string): boolean { |
| 13 | 14 | return /^win(dows)?/i.test(platform); |
| 14 | 15 | } |
| 16 | ||
| 17 | export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID()); | |
| 18 | ||
| 19 | export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 20 | ||
| 21 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 22 | ||
| 23 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); |
apps/web/src/markdown-links.test.ts created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { resolveMarkdownFileLinkTarget } from "./markdown-links"; | |
| 4 | ||
| 5 | describe("resolveMarkdownFileLinkTarget", () => { | |
| 6 | it("resolves absolute posix file paths", () => { | |
| 7 | expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe( | |
| 8 | "/Users/julius/project/AGENTS.md", | |
| 9 | ); | |
| 10 | }); | |
| 11 | ||
| 12 | it("resolves relative file paths against cwd", () => { | |
| 13 | expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe( | |
| 14 | "/Users/julius/project/src/processRunner.ts:71", | |
| 15 | ); | |
| 16 | }); | |
| 17 | ||
| 18 | it("does not treat filename line references as external schemes", () => { | |
| 19 | expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe( | |
| 20 | "/Users/julius/project/script.ts:10", | |
| 21 | ); | |
| 22 | }); | |
| 23 | ||
| 24 | it("resolves bare file names against cwd", () => { | |
| 25 | expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe( | |
| 26 | "/Users/julius/project/AGENTS.md", | |
| 27 | ); | |
| 28 | }); | |
| 29 | ||
| 30 | it("maps #L line anchors to editor line suffixes", () => { | |
| 31 | expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe( | |
| 32 | "/Users/julius/project/src/main.ts:42:7", | |
| 33 | ); | |
| 34 | }); | |
| 35 | ||
| 36 | it("ignores external urls", () => { | |
| 37 | expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); | |
| 38 | }); | |
| 39 | ||
| 40 | it("does not double-decode file URLs", () => { | |
| 41 | expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe( | |
| 42 | "/Users/julius/project/file%20name.md", | |
| 43 | ); | |
| 44 | }); | |
| 45 | ||
| 46 | it("does not treat app routes as file links", () => { | |
| 47 | expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); | |
| 48 | }); | |
| 49 | }); |
apps/web/src/markdown-links.ts created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import { resolvePathLinkTarget } from "./terminal-links"; | |
| 2 | ||
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 4 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | |
| 5 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | |
| 6 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | |
| 7 | const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; | |
| 8 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | |
| 9 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | |
| 10 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | |
| 11 | const POSIX_FILE_ROOT_PREFIXES = [ | |
| 12 | "/Users/", | |
| 13 | "/home/", | |
| 14 | "/tmp/", | |
| 15 | "/var/", | |
| 16 | "/etc/", | |
| 17 | "/opt/", | |
| 18 | "/mnt/", | |
| 19 | "/Volumes/", | |
| 20 | "/private/", | |
| 21 | "/root/", | |
| 22 | ] as const; | |
| 23 | ||
| 24 | function safeDecode(value: string): string { | |
| 25 | try { | |
| 26 | return decodeURIComponent(value); | |
| 27 | } catch { | |
| 28 | return value; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | function stripSearchAndHash(value: string): { path: string; hash: string } { | |
| 33 | const hashIndex = value.indexOf("#"); | |
| 34 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; | |
| 35 | const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : ""; | |
| 36 | const queryIndex = pathWithSearch.indexOf("?"); | |
| 37 | const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch; | |
| 38 | return { path, hash: rawHash }; | |
| 39 | } | |
| 40 | ||
| 41 | function parseFileUrlHref(href: string): { path: string; hash: string } | null { | |
| 42 | try { | |
| 43 | const parsed = new URL(href); | |
| 44 | if (parsed.protocol.toLowerCase() !== "file:") return null; | |
| 45 | ||
| 46 | const decodedPath = safeDecode(parsed.pathname); | |
| 47 | if (decodedPath.length === 0) return null; | |
| 48 | ||
| 49 | // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. | |
| 50 | const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath) | |
| 51 | ? decodedPath.slice(1) | |
| 52 | : decodedPath; | |
| 53 | ||
| 54 | return { path: normalizedPath, hash: parsed.hash }; | |
| 55 | } catch { | |
| 56 | return null; | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | function looksLikePosixFilesystemPath(path: string): boolean { | |
| 61 | if (!path.startsWith("/")) return false; | |
| 62 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; | |
| 63 | if (POSITION_SUFFIX_PATTERN.test(path)) return true; | |
| 64 | const basename = path.slice(path.lastIndexOf("/") + 1); | |
| 65 | return /\.[A-Za-z0-9_-]+$/.test(basename); | |
| 66 | } | |
| 67 | ||
| 68 | function appendLineColumnFromHash(path: string, hash: string): string { | |
| 69 | if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path; | |
| 70 | const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i); | |
| 71 | if (!match?.[1]) return path; | |
| 72 | const line = match[1]; | |
| 73 | const column = match[2]; | |
| 74 | return `${path}:${line}${column ? `:${column}` : ""}`; | |
| 75 | } | |
| 76 | ||
| 77 | function isLikelyPathCandidate(path: string): boolean { | |
| 78 | if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; | |
| 79 | if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; | |
| 80 | if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); | |
| 81 | return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path); | |
| 82 | } | |
| 83 | ||
| 84 | function isRelativePath(path: string): boolean { | |
| 85 | return ( | |
| 86 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | |
| 87 | (!path.startsWith("/") && | |
| 88 | !WINDOWS_DRIVE_PATH_PATTERN.test(path) && | |
| 89 | !WINDOWS_UNC_PATH_PATTERN.test(path)) | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | function hasExternalScheme(path: string): boolean { | |
| 94 | const match = path.match(EXTERNAL_SCHEME_PATTERN); | |
| 95 | if (!match) return false; | |
| 96 | const rest = match[2] ?? ""; | |
| 97 | if (rest.startsWith("//")) return true; | |
| 98 | return !POSITION_ONLY_PATTERN.test(rest); | |
| 99 | } | |
| 100 | ||
| 101 | export function resolveMarkdownFileLinkTarget( | |
| 102 | href: string | undefined, | |
| 103 | cwd?: string, | |
| 104 | ): string | null { | |
| 105 | if (!href) return null; | |
| 106 | const rawHref = href.trim(); | |
| 107 | if (rawHref.length === 0 || rawHref.startsWith("#")) return null; | |
| 108 | ||
| 109 | const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") | |
| 110 | ? parseFileUrlHref(rawHref) | |
| 111 | : null; | |
| 112 | const source = fileUrlTarget ?? stripSearchAndHash(rawHref); | |
| 113 | const decodedPath = fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()); | |
| 114 | const decodedHash = safeDecode(source.hash.trim()); | |
| 115 | ||
| 116 | if (decodedPath.length === 0) return null; | |
| 117 | if ( | |
| 118 | !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) && | |
| 119 | !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) && | |
| 120 | hasExternalScheme(decodedPath) | |
| 121 | ) { | |
| 122 | return null; | |
| 123 | } | |
| 124 | ||
| 125 | if (!isLikelyPathCandidate(decodedPath)) return null; | |
| 126 | ||
| 127 | const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash); | |
| 128 | if (!isRelativePath(pathWithPosition)) { | |
| 129 | return pathWithPosition; | |
| 130 | } | |
| 131 | ||
| 132 | if (!cwd) return null; | |
| 133 | return resolvePathLinkTarget(pathWithPosition, cwd); | |
| 134 | } |
apps/web/src/model-logic.test.ts deleted-33| ... | ... | @@ -1,33 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | |
| 3 | ||
| 4 | describe("normalizeModelSlug", () => { | |
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | |
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | |
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | |
| 8 | }); | |
| 9 | ||
| 10 | it("preserves non-aliased slugs", () => { | |
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 13 | }); | |
| 14 | ||
| 15 | it("returns null for empty input", () => { | |
| 16 | expect(normalizeModelSlug("")).toBeNull(); | |
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | |
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | |
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("resolveModelSlug", () => { | |
| 24 | it("falls back to default for unknown models", () => { | |
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | |
| 26 | }); | |
| 27 | ||
| 28 | it("resolves supported hard-coded models", () => { | |
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | |
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | |
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | |
| 32 | }); | |
| 33 | }); |
apps/web/src/model-logic.ts deleted-10| ... | ... | @@ -1,10 +0,0 @@ |
| 1 | export { | |
| 2 | DEFAULT_MODEL, | |
| 3 | MODEL_OPTIONS, | |
| 4 | normalizeModelSlug, | |
| 5 | resolveModelSlug, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | |
| 9 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | |
| 10 | export const DEFAULT_REASONING: ReasoningEffort = "high"; |
apps/web/src/nativeApi.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | |
| 4 | ||
| 5 | let cachedApi: NativeApi | undefined; | |
| 6 | ||
| 7 | export function readNativeApi(): NativeApi | undefined { | |
| 8 | if (typeof window === "undefined") return undefined; | |
| 9 | if (cachedApi) return cachedApi; | |
| 10 | ||
| 11 | if (window.nativeApi) { | |
| 12 | cachedApi = window.nativeApi; | |
| 13 | return cachedApi; | |
| 14 | } | |
| 15 | ||
| 16 | cachedApi = createWsNativeApi(); | |
| 17 | return cachedApi; | |
| 18 | } | |
| 19 | ||
| 20 | export function ensureNativeApi(): NativeApi { | |
| 21 | const api = readNativeApi(); | |
| 22 | if (!api) { | |
| 23 | throw new Error("Native API not found"); | |
| 24 | } | |
| 25 | return api; | |
| 26 | } |
apps/web/src/persistenceSchema.test.ts deleted-507| ... | ... | @@ -1,507 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 6 | import type { Thread } from "./types"; | |
| 7 | ||
| 8 | describe("hydratePersistedState", () => { | |
| 9 | it("returns null for invalid payloads", () => { | |
| 10 | expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); | |
| 11 | expect(hydratePersistedState("not-json", false)).toBeNull(); | |
| 12 | }); | |
| 13 | ||
| 14 | it("migrates the legacy default model to the current default", () => { | |
| 15 | const payload = JSON.stringify({ | |
| 16 | projects: [ | |
| 17 | { | |
| 18 | id: "p-1", | |
| 19 | name: "Project", | |
| 20 | cwd: "/tmp/project", | |
| 21 | model: "gpt-5.2-codex", | |
| 22 | expanded: true, | |
| 23 | scripts: [], | |
| 24 | }, | |
| 25 | ], | |
| 26 | threads: [ | |
| 27 | { | |
| 28 | id: "t-1", | |
| 29 | projectId: "p-1", | |
| 30 | title: "Thread", | |
| 31 | model: "gpt-5.2-codex", | |
| 32 | messages: [ | |
| 33 | { | |
| 34 | id: "m-1", | |
| 35 | role: "assistant", | |
| 36 | text: "Hello", | |
| 37 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 38 | streaming: true, | |
| 39 | }, | |
| 40 | ], | |
| 41 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 42 | }, | |
| 43 | ], | |
| 44 | activeThreadId: "t-1", | |
| 45 | }); | |
| 46 | ||
| 47 | const hydrated = hydratePersistedState(payload, true); | |
| 48 | expect(hydrated).not.toBeNull(); | |
| 49 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); | |
| 50 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); | |
| 51 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | |
| 52 | expect(hydrated?.threads[0]?.terminalOpen).toBe(false); | |
| 53 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | |
| 54 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 55 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | |
| 56 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | |
| 57 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 58 | ]); | |
| 59 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 60 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | |
| 61 | expect(hydrated?.runtimeMode).toBe("full-access"); | |
| 62 | }); | |
| 63 | ||
| 64 | it("filters unknown project references and repairs active thread", () => { | |
| 65 | const payload = JSON.stringify({ | |
| 66 | projects: [ | |
| 67 | { | |
| 68 | id: "p-1", | |
| 69 | name: "Project", | |
| 70 | cwd: "/tmp/project", | |
| 71 | model: "gpt-5.3-codex", | |
| 72 | expanded: false, | |
| 73 | scripts: [], | |
| 74 | }, | |
| 75 | ], | |
| 76 | threads: [ | |
| 77 | { | |
| 78 | id: "t-1", | |
| 79 | projectId: "p-1", | |
| 80 | title: "Valid thread", | |
| 81 | model: "gpt-5.3-codex", | |
| 82 | messages: [], | |
| 83 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 84 | }, | |
| 85 | { | |
| 86 | id: "t-2", | |
| 87 | projectId: "p-missing", | |
| 88 | title: "Dangling thread", | |
| 89 | model: "gpt-5.3-codex", | |
| 90 | messages: [], | |
| 91 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 92 | }, | |
| 93 | ], | |
| 94 | activeThreadId: "t-2", | |
| 95 | }); | |
| 96 | ||
| 97 | const hydrated = hydratePersistedState(payload, false); | |
| 98 | expect(hydrated).not.toBeNull(); | |
| 99 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); | |
| 100 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | |
| 101 | expect(hydrated?.threads[0]?.terminalOpen).toBe(false); | |
| 102 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | |
| 103 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 104 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | |
| 105 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | |
| 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 107 | ]); | |
| 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 109 | expect(hydrated?.runtimeMode).toBe("full-access"); | |
| 110 | }); | |
| 111 | ||
| 112 | it("hydrates runtime mode from v3 payload", () => { | |
| 113 | const payload = JSON.stringify({ | |
| 114 | version: 3, | |
| 115 | runtimeMode: "approval-required", | |
| 116 | projects: [ | |
| 117 | { | |
| 118 | id: "p-1", | |
| 119 | name: "Project", | |
| 120 | cwd: "/tmp/project", | |
| 121 | model: "gpt-5.3-codex", | |
| 122 | expanded: false, | |
| 123 | scripts: [], | |
| 124 | }, | |
| 125 | ], | |
| 126 | threads: [], | |
| 127 | activeThreadId: null, | |
| 128 | }); | |
| 129 | ||
| 130 | const hydrated = hydratePersistedState(payload, false); | |
| 131 | expect(hydrated?.runtimeMode).toBe("approval-required"); | |
| 132 | }); | |
| 133 | ||
| 134 | it("hydrates terminal fields from legacy v6 payload", () => { | |
| 135 | const payload = JSON.stringify({ | |
| 136 | version: 6, | |
| 137 | runtimeMode: "full-access", | |
| 138 | projects: [ | |
| 139 | { | |
| 140 | id: "p-1", | |
| 141 | name: "Project", | |
| 142 | cwd: "/tmp/project", | |
| 143 | model: "gpt-5.3-codex", | |
| 144 | expanded: true, | |
| 145 | scripts: [], | |
| 146 | }, | |
| 147 | ], | |
| 148 | threads: [ | |
| 149 | { | |
| 150 | id: "t-1", | |
| 151 | codexThreadId: null, | |
| 152 | projectId: "p-1", | |
| 153 | title: "Thread", | |
| 154 | model: "gpt-5.3-codex", | |
| 155 | terminalOpen: true, | |
| 156 | terminalHeight: 360, | |
| 157 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 158 | activeTerminalId: "term-2", | |
| 159 | terminalLayout: "tabs", | |
| 160 | splitTerminalIds: [], | |
| 161 | messages: [], | |
| 162 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 163 | lastVisitedAt: "2026-02-08T10:01:00.000Z", | |
| 164 | }, | |
| 165 | ], | |
| 166 | activeThreadId: "t-1", | |
| 167 | }); | |
| 168 | ||
| 169 | const hydrated = hydratePersistedState(payload, false); | |
| 170 | expect(hydrated?.threads[0]?.terminalOpen).toBe(true); | |
| 171 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); | |
| 172 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | |
| 173 | expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); | |
| 174 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | |
| 175 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 176 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 177 | ]); | |
| 178 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); | |
| 179 | expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); | |
| 180 | }); | |
| 181 | ||
| 182 | it("hydrates legacy split layout into a grouped split", () => { | |
| 183 | const payload = JSON.stringify({ | |
| 184 | version: 6, | |
| 185 | runtimeMode: "full-access", | |
| 186 | projects: [ | |
| 187 | { | |
| 188 | id: "p-1", | |
| 189 | name: "Project", | |
| 190 | cwd: "/tmp/project", | |
| 191 | model: "gpt-5.3-codex", | |
| 192 | expanded: true, | |
| 193 | scripts: [], | |
| 194 | }, | |
| 195 | ], | |
| 196 | threads: [ | |
| 197 | { | |
| 198 | id: "t-1", | |
| 199 | codexThreadId: null, | |
| 200 | projectId: "p-1", | |
| 201 | title: "Thread", | |
| 202 | model: "gpt-5.3-codex", | |
| 203 | terminalOpen: true, | |
| 204 | terminalHeight: 360, | |
| 205 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | |
| 206 | activeTerminalId: "term-2", | |
| 207 | terminalLayout: "split", | |
| 208 | splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 209 | messages: [], | |
| 210 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 211 | }, | |
| 212 | ], | |
| 213 | activeThreadId: "t-1", | |
| 214 | }); | |
| 215 | ||
| 216 | const hydrated = hydratePersistedState(payload, false); | |
| 217 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | |
| 218 | { | |
| 219 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 220 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 221 | }, | |
| 222 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 223 | ]); | |
| 224 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 225 | }); | |
| 226 | ||
| 227 | it("defaults terminalHeight when hydrating v5 payloads", () => { | |
| 228 | const payload = JSON.stringify({ | |
| 229 | version: 5, | |
| 230 | runtimeMode: "full-access", | |
| 231 | projects: [ | |
| 232 | { | |
| 233 | id: "p-1", | |
| 234 | name: "Project", | |
| 235 | cwd: "/tmp/project", | |
| 236 | model: "gpt-5.3-codex", | |
| 237 | expanded: true, | |
| 238 | scripts: [], | |
| 239 | }, | |
| 240 | ], | |
| 241 | threads: [ | |
| 242 | { | |
| 243 | id: "t-1", | |
| 244 | codexThreadId: null, | |
| 245 | projectId: "p-1", | |
| 246 | title: "Thread", | |
| 247 | model: "gpt-5.3-codex", | |
| 248 | terminalOpen: true, | |
| 249 | messages: [], | |
| 250 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 251 | }, | |
| 252 | ], | |
| 253 | activeThreadId: "t-1", | |
| 254 | }); | |
| 255 | ||
| 256 | const hydrated = hydratePersistedState(payload, false); | |
| 257 | expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); | |
| 258 | expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 259 | expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | |
| 260 | expect(hydrated?.threads[0]?.terminalGroups).toEqual([ | |
| 261 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 262 | ]); | |
| 263 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 264 | }); | |
| 265 | ||
| 266 | it("hydrates persisted turn diff summaries as metadata-only entries", () => { | |
| 267 | const payload = JSON.stringify({ | |
| 268 | version: 7, | |
| 269 | runtimeMode: "full-access", | |
| 270 | projects: [ | |
| 271 | { | |
| 272 | id: "p-1", | |
| 273 | name: "Project", | |
| 274 | cwd: "/tmp/project", | |
| 275 | model: "gpt-5.3-codex", | |
| 276 | expanded: true, | |
| 277 | }, | |
| 278 | ], | |
| 279 | threads: [ | |
| 280 | { | |
| 281 | id: "t-1", | |
| 282 | codexThreadId: "thr_1", | |
| 283 | projectId: "p-1", | |
| 284 | title: "Thread", | |
| 285 | model: "gpt-5.3-codex", | |
| 286 | messages: [], | |
| 287 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 288 | turnDiffSummaries: [ | |
| 289 | { | |
| 290 | turnId: "turn-1", | |
| 291 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 292 | checkpointTurnCount: 1, | |
| 293 | files: [ | |
| 294 | { | |
| 295 | path: "src/app.ts", | |
| 296 | kind: "modified", | |
| 297 | additions: 3, | |
| 298 | deletions: 1, | |
| 299 | }, | |
| 300 | ], | |
| 301 | }, | |
| 302 | ], | |
| 303 | }, | |
| 304 | ], | |
| 305 | activeThreadId: "t-1", | |
| 306 | }); | |
| 307 | ||
| 308 | const hydrated = hydratePersistedState(payload, false); | |
| 309 | expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([ | |
| 310 | { | |
| 311 | turnId: "turn-1", | |
| 312 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 313 | checkpointTurnCount: 1, | |
| 314 | files: [ | |
| 315 | { | |
| 316 | path: "src/app.ts", | |
| 317 | kind: "modified", | |
| 318 | additions: 3, | |
| 319 | deletions: 1, | |
| 320 | }, | |
| 321 | ], | |
| 322 | }, | |
| 323 | ]); | |
| 324 | }); | |
| 325 | ||
| 326 | it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { | |
| 327 | const payload = JSON.stringify({ | |
| 328 | version: 7, | |
| 329 | runtimeMode: "full-access", | |
| 330 | projects: [ | |
| 331 | { | |
| 332 | id: "p-1", | |
| 333 | name: "Project", | |
| 334 | cwd: "/tmp/project", | |
| 335 | model: "gpt-5.3-codex", | |
| 336 | expanded: true, | |
| 337 | }, | |
| 338 | ], | |
| 339 | threads: [ | |
| 340 | { | |
| 341 | id: "t-1", | |
| 342 | codexThreadId: "thr_1", | |
| 343 | projectId: "p-1", | |
| 344 | title: "Thread", | |
| 345 | model: "gpt-5.3-codex", | |
| 346 | messages: [], | |
| 347 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 348 | turnDiffSummaries: [ | |
| 349 | { | |
| 350 | turnId: "turn-1", | |
| 351 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 352 | files: [{ path: 123 }], | |
| 353 | }, | |
| 354 | ], | |
| 355 | }, | |
| 356 | ], | |
| 357 | activeThreadId: "t-1", | |
| 358 | }); | |
| 359 | ||
| 360 | const hydrated = hydratePersistedState(payload, false); | |
| 361 | expect(hydrated).not.toBeNull(); | |
| 362 | expect(hydrated?.threads[0]?.id).toBe("t-1"); | |
| 363 | expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); | |
| 364 | }); | |
| 365 | }); | |
| 366 | ||
| 367 | describe("toPersistedState", () => { | |
| 368 | it("writes v7 payload and strips non-persisted thread fields", () => { | |
| 369 | const thread: Thread = { | |
| 370 | id: "t-1", | |
| 371 | codexThreadId: "thr_1", | |
| 372 | projectId: "p-1", | |
| 373 | title: "Thread", | |
| 374 | model: "gpt-5.3-codex", | |
| 375 | terminalOpen: true, | |
| 376 | terminalHeight: 320, | |
| 377 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 378 | runningTerminalIds: [], | |
| 379 | activeTerminalId: "term-2", | |
| 380 | terminalGroups: [ | |
| 381 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 382 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 383 | ], | |
| 384 | activeTerminalGroupId: "group-term-2", | |
| 385 | session: null, | |
| 386 | messages: [ | |
| 387 | { | |
| 388 | id: "m-1", | |
| 389 | role: "user", | |
| 390 | text: "Hi", | |
| 391 | attachments: [ | |
| 392 | { | |
| 393 | type: "image", | |
| 394 | id: "img-1", | |
| 395 | name: "diagram.png", | |
| 396 | mimeType: "image/png", | |
| 397 | sizeBytes: 4_096, | |
| 398 | previewUrl: "blob:preview-1", | |
| 399 | }, | |
| 400 | ], | |
| 401 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 402 | streaming: false, | |
| 403 | }, | |
| 404 | ], | |
| 405 | events: [], | |
| 406 | error: "boom", | |
| 407 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 408 | lastVisitedAt: "2026-02-08T10:02:00.000Z", | |
| 409 | branch: null, | |
| 410 | worktreePath: null, | |
| 411 | turnDiffSummaries: [ | |
| 412 | { | |
| 413 | turnId: "turn-1", | |
| 414 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 415 | status: "completed", | |
| 416 | checkpointTurnCount: 1, | |
| 417 | files: [ | |
| 418 | { | |
| 419 | path: "src/app.ts", | |
| 420 | kind: "modified", | |
| 421 | additions: 3, | |
| 422 | deletions: 1, | |
| 423 | }, | |
| 424 | ], | |
| 425 | }, | |
| 426 | ], | |
| 427 | }; | |
| 428 | ||
| 429 | const persisted = toPersistedState({ | |
| 430 | projects: [ | |
| 431 | { | |
| 432 | id: "p-1", | |
| 433 | name: "Project", | |
| 434 | cwd: "/tmp/project", | |
| 435 | model: "gpt-5.3-codex", | |
| 436 | expanded: true, | |
| 437 | scripts: [], | |
| 438 | }, | |
| 439 | ], | |
| 440 | threads: [thread], | |
| 441 | runtimeMode: "full-access", | |
| 442 | }); | |
| 443 | ||
| 444 | expect(persisted.version).toBe(7); | |
| 445 | expect(persisted.runtimeMode).toBe("full-access"); | |
| 446 | expect(persisted.threads[0]).toEqual({ | |
| 447 | id: "t-1", | |
| 448 | codexThreadId: "thr_1", | |
| 449 | projectId: "p-1", | |
| 450 | title: "Thread", | |
| 451 | model: "gpt-5.3-codex", | |
| 452 | terminalOpen: true, | |
| 453 | terminalHeight: 320, | |
| 454 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 455 | activeTerminalId: "term-2", | |
| 456 | terminalGroups: [ | |
| 457 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, | |
| 458 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 459 | ], | |
| 460 | activeTerminalGroupId: "group-term-2", | |
| 461 | messages: [ | |
| 462 | { | |
| 463 | id: "m-1", | |
| 464 | role: "user", | |
| 465 | text: "Hi", | |
| 466 | attachments: [ | |
| 467 | { | |
| 468 | type: "image", | |
| 469 | id: "img-1", | |
| 470 | name: "diagram.png", | |
| 471 | mimeType: "image/png", | |
| 472 | sizeBytes: 4_096, | |
| 473 | }, | |
| 474 | ], | |
| 475 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 476 | streaming: false, | |
| 477 | }, | |
| 478 | ], | |
| 479 | createdAt: thread.createdAt, | |
| 480 | lastVisitedAt: thread.lastVisitedAt, | |
| 481 | branch: null, | |
| 482 | worktreePath: null, | |
| 483 | turnDiffSummaries: [ | |
| 484 | { | |
| 485 | turnId: "turn-1", | |
| 486 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 487 | status: "completed", | |
| 488 | checkpointTurnCount: 1, | |
| 489 | files: [ | |
| 490 | { | |
| 491 | path: "src/app.ts", | |
| 492 | kind: "modified", | |
| 493 | additions: 3, | |
| 494 | deletions: 1, | |
| 495 | }, | |
| 496 | ], | |
| 497 | }, | |
| 498 | ], | |
| 499 | }); | |
| 500 | const persistedThread = persisted.threads[0]; | |
| 501 | expect(persistedThread).toBeDefined(); | |
| 502 | if (!persistedThread) return; | |
| 503 | ||
| 504 | expect("error" in persistedThread).toBe(false); | |
| 505 | expect("session" in persistedThread).toBe(false); | |
| 506 | }); | |
| 507 | }); |
apps/web/src/persistenceSchema.ts deleted-399| ... | ... | @@ -1,399 +0,0 @@ |
| 1 | import { z } from "zod"; | |
| 2 | import { projectScriptSchema } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | |
| 5 | import { | |
| 6 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 7 | DEFAULT_THREAD_TERMINAL_ID, | |
| 8 | DEFAULT_RUNTIME_MODE, | |
| 9 | type Project, | |
| 10 | type RuntimeMode, | |
| 11 | type Thread, | |
| 12 | type ThreadTerminalGroup, | |
| 13 | } from "./types"; | |
| 14 | ||
| 15 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 16 | ||
| 17 | const persistedProjectSchema = z.object({ | |
| 18 | id: z.string().min(1), | |
| 19 | name: z.string().min(1), | |
| 20 | cwd: z.string().min(1), | |
| 21 | model: z.string().min(1), | |
| 22 | expanded: z.boolean(), | |
| 23 | scripts: z.array(projectScriptSchema).default([]), | |
| 24 | }); | |
| 25 | ||
| 26 | const persistedMessageSchema = z.object({ | |
| 27 | id: z.string().min(1), | |
| 28 | role: z.enum(["user", "assistant"]), | |
| 29 | text: z.string(), | |
| 30 | attachments: z | |
| 31 | .array( | |
| 32 | z.object({ | |
| 33 | type: z.literal("image"), | |
| 34 | id: z.string().min(1), | |
| 35 | name: z.string().min(1), | |
| 36 | mimeType: z.string().min(1), | |
| 37 | sizeBytes: z.number().int().min(1), | |
| 38 | }), | |
| 39 | ) | |
| 40 | .optional(), | |
| 41 | createdAt: z.string().min(1), | |
| 42 | streaming: z.boolean(), | |
| 43 | }); | |
| 44 | ||
| 45 | const persistedTerminalGroupSchema = z.object({ | |
| 46 | id: z.string().trim().min(1), | |
| 47 | terminalIds: z.array(z.string().trim().min(1)), | |
| 48 | }); | |
| 49 | ||
| 50 | const persistedTurnDiffFileChangeSchema = z.object({ | |
| 51 | path: z.string().min(1), | |
| 52 | kind: z.string().min(1).optional(), | |
| 53 | additions: z.number().int().min(0).optional(), | |
| 54 | deletions: z.number().int().min(0).optional(), | |
| 55 | }); | |
| 56 | ||
| 57 | const persistedTurnDiffSummarySchema = z.object({ | |
| 58 | turnId: z.string().min(1), | |
| 59 | completedAt: z.string().min(1), | |
| 60 | status: z.string().min(1).optional(), | |
| 61 | files: z.array(persistedTurnDiffFileChangeSchema), | |
| 62 | assistantMessageId: z.string().min(1).optional(), | |
| 63 | checkpointTurnCount: z.number().int().min(0).optional(), | |
| 64 | }); | |
| 65 | ||
| 66 | const persistedThreadSchema = z.object({ | |
| 67 | id: z.string().min(1), | |
| 68 | codexThreadId: z.string().min(1).nullable().default(null), | |
| 69 | projectId: z.string().min(1), | |
| 70 | title: z.string().min(1), | |
| 71 | model: z.string().min(1), | |
| 72 | terminalOpen: z.boolean().default(false), | |
| 73 | terminalHeight: z.number().int().min(120).max(4_096).default(DEFAULT_THREAD_TERMINAL_HEIGHT), | |
| 74 | terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), | |
| 75 | activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), | |
| 76 | terminalGroups: z.array(persistedTerminalGroupSchema).default([]), | |
| 77 | activeTerminalGroupId: z.string().trim().min(1).optional(), | |
| 78 | // Legacy v6 and older fields retained for migration. | |
| 79 | terminalLayout: z.enum(["single", "split", "tabs"]).optional(), | |
| 80 | splitTerminalIds: z.array(z.string().trim().min(1)).optional(), | |
| 81 | messages: z.array(persistedMessageSchema), | |
| 82 | createdAt: z.string().min(1), | |
| 83 | lastVisitedAt: z.string().min(1).optional(), | |
| 84 | branch: z.string().min(1).nullable().optional(), | |
| 85 | worktreePath: z.string().min(1).nullable().optional(), | |
| 86 | turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), | |
| 87 | }); | |
| 88 | ||
| 89 | const persistedStateBodySchema = z.object({ | |
| 90 | projects: z.array(persistedProjectSchema), | |
| 91 | threads: z.array(persistedThreadSchema), | |
| 92 | activeThreadId: z.string().min(1).nullable().optional(), | |
| 93 | }); | |
| 94 | ||
| 95 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | |
| 96 | ||
| 97 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | |
| 98 | version: z.literal(1).optional(), | |
| 99 | }); | |
| 100 | ||
| 101 | export const persistedStateV2Schema = persistedStateBodySchema.extend({ | |
| 102 | version: z.literal(2).optional(), | |
| 103 | }); | |
| 104 | ||
| 105 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | |
| 106 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 107 | version: z.literal(3).optional(), | |
| 108 | }); | |
| 109 | ||
| 110 | export const persistedStateV4Schema = persistedStateBodySchema.extend({ | |
| 111 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 112 | version: z.literal(4).optional(), | |
| 113 | }); | |
| 114 | ||
| 115 | export const persistedStateV6Schema = persistedStateBodySchema.extend({ | |
| 116 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 117 | version: z.literal(6).optional(), | |
| 118 | }); | |
| 119 | ||
| 120 | export const persistedStateV7Schema = persistedStateBodySchema.extend({ | |
| 121 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 122 | version: z.literal(7).optional(), | |
| 123 | }); | |
| 124 | ||
| 125 | export const persistedStateV5Schema = persistedStateBodySchema.extend({ | |
| 126 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | |
| 127 | version: z.literal(5).optional(), | |
| 128 | }); | |
| 129 | ||
| 130 | const persistedStateSchema = z.union([ | |
| 131 | persistedStateV7Schema, | |
| 132 | persistedStateV6Schema, | |
| 133 | persistedStateV5Schema, | |
| 134 | persistedStateV4Schema, | |
| 135 | persistedStateV3Schema, | |
| 136 | persistedStateV2Schema, | |
| 137 | persistedStateV1Schema, | |
| 138 | ]); | |
| 139 | ||
| 140 | export interface PersistedStoreSnapshot { | |
| 141 | projects: Project[]; | |
| 142 | threads: Thread[]; | |
| 143 | runtimeMode: RuntimeMode; | |
| 144 | } | |
| 145 | ||
| 146 | function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { | |
| 147 | if (!isLegacyPayload) { | |
| 148 | return model; | |
| 149 | } | |
| 150 | ||
| 151 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | |
| 152 | } | |
| 153 | ||
| 154 | function hydrateProject( | |
| 155 | project: z.infer<typeof persistedProjectSchema>, | |
| 156 | isLegacyPayload: boolean, | |
| 157 | ): Project { | |
| 158 | return { | |
| 159 | ...project, | |
| 160 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), | |
| 161 | scripts: project.scripts, | |
| 162 | }; | |
| 163 | } | |
| 164 | ||
| 165 | function hydrateThread( | |
| 166 | thread: z.infer<typeof persistedThreadSchema>, | |
| 167 | isLegacyPayload: boolean, | |
| 168 | ): Thread { | |
| 169 | const terminalIds = [ | |
| 170 | ...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | |
| 171 | ]; | |
| 172 | const safeTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 173 | const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) | |
| 174 | ? thread.activeTerminalId | |
| 175 | : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 176 | const safeTerminalIdSet = new Set(safeTerminalIds); | |
| 177 | const assignedTerminalIds = new Set<string>(); | |
| 178 | const usedGroupIds = new Set<string>(); | |
| 179 | const normalizedGroups: ThreadTerminalGroup[] = []; | |
| 180 | const assignUniqueGroupId = (groupId: string): string => { | |
| 181 | if (!usedGroupIds.has(groupId)) { | |
| 182 | usedGroupIds.add(groupId); | |
| 183 | return groupId; | |
| 184 | } | |
| 185 | let suffix = 2; | |
| 186 | while (usedGroupIds.has(`${groupId}-${suffix}`)) { | |
| 187 | suffix += 1; | |
| 188 | } | |
| 189 | const uniqueGroupId = `${groupId}-${suffix}`; | |
| 190 | usedGroupIds.add(uniqueGroupId); | |
| 191 | return uniqueGroupId; | |
| 192 | }; | |
| 193 | ||
| 194 | for (const terminalGroup of thread.terminalGroups) { | |
| 195 | const nextTerminalIds = [ | |
| 196 | ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | |
| 197 | ].filter((terminalId) => { | |
| 198 | if (!safeTerminalIdSet.has(terminalId)) return false; | |
| 199 | if (assignedTerminalIds.has(terminalId)) return false; | |
| 200 | return true; | |
| 201 | }); | |
| 202 | if (nextTerminalIds.length === 0) continue; | |
| 203 | for (const terminalId of nextTerminalIds) { | |
| 204 | assignedTerminalIds.add(terminalId); | |
| 205 | } | |
| 206 | const baseGroupId = | |
| 207 | terminalGroup.id.trim().length > 0 | |
| 208 | ? terminalGroup.id.trim() | |
| 209 | : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | |
| 210 | normalizedGroups.push({ | |
| 211 | id: assignUniqueGroupId(baseGroupId), | |
| 212 | terminalIds: nextTerminalIds, | |
| 213 | }); | |
| 214 | } | |
| 215 | ||
| 216 | if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { | |
| 217 | const splitTerminalIds = [ | |
| 218 | ...new Set( | |
| 219 | (thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0), | |
| 220 | ), | |
| 221 | ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); | |
| 222 | if (splitTerminalIds.length >= 2) { | |
| 223 | const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); | |
| 224 | for (const terminalId of splitGroupTerminalIds) { | |
| 225 | assignedTerminalIds.add(terminalId); | |
| 226 | } | |
| 227 | normalizedGroups.push({ | |
| 228 | id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), | |
| 229 | terminalIds: splitGroupTerminalIds, | |
| 230 | }); | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | for (const terminalId of safeTerminalIds) { | |
| 235 | if (assignedTerminalIds.has(terminalId)) continue; | |
| 236 | normalizedGroups.push({ | |
| 237 | id: assignUniqueGroupId(`group-${terminalId}`), | |
| 238 | terminalIds: [terminalId], | |
| 239 | }); | |
| 240 | } | |
| 241 | ||
| 242 | const activeGroupIndexFromId = normalizedGroups.findIndex( | |
| 243 | (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, | |
| 244 | ); | |
| 245 | const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => | |
| 246 | terminalGroup.terminalIds.includes(activeTerminalId), | |
| 247 | ); | |
| 248 | const activeGroupIndex = | |
| 249 | activeGroupIndexFromId >= 0 | |
| 250 | ? activeGroupIndexFromId | |
| 251 | : activeGroupIndexFromTerminal >= 0 | |
| 252 | ? activeGroupIndexFromTerminal | |
| 253 | : 0; | |
| 254 | const activeTerminalGroupId = | |
| 255 | normalizedGroups[activeGroupIndex]?.id ?? | |
| 256 | normalizedGroups[0]?.id ?? | |
| 257 | `group-${DEFAULT_THREAD_TERMINAL_ID}`; | |
| 258 | ||
| 259 | return { | |
| 260 | id: thread.id, | |
| 261 | codexThreadId: thread.codexThreadId, | |
| 262 | projectId: thread.projectId, | |
| 263 | title: thread.title, | |
| 264 | model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), | |
| 265 | terminalOpen: thread.terminalOpen ?? false, | |
| 266 | terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 267 | terminalIds: safeTerminalIds, | |
| 268 | runningTerminalIds: [], | |
| 269 | activeTerminalId, | |
| 270 | terminalGroups: normalizedGroups, | |
| 271 | activeTerminalGroupId, | |
| 272 | session: null, | |
| 273 | messages: thread.messages.map((message) => { | |
| 274 | const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); | |
| 275 | return { | |
| 276 | id: message.id, | |
| 277 | role: message.role, | |
| 278 | text: message.text, | |
| 279 | ...(hydratedAttachments && hydratedAttachments.length > 0 | |
| 280 | ? { attachments: hydratedAttachments } | |
| 281 | : {}), | |
| 282 | createdAt: message.createdAt, | |
| 283 | streaming: false, | |
| 284 | }; | |
| 285 | }), | |
| 286 | events: [], | |
| 287 | error: null, | |
| 288 | createdAt: thread.createdAt, | |
| 289 | lastVisitedAt: thread.lastVisitedAt, | |
| 290 | branch: thread.branch ?? null, | |
| 291 | worktreePath: thread.worktreePath ?? null, | |
| 292 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | |
| 293 | turnId: summary.turnId, | |
| 294 | completedAt: summary.completedAt, | |
| 295 | ...(summary.status ? { status: summary.status } : {}), | |
| 296 | files: summary.files.map((file) => ({ | |
| 297 | path: file.path, | |
| 298 | ...(file.kind ? { kind: file.kind } : {}), | |
| 299 | ...(typeof file.additions === "number" ? { additions: file.additions } : {}), | |
| 300 | ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), | |
| 301 | })), | |
| 302 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 303 | ...(typeof summary.checkpointTurnCount === "number" | |
| 304 | ? { checkpointTurnCount: summary.checkpointTurnCount } | |
| 305 | : {}), | |
| 306 | })), | |
| 307 | }; | |
| 308 | } | |
| 309 | ||
| 310 | export function hydratePersistedState( | |
| 311 | raw: string, | |
| 312 | isLegacyPayload: boolean, | |
| 313 | ): PersistedStoreSnapshot | null { | |
| 314 | let parsedJson: unknown; | |
| 315 | try { | |
| 316 | parsedJson = JSON.parse(raw); | |
| 317 | } catch { | |
| 318 | return null; | |
| 319 | } | |
| 320 | ||
| 321 | const parsedState = persistedStateSchema.safeParse(parsedJson); | |
| 322 | if (!parsedState.success) { | |
| 323 | return null; | |
| 324 | } | |
| 325 | ||
| 326 | const projects = parsedState.data.projects.map((project) => | |
| 327 | hydrateProject(project, isLegacyPayload), | |
| 328 | ); | |
| 329 | const projectIds = new Set(projects.map((project) => project.id)); | |
| 330 | const threads = parsedState.data.threads | |
| 331 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | |
| 332 | .filter((thread) => projectIds.has(thread.projectId)); | |
| 333 | return { | |
| 334 | projects, | |
| 335 | threads, | |
| 336 | runtimeMode: | |
| 337 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, | |
| 338 | }; | |
| 339 | } | |
| 340 | ||
| 341 | export function toPersistedState( | |
| 342 | state: PersistedStoreSnapshot, | |
| 343 | ): z.infer<typeof persistedStateV7Schema> { | |
| 344 | return { | |
| 345 | version: 7, | |
| 346 | projects: state.projects, | |
| 347 | threads: state.threads.map((thread) => ({ | |
| 348 | id: thread.id, | |
| 349 | codexThreadId: thread.codexThreadId, | |
| 350 | projectId: thread.projectId, | |
| 351 | title: thread.title, | |
| 352 | model: thread.model, | |
| 353 | terminalOpen: thread.terminalOpen, | |
| 354 | terminalHeight: thread.terminalHeight, | |
| 355 | terminalIds: thread.terminalIds, | |
| 356 | activeTerminalId: thread.activeTerminalId, | |
| 357 | terminalGroups: thread.terminalGroups, | |
| 358 | activeTerminalGroupId: thread.activeTerminalGroupId, | |
| 359 | messages: thread.messages.map((message) => ({ | |
| 360 | id: message.id, | |
| 361 | role: message.role, | |
| 362 | text: message.text, | |
| 363 | ...(message.attachments && message.attachments.length > 0 | |
| 364 | ? { | |
| 365 | attachments: message.attachments.map((attachment) => ({ | |
| 366 | type: attachment.type, | |
| 367 | id: attachment.id, | |
| 368 | name: attachment.name, | |
| 369 | mimeType: attachment.mimeType, | |
| 370 | sizeBytes: attachment.sizeBytes, | |
| 371 | })), | |
| 372 | } | |
| 373 | : {}), | |
| 374 | createdAt: message.createdAt, | |
| 375 | streaming: message.streaming, | |
| 376 | })), | |
| 377 | createdAt: thread.createdAt, | |
| 378 | ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), | |
| 379 | branch: thread.branch, | |
| 380 | worktreePath: thread.worktreePath, | |
| 381 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | |
| 382 | turnId: summary.turnId, | |
| 383 | completedAt: summary.completedAt, | |
| 384 | ...(summary.status ? { status: summary.status } : {}), | |
| 385 | files: summary.files.map((file) => ({ | |
| 386 | path: file.path, | |
| 387 | ...(file.kind ? { kind: file.kind } : {}), | |
| 388 | ...(typeof file.additions === "number" ? { additions: file.additions } : {}), | |
| 389 | ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), | |
| 390 | })), | |
| 391 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 392 | ...(typeof summary.checkpointTurnCount === "number" | |
| 393 | ? { checkpointTurnCount: summary.checkpointTurnCount } | |
| 394 | : {}), | |
| 395 | })), | |
| 396 | })), | |
| 397 | runtimeMode: state.runtimeMode, | |
| 398 | }; | |
| 399 | } |
apps/web/src/projectScripts.ts+18-10| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | import type { ProjectScript } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/; | |
| 4 | const MAX_SCRIPT_ID_LENGTH = 64; | |
| 1 | import { | |
| 2 | MAX_SCRIPT_ID_LENGTH, | |
| 3 | SCRIPT_RUN_COMMAND_PATTERN, | |
| 4 | type KeybindingCommand, | |
| 5 | type ProjectScript, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Schema } from "effect"; | |
| 5 | 8 | |
| 6 | 9 | function normalizeScriptId(value: string): string { |
| 7 | 10 | const cleaned = value |
| ... | ... | @@ -18,13 +21,16 @@ function normalizeScriptId(value: string): string { |
| 18 | 21 | return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script"; |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | export function commandForProjectScript(scriptId: string): string { | |
| 22 | return `script.${scriptId}.run`; | |
| 23 | } | |
| 24 | export const commandForProjectScript = (scriptId: string): KeybindingCommand => | |
| 25 | SCRIPT_RUN_COMMAND_PATTERN.makeUnsafe(`script.${scriptId}.run`); | |
| 24 | 26 | |
| 25 | 27 | export function projectScriptIdFromCommand(command: string): string | null { |
| 26 | const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim()); | |
| 27 | return match?.[1] ?? null; | |
| 28 | const trimmed = command.trim(); | |
| 29 | if (!Schema.is(SCRIPT_RUN_COMMAND_PATTERN)(trimmed)) { | |
| 30 | return null; | |
| 31 | } | |
| 32 | const [prefix, , suffix] = SCRIPT_RUN_COMMAND_PATTERN.parts; | |
| 33 | return trimmed.slice(prefix.literal.length, -suffix.literal.length); | |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | 36 | export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string { |
| ... | ... | @@ -57,7 +63,9 @@ interface ProjectScriptRuntimeEnvInput { |
| 57 | 63 | extraEnv?: Record<string, string>; |
| 58 | 64 | } |
| 59 | 65 | |
| 60 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { | |
| 66 | export function projectScriptRuntimeEnv( | |
| 67 | input: ProjectScriptRuntimeEnvInput, | |
| 68 | ): Record<string, string> { | |
| 61 | 69 | const env: Record<string, string> = { |
| 62 | 70 | T3CODE_PROJECT_ROOT: input.project.cwd, |
| 63 | 71 | }; |
apps/web/src/routes/__root.tsx+218-135| ... | ... | @@ -1,26 +1,34 @@ |
| 1 | import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router"; | |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | Outlet, | |
| 4 | createRootRouteWithContext, | |
| 5 | type ErrorComponentProps, | |
| 6 | useNavigate, | |
| 7 | useRouterState, | |
| 8 | } from "@tanstack/react-router"; | |
| 2 | 9 | import { useEffect, useRef } from "react"; |
| 3 | 10 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 4 | 11 | |
| 5 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | |
| 6 | import { isElectron } from "../env"; | |
| 7 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 8 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | |
| 10 | import { useStore } from "../store"; | |
| 11 | import { onServerWelcome } from "../wsNativeApi"; | |
| 12 | 12 | import { APP_DISPLAY_NAME } from "../branding"; |
| 13 | import { Button } from "../components/ui/button"; | |
| 14 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | |
| 15 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | |
| 16 | import { readNativeApi } from "../nativeApi"; | |
| 17 | import { useStore } from "../store"; | |
| 18 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 19 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; | |
| 20 | import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; | |
| 21 | import { providerQueryKeys } from "../lib/providerReactQuery"; | |
| 13 | 22 | |
| 14 | 23 | export const Route = createRootRouteWithContext<{ |
| 15 | 24 | queryClient: QueryClient; |
| 16 | 25 | }>()({ |
| 17 | 26 | component: RootRouteView, |
| 27 | errorComponent: RootRouteErrorView, | |
| 18 | 28 | }); |
| 19 | 29 | |
| 20 | 30 | function RootRouteView() { |
| 21 | const api = useNativeApi(); | |
| 22 | ||
| 23 | if (!api) { | |
| 31 | if (!readNativeApi()) { | |
| 24 | 32 | return ( |
| 25 | 33 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 26 | 34 | <div className="flex flex-1 items-center justify-center"> |
| ... | ... | @@ -36,7 +44,6 @@ function RootRouteView() { |
| 36 | 44 | <ToastProvider> |
| 37 | 45 | <AnchoredToastProvider> |
| 38 | 46 | <EventRouter /> |
| 39 | <AutoProjectBootstrap /> | |
| 40 | 47 | <DesktopProjectBootstrap /> |
| 41 | 48 | <Outlet /> |
| 42 | 49 | </AnchoredToastProvider> |
| ... | ... | @@ -44,155 +51,231 @@ function RootRouteView() { |
| 44 | 51 | ); |
| 45 | 52 | } |
| 46 | 53 | |
| 47 | function EventRouter() { | |
| 48 | const api = useNativeApi(); | |
| 49 | const { dispatch } = useStore(); | |
| 50 | const queryClient = useQueryClient(); | |
| 51 | const activeAssistantItemRef = useRef<string | null>(null); | |
| 52 | const activeThreadId = useParams({ | |
| 53 | strict: false, | |
| 54 | select: (params) => params.threadId, | |
| 55 | }); | |
| 54 | function RootRouteErrorView({ error, reset }: ErrorComponentProps) { | |
| 55 | const message = errorMessage(error); | |
| 56 | const details = errorDetails(error); | |
| 56 | 57 | |
| 57 | useEffect(() => { | |
| 58 | if (!api) return; | |
| 59 | return api.providers.onEvent((event) => { | |
| 60 | if (event.method === "turn/completed") { | |
| 61 | void invalidateGitQueries(queryClient); | |
| 62 | } | |
| 63 | if (event.method === "checkpoint/captured") { | |
| 64 | const payload = event.payload as { turnCount?: number } | undefined; | |
| 65 | const turnCount = payload?.turnCount; | |
| 66 | void queryClient.invalidateQueries({ | |
| 67 | queryKey: ["providers", "checkpointDiff"] as const, | |
| 68 | predicate: (query) => { | |
| 69 | if (typeof turnCount !== "number") return true; | |
| 70 | return query.queryKey[5] === turnCount; | |
| 71 | }, | |
| 72 | }); | |
| 73 | } | |
| 74 | if (!activeThreadId) return; | |
| 75 | dispatch({ | |
| 76 | type: "APPLY_EVENT", | |
| 77 | event, | |
| 78 | activeAssistantItemRef, | |
| 79 | activeThreadId, | |
| 80 | }); | |
| 81 | }); | |
| 82 | }, [activeThreadId, api, dispatch, queryClient]); | |
| 58 | return ( | |
| 59 | <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6"> | |
| 60 | <div className="pointer-events-none absolute inset-0 opacity-80"> | |
| 61 | <div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-red-500)_16%,transparent),transparent)]" /> | |
| 62 | <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" /> | |
| 63 | </div> | |
| 83 | 64 | |
| 84 | useEffect(() => { | |
| 85 | if (!activeThreadId) return; | |
| 86 | dispatch({ | |
| 87 | type: "MARK_THREAD_VISITED", | |
| 88 | threadId: activeThreadId, | |
| 89 | visitedAt: new Date().toISOString(), | |
| 90 | }); | |
| 91 | }, [activeThreadId, dispatch]); | |
| 65 | <section className="relative w-full max-w-xl rounded-2xl border border-border/80 bg-card/90 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8"> | |
| 66 | <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase"> | |
| 67 | {APP_DISPLAY_NAME} | |
| 68 | </p> | |
| 69 | <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl"> | |
| 70 | Something went wrong. | |
| 71 | </h1> | |
| 72 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{message}</p> | |
| 92 | 73 | |
| 93 | useEffect(() => { | |
| 94 | if (!api) return; | |
| 95 | return api.terminal.onEvent((event) => { | |
| 96 | dispatch({ | |
| 97 | type: "APPLY_TERMINAL_EVENT", | |
| 98 | event, | |
| 99 | }); | |
| 100 | }); | |
| 101 | }, [api, dispatch]); | |
| 74 | <div className="mt-5 flex flex-wrap gap-2"> | |
| 75 | <Button size="sm" onClick={() => reset()}> | |
| 76 | Try again | |
| 77 | </Button> | |
| 78 | <Button size="sm" variant="outline" onClick={() => window.location.reload()}> | |
| 79 | Reload app | |
| 80 | </Button> | |
| 81 | </div> | |
| 102 | 82 | |
| 103 | return null; | |
| 83 | <details className="group mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55"> | |
| 84 | <summary className="cursor-pointer list-none px-3 py-2 text-xs font-medium text-muted-foreground"> | |
| 85 | <span className="group-open:hidden">Show error details</span> | |
| 86 | <span className="hidden group-open:inline">Hide error details</span> | |
| 87 | </summary> | |
| 88 | <pre className="max-h-56 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs text-foreground/85"> | |
| 89 | {details} | |
| 90 | </pre> | |
| 91 | </details> | |
| 92 | </section> | |
| 93 | </div> | |
| 94 | ); | |
| 104 | 95 | } |
| 105 | 96 | |
| 106 | function AutoProjectBootstrap() { | |
| 107 | const { state, dispatch } = useStore(); | |
| 108 | const bootstrappedRef = useRef(false); | |
| 97 | function errorMessage(error: unknown): string { | |
| 98 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 99 | return error.message; | |
| 100 | } | |
| 109 | 101 | |
| 110 | useEffect(() => { | |
| 111 | // Browser mode bootstraps from server welcome. | |
| 112 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | |
| 113 | if (isElectron) return; | |
| 114 | ||
| 115 | return onServerWelcome((payload) => { | |
| 116 | if (bootstrappedRef.current) return; | |
| 117 | ||
| 118 | // Don't create duplicate projects for the same cwd | |
| 119 | const existing = state.projects.find((project) => project.cwd === payload.cwd); | |
| 120 | if (existing) { | |
| 121 | bootstrappedRef.current = true; | |
| 122 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 123 | return; | |
| 124 | } | |
| 102 | if (typeof error === "string" && error.trim().length > 0) { | |
| 103 | return error; | |
| 104 | } | |
| 105 | ||
| 106 | return "An unexpected router error occurred."; | |
| 107 | } | |
| 125 | 108 | |
| 126 | bootstrappedRef.current = true; | |
| 109 | function errorDetails(error: unknown): string { | |
| 110 | if (error instanceof Error) { | |
| 111 | return error.stack ?? error.message; | |
| 112 | } | |
| 127 | 113 | |
| 128 | // Create project + thread from server cwd | |
| 129 | const projectId = crypto.randomUUID(); | |
| 130 | dispatch({ | |
| 131 | type: "ADD_PROJECT", | |
| 132 | project: { | |
| 133 | id: projectId, | |
| 134 | name: payload.projectName, | |
| 135 | cwd: payload.cwd, | |
| 136 | model: DEFAULT_MODEL, | |
| 137 | expanded: true, | |
| 138 | scripts: [], | |
| 139 | }, | |
| 140 | }); | |
| 141 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 142 | }); | |
| 143 | }, [state.projects, dispatch]); | |
| 114 | if (typeof error === "string") { | |
| 115 | return error; | |
| 116 | } | |
| 144 | 117 | |
| 145 | return null; | |
| 118 | try { | |
| 119 | return JSON.stringify(error, null, 2); | |
| 120 | } catch { | |
| 121 | return "No additional error details are available."; | |
| 122 | } | |
| 146 | 123 | } |
| 147 | 124 | |
| 148 | function DesktopProjectBootstrap() { | |
| 149 | const api = useNativeApi(); | |
| 125 | function EventRouter() { | |
| 150 | 126 | const { dispatch } = useStore(); |
| 151 | const bootstrappedRef = useRef(false); | |
| 127 | const queryClient = useQueryClient(); | |
| 128 | const navigate = useNavigate(); | |
| 129 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | |
| 130 | const pathnameRef = useRef(pathname); | |
| 131 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); | |
| 132 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | |
| 133 | pathnameRef.current = pathname; | |
| 152 | 134 | |
| 153 | 135 | useEffect(() => { |
| 154 | if (!isElectron || !api || bootstrappedRef.current) return; | |
| 155 | ||
| 136 | const api = readNativeApi(); | |
| 137 | if (!api) return; | |
| 156 | 138 | let disposed = false; |
| 157 | let retryDelayMs = 500; | |
| 158 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 139 | let latestSequence = 0; | |
| 140 | let syncing = false; | |
| 141 | let pending = false; | |
| 159 | 142 | |
| 160 | const attemptBootstrap = async () => { | |
| 143 | const flushSnapshotSync = async (): Promise<void> => { | |
| 144 | const snapshot = await api.orchestration.getSnapshot(); | |
| 145 | if (disposed) return; | |
| 146 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); | |
| 147 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); | |
| 148 | if (pending) { | |
| 149 | pending = false; | |
| 150 | await flushSnapshotSync(); | |
| 151 | } | |
| 152 | }; | |
| 153 | ||
| 154 | const syncSnapshot = async () => { | |
| 155 | if (syncing) { | |
| 156 | pending = true; | |
| 157 | return; | |
| 158 | } | |
| 159 | syncing = true; | |
| 161 | 160 | try { |
| 162 | const projects = await api.projects.list(); | |
| 163 | if (disposed) return; | |
| 164 | dispatch({ | |
| 165 | type: "SYNC_PROJECTS", | |
| 166 | projects: projects.map((project) => ({ | |
| 167 | id: project.id, | |
| 168 | name: project.name, | |
| 169 | cwd: project.cwd, | |
| 170 | model: DEFAULT_MODEL, | |
| 171 | expanded: true, | |
| 172 | scripts: project.scripts, | |
| 173 | })), | |
| 174 | }); | |
| 175 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 176 | bootstrappedRef.current = true; | |
| 161 | pending = false; | |
| 162 | await flushSnapshotSync(); | |
| 177 | 163 | } catch { |
| 178 | if (disposed) return; | |
| 179 | retryTimer = setTimeout(() => { | |
| 180 | retryTimer = null; | |
| 181 | void attemptBootstrap(); | |
| 182 | }, retryDelayMs); | |
| 183 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | |
| 164 | // Keep prior state and wait for next domain event to trigger a resync. | |
| 165 | } finally { | |
| 166 | syncing = false; | |
| 184 | 167 | } |
| 185 | 168 | }; |
| 186 | 169 | |
| 187 | void attemptBootstrap(); | |
| 170 | void syncSnapshot().catch(() => undefined); | |
| 188 | 171 | |
| 172 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { | |
| 173 | if (event.sequence <= latestSequence) { | |
| 174 | return; | |
| 175 | } | |
| 176 | latestSequence = event.sequence; | |
| 177 | if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { | |
| 178 | void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); | |
| 179 | } | |
| 180 | void syncSnapshot(); | |
| 181 | }); | |
| 182 | const unsubTerminalEvent = api.terminal.onEvent((event) => { | |
| 183 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); | |
| 184 | if (hasRunningSubprocess === null) { | |
| 185 | return; | |
| 186 | } | |
| 187 | dispatch({ | |
| 188 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 189 | threadId: ThreadId.makeUnsafe(event.threadId), | |
| 190 | terminalId: event.terminalId, | |
| 191 | hasRunningSubprocess, | |
| 192 | }); | |
| 193 | }); | |
| 194 | const unsubWelcome = onServerWelcome((payload) => { | |
| 195 | void (async () => { | |
| 196 | await syncSnapshot(); | |
| 197 | if (disposed) { | |
| 198 | return; | |
| 199 | } | |
| 200 | ||
| 201 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | |
| 202 | return; | |
| 203 | } | |
| 204 | dispatch({ | |
| 205 | type: "SET_PROJECT_EXPANDED", | |
| 206 | projectId: payload.bootstrapProjectId, | |
| 207 | expanded: true, | |
| 208 | }); | |
| 209 | ||
| 210 | if (pathnameRef.current !== "/") { | |
| 211 | return; | |
| 212 | } | |
| 213 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { | |
| 214 | return; | |
| 215 | } | |
| 216 | await navigate({ | |
| 217 | to: "/$threadId", | |
| 218 | params: { threadId: payload.bootstrapThreadId }, | |
| 219 | replace: true, | |
| 220 | }); | |
| 221 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | |
| 222 | })().catch(() => undefined); | |
| 223 | }); | |
| 224 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { | |
| 225 | const signature = JSON.stringify(payload.issues); | |
| 226 | if (lastConfigIssuesSignatureRef.current === signature) { | |
| 227 | return; | |
| 228 | } | |
| 229 | lastConfigIssuesSignatureRef.current = signature; | |
| 230 | ||
| 231 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | |
| 232 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | |
| 233 | if (!issue) { | |
| 234 | toastManager.add({ | |
| 235 | type: "success", | |
| 236 | title: "Keybindings updated", | |
| 237 | description: "Keybindings configuration reloaded successfully.", | |
| 238 | }); | |
| 239 | return; | |
| 240 | } | |
| 241 | ||
| 242 | toastManager.add({ | |
| 243 | type: "warning", | |
| 244 | title: "Invalid keybindings configuration", | |
| 245 | description: issue.message, | |
| 246 | actionProps: { | |
| 247 | children: "Open keybindings.json", | |
| 248 | onClick: () => { | |
| 249 | void queryClient | |
| 250 | .ensureQueryData(serverConfigQueryOptions()) | |
| 251 | .then((config) => | |
| 252 | api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()), | |
| 253 | ) | |
| 254 | .catch((error) => { | |
| 255 | toastManager.add({ | |
| 256 | type: "error", | |
| 257 | title: "Unable to open keybindings file", | |
| 258 | description: | |
| 259 | error instanceof Error ? error.message : "Unknown error opening file.", | |
| 260 | }); | |
| 261 | }); | |
| 262 | }, | |
| 263 | }, | |
| 264 | }); | |
| 265 | }); | |
| 189 | 266 | return () => { |
| 190 | 267 | disposed = true; |
| 191 | if (retryTimer) { | |
| 192 | clearTimeout(retryTimer); | |
| 193 | } | |
| 268 | unsubDomainEvent(); | |
| 269 | unsubTerminalEvent(); | |
| 270 | unsubWelcome(); | |
| 271 | unsubServerConfigUpdated(); | |
| 194 | 272 | }; |
| 195 | }, [api, dispatch]); | |
| 273 | }, [dispatch, navigate, queryClient]); | |
| 196 | 274 | |
| 197 | 275 | return null; |
| 198 | 276 | } |
| 277 | ||
| 278 | function DesktopProjectBootstrap() { | |
| 279 | // Desktop hydration runs through EventRouter project + orchestration sync. | |
| 280 | return null; | |
| 281 | } |
apps/web/src/routes/_chat.$threadId.tsx+141-48| ... | ... | @@ -1,56 +1,144 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | |
| 3 | 4 | |
| 4 | 5 | import ChatView from "../components/ChatView"; |
| 5 | 6 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 6 | 7 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 7 | 8 | import { useStore } from "../store"; |
| 8 | 9 | import { Sheet, SheetPopup } from "../components/ui/sheet"; |
| 10 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | |
| 9 | 11 | |
| 10 | 12 | const DiffPanel = lazy(() => import("../components/DiffPanel")); |
| 11 | 13 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; |
| 14 | const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; | |
| 15 | const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; | |
| 16 | const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; | |
| 12 | 17 | |
| 13 | const DiffPanelWrapper = (props: { | |
| 18 | const DiffPanelSheet = (props: { | |
| 14 | 19 | children: ReactNode; |
| 15 | sheet: boolean; | |
| 16 | 20 | diffOpen: boolean; |
| 17 | 21 | onCloseDiff: () => void; |
| 18 | 22 | }) => { |
| 19 | if (props.sheet) { | |
| 20 | return ( | |
| 21 | <Sheet | |
| 22 | open={props.diffOpen} | |
| 23 | onOpenChange={(open) => { | |
| 24 | if (!open) { | |
| 25 | props.onCloseDiff(); | |
| 26 | } | |
| 27 | }} | |
| 23 | return ( | |
| 24 | <Sheet | |
| 25 | open={props.diffOpen} | |
| 26 | onOpenChange={(open) => { | |
| 27 | if (!open) { | |
| 28 | props.onCloseDiff(); | |
| 29 | } | |
| 30 | }} | |
| 31 | > | |
| 32 | <SheetPopup | |
| 33 | side="right" | |
| 34 | showCloseButton={false} | |
| 35 | keepMounted | |
| 36 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 28 | 37 | > |
| 29 | <SheetPopup | |
| 30 | side="right" | |
| 31 | showCloseButton={false} | |
| 32 | keepMounted | |
| 33 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 34 | > | |
| 35 | {props.children} | |
| 36 | </SheetPopup> | |
| 37 | </Sheet> | |
| 38 | {props.children} | |
| 39 | </SheetPopup> | |
| 40 | </Sheet> | |
| 41 | ); | |
| 42 | }; | |
| 43 | ||
| 44 | const DiffLoadingFallback = (props: { inline: boolean }) => { | |
| 45 | if (props.inline) { | |
| 46 | return ( | |
| 47 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | |
| 48 | Loading diff viewer... | |
| 49 | </div> | |
| 38 | 50 | ); |
| 39 | 51 | } |
| 40 | 52 | |
| 41 | 53 | return ( |
| 42 | <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}> | |
| 43 | {props.children} | |
| 54 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | |
| 55 | Loading diff viewer... | |
| 44 | 56 | </aside> |
| 45 | 57 | ); |
| 46 | 58 | }; |
| 47 | 59 | |
| 60 | const DiffPanelInlineSidebar = (props: { | |
| 61 | diffOpen: boolean; | |
| 62 | onCloseDiff: () => void; | |
| 63 | onOpenDiff: () => void; | |
| 64 | }) => { | |
| 65 | const { diffOpen, onCloseDiff, onOpenDiff } = props; | |
| 66 | const onOpenChange = useCallback( | |
| 67 | (open: boolean) => { | |
| 68 | if (open) { | |
| 69 | onOpenDiff(); | |
| 70 | return; | |
| 71 | } | |
| 72 | onCloseDiff(); | |
| 73 | }, | |
| 74 | [onCloseDiff, onOpenDiff], | |
| 75 | ); | |
| 76 | const shouldAcceptInlineSidebarWidth = useCallback( | |
| 77 | ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { | |
| 78 | const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']"); | |
| 79 | if (!composerForm) return true; | |
| 80 | const composerViewport = composerForm.parentElement; | |
| 81 | if (!composerViewport) return true; | |
| 82 | const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); | |
| 83 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | |
| 84 | ||
| 85 | const viewportStyle = window.getComputedStyle(composerViewport); | |
| 86 | const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; | |
| 87 | const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; | |
| 88 | const viewportContentWidth = Math.max( | |
| 89 | 0, | |
| 90 | composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, | |
| 91 | ); | |
| 92 | const formRect = composerForm.getBoundingClientRect(); | |
| 93 | const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; | |
| 94 | const overflowsViewport = formRect.width > viewportContentWidth + 0.5; | |
| 95 | ||
| 96 | if (previousSidebarWidth.length > 0) { | |
| 97 | wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); | |
| 98 | } else { | |
| 99 | wrapper.style.removeProperty("--sidebar-width"); | |
| 100 | } | |
| 101 | ||
| 102 | return !hasComposerOverflow && !overflowsViewport; | |
| 103 | }, | |
| 104 | [], | |
| 105 | ); | |
| 106 | ||
| 107 | return ( | |
| 108 | <SidebarProvider | |
| 109 | defaultOpen={false} | |
| 110 | open={diffOpen} | |
| 111 | onOpenChange={onOpenChange} | |
| 112 | className="w-auto min-h-0 flex-none bg-transparent" | |
| 113 | style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties} | |
| 114 | > | |
| 115 | <Sidebar | |
| 116 | side="right" | |
| 117 | collapsible="offcanvas" | |
| 118 | className="border-l border-border bg-card text-foreground" | |
| 119 | resizable={{ | |
| 120 | minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH, | |
| 121 | shouldAcceptWidth: shouldAcceptInlineSidebarWidth, | |
| 122 | storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, | |
| 123 | }} | |
| 124 | > | |
| 125 | <Suspense fallback={<DiffLoadingFallback inline />}> | |
| 126 | <DiffPanel mode="sidebar" /> | |
| 127 | </Suspense> | |
| 128 | <SidebarRail /> | |
| 129 | </Sidebar> | |
| 130 | </SidebarProvider> | |
| 131 | ); | |
| 132 | }; | |
| 133 | ||
| 48 | 134 | function ChatThreadRouteView() { |
| 49 | 135 | const { state } = useStore(); |
| 50 | 136 | const navigate = useNavigate(); |
| 51 | const { threadId } = Route.useParams(); | |
| 137 | const threadId = Route.useParams({ | |
| 138 | select: (params) => ThreadId.makeUnsafe(params.threadId), | |
| 139 | }); | |
| 52 | 140 | const search = Route.useSearch(); |
| 53 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; | |
| 141 | const threadExists = state.threads.some((thread) => thread.id === threadId); | |
| 54 | 142 | const diffOpen = search.diff === "1"; |
| 55 | 143 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 56 | 144 | const closeDiff = useCallback(() => { |
| ... | ... | @@ -62,13 +150,18 @@ function ChatThreadRouteView() { |
| 62 | 150 | }, |
| 63 | 151 | }); |
| 64 | 152 | }, [navigate, threadId]); |
| 153 | const openDiff = useCallback(() => { | |
| 154 | void navigate({ | |
| 155 | to: "/$threadId", | |
| 156 | params: { threadId }, | |
| 157 | search: (previous) => { | |
| 158 | const rest = stripDiffSearchParams(previous); | |
| 159 | return { ...rest, diff: "1" }; | |
| 160 | }, | |
| 161 | }); | |
| 162 | }, [navigate, threadId]); | |
| 65 | 163 | |
| 66 | 164 | useEffect(() => { |
| 67 | if (!threadId) { | |
| 68 | void navigate({ to: "/", replace: true }); | |
| 69 | return; | |
| 70 | } | |
| 71 | ||
| 72 | 165 | if (!state.threadsHydrated) { |
| 73 | 166 | return; |
| 74 | 167 | } |
| ... | ... | @@ -79,31 +172,31 @@ function ChatThreadRouteView() { |
| 79 | 172 | } |
| 80 | 173 | }, [navigate, state.threadsHydrated, threadExists, threadId]); |
| 81 | 174 | |
| 82 | if (!threadId || !state.threadsHydrated || !threadExists) { | |
| 175 | if (!state.threadsHydrated || !threadExists) { | |
| 83 | 176 | return null; |
| 84 | 177 | } |
| 85 | 178 | |
| 86 | const diffLoadingFallback = | |
| 87 | !diffOpen || shouldUseDiffSheet ? ( | |
| 88 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | |
| 89 | Loading diff viewer... | |
| 90 | </div> | |
| 91 | ) : ( | |
| 92 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | |
| 93 | Loading diff viewer... | |
| 94 | </aside> | |
| 179 | if (!shouldUseDiffSheet) { | |
| 180 | return ( | |
| 181 | <> | |
| 182 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 183 | <ChatView threadId={threadId} /> | |
| 184 | </SidebarInset> | |
| 185 | <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} /> | |
| 186 | </> | |
| 95 | 187 | ); |
| 188 | } | |
| 96 | 189 | |
| 97 | 190 | return ( |
| 98 | 191 | <> |
| 99 | <ChatView threadId={threadId} /> | |
| 100 | <Activity mode={diffOpen ? "visible" : "hidden"}> | |
| 101 | <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 102 | <Suspense fallback={diffLoadingFallback}> | |
| 103 | <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} /> | |
| 104 | </Suspense> | |
| 105 | </DiffPanelWrapper> | |
| 106 | </Activity> | |
| 192 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 193 | <ChatView threadId={threadId} /> | |
| 194 | </SidebarInset> | |
| 195 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 196 | <Suspense fallback={<DiffLoadingFallback inline={false} />}> | |
| 197 | <DiffPanel mode="sheet" /> | |
| 198 | </Suspense> | |
| 199 | </DiffPanelSheet> | |
| 107 | 200 | </> |
| 108 | 201 | ); |
| 109 | 202 | } |
apps/web/src/routes/_chat.index.tsx+10| ... | ... | @@ -1,10 +1,20 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | 3 | import { isElectron } from "../env"; |
| 4 | import { SidebarTrigger } from "../components/ui/sidebar"; | |
| 4 | 5 | |
| 5 | 6 | function ChatIndexRouteView() { |
| 6 | 7 | return ( |
| 7 | 8 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> |
| 9 | {!isElectron && ( | |
| 10 | <header className="border-b border-border px-3 py-2 md:hidden"> | |
| 11 | <div className="flex items-center gap-2"> | |
| 12 | <SidebarTrigger className="size-7 shrink-0" /> | |
| 13 | <span className="text-sm font-medium text-foreground">Threads</span> | |
| 14 | </div> | |
| 15 | </header> | |
| 16 | )} | |
| 17 | ||
| 8 | 18 | {isElectron && ( |
| 9 | 19 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> |
| 10 | 20 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
apps/web/src/routes/_chat.settings.tsx+286-175| ... | ... | @@ -1,12 +1,18 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | |
| 3 | import { useCallback, useState } from "react"; | |
| 2 | 4 | |
| 3 | 5 | import { useAppSettings } from "../appSettings"; |
| 4 | 6 | import { isElectron } from "../env"; |
| 5 | 7 | import { useTheme } from "../hooks/useTheme"; |
| 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 9 | import { ensureNativeApi } from "../nativeApi"; | |
| 6 | 10 | import { useStore } from "../store"; |
| 11 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 7 | 12 | import { Button } from "../components/ui/button"; |
| 8 | 13 | import { Input } from "../components/ui/input"; |
| 9 | 14 | import { Switch } from "../components/ui/switch"; |
| 15 | import { SidebarInset } from "~/components/ui/sidebar"; | |
| 10 | 16 | |
| 11 | 17 | const THEME_OPTIONS = [ |
| 12 | 18 | { |
| ... | ... | @@ -43,214 +49,319 @@ function SettingsRouteView() { |
| 43 | 49 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 44 | 50 | const { state, dispatch } = useStore(); |
| 45 | 51 | const { settings, defaults, updateSettings } = useAppSettings(); |
| 52 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | |
| 53 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); | |
| 54 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); | |
| 46 | 55 | |
| 47 | 56 | const codexBinaryPath = settings.codexBinaryPath; |
| 48 | 57 | const codexHomePath = settings.codexHomePath; |
| 58 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | |
| 49 | 59 | |
| 50 | return ( | |
| 51 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | |
| 52 | {isElectron && ( | |
| 53 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 54 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span> | |
| 55 | </div> | |
| 56 | )} | |
| 60 | const openKeybindingsFile = useCallback(() => { | |
| 61 | if (!keybindingsConfigPath) return; | |
| 62 | setOpenKeybindingsError(null); | |
| 63 | setIsOpeningKeybindings(true); | |
| 64 | const api = ensureNativeApi(); | |
| 65 | void api.shell | |
| 66 | .openInEditor(keybindingsConfigPath, preferredTerminalEditor()) | |
| 67 | .catch((error) => { | |
| 68 | setOpenKeybindingsError( | |
| 69 | error instanceof Error ? error.message : "Unable to open keybindings file.", | |
| 70 | ); | |
| 71 | }) | |
| 72 | .finally(() => { | |
| 73 | setIsOpeningKeybindings(false); | |
| 74 | }); | |
| 75 | }, [keybindingsConfigPath]); | |
| 57 | 76 | |
| 58 | <div className="flex-1 overflow-y-auto p-6"> | |
| 59 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> | |
| 60 | <header className="space-y-1"> | |
| 61 | <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1> | |
| 62 | <p className="text-sm text-muted-foreground"> | |
| 63 | Configure app-level preferences for this device. | |
| 64 | </p> | |
| 65 | </header> | |
| 77 | return ( | |
| 78 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | |
| 79 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | |
| 80 | {isElectron && ( | |
| 81 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 82 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70"> | |
| 83 | Settings | |
| 84 | </span> | |
| 85 | </div> | |
| 86 | )} | |
| 66 | 87 | |
| 67 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 68 | <div className="mb-4"> | |
| 69 | <h2 className="text-sm font-medium text-foreground">Appearance</h2> | |
| 70 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 71 | Choose how T3 Code handles light and dark mode. | |
| 88 | <div className="flex-1 overflow-y-auto p-6"> | |
| 89 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> | |
| 90 | <header className="space-y-1"> | |
| 91 | <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1> | |
| 92 | <p className="text-sm text-muted-foreground"> | |
| 93 | Configure app-level preferences for this device. | |
| 72 | 94 | </p> |
| 73 | </div> | |
| 95 | </header> | |
| 74 | 96 | |
| 75 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 76 | {THEME_OPTIONS.map((option) => { | |
| 77 | const selected = theme === option.value; | |
| 78 | return ( | |
| 79 | <button | |
| 80 | key={option.value} | |
| 81 | type="button" | |
| 82 | role="radio" | |
| 83 | aria-checked={selected} | |
| 84 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 85 | selected | |
| 86 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 87 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 88 | }`} | |
| 89 | onClick={() => setTheme(option.value)} | |
| 90 | > | |
| 91 | <span className="flex flex-col"> | |
| 92 | <span className="text-sm font-medium">{option.label}</span> | |
| 93 | <span className="text-xs">{option.description}</span> | |
| 94 | </span> | |
| 95 | {selected ? ( | |
| 96 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 97 | Selected | |
| 98 | </span> | |
| 99 | ) : null} | |
| 100 | </button> | |
| 101 | ); | |
| 102 | })} | |
| 103 | </div> | |
| 97 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 98 | <div className="mb-4"> | |
| 99 | <h2 className="text-sm font-medium text-foreground">Appearance</h2> | |
| 100 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 101 | Choose how T3 Code handles light and dark mode. | |
| 102 | </p> | |
| 103 | </div> | |
| 104 | 104 | |
| 105 | <p className="mt-4 text-xs text-muted-foreground"> | |
| 106 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 107 | </p> | |
| 108 | </section> | |
| 105 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 106 | {THEME_OPTIONS.map((option) => { | |
| 107 | const selected = theme === option.value; | |
| 108 | return ( | |
| 109 | <button | |
| 110 | key={option.value} | |
| 111 | type="button" | |
| 112 | role="radio" | |
| 113 | aria-checked={selected} | |
| 114 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 115 | selected | |
| 116 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 117 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 118 | }`} | |
| 119 | onClick={() => setTheme(option.value)} | |
| 120 | > | |
| 121 | <span className="flex flex-col"> | |
| 122 | <span className="text-sm font-medium">{option.label}</span> | |
| 123 | <span className="text-xs">{option.description}</span> | |
| 124 | </span> | |
| 125 | {selected ? ( | |
| 126 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 127 | Selected | |
| 128 | </span> | |
| 129 | ) : null} | |
| 130 | </button> | |
| 131 | ); | |
| 132 | })} | |
| 133 | </div> | |
| 109 | 134 | |
| 110 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 111 | <div className="mb-4"> | |
| 112 | <h2 className="text-sm font-medium text-foreground">Codex App Server</h2> | |
| 113 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 114 | These overrides apply to new sessions and let you use a non-default Codex install. | |
| 135 | <p className="mt-4 text-xs text-muted-foreground"> | |
| 136 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 115 | 137 | </p> |
| 116 | </div> | |
| 138 | </section> | |
| 117 | 139 | |
| 118 | <div className="space-y-4"> | |
| 119 | <label className="block space-y-1"> | |
| 120 | <span className="text-xs font-medium text-foreground">Codex binary path</span> | |
| 121 | <Input | |
| 122 | value={codexBinaryPath} | |
| 123 | onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} | |
| 124 | placeholder="codex" | |
| 125 | spellCheck={false} | |
| 126 | /> | |
| 127 | <span className="text-xs text-muted-foreground"> | |
| 128 | Leave blank to use <code>codex</code> from your PATH. | |
| 129 | </span> | |
| 130 | </label> | |
| 140 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 141 | <div className="mb-4"> | |
| 142 | <h2 className="text-sm font-medium text-foreground">Codex App Server</h2> | |
| 143 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 144 | These overrides apply to new sessions and let you use a non-default Codex install. | |
| 145 | </p> | |
| 146 | </div> | |
| 131 | 147 | |
| 132 | <label className="block space-y-1"> | |
| 133 | <span className="text-xs font-medium text-foreground">CODEX_HOME path</span> | |
| 134 | <Input | |
| 135 | value={codexHomePath} | |
| 136 | onChange={(event) => updateSettings({ codexHomePath: event.target.value })} | |
| 137 | placeholder="/Users/you/.codex" | |
| 138 | spellCheck={false} | |
| 139 | /> | |
| 140 | <span className="text-xs text-muted-foreground"> | |
| 141 | Optional custom Codex home/config directory. | |
| 142 | </span> | |
| 143 | </label> | |
| 148 | <div className="space-y-4"> | |
| 149 | <label className="block space-y-1"> | |
| 150 | <span className="text-xs font-medium text-foreground">Codex binary path</span> | |
| 151 | <Input | |
| 152 | value={codexBinaryPath} | |
| 153 | onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} | |
| 154 | placeholder="codex" | |
| 155 | spellCheck={false} | |
| 156 | /> | |
| 157 | <span className="text-xs text-muted-foreground"> | |
| 158 | Leave blank to use <code>codex</code> from your PATH. | |
| 159 | </span> | |
| 160 | </label> | |
| 161 | ||
| 162 | <label className="block space-y-1"> | |
| 163 | <span className="text-xs font-medium text-foreground">CODEX_HOME path</span> | |
| 164 | <Input | |
| 165 | value={codexHomePath} | |
| 166 | onChange={(event) => updateSettings({ codexHomePath: event.target.value })} | |
| 167 | placeholder="/Users/you/.codex" | |
| 168 | spellCheck={false} | |
| 169 | /> | |
| 170 | <span className="text-xs text-muted-foreground"> | |
| 171 | Optional custom Codex home/config directory. | |
| 172 | </span> | |
| 173 | </label> | |
| 174 | ||
| 175 | <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> | |
| 176 | <p> | |
| 177 | Binary source:{" "} | |
| 178 | <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span> | |
| 179 | </p> | |
| 180 | <Button | |
| 181 | size="xs" | |
| 182 | variant="outline" | |
| 183 | onClick={() => | |
| 184 | updateSettings({ | |
| 185 | codexBinaryPath: defaults.codexBinaryPath, | |
| 186 | codexHomePath: defaults.codexHomePath, | |
| 187 | }) | |
| 188 | } | |
| 189 | > | |
| 190 | Reset codex overrides | |
| 191 | </Button> | |
| 192 | </div> | |
| 193 | </div> | |
| 194 | </section> | |
| 195 | ||
| 196 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 197 | <div className="mb-4"> | |
| 198 | <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2> | |
| 199 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 200 | Select the default execution policy for this client. | |
| 201 | </p> | |
| 202 | </div> | |
| 144 | 203 | |
| 145 | <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> | |
| 146 | <p> | |
| 147 | Binary source: <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span> | |
| 204 | <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference"> | |
| 205 | {RUNTIME_MODE_OPTIONS.map((option) => { | |
| 206 | const selected = state.runtimeMode === option.value; | |
| 207 | return ( | |
| 208 | <button | |
| 209 | key={option.value} | |
| 210 | type="button" | |
| 211 | role="radio" | |
| 212 | aria-checked={selected} | |
| 213 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 214 | selected | |
| 215 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 216 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 217 | }`} | |
| 218 | onClick={() => { | |
| 219 | dispatch({ | |
| 220 | type: "SET_RUNTIME_MODE", | |
| 221 | mode: option.value, | |
| 222 | }); | |
| 223 | }} | |
| 224 | > | |
| 225 | <span className="flex flex-col"> | |
| 226 | <span className="text-sm font-medium">{option.label}</span> | |
| 227 | <span className="text-xs">{option.description}</span> | |
| 228 | </span> | |
| 229 | {selected ? ( | |
| 230 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 231 | Selected | |
| 232 | </span> | |
| 233 | ) : null} | |
| 234 | </button> | |
| 235 | ); | |
| 236 | })} | |
| 237 | </div> | |
| 238 | </section> | |
| 239 | ||
| 240 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 241 | <div className="mb-4"> | |
| 242 | <h2 className="text-sm font-medium text-foreground">Responses</h2> | |
| 243 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 244 | Control how assistant output is rendered during a turn. | |
| 148 | 245 | </p> |
| 149 | <Button | |
| 150 | size="xs" | |
| 151 | variant="outline" | |
| 152 | onClick={() => | |
| 246 | </div> | |
| 247 | ||
| 248 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 249 | <div> | |
| 250 | <p className="text-sm font-medium text-foreground">Stream assistant messages</p> | |
| 251 | <p className="text-xs text-muted-foreground"> | |
| 252 | Show token-by-token output while a response is in progress. | |
| 253 | </p> | |
| 254 | </div> | |
| 255 | <Switch | |
| 256 | checked={settings.enableAssistantStreaming} | |
| 257 | onCheckedChange={(checked) => | |
| 153 | 258 | updateSettings({ |
| 154 | codexBinaryPath: defaults.codexBinaryPath, | |
| 155 | codexHomePath: defaults.codexHomePath, | |
| 259 | enableAssistantStreaming: Boolean(checked), | |
| 156 | 260 | }) |
| 157 | 261 | } |
| 158 | > | |
| 159 | Reset codex overrides | |
| 160 | </Button> | |
| 262 | aria-label="Stream assistant messages" | |
| 263 | /> | |
| 161 | 264 | </div> |
| 162 | </div> | |
| 163 | </section> | |
| 164 | ||
| 165 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 166 | <div className="mb-4"> | |
| 167 | <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2> | |
| 168 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 169 | Select the default execution policy for this client. | |
| 170 | </p> | |
| 171 | </div> | |
| 172 | 265 | |
| 173 | <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference"> | |
| 174 | {RUNTIME_MODE_OPTIONS.map((option) => { | |
| 175 | const selected = state.runtimeMode === option.value; | |
| 176 | return ( | |
| 177 | <button | |
| 178 | key={option.value} | |
| 179 | type="button" | |
| 180 | role="radio" | |
| 181 | aria-checked={selected} | |
| 182 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 183 | selected | |
| 184 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 185 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 186 | }`} | |
| 187 | onClick={() => { | |
| 188 | dispatch({ | |
| 189 | type: "SET_RUNTIME_MODE", | |
| 190 | mode: option.value, | |
| 191 | }); | |
| 192 | }} | |
| 266 | {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( | |
| 267 | <div className="mt-3 flex justify-end"> | |
| 268 | <Button | |
| 269 | size="xs" | |
| 270 | variant="outline" | |
| 271 | onClick={() => | |
| 272 | updateSettings({ | |
| 273 | enableAssistantStreaming: defaults.enableAssistantStreaming, | |
| 274 | }) | |
| 275 | } | |
| 193 | 276 | > |
| 194 | <span className="flex flex-col"> | |
| 195 | <span className="text-sm font-medium">{option.label}</span> | |
| 196 | <span className="text-xs">{option.description}</span> | |
| 197 | </span> | |
| 198 | {selected ? ( | |
| 199 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 200 | Selected | |
| 201 | </span> | |
| 202 | ) : null} | |
| 203 | </button> | |
| 204 | ); | |
| 205 | })} | |
| 206 | </div> | |
| 207 | </section> | |
| 277 | Restore default | |
| 278 | </Button> | |
| 279 | </div> | |
| 280 | ) : null} | |
| 281 | </section> | |
| 208 | 282 | |
| 209 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 210 | <div className="mb-4"> | |
| 211 | <h2 className="text-sm font-medium text-foreground">Safety</h2> | |
| 212 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 213 | Additional guardrails for destructive local actions. | |
| 214 | </p> | |
| 215 | </div> | |
| 283 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 284 | <div className="mb-4"> | |
| 285 | <h2 className="text-sm font-medium text-foreground">Keybindings</h2> | |
| 286 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 287 | Open the persisted <code>keybindings.json</code> file to edit advanced bindings | |
| 288 | directly. | |
| 289 | </p> | |
| 290 | </div> | |
| 291 | ||
| 292 | <div className="space-y-3"> | |
| 293 | <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"> | |
| 294 | <div className="min-w-0 flex-1"> | |
| 295 | <p className="text-xs font-medium text-foreground">Config file path</p> | |
| 296 | <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground"> | |
| 297 | {keybindingsConfigPath ?? "Resolving keybindings path..."} | |
| 298 | </p> | |
| 299 | </div> | |
| 300 | <Button | |
| 301 | size="xs" | |
| 302 | variant="outline" | |
| 303 | disabled={!keybindingsConfigPath || isOpeningKeybindings} | |
| 304 | onClick={openKeybindingsFile} | |
| 305 | > | |
| 306 | {isOpeningKeybindings ? "Opening..." : "Open keybindings.json"} | |
| 307 | </Button> | |
| 308 | </div> | |
| 216 | 309 | |
| 217 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 218 | <div> | |
| 219 | <p className="text-sm font-medium text-foreground">Confirm thread deletion</p> | |
| 220 | 310 | <p className="text-xs text-muted-foreground"> |
| 221 | Ask for confirmation before deleting a thread and its chat history. | |
| 311 | Opens in your preferred editor selection. | |
| 312 | </p> | |
| 313 | {openKeybindingsError ? ( | |
| 314 | <p className="text-xs text-destructive">{openKeybindingsError}</p> | |
| 315 | ) : null} | |
| 316 | </div> | |
| 317 | </section> | |
| 318 | ||
| 319 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 320 | <div className="mb-4"> | |
| 321 | <h2 className="text-sm font-medium text-foreground">Safety</h2> | |
| 322 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 323 | Additional guardrails for destructive local actions. | |
| 222 | 324 | </p> |
| 223 | 325 | </div> |
| 224 | <Switch | |
| 225 | checked={settings.confirmThreadDelete} | |
| 226 | onCheckedChange={(checked) => | |
| 227 | updateSettings({ | |
| 228 | confirmThreadDelete: Boolean(checked), | |
| 229 | }) | |
| 230 | } | |
| 231 | aria-label="Confirm thread deletion" | |
| 232 | /> | |
| 233 | </div> | |
| 234 | 326 | |
| 235 | {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | |
| 236 | <div className="mt-3 flex justify-end"> | |
| 237 | <Button | |
| 238 | size="xs" | |
| 239 | variant="outline" | |
| 240 | onClick={() => | |
| 327 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 328 | <div> | |
| 329 | <p className="text-sm font-medium text-foreground">Confirm thread deletion</p> | |
| 330 | <p className="text-xs text-muted-foreground"> | |
| 331 | Ask for confirmation before deleting a thread and its chat history. | |
| 332 | </p> | |
| 333 | </div> | |
| 334 | <Switch | |
| 335 | checked={settings.confirmThreadDelete} | |
| 336 | onCheckedChange={(checked) => | |
| 241 | 337 | updateSettings({ |
| 242 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 338 | confirmThreadDelete: Boolean(checked), | |
| 243 | 339 | }) |
| 244 | 340 | } |
| 245 | > | |
| 246 | Restore default | |
| 247 | </Button> | |
| 341 | aria-label="Confirm thread deletion" | |
| 342 | /> | |
| 248 | 343 | </div> |
| 249 | ) : null} | |
| 250 | </section> | |
| 344 | ||
| 345 | {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | |
| 346 | <div className="mt-3 flex justify-end"> | |
| 347 | <Button | |
| 348 | size="xs" | |
| 349 | variant="outline" | |
| 350 | onClick={() => | |
| 351 | updateSettings({ | |
| 352 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 353 | }) | |
| 354 | } | |
| 355 | > | |
| 356 | Restore default | |
| 357 | </Button> | |
| 358 | </div> | |
| 359 | ) : null} | |
| 360 | </section> | |
| 361 | </div> | |
| 251 | 362 | </div> |
| 252 | 363 | </div> |
| 253 | </div> | |
| 364 | </SidebarInset> | |
| 254 | 365 | ); |
| 255 | 366 | } |
| 256 | 367 |
apps/web/src/routes/_chat.tsx+11-5| ... | ... | @@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 5 | import Sidebar from "../components/Sidebar"; | |
| 5 | import ThreadSidebar from "../components/Sidebar"; | |
| 6 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | |
| 6 | 7 | |
| 7 | 8 | function ChatRouteLayout() { |
| 8 | 9 | const navigate = useNavigate(); |
| ... | ... | @@ -24,16 +25,21 @@ function ChatRouteLayout() { |
| 24 | 25 | }, [navigate]); |
| 25 | 26 | |
| 26 | 27 | return ( |
| 27 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> | |
| 28 | <Sidebar /> | |
| 28 | <SidebarProvider defaultOpen> | |
| 29 | <Sidebar | |
| 30 | side="left" | |
| 31 | collapsible="offcanvas" | |
| 32 | className="border-r border-border bg-card text-foreground" | |
| 33 | > | |
| 34 | <ThreadSidebar /> | |
| 35 | </Sidebar> | |
| 29 | 36 | <DiffWorkerPoolProvider> |
| 30 | 37 | <Outlet /> |
| 31 | 38 | </DiffWorkerPoolProvider> |
| 32 | </div> | |
| 39 | </SidebarProvider> | |
| 33 | 40 | ); |
| 34 | 41 | } |
| 35 | 42 | |
| 36 | 43 | export const Route = createFileRoute("/_chat")({ |
| 37 | 44 | component: ChatRouteLayout, |
| 38 | 45 | }); |
| 39 |
apps/web/src/session-logic.test.ts+147-880| ... | ... | @@ -1,935 +1,202 @@ |
| 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | |
| 4 | 4 | import { |
| 5 | type WorkLogEntry, | |
| 6 | applyEventToMessages, | |
| 7 | 5 | derivePendingApprovals, |
| 8 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 9 | deriveTurnDiffSummaries, | |
| 10 | deriveTimelineEntries, | |
| 11 | 6 | deriveWorkLogEntries, |
| 12 | evolveSession, | |
| 7 | hasToolActivityForTurn, | |
| 8 | isLatestTurnSettled, | |
| 13 | 9 | } from "./session-logic"; |
| 14 | import type { ChatMessage } from "./types"; | |
| 15 | 10 | |
| 16 | function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent { | |
| 11 | function makeActivity(overrides: { | |
| 12 | id?: string; | |
| 13 | createdAt?: string; | |
| 14 | kind?: string; | |
| 15 | summary?: string; | |
| 16 | tone?: OrchestrationThreadActivity["tone"]; | |
| 17 | payload?: Record<string, unknown>; | |
| 18 | turnId?: string; | |
| 19 | }): OrchestrationThreadActivity { | |
| 20 | const payload = overrides.payload ?? {}; | |
| 17 | 21 | return { |
| 18 | id: "evt-1", | |
| 19 | kind: "notification", | |
| 20 | provider: "codex", | |
| 21 | sessionId: "sess-1", | |
| 22 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 23 | method: "noop", | |
| 24 | ...overrides, | |
| 22 | id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()), | |
| 23 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", | |
| 24 | kind: overrides.kind ?? "tool.started", | |
| 25 | summary: overrides.summary ?? "Tool call", | |
| 26 | tone: overrides.tone ?? "tool", | |
| 27 | payload, | |
| 28 | turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, | |
| 25 | 29 | }; |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | |
| 29 | return { | |
| 30 | sessionId: "sess-1", | |
| 31 | provider: "codex", | |
| 32 | status: "ready", | |
| 33 | createdAt: "2026-02-08T09:59:00.000Z", | |
| 34 | updatedAt: "2026-02-08T09:59:00.000Z", | |
| 35 | ...overrides, | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | describe("deriveTimelineEntries", () => { | |
| 40 | it("interleaves messages and work entries by timestamp", () => { | |
| 41 | const messages: ChatMessage[] = [ | |
| 42 | { | |
| 43 | id: "m-user", | |
| 44 | role: "user", | |
| 45 | text: "Hi", | |
| 46 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 47 | streaming: false, | |
| 48 | }, | |
| 49 | { | |
| 50 | id: "m-assistant", | |
| 51 | role: "assistant", | |
| 52 | text: "Hello", | |
| 53 | createdAt: "2026-02-08T10:05:00.000Z", | |
| 54 | streaming: false, | |
| 55 | }, | |
| 56 | ]; | |
| 57 | const workEntries: WorkLogEntry[] = [ | |
| 58 | { | |
| 59 | id: "w-1", | |
| 60 | label: "Tool call", | |
| 61 | createdAt: "2026-02-08T10:02:00.000Z", | |
| 62 | tone: "tool", | |
| 63 | }, | |
| 64 | { | |
| 65 | id: "w-2", | |
| 66 | label: "Preamble", | |
| 67 | createdAt: "2026-02-08T10:03:00.000Z", | |
| 68 | tone: "thinking", | |
| 69 | }, | |
| 70 | ]; | |
| 71 | ||
| 72 | const timeline = deriveTimelineEntries(messages, workEntries); | |
| 73 | ||
| 74 | expect(timeline.map((entry) => entry.id)).toEqual([ | |
| 75 | "message:m-user", | |
| 76 | "work:w-1", | |
| 77 | "work:w-2", | |
| 78 | "message:m-assistant", | |
| 79 | ]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("prefers work entries when timestamps are equal", () => { | |
| 83 | const messages: ChatMessage[] = [ | |
| 84 | { | |
| 85 | id: "m-1", | |
| 86 | role: "assistant", | |
| 87 | text: "Done", | |
| 88 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 89 | streaming: false, | |
| 90 | }, | |
| 91 | ]; | |
| 92 | const workEntries: WorkLogEntry[] = [ | |
| 93 | { | |
| 94 | id: "w-1", | |
| 95 | label: "Tool call", | |
| 96 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 97 | tone: "tool", | |
| 98 | }, | |
| 99 | ]; | |
| 100 | ||
| 101 | const timeline = deriveTimelineEntries(messages, workEntries); | |
| 102 | ||
| 103 | expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | describe("deriveWorkLogEntries", () => { | |
| 108 | it("drops preamble/work events from the visible work log", () => { | |
| 109 | const entries = deriveWorkLogEntries( | |
| 110 | [ | |
| 111 | makeEvent({ | |
| 112 | id: "evt-1", | |
| 113 | method: "item/started", | |
| 114 | turnId: "turn-1", | |
| 115 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 116 | payload: { item: { type: "preamble", text: "thinking" } }, | |
| 117 | }), | |
| 118 | makeEvent({ | |
| 119 | id: "evt-2", | |
| 120 | method: "item/started", | |
| 121 | turnId: "turn-1", | |
| 122 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 123 | payload: { item: { type: "work", text: "planning" } }, | |
| 124 | }), | |
| 125 | makeEvent({ | |
| 126 | id: "evt-3", | |
| 127 | method: "item/started", | |
| 128 | turnId: "turn-1", | |
| 129 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 130 | payload: { item: { type: "tool_call", command: "ls -la" } }, | |
| 131 | }), | |
| 132 | ], | |
| 133 | "turn-1", | |
| 134 | ); | |
| 135 | ||
| 136 | expect(entries).toHaveLength(1); | |
| 137 | expect(entries[0]?.label).toBe("Tool call"); | |
| 138 | }); | |
| 139 | ||
| 140 | it("does not surface successful turn completion as a work-log row", () => { | |
| 141 | const entries = deriveWorkLogEntries( | |
| 142 | [ | |
| 143 | makeEvent({ | |
| 144 | id: "evt-start", | |
| 145 | method: "turn/started", | |
| 146 | turnId: "turn-1", | |
| 147 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 148 | }), | |
| 149 | makeEvent({ | |
| 150 | id: "evt-complete", | |
| 151 | method: "turn/completed", | |
| 152 | turnId: "turn-1", | |
| 153 | createdAt: "2026-02-08T10:00:10.000Z", | |
| 154 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 155 | }), | |
| 156 | ], | |
| 157 | "turn-1", | |
| 158 | ); | |
| 159 | ||
| 160 | expect(entries).toHaveLength(0); | |
| 161 | }); | |
| 162 | ||
| 163 | it("shows failed turn completion in the work log", () => { | |
| 164 | const entries = deriveWorkLogEntries( | |
| 165 | [ | |
| 166 | makeEvent({ | |
| 167 | id: "evt-start", | |
| 168 | method: "turn/started", | |
| 169 | turnId: "turn-1", | |
| 170 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 171 | }), | |
| 172 | makeEvent({ | |
| 173 | id: "evt-complete", | |
| 174 | method: "turn/completed", | |
| 175 | turnId: "turn-1", | |
| 176 | createdAt: "2026-02-08T10:00:10.000Z", | |
| 177 | payload: { | |
| 178 | turn: { | |
| 179 | id: "turn-1", | |
| 180 | status: "failed", | |
| 181 | error: { message: "sandbox denied" }, | |
| 182 | }, | |
| 183 | }, | |
| 184 | }), | |
| 185 | ], | |
| 186 | "turn-1", | |
| 187 | ); | |
| 188 | ||
| 189 | expect(entries).toHaveLength(1); | |
| 190 | expect(entries[0]?.label).toBe("Turn failed"); | |
| 191 | expect(entries[0]?.detail).toBe("sandbox denied"); | |
| 192 | }); | |
| 193 | ||
| 194 | it("hides reasoning and agent-message noise from the visible work log", () => { | |
| 195 | const entries = deriveWorkLogEntries( | |
| 196 | [ | |
| 197 | makeEvent({ | |
| 198 | id: "evt-turn-start", | |
| 199 | method: "turn/started", | |
| 200 | turnId: "turn-1", | |
| 201 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 202 | }), | |
| 203 | makeEvent({ | |
| 204 | id: "evt-summary-part", | |
| 205 | method: "item/reasoning/summaryPartAdded", | |
| 206 | turnId: "turn-1", | |
| 207 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 208 | }), | |
| 209 | makeEvent({ | |
| 210 | id: "evt-summary-delta", | |
| 211 | method: "item/reasoning/summaryTextDelta", | |
| 212 | turnId: "turn-1", | |
| 213 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 214 | }), | |
| 215 | makeEvent({ | |
| 216 | id: "evt-agent-start", | |
| 217 | method: "item/started", | |
| 218 | turnId: "turn-1", | |
| 219 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 220 | payload: { | |
| 221 | item: { id: "item-msg", type: "agentMessage", text: "Working..." }, | |
| 222 | }, | |
| 223 | }), | |
| 224 | makeEvent({ | |
| 225 | id: "evt-agent-complete", | |
| 226 | method: "item/completed", | |
| 227 | turnId: "turn-1", | |
| 228 | createdAt: "2026-02-08T10:00:04.000Z", | |
| 229 | payload: { | |
| 230 | item: { | |
| 231 | id: "item-msg", | |
| 232 | type: "agentMessage", | |
| 233 | text: "Done response", | |
| 234 | }, | |
| 235 | }, | |
| 236 | }), | |
| 237 | makeEvent({ | |
| 238 | id: "evt-tool-start", | |
| 239 | method: "item/started", | |
| 240 | turnId: "turn-1", | |
| 241 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 242 | payload: { | |
| 243 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | |
| 244 | }, | |
| 245 | }), | |
| 246 | makeEvent({ | |
| 247 | id: "evt-tool-complete", | |
| 248 | method: "item/completed", | |
| 249 | turnId: "turn-1", | |
| 250 | createdAt: "2026-02-08T10:00:06.000Z", | |
| 251 | payload: { | |
| 252 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | |
| 253 | }, | |
| 254 | }), | |
| 255 | makeEvent({ | |
| 256 | id: "evt-turn-complete", | |
| 257 | method: "turn/completed", | |
| 258 | turnId: "turn-1", | |
| 259 | createdAt: "2026-02-08T10:00:08.000Z", | |
| 260 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 261 | }), | |
| 262 | ], | |
| 263 | "turn-1", | |
| 264 | ); | |
| 265 | ||
| 266 | expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); | |
| 267 | }); | |
| 268 | ||
| 269 | it("coalesces command start/completed lifecycle into one entry", () => { | |
| 270 | const entries = deriveWorkLogEntries( | |
| 271 | [ | |
| 272 | makeEvent({ | |
| 273 | id: "evt-command-start", | |
| 274 | method: "item/started", | |
| 275 | turnId: "turn-1", | |
| 276 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 277 | payload: { | |
| 278 | item: { | |
| 279 | id: "item-command", | |
| 280 | type: "command_execution", | |
| 281 | command: "git status --short", | |
| 282 | }, | |
| 283 | }, | |
| 284 | }), | |
| 285 | makeEvent({ | |
| 286 | id: "evt-command-complete", | |
| 287 | method: "item/completed", | |
| 288 | turnId: "turn-1", | |
| 289 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 290 | payload: { | |
| 291 | item: { | |
| 292 | id: "item-command", | |
| 293 | type: "command_execution", | |
| 294 | command: "git status --short", | |
| 295 | }, | |
| 296 | }, | |
| 297 | }), | |
| 298 | ], | |
| 299 | "turn-1", | |
| 300 | ); | |
| 301 | ||
| 302 | expect(entries).toHaveLength(1); | |
| 303 | expect(entries[0]?.label).toBe("Command run"); | |
| 304 | expect(entries[0]?.detail).toBe("git status --short"); | |
| 305 | }); | |
| 306 | ||
| 307 | it("preserves full tool-call detail text without data truncation", () => { | |
| 308 | const longCommand = | |
| 309 | 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; | |
| 310 | const entries = deriveWorkLogEntries( | |
| 311 | [ | |
| 312 | makeEvent({ | |
| 313 | id: "evt-tool-start", | |
| 314 | method: "item/started", | |
| 315 | turnId: "turn-1", | |
| 316 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 317 | payload: { | |
| 318 | item: { id: "item-tool", type: "tool_call", command: longCommand }, | |
| 319 | }, | |
| 320 | }), | |
| 321 | ], | |
| 322 | "turn-1", | |
| 323 | ); | |
| 324 | ||
| 325 | expect(entries[0]?.detail).toBe(longCommand); | |
| 326 | }); | |
| 327 | ||
| 328 | it("hides generic tool-call rows that have no identifying detail", () => { | |
| 329 | const entries = deriveWorkLogEntries( | |
| 330 | [ | |
| 331 | makeEvent({ | |
| 332 | id: "evt-tool-start", | |
| 333 | method: "item/started", | |
| 334 | turnId: "turn-1", | |
| 335 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 336 | payload: { | |
| 337 | item: { id: "item-tool", type: "tool_call" }, | |
| 338 | }, | |
| 339 | }), | |
| 340 | ], | |
| 341 | "turn-1", | |
| 342 | ); | |
| 343 | ||
| 344 | expect(entries).toHaveLength(0); | |
| 345 | }); | |
| 346 | ||
| 347 | it("includes collab tool-call rows with tool name detail", () => { | |
| 348 | const entries = deriveWorkLogEntries( | |
| 349 | [ | |
| 350 | makeEvent({ | |
| 351 | id: "evt-collab-start", | |
| 352 | method: "item/started", | |
| 353 | turnId: "turn-1", | |
| 354 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 355 | payload: { | |
| 356 | item: { | |
| 357 | id: "item-collab", | |
| 358 | type: "collabAgentToolCall", | |
| 359 | tool: "spawnAgent", | |
| 360 | }, | |
| 361 | }, | |
| 362 | }), | |
| 363 | ], | |
| 364 | "turn-1", | |
| 365 | ); | |
| 366 | ||
| 367 | expect(entries).toHaveLength(1); | |
| 368 | expect(entries[0]?.label).toBe("Tool call"); | |
| 369 | expect(entries[0]?.detail).toBe("spawnAgent"); | |
| 370 | }); | |
| 371 | ||
| 372 | it("can derive work-log entries across all turns when not scoped", () => { | |
| 373 | const entries = deriveWorkLogEntries( | |
| 374 | [ | |
| 375 | makeEvent({ | |
| 376 | id: "evt-turn-1-tool", | |
| 377 | method: "item/started", | |
| 378 | turnId: "turn-1", | |
| 379 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 380 | payload: { item: { type: "tool_call", command: "ls -la" } }, | |
| 381 | }), | |
| 382 | makeEvent({ | |
| 383 | id: "evt-turn-2-tool", | |
| 384 | method: "item/started", | |
| 385 | turnId: "turn-2", | |
| 386 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 387 | payload: { item: { type: "tool_call", command: "pwd" } }, | |
| 388 | }), | |
| 389 | ], | |
| 390 | undefined, | |
| 391 | ); | |
| 392 | ||
| 393 | expect(entries).toHaveLength(2); | |
| 394 | expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); | |
| 395 | }); | |
| 396 | }); | |
| 397 | ||
| 398 | describe("deriveTurnDiffSummaries", () => { | |
| 399 | it("tracks completed turns and links them to assistant message completion", () => { | |
| 400 | const summaries = deriveTurnDiffSummaries([ | |
| 401 | makeEvent({ | |
| 402 | id: "evt-turn-start", | |
| 403 | method: "turn/started", | |
| 404 | turnId: "turn-1", | |
| 405 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 406 | }), | |
| 407 | makeEvent({ | |
| 408 | id: "evt-file-change", | |
| 409 | method: "item/completed", | |
| 410 | turnId: "turn-1", | |
| 411 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 412 | payload: { | |
| 413 | item: { | |
| 414 | id: "item-file-change", | |
| 415 | type: "fileChange", | |
| 416 | status: "completed", | |
| 417 | changes: [ | |
| 418 | { | |
| 419 | path: "src/a.ts", | |
| 420 | kind: "modified", | |
| 421 | diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n", | |
| 422 | }, | |
| 423 | ], | |
| 424 | }, | |
| 425 | }, | |
| 426 | }), | |
| 427 | makeEvent({ | |
| 428 | id: "evt-turn-diff-updated", | |
| 429 | method: "turn/diff/updated", | |
| 430 | turnId: "turn-1", | |
| 431 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 432 | payload: { | |
| 433 | threadId: "thr-1", | |
| 434 | turnId: "turn-1", | |
| 435 | diff: [ | |
| 436 | "diff --git a/src/a.ts b/src/a.ts", | |
| 437 | "@@ -1 +1 @@", | |
| 438 | "-old", | |
| 439 | "+new", | |
| 440 | "diff --git a/src/b.ts b/src/b.ts", | |
| 441 | "@@ -0,0 +1 @@", | |
| 442 | "+created", | |
| 443 | ].join("\n"), | |
| 444 | }, | |
| 445 | }), | |
| 446 | makeEvent({ | |
| 447 | id: "evt-agent-msg", | |
| 448 | method: "item/completed", | |
| 449 | turnId: "turn-1", | |
| 450 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 451 | payload: { | |
| 452 | item: { | |
| 453 | id: "msg-1", | |
| 454 | type: "agentMessage", | |
| 455 | text: "Done", | |
| 456 | }, | |
| 457 | }, | |
| 458 | }), | |
| 459 | makeEvent({ | |
| 460 | id: "evt-turn-completed", | |
| 461 | method: "turn/completed", | |
| 462 | turnId: "turn-1", | |
| 463 | createdAt: "2026-02-08T10:00:04.000Z", | |
| 464 | payload: { | |
| 465 | turn: { | |
| 466 | id: "turn-1", | |
| 467 | status: "completed", | |
| 468 | }, | |
| 469 | }, | |
| 470 | }), | |
| 471 | makeEvent({ | |
| 472 | id: "evt-turn-2-file-change", | |
| 473 | method: "item/completed", | |
| 474 | turnId: "turn-2", | |
| 475 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 476 | payload: { | |
| 477 | item: { | |
| 478 | id: "item-file-change-2", | |
| 479 | type: "fileChange", | |
| 480 | status: "completed", | |
| 481 | changes: [ | |
| 482 | { | |
| 483 | path: "src/unfinished.ts", | |
| 484 | kind: "modified", | |
| 485 | diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts", | |
| 486 | }, | |
| 487 | ], | |
| 488 | }, | |
| 489 | }, | |
| 490 | }), | |
| 491 | ]); | |
| 492 | ||
| 493 | expect(summaries).toHaveLength(1); | |
| 494 | expect(summaries[0]?.turnId).toBe("turn-1"); | |
| 495 | expect(summaries[0]?.assistantMessageId).toBe("msg-1"); | |
| 496 | expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 497 | expect(summaries[0]?.files[0]?.kind).toBe("modified"); | |
| 498 | expect(summaries[0]?.files[0]?.additions).toBe(1); | |
| 499 | expect(summaries[0]?.files[0]?.deletions).toBe(1); | |
| 500 | expect(summaries[0]).not.toHaveProperty("unifiedDiff"); | |
| 501 | }); | |
| 502 | ||
| 503 | it("includes completed turns even when no fileChange tool events were emitted", () => { | |
| 504 | const summaries = deriveTurnDiffSummaries([ | |
| 505 | makeEvent({ | |
| 506 | id: "evt-turn-start", | |
| 507 | method: "turn/started", | |
| 508 | turnId: "turn-1", | |
| 509 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 510 | }), | |
| 511 | makeEvent({ | |
| 512 | id: "evt-turn-completed", | |
| 513 | method: "turn/completed", | |
| 514 | turnId: "turn-1", | |
| 515 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 516 | payload: { | |
| 517 | turn: { | |
| 518 | id: "turn-1", | |
| 519 | status: "completed", | |
| 520 | }, | |
| 521 | }, | |
| 522 | }), | |
| 523 | ]); | |
| 524 | ||
| 525 | expect(summaries).toHaveLength(1); | |
| 526 | expect(summaries[0]?.turnId).toBe("turn-1"); | |
| 527 | expect(summaries[0]?.files).toEqual([]); | |
| 528 | }); | |
| 529 | ||
| 530 | it("keeps newest diff data when older events provide stale values", () => { | |
| 531 | const summaries = deriveTurnDiffSummaries([ | |
| 532 | makeEvent({ | |
| 533 | id: "evt-turn-diff-old", | |
| 534 | method: "turn/diff/updated", | |
| 535 | turnId: "turn-1", | |
| 536 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 537 | payload: { | |
| 538 | diff: [ | |
| 539 | "diff --git a/src/example.ts b/src/example.ts", | |
| 540 | "@@ -1 +1,2 @@", | |
| 541 | "-old", | |
| 542 | "+older", | |
| 543 | "+extra", | |
| 544 | ].join("\n"), | |
| 545 | }, | |
| 546 | }), | |
| 547 | makeEvent({ | |
| 548 | id: "evt-turn-diff-new", | |
| 549 | method: "turn/diff/updated", | |
| 550 | turnId: "turn-1", | |
| 551 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 32 | describe("derivePendingApprovals", () => { | |
| 33 | it("tracks open approvals and removes resolved ones", () => { | |
| 34 | const activities: OrchestrationThreadActivity[] = [ | |
| 35 | makeActivity({ | |
| 36 | id: "approval-open", | |
| 37 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 38 | kind: "approval.requested", | |
| 39 | summary: "Command approval requested", | |
| 40 | tone: "approval", | |
| 552 | 41 | payload: { |
| 553 | diff: [ | |
| 554 | "diff --git a/src/example.ts b/src/example.ts", | |
| 555 | "@@ -1 +1 @@", | |
| 556 | "-old", | |
| 557 | "+newest", | |
| 558 | ].join("\n"), | |
| 42 | requestId: "req-1", | |
| 43 | requestKind: "command", | |
| 44 | detail: "bun run lint", | |
| 559 | 45 | }, |
| 560 | 46 | }), |
| 561 | makeEvent({ | |
| 562 | id: "evt-turn-completed", | |
| 563 | method: "turn/completed", | |
| 564 | turnId: "turn-1", | |
| 565 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 566 | payload: { | |
| 567 | turn: { | |
| 568 | id: "turn-1", | |
| 569 | status: "completed", | |
| 570 | }, | |
| 571 | }, | |
| 47 | makeActivity({ | |
| 48 | id: "approval-close", | |
| 49 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 50 | kind: "approval.resolved", | |
| 51 | summary: "Approval resolved", | |
| 52 | tone: "info", | |
| 53 | payload: { requestId: "req-2" }, | |
| 54 | }), | |
| 55 | makeActivity({ | |
| 56 | id: "approval-closed-request", | |
| 57 | createdAt: "2026-02-23T00:00:01.500Z", | |
| 58 | kind: "approval.requested", | |
| 59 | summary: "File-change approval requested", | |
| 60 | tone: "approval", | |
| 61 | payload: { requestId: "req-2", requestKind: "file-change" }, | |
| 572 | 62 | }), |
| 573 | ]); | |
| 574 | ||
| 575 | expect(summaries).toHaveLength(1); | |
| 576 | expect(summaries[0]?.files[0]?.path).toBe("src/example.ts"); | |
| 577 | expect(summaries[0]?.files[0]?.additions).toBe(1); | |
| 578 | expect(summaries[0]?.files[0]?.deletions).toBe(1); | |
| 579 | }); | |
| 580 | }); | |
| 581 | ||
| 582 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | |
| 583 | it("splits a multi-file patch into sorted per-file entries", () => { | |
| 584 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 585 | [ | |
| 586 | "diff --git a/src/b.ts b/src/b.ts", | |
| 587 | "@@ -1 +1 @@", | |
| 588 | "-old-b", | |
| 589 | "+new-b", | |
| 590 | "diff --git a/src/a.ts b/src/a.ts", | |
| 591 | "@@ -1 +1 @@", | |
| 592 | "-old-a", | |
| 593 | "+new-a", | |
| 594 | ].join("\n"), | |
| 595 | ); | |
| 596 | ||
| 597 | expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 598 | expect(files[0]?.additions).toBe(1); | |
| 599 | expect(files[0]?.deletions).toBe(1); | |
| 600 | }); | |
| 601 | ||
| 602 | it("parses git headers when file paths include ` b/`", () => { | |
| 603 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 604 | [ | |
| 605 | "diff --git a/subdir b/example.ts b/subdir b/example.ts", | |
| 606 | "--- a/subdir b/example.ts", | |
| 607 | "+++ b/subdir b/example.ts", | |
| 608 | "@@ -1 +1 @@", | |
| 609 | "-old", | |
| 610 | "+new", | |
| 611 | ].join("\n"), | |
| 612 | ); | |
| 613 | ||
| 614 | expect(files).toHaveLength(1); | |
| 615 | expect(files[0]?.path).toBe("subdir b/example.ts"); | |
| 616 | }); | |
| 617 | ||
| 618 | it("parses git header fallback for binary diffs with ` b/` in path", () => { | |
| 619 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 620 | [ | |
| 621 | "diff --git a/subdir b/image.png b/subdir b/image.png", | |
| 622 | "Binary files a/subdir b/image.png and b/subdir b/image.png differ", | |
| 623 | ].join("\n"), | |
| 624 | ); | |
| 625 | ||
| 626 | expect(files).toHaveLength(1); | |
| 627 | expect(files[0]?.path).toBe("subdir b/image.png"); | |
| 628 | }); | |
| 629 | }); | |
| 630 | ||
| 631 | describe("derivePendingApprovals", () => { | |
| 632 | it("returns pending command/file-change approvals", () => { | |
| 633 | const approvals = derivePendingApprovals([ | |
| 634 | makeEvent({ | |
| 635 | id: "evt-request", | |
| 636 | kind: "request", | |
| 637 | method: "item/commandExecution/requestApproval", | |
| 638 | requestId: "req-1", | |
| 639 | requestKind: "command", | |
| 640 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 641 | payload: { command: "git commit -m 'msg'" }, | |
| 642 | }), | |
| 643 | ]); | |
| 63 | ]; | |
| 644 | 64 | |
| 645 | expect(approvals).toEqual([ | |
| 65 | expect(derivePendingApprovals(activities)).toEqual([ | |
| 646 | 66 | { |
| 647 | 67 | requestId: "req-1", |
| 648 | 68 | requestKind: "command", |
| 649 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 650 | detail: "git commit -m 'msg'", | |
| 69 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 70 | detail: "bun run lint", | |
| 651 | 71 | }, |
| 652 | 72 | ]); |
| 653 | 73 | }); |
| 654 | ||
| 655 | it("removes approvals after a decision event", () => { | |
| 656 | const approvals = derivePendingApprovals([ | |
| 657 | makeEvent({ | |
| 658 | id: "evt-decision", | |
| 659 | method: "item/requestApproval/decision", | |
| 660 | requestId: "req-1", | |
| 661 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 662 | }), | |
| 663 | makeEvent({ | |
| 664 | id: "evt-request", | |
| 665 | kind: "request", | |
| 666 | method: "item/fileChange/requestApproval", | |
| 667 | requestId: "req-1", | |
| 668 | requestKind: "file-change", | |
| 669 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 670 | }), | |
| 671 | ]); | |
| 672 | ||
| 673 | expect(approvals).toHaveLength(0); | |
| 674 | }); | |
| 675 | ||
| 676 | it("clears pending approvals after turn completion", () => { | |
| 677 | const approvals = derivePendingApprovals([ | |
| 678 | makeEvent({ | |
| 679 | id: "evt-turn-complete", | |
| 680 | method: "turn/completed", | |
| 681 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 682 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 683 | }), | |
| 684 | makeEvent({ | |
| 685 | id: "evt-request", | |
| 686 | kind: "request", | |
| 687 | method: "item/commandExecution/requestApproval", | |
| 688 | requestId: "req-1", | |
| 689 | requestKind: "command", | |
| 690 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 691 | }), | |
| 692 | ]); | |
| 693 | ||
| 694 | expect(approvals).toHaveLength(0); | |
| 695 | }); | |
| 696 | 74 | }); |
| 697 | 75 | |
| 698 | describe("evolveSession", () => { | |
| 699 | it("updates thread id when thread starts", () => { | |
| 700 | const previous = makeSession(); | |
| 701 | const next = evolveSession( | |
| 702 | previous, | |
| 703 | makeEvent({ | |
| 704 | method: "thread/started", | |
| 705 | createdAt: "2026-02-08T10:01:00.000Z", | |
| 706 | payload: { thread: { id: "thread-1" } }, | |
| 76 | describe("deriveWorkLogEntries", () => { | |
| 77 | it("omits tool started entries and keeps completed entries", () => { | |
| 78 | const activities: OrchestrationThreadActivity[] = [ | |
| 79 | makeActivity({ | |
| 80 | id: "tool-complete", | |
| 81 | createdAt: "2026-02-23T00:00:03.000Z", | |
| 82 | summary: "Tool call complete", | |
| 83 | kind: "tool.completed", | |
| 84 | }), | |
| 85 | makeActivity({ | |
| 86 | id: "tool-start", | |
| 87 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 88 | summary: "Tool call", | |
| 89 | kind: "tool.started", | |
| 707 | 90 | }), |
| 708 | ); | |
| 91 | ]; | |
| 709 | 92 | |
| 710 | expect(next.threadId).toBe("thread-1"); | |
| 711 | expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); | |
| 93 | const entries = deriveWorkLogEntries(activities, undefined); | |
| 94 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | |
| 712 | 95 | }); |
| 713 | 96 | |
| 714 | it("moves to running and records active turn on turn start", () => { | |
| 715 | const previous = makeSession(); | |
| 716 | const next = evolveSession( | |
| 717 | previous, | |
| 718 | makeEvent({ | |
| 719 | method: "turn/started", | |
| 720 | turnId: "turn-1", | |
| 721 | createdAt: "2026-02-08T10:02:00.000Z", | |
| 97 | it("filters by turn id when provided", () => { | |
| 98 | const activities: OrchestrationThreadActivity[] = [ | |
| 99 | makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), | |
| 100 | makeActivity({ | |
| 101 | id: "turn-2", | |
| 102 | turnId: "turn-2", | |
| 103 | summary: "Tool call complete", | |
| 104 | kind: "tool.completed", | |
| 722 | 105 | }), |
| 723 | ); | |
| 106 | makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }), | |
| 107 | ]; | |
| 724 | 108 | |
| 725 | expect(next.status).toBe("running"); | |
| 726 | expect(next.activeTurnId).toBe("turn-1"); | |
| 727 | expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); | |
| 109 | const entries = deriveWorkLogEntries(activities, TurnId.makeUnsafe("turn-2")); | |
| 110 | expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]); | |
| 728 | 111 | }); |
| 729 | 112 | |
| 730 | it("returns to ready and clears active turn on successful completion", () => { | |
| 731 | const previous = makeSession({ | |
| 732 | status: "running", | |
| 733 | activeTurnId: "turn-1", | |
| 734 | lastError: "older error", | |
| 735 | }); | |
| 736 | const next = evolveSession( | |
| 737 | previous, | |
| 738 | makeEvent({ | |
| 739 | method: "turn/completed", | |
| 740 | createdAt: "2026-02-08T10:03:00.000Z", | |
| 741 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 113 | it("omits checkpoint captured info entries", () => { | |
| 114 | const activities: OrchestrationThreadActivity[] = [ | |
| 115 | makeActivity({ | |
| 116 | id: "checkpoint", | |
| 117 | createdAt: "2026-02-23T00:00:01.000Z", | |
| 118 | summary: "Checkpoint captured", | |
| 119 | tone: "info", | |
| 742 | 120 | }), |
| 743 | ); | |
| 744 | ||
| 745 | expect(next.status).toBe("ready"); | |
| 746 | expect(next.activeTurnId).toBeUndefined(); | |
| 747 | expect(next.lastError).toBe("older error"); | |
| 748 | }); | |
| 749 | ||
| 750 | it("marks session as error when turn fails", () => { | |
| 751 | const previous = makeSession({ | |
| 752 | status: "running", | |
| 753 | activeTurnId: "turn-1", | |
| 754 | }); | |
| 755 | const next = evolveSession( | |
| 756 | previous, | |
| 757 | makeEvent({ | |
| 758 | method: "turn/completed", | |
| 759 | createdAt: "2026-02-08T10:03:00.000Z", | |
| 760 | payload: { | |
| 761 | turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, | |
| 762 | }, | |
| 121 | makeActivity({ | |
| 122 | id: "tool-complete", | |
| 123 | createdAt: "2026-02-23T00:00:02.000Z", | |
| 124 | summary: "Command run complete", | |
| 125 | tone: "tool", | |
| 126 | kind: "tool.completed", | |
| 763 | 127 | }), |
| 764 | ); | |
| 128 | ]; | |
| 765 | 129 | |
| 766 | expect(next.status).toBe("error"); | |
| 767 | expect(next.activeTurnId).toBeUndefined(); | |
| 768 | expect(next.lastError).toBe("boom"); | |
| 130 | const entries = deriveWorkLogEntries(activities, undefined); | |
| 131 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | |
| 769 | 132 | }); |
| 133 | }); | |
| 770 | 134 | |
| 771 | it("moves to error on runtime error events", () => { | |
| 772 | const previous = makeSession(); | |
| 773 | const next = evolveSession( | |
| 774 | previous, | |
| 775 | makeEvent({ | |
| 776 | kind: "error", | |
| 777 | method: "runtime/error", | |
| 778 | createdAt: "2026-02-08T10:04:00.000Z", | |
| 779 | message: "runtime failure", | |
| 780 | }), | |
| 781 | ); | |
| 135 | describe("hasToolActivityForTurn", () => { | |
| 136 | it("returns false when turn id is missing", () => { | |
| 137 | const activities: OrchestrationThreadActivity[] = [ | |
| 138 | makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), | |
| 139 | ]; | |
| 782 | 140 | |
| 783 | expect(next.status).toBe("error"); | |
| 784 | expect(next.lastError).toBe("runtime failure"); | |
| 141 | expect(hasToolActivityForTurn(activities, undefined)).toBe(false); | |
| 142 | expect(hasToolActivityForTurn(activities, null)).toBe(false); | |
| 785 | 143 | }); |
| 786 | 144 | |
| 787 | it("closes session on close lifecycle events", () => { | |
| 788 | const previous = makeSession({ | |
| 789 | status: "running", | |
| 790 | activeTurnId: "turn-1", | |
| 791 | }); | |
| 792 | const next = evolveSession( | |
| 793 | previous, | |
| 794 | makeEvent({ | |
| 795 | method: "session/closed", | |
| 796 | createdAt: "2026-02-08T10:05:00.000Z", | |
| 797 | message: "closed", | |
| 798 | }), | |
| 799 | ); | |
| 145 | it("returns true only for matching tool activity in the target turn", () => { | |
| 146 | const activities: OrchestrationThreadActivity[] = [ | |
| 147 | makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), | |
| 148 | makeActivity({ id: "info-1", turnId: "turn-2", kind: "turn.completed", tone: "info" }), | |
| 149 | ]; | |
| 800 | 150 | |
| 801 | expect(next.status).toBe("closed"); | |
| 802 | expect(next.activeTurnId).toBeUndefined(); | |
| 803 | expect(next.lastError).toBe("closed"); | |
| 151 | expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-1"))).toBe(true); | |
| 152 | expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false); | |
| 804 | 153 | }); |
| 805 | 154 | }); |
| 806 | 155 | |
| 807 | describe("applyEventToMessages", () => { | |
| 808 | it("handles start/delta/completed flow for assistant messages", () => { | |
| 809 | const activeAssistantItemRef = { current: null as string | null }; | |
| 810 | const started = applyEventToMessages( | |
| 811 | [], | |
| 812 | makeEvent({ | |
| 813 | method: "item/started", | |
| 814 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 815 | payload: { | |
| 816 | item: { | |
| 817 | id: "item-1", | |
| 818 | type: "agentMessage", | |
| 819 | text: "Hello", | |
| 820 | }, | |
| 821 | }, | |
| 822 | }), | |
| 823 | activeAssistantItemRef, | |
| 824 | ); | |
| 156 | describe("isLatestTurnSettled", () => { | |
| 157 | const latestTurn = { | |
| 158 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 159 | startedAt: "2026-02-27T21:10:00.000Z", | |
| 160 | completedAt: "2026-02-27T21:10:06.000Z", | |
| 161 | } as const; | |
| 825 | 162 | |
| 826 | expect(started).toEqual([ | |
| 827 | { | |
| 828 | id: "item-1", | |
| 829 | role: "assistant", | |
| 830 | text: "Hello", | |
| 831 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 832 | streaming: true, | |
| 833 | }, | |
| 834 | ]); | |
| 835 | expect(activeAssistantItemRef.current).toBe("item-1"); | |
| 836 | ||
| 837 | const withDelta = applyEventToMessages( | |
| 838 | started, | |
| 839 | makeEvent({ | |
| 840 | method: "item/agentMessage/delta", | |
| 841 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 842 | itemId: "item-1", | |
| 843 | textDelta: " world", | |
| 163 | it("returns false while the same turn is still active in a running session", () => { | |
| 164 | expect( | |
| 165 | isLatestTurnSettled(latestTurn, { | |
| 166 | orchestrationStatus: "running", | |
| 167 | activeTurnId: TurnId.makeUnsafe("turn-1"), | |
| 844 | 168 | }), |
| 845 | activeAssistantItemRef, | |
| 846 | ); | |
| 847 | expect(withDelta[0]?.text).toBe("Hello world"); | |
| 848 | expect(withDelta[0]?.streaming).toBe(true); | |
| 849 | ||
| 850 | const completed = applyEventToMessages( | |
| 851 | withDelta, | |
| 852 | makeEvent({ | |
| 853 | method: "item/completed", | |
| 854 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 855 | payload: { | |
| 856 | item: { | |
| 857 | id: "item-1", | |
| 858 | type: "agentMessage", | |
| 859 | text: "Hello world!", | |
| 860 | }, | |
| 861 | }, | |
| 862 | }), | |
| 863 | activeAssistantItemRef, | |
| 864 | ); | |
| 865 | expect(completed[0]?.text).toBe("Hello world!"); | |
| 866 | expect(completed[0]?.streaming).toBe(false); | |
| 867 | expect(activeAssistantItemRef.current).toBeNull(); | |
| 169 | ).toBe(false); | |
| 868 | 170 | }); |
| 869 | 171 | |
| 870 | it("supports out-of-order delta before started/completed", () => { | |
| 871 | const activeAssistantItemRef = { current: null as string | null }; | |
| 872 | const withDelta = applyEventToMessages( | |
| 873 | [], | |
| 874 | makeEvent({ | |
| 875 | method: "item/agentMessage/delta", | |
| 876 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 877 | itemId: "item-2", | |
| 878 | textDelta: "Partial", | |
| 172 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { | |
| 173 | expect( | |
| 174 | isLatestTurnSettled(latestTurn, { | |
| 175 | orchestrationStatus: "running", | |
| 176 | activeTurnId: TurnId.makeUnsafe("turn-2"), | |
| 879 | 177 | }), |
| 880 | activeAssistantItemRef, | |
| 881 | ); | |
| 882 | expect(withDelta[0]?.id).toBe("item-2"); | |
| 883 | expect(withDelta[0]?.text).toBe("Partial"); | |
| 884 | expect(withDelta[0]?.streaming).toBe(true); | |
| 885 | expect(activeAssistantItemRef.current).toBe("item-2"); | |
| 886 | ||
| 887 | const completed = applyEventToMessages( | |
| 888 | withDelta, | |
| 889 | makeEvent({ | |
| 890 | method: "item/completed", | |
| 891 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 892 | payload: { | |
| 893 | item: { | |
| 894 | id: "item-2", | |
| 895 | type: "agentMessage", | |
| 896 | text: "Partial + final", | |
| 897 | }, | |
| 898 | }, | |
| 899 | }), | |
| 900 | activeAssistantItemRef, | |
| 901 | ); | |
| 902 | ||
| 903 | expect(completed[0]?.text).toBe("Partial + final"); | |
| 904 | expect(completed[0]?.streaming).toBe(false); | |
| 178 | ).toBe(false); | |
| 905 | 179 | }); |
| 906 | 180 | |
| 907 | it("clears all streaming flags when turn completes", () => { | |
| 908 | const previous: ChatMessage[] = [ | |
| 909 | { | |
| 910 | id: "m-user", | |
| 911 | role: "user", | |
| 912 | text: "Hi", | |
| 913 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 914 | streaming: false, | |
| 915 | }, | |
| 916 | { | |
| 917 | id: "m-assistant", | |
| 918 | role: "assistant", | |
| 919 | text: "Typing", | |
| 920 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 921 | streaming: true, | |
| 922 | }, | |
| 923 | ]; | |
| 924 | ||
| 925 | const next = applyEventToMessages( | |
| 926 | previous, | |
| 927 | makeEvent({ | |
| 928 | method: "turn/completed", | |
| 181 | it("returns true once the session is no longer running that turn", () => { | |
| 182 | expect( | |
| 183 | isLatestTurnSettled(latestTurn, { | |
| 184 | orchestrationStatus: "ready", | |
| 185 | activeTurnId: undefined, | |
| 929 | 186 | }), |
| 930 | { current: null }, | |
| 931 | ); | |
| 187 | ).toBe(true); | |
| 188 | }); | |
| 932 | 189 | |
| 933 | expect(next.every((entry) => entry.streaming === false)).toBe(true); | |
| 190 | it("returns false when turn timestamps are incomplete", () => { | |
| 191 | expect( | |
| 192 | isLatestTurnSettled( | |
| 193 | { | |
| 194 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 195 | startedAt: null, | |
| 196 | completedAt: "2026-02-27T21:10:06.000Z", | |
| 197 | }, | |
| 198 | null, | |
| 199 | ), | |
| 200 | ).toBe(false); | |
| 934 | 201 | }); |
| 935 | 202 | }); |
apps/web/src/session-logic.ts+129-872| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; | |
| 2 | import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | type OrchestrationLatestTurn, | |
| 4 | type OrchestrationThreadActivity, | |
| 5 | type ProviderKind, | |
| 6 | type TurnId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | ||
| 9 | import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | |
| 4 | 10 | |
| 5 | 11 | export const PROVIDER_OPTIONS: Array<{ |
| 6 | 12 | value: ProviderKind; |
| ... | ... | @@ -11,39 +17,34 @@ export const PROVIDER_OPTIONS: Array<{ |
| 11 | 17 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, |
| 12 | 18 | ]; |
| 13 | 19 | |
| 14 | let cachedApi: NativeApi | undefined; | |
| 15 | ||
| 16 | export function readNativeApi(): NativeApi | undefined { | |
| 17 | if (typeof window === "undefined") return undefined; | |
| 18 | if (cachedApi) return cachedApi; | |
| 19 | ||
| 20 | // Prefer Electron preload bridge if available | |
| 21 | if (window.nativeApi) { | |
| 22 | cachedApi = window.nativeApi; | |
| 23 | return cachedApi; | |
| 24 | } | |
| 25 | ||
| 26 | // Fall back to WebSocket transport | |
| 27 | cachedApi = createWsNativeApi(); | |
| 28 | return cachedApi; | |
| 29 | } | |
| 30 | ||
| 31 | export function asObject(value: unknown): Record<string, unknown> | undefined { | |
| 32 | if (!value || typeof value !== "object") return undefined; | |
| 33 | return value as Record<string, unknown>; | |
| 34 | } | |
| 35 | ||
| 36 | function asArray(value: unknown): unknown[] | undefined { | |
| 37 | return Array.isArray(value) ? value : undefined; | |
| 20 | export interface WorkLogEntry { | |
| 21 | id: string; | |
| 22 | createdAt: string; | |
| 23 | label: string; | |
| 24 | detail?: string; | |
| 25 | tone: "thinking" | "tool" | "info" | "error"; | |
| 38 | 26 | } |
| 39 | 27 | |
| 40 | export function asString(value: unknown): string | undefined { | |
| 41 | return typeof value === "string" ? value : undefined; | |
| 28 | export interface PendingApproval { | |
| 29 | requestId: ApprovalRequestId; | |
| 30 | requestKind: "command" | "file-change"; | |
| 31 | createdAt: string; | |
| 32 | detail?: string; | |
| 42 | 33 | } |
| 43 | 34 | |
| 44 | function asNumber(value: unknown): number | undefined { | |
| 45 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 46 | } | |
| 35 | export type TimelineEntry = | |
| 36 | | { | |
| 37 | id: string; | |
| 38 | kind: "message"; | |
| 39 | createdAt: string; | |
| 40 | message: ChatMessage; | |
| 41 | } | |
| 42 | | { | |
| 43 | id: string; | |
| 44 | kind: "work"; | |
| 45 | createdAt: string; | |
| 46 | entry: WorkLogEntry; | |
| 47 | }; | |
| 47 | 48 | |
| 48 | 49 | export function formatTimestamp(isoDate: string): string { |
| 49 | 50 | return new Intl.DateTimeFormat(undefined, { |
| ... | ... | @@ -58,7 +59,6 @@ export function formatDuration(durationMs: number): string { |
| 58 | 59 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
| 59 | 60 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; |
| 60 | 61 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; |
| 61 | ||
| 62 | 62 | const minutes = Math.floor(durationMs / 60_000); |
| 63 | 63 | const seconds = Math.round((durationMs % 60_000) / 1_000); |
| 64 | 64 | if (seconds === 0) return `${minutes}m`; |
| ... | ... | @@ -68,883 +68,140 @@ export function formatDuration(durationMs: number): string { |
| 68 | 68 | |
| 69 | 69 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { |
| 70 | 70 | if (!endIso) return null; |
| 71 | ||
| 72 | 71 | const startedAt = Date.parse(startIso); |
| 73 | 72 | const endedAt = Date.parse(endIso); |
| 74 | 73 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { |
| 75 | 74 | return null; |
| 76 | 75 | } |
| 77 | ||
| 78 | 76 | return formatDuration(endedAt - startedAt); |
| 79 | 77 | } |
| 80 | 78 | |
| 81 | export interface WorkLogEntry { | |
| 82 | id: string; | |
| 83 | createdAt: string; | |
| 84 | label: string; | |
| 85 | detail?: string; | |
| 86 | tone: "thinking" | "tool" | "info" | "error"; | |
| 87 | } | |
| 88 | ||
| 89 | export interface PendingApproval { | |
| 90 | requestId: string; | |
| 91 | requestKind: "command" | "file-change"; | |
| 92 | createdAt: string; | |
| 93 | detail?: string; | |
| 94 | } | |
| 95 | ||
| 96 | export type TimelineEntry = | |
| 97 | | { | |
| 98 | id: string; | |
| 99 | kind: "message"; | |
| 100 | createdAt: string; | |
| 101 | message: ChatMessage; | |
| 102 | } | |
| 103 | | { | |
| 104 | id: string; | |
| 105 | kind: "work"; | |
| 106 | createdAt: string; | |
| 107 | entry: WorkLogEntry; | |
| 108 | }; | |
| 109 | ||
| 110 | export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 111 | ||
| 112 | function normalizeDetail(value: string | undefined): string | undefined { | |
| 113 | if (!value) return undefined; | |
| 114 | const trimmed = value.trim(); | |
| 115 | return trimmed.length > 0 ? trimmed : undefined; | |
| 116 | } | |
| 117 | ||
| 118 | function approvalDetail(event: ProviderEvent): string | undefined { | |
| 119 | const payload = asObject(event.payload); | |
| 120 | const command = asString(payload?.command); | |
| 121 | if (command) return command; | |
| 122 | return asString(payload?.reason); | |
| 123 | } | |
| 124 | ||
| 125 | export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] { | |
| 126 | const pending = new Map<string, PendingApproval>(); | |
| 127 | const ordered = [...events].toReversed(); | |
| 128 | ||
| 129 | for (const event of ordered) { | |
| 130 | if ( | |
| 131 | event.method === "session/closed" || | |
| 132 | event.method === "session/exited" || | |
| 133 | event.method === "turn/completed" | |
| 134 | ) { | |
| 135 | pending.clear(); | |
| 136 | continue; | |
| 137 | } | |
| 138 | ||
| 139 | const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId); | |
| 140 | if (!requestId) continue; | |
| 141 | ||
| 142 | if ( | |
| 143 | event.kind === "request" && | |
| 144 | (event.requestKind === "command" || event.requestKind === "file-change") | |
| 145 | ) { | |
| 146 | const detail = approvalDetail(event); | |
| 147 | pending.set(requestId, { | |
| 79 | export function isLatestTurnSettled( | |
| 80 | latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt"> | null, | |
| 81 | session: Pick<ThreadSession, "orchestrationStatus" | "activeTurnId"> | null, | |
| 82 | ): boolean { | |
| 83 | if (!latestTurn?.startedAt) return false; | |
| 84 | if (!latestTurn.completedAt) return false; | |
| 85 | if (!session) return true; | |
| 86 | if (session.orchestrationStatus === "running") return false; | |
| 87 | return true; | |
| 88 | } | |
| 89 | ||
| 90 | export function derivePendingApprovals( | |
| 91 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 92 | ): PendingApproval[] { | |
| 93 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | |
| 94 | const ordered = [...activities].toSorted((left, right) => | |
| 95 | left.createdAt.localeCompare(right.createdAt), | |
| 96 | ); | |
| 97 | ||
| 98 | for (const activity of ordered) { | |
| 99 | const payload = | |
| 100 | activity.payload && typeof activity.payload === "object" | |
| 101 | ? (activity.payload as Record<string, unknown>) | |
| 102 | : null; | |
| 103 | const requestId = | |
| 104 | payload && typeof payload.requestId === "string" | |
| 105 | ? ApprovalRequestId.makeUnsafe(payload.requestId) | |
| 106 | : null; | |
| 107 | const requestKind = | |
| 108 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") | |
| 109 | ? payload.requestKind | |
| 110 | : null; | |
| 111 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; | |
| 112 | ||
| 113 | if (activity.kind === "approval.requested" && requestId && requestKind) { | |
| 114 | openByRequestId.set(requestId, { | |
| 148 | 115 | requestId, |
| 149 | requestKind: event.requestKind, | |
| 150 | createdAt: event.createdAt, | |
| 116 | requestKind, | |
| 117 | createdAt: activity.createdAt, | |
| 151 | 118 | ...(detail ? { detail } : {}), |
| 152 | 119 | }); |
| 153 | 120 | continue; |
| 154 | 121 | } |
| 155 | 122 | |
| 156 | if (event.method === "item/requestApproval/decision") { | |
| 157 | pending.delete(requestId); | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | return Array.from(pending.values()); | |
| 162 | } | |
| 163 | ||
| 164 | function normalizeItemType(raw: string | undefined): string { | |
| 165 | if (!raw) return "item"; | |
| 166 | return raw | |
| 167 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | |
| 168 | .replace(/[._/-]/g, " ") | |
| 169 | .replace(/\s+/g, " ") | |
| 170 | .trim() | |
| 171 | .toLowerCase(); | |
| 172 | } | |
| 173 | ||
| 174 | function shouldDropItemType(type: string): boolean { | |
| 175 | if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { | |
| 176 | return true; | |
| 177 | } | |
| 178 | ||
| 179 | return type === "work" || type.startsWith("work "); | |
| 180 | } | |
| 181 | ||
| 182 | function shouldShowItemLifecycle(type: string): boolean { | |
| 183 | return type.includes("tool") || type.includes("command") || type.includes("file change"); | |
| 184 | } | |
| 185 | ||
| 186 | function shouldDropMethod(method: string): boolean { | |
| 187 | return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); | |
| 188 | } | |
| 189 | ||
| 190 | function itemTypeMeta(type: string): { | |
| 191 | label: string; | |
| 192 | tone: WorkLogEntry["tone"]; | |
| 193 | } { | |
| 194 | if (type.includes("command")) { | |
| 195 | return { label: "Command run", tone: "tool" }; | |
| 196 | } | |
| 197 | if (type.includes("file change")) { | |
| 198 | return { label: "File change", tone: "tool" }; | |
| 199 | } | |
| 200 | if (type.includes("tool")) { | |
| 201 | return { label: "Tool call", tone: "tool" }; | |
| 202 | } | |
| 203 | return { label: "Work item", tone: "info" }; | |
| 204 | } | |
| 205 | ||
| 206 | interface ItemLifecycleCandidate { | |
| 207 | id: string; | |
| 208 | itemId?: string; | |
| 209 | createdAt: string; | |
| 210 | label: string; | |
| 211 | detail?: string; | |
| 212 | tone: WorkLogEntry["tone"]; | |
| 213 | phase: "started" | "completed"; | |
| 214 | } | |
| 215 | ||
| 216 | function extractDetail( | |
| 217 | payload: Record<string, unknown> | undefined, | |
| 218 | item: Record<string, unknown> | undefined, | |
| 219 | ): string | undefined { | |
| 220 | const candidates = [ | |
| 221 | asString(item?.command), | |
| 222 | asString(item?.tool), | |
| 223 | asString(item?.name), | |
| 224 | asString(item?.title), | |
| 225 | asString(item?.summary), | |
| 226 | asString(item?.text), | |
| 227 | asString(item?.prompt), | |
| 228 | asString(payload?.message), | |
| 229 | asString(payload?.prompt), | |
| 230 | asString(payload?.command), | |
| 231 | ]; | |
| 232 | ||
| 233 | for (const candidate of candidates) { | |
| 234 | const detail = normalizeDetail(candidate); | |
| 235 | if (detail) return detail; | |
| 236 | } | |
| 237 | return undefined; | |
| 238 | } | |
| 239 | ||
| 240 | function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null { | |
| 241 | const payload = asObject(event.payload); | |
| 242 | const item = asObject(payload?.item); | |
| 243 | const normalizedType = normalizeItemType(asString(item?.type)); | |
| 244 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { | |
| 245 | return null; | |
| 246 | } | |
| 247 | if (!shouldShowItemLifecycle(normalizedType)) { | |
| 248 | return null; | |
| 249 | } | |
| 250 | ||
| 251 | const meta = itemTypeMeta(normalizedType); | |
| 252 | const isStarted = event.method === "item/started"; | |
| 253 | const isCompleted = event.method === "item/completed"; | |
| 254 | if (!isStarted && !isCompleted) { | |
| 255 | return null; | |
| 256 | } | |
| 257 | ||
| 258 | const detail = extractDetail(payload, item); | |
| 259 | const itemId = event.itemId ?? asString(item?.id); | |
| 260 | return { | |
| 261 | id: event.id, | |
| 262 | ...(itemId ? { itemId } : {}), | |
| 263 | createdAt: event.createdAt, | |
| 264 | label: meta.label, | |
| 265 | ...(detail ? { detail } : {}), | |
| 266 | tone: meta.tone, | |
| 267 | phase: isCompleted ? "completed" : "started", | |
| 268 | }; | |
| 269 | } | |
| 270 | ||
| 271 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | |
| 272 | if (event.kind !== "request") return null; | |
| 273 | if (shouldDropMethod(event.method)) return null; | |
| 274 | ||
| 275 | if (event.method.includes("commandExecution")) { | |
| 276 | return { | |
| 277 | id: event.id, | |
| 278 | createdAt: event.createdAt, | |
| 279 | label: "Command approval requested", | |
| 280 | tone: "tool", | |
| 281 | }; | |
| 282 | } | |
| 283 | ||
| 284 | if (event.method.includes("fileChange")) { | |
| 285 | return { | |
| 286 | id: event.id, | |
| 287 | createdAt: event.createdAt, | |
| 288 | label: "File-change approval requested", | |
| 289 | tone: "tool", | |
| 290 | }; | |
| 291 | } | |
| 292 | ||
| 293 | if (event.method.includes("requestUserInput")) { | |
| 294 | return { | |
| 295 | id: event.id, | |
| 296 | createdAt: event.createdAt, | |
| 297 | label: "Tool requested user input", | |
| 298 | tone: "tool", | |
| 299 | }; | |
| 300 | } | |
| 301 | ||
| 302 | return { | |
| 303 | id: event.id, | |
| 304 | createdAt: event.createdAt, | |
| 305 | label: `Request: ${event.method}`, | |
| 306 | tone: "info", | |
| 307 | }; | |
| 308 | } | |
| 309 | ||
| 310 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | |
| 311 | if (event.kind !== "notification") return null; | |
| 312 | if (shouldDropMethod(event.method)) return null; | |
| 313 | if (event.method === "item/agentMessage/delta") return null; | |
| 314 | if (event.method === "turn/started") { | |
| 315 | return null; | |
| 316 | } | |
| 317 | if (event.method === "thread/started") return null; | |
| 318 | ||
| 319 | if (event.method === "turn/completed") { | |
| 320 | const payload = asObject(event.payload); | |
| 321 | const turn = asObject(payload?.turn); | |
| 322 | const status = asString(turn?.status); | |
| 323 | if (status !== "failed") { | |
| 324 | return null; | |
| 123 | if (activity.kind === "approval.resolved" && requestId) { | |
| 124 | openByRequestId.delete(requestId); | |
| 325 | 125 | } |
| 326 | const turnError = asObject(turn?.error); | |
| 327 | const turnErrorMessage = asString(turnError?.message); | |
| 328 | const turnErrorDetail = normalizeDetail(turnErrorMessage); | |
| 329 | ||
| 330 | return { | |
| 331 | id: event.id, | |
| 332 | createdAt: event.createdAt, | |
| 333 | label: "Turn failed", | |
| 334 | ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), | |
| 335 | tone: "error", | |
| 336 | }; | |
| 337 | 126 | } |
| 338 | 127 | |
| 339 | if (event.method.startsWith("item/")) return null; | |
| 340 | ||
| 341 | return null; | |
| 342 | } | |
| 343 | ||
| 344 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | |
| 345 | if (event.kind !== "error") return null; | |
| 346 | if (shouldDropMethod(event.method)) return null; | |
| 347 | const detail = normalizeDetail(event.message); | |
| 348 | ||
| 349 | return { | |
| 350 | id: event.id, | |
| 351 | createdAt: event.createdAt, | |
| 352 | label: "Runtime error", | |
| 353 | ...(detail ? { detail } : {}), | |
| 354 | tone: "error", | |
| 355 | }; | |
| 356 | } | |
| 357 | ||
| 358 | function eventTurnId(event: ProviderEvent): string | undefined { | |
| 359 | const payload = asObject(event.payload); | |
| 360 | const turn = asObject(payload?.turn); | |
| 361 | return event.turnId ?? asString(turn?.id); | |
| 128 | return [...openByRequestId.values()].toSorted((left, right) => | |
| 129 | left.createdAt.localeCompare(right.createdAt), | |
| 130 | ); | |
| 362 | 131 | } |
| 363 | 132 | |
| 364 | 133 | export function deriveWorkLogEntries( |
| 365 | events: ProviderEvent[], | |
| 366 | turnId: string | undefined, | |
| 134 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 135 | latestTurnId: TurnId | undefined, | |
| 367 | 136 | ): WorkLogEntry[] { |
| 368 | const ordered = [...events].toReversed(); | |
| 369 | const entries: WorkLogEntry[] = []; | |
| 370 | const turnStartedAtIso = turnId | |
| 371 | ? ordered.find((event) => { | |
| 372 | if (event.method !== "turn/started") return false; | |
| 373 | return eventTurnId(event) === turnId; | |
| 374 | })?.createdAt | |
| 375 | : undefined; | |
| 376 | const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; | |
| 377 | ||
| 378 | const shouldIncludeEvent = (event: ProviderEvent): boolean => { | |
| 379 | if (!turnId) return true; | |
| 380 | const scopedTurnId = eventTurnId(event); | |
| 381 | if (scopedTurnId && scopedTurnId !== turnId) { | |
| 382 | return false; | |
| 383 | } | |
| 384 | ||
| 385 | if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { | |
| 386 | const eventAt = Date.parse(event.createdAt); | |
| 387 | if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { | |
| 388 | return false; | |
| 389 | } | |
| 390 | } | |
| 391 | ||
| 392 | return true; | |
| 393 | }; | |
| 394 | ||
| 395 | const completedLifecycleItemIds = new Set<string>(); | |
| 396 | for (const event of ordered) { | |
| 397 | if (!shouldIncludeEvent(event)) continue; | |
| 398 | const candidate = lifecycleCandidateFromItemEvent(event); | |
| 399 | if (candidate?.phase === "completed" && candidate.itemId) { | |
| 400 | completedLifecycleItemIds.add(candidate.itemId); | |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | for (const event of ordered) { | |
| 405 | if (!shouldIncludeEvent(event)) continue; | |
| 406 | ||
| 407 | const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); | |
| 408 | if (lifecycleCandidate) { | |
| 409 | if ( | |
| 410 | lifecycleCandidate.phase === "started" && | |
| 411 | lifecycleCandidate.itemId && | |
| 412 | completedLifecycleItemIds.has(lifecycleCandidate.itemId) | |
| 413 | ) { | |
| 414 | continue; | |
| 415 | } | |
| 416 | if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) { | |
| 417 | continue; | |
| 418 | } | |
| 419 | ||
| 420 | entries.push({ | |
| 421 | id: lifecycleCandidate.id, | |
| 422 | createdAt: lifecycleCandidate.createdAt, | |
| 423 | label: lifecycleCandidate.label, | |
| 424 | ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}), | |
| 425 | tone: lifecycleCandidate.tone, | |
| 426 | }); | |
| 427 | continue; | |
| 428 | } | |
| 429 | ||
| 430 | const fromRequest = entryFromRequest(event); | |
| 431 | if (fromRequest) { | |
| 432 | entries.push(fromRequest); | |
| 433 | continue; | |
| 434 | } | |
| 435 | ||
| 436 | const fromNotification = entryFromNotification(event); | |
| 437 | if (fromNotification) { | |
| 438 | entries.push(fromNotification); | |
| 439 | continue; | |
| 440 | } | |
| 441 | ||
| 442 | const fromError = entryFromError(event); | |
| 443 | if (fromError) { | |
| 444 | entries.push(fromError); | |
| 445 | } | |
| 446 | } | |
| 447 | ||
| 448 | return entries; | |
| 449 | } | |
| 450 | ||
| 451 | interface MutableTurnDiffSummary { | |
| 452 | turnId: string; | |
| 453 | completedAt: string | undefined; | |
| 454 | status: string | undefined; | |
| 455 | assistantMessageId: string | undefined; | |
| 456 | hasTurnDiffUpdate: boolean; | |
| 457 | filesByPath: Map<string, TurnDiffFileChange>; | |
| 458 | } | |
| 459 | ||
| 460 | function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { | |
| 461 | if (event.method !== "item/completed") { | |
| 462 | return []; | |
| 463 | } | |
| 464 | ||
| 465 | const item = asObject(asObject(event.payload)?.item); | |
| 466 | const itemType = normalizeItemType(asString(item?.type)); | |
| 467 | if (itemType !== "file change") { | |
| 468 | return []; | |
| 469 | } | |
| 470 | ||
| 471 | const changes = asArray(item?.changes) ?? []; | |
| 472 | const parsed: TurnDiffFileChange[] = []; | |
| 473 | for (const rawChange of changes) { | |
| 474 | const change = asObject(rawChange); | |
| 475 | if (!change) continue; | |
| 476 | ||
| 477 | const diff = normalizeDetail(asString(change.diff)); | |
| 478 | const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); | |
| 479 | if (!path) continue; | |
| 480 | ||
| 481 | const additions = asNumber(change.additions); | |
| 482 | const deletions = asNumber(change.deletions); | |
| 483 | const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; | |
| 484 | const kind = normalizeDetail(asString(change.kind)); | |
| 485 | parsed.push({ | |
| 486 | path, | |
| 487 | ...(kind ? { kind } : {}), | |
| 488 | ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), | |
| 489 | ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), | |
| 490 | }); | |
| 491 | } | |
| 492 | ||
| 493 | return parsed; | |
| 494 | } | |
| 495 | ||
| 496 | function mergeTurnDiffFileChange( | |
| 497 | byPath: Map<string, TurnDiffFileChange>, | |
| 498 | incoming: TurnDiffFileChange, | |
| 499 | ): void { | |
| 500 | const existing = byPath.get(incoming.path); | |
| 501 | if (!existing) { | |
| 502 | byPath.set(incoming.path, incoming); | |
| 503 | return; | |
| 504 | } | |
| 505 | ||
| 506 | if (existing.kind === undefined && incoming.kind !== undefined) { | |
| 507 | existing.kind = incoming.kind; | |
| 508 | } | |
| 509 | if (existing.additions === undefined && incoming.additions !== undefined) { | |
| 510 | existing.additions = incoming.additions; | |
| 511 | } | |
| 512 | if (existing.deletions === undefined && incoming.deletions !== undefined) { | |
| 513 | existing.deletions = incoming.deletions; | |
| 514 | } | |
| 515 | } | |
| 516 | ||
| 517 | function parsePathFromDiff(diff: string): string | undefined { | |
| 518 | const normalized = diff.replace(/\r\n/g, "\n"); | |
| 519 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); | |
| 520 | if (bPath && bPath[1]) return bPath[1]; | |
| 521 | const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); | |
| 522 | if (gitHeader && gitHeader[1]) return gitHeader[1]; | |
| 523 | const direct = normalized.match(/^\+\+\+ (.+)$/m); | |
| 524 | if (!direct || !direct[1] || direct[1] === "/dev/null") { | |
| 525 | return undefined; | |
| 526 | } | |
| 527 | return direct[1]; | |
| 528 | } | |
| 529 | ||
| 530 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | |
| 531 | const normalized = diff.replace(/\r\n/g, "\n"); | |
| 532 | const byPath = new Map<string, string>(); | |
| 533 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | |
| 534 | ||
| 535 | if (headerMatches.length === 0) { | |
| 536 | const path = parsePathFromDiff(normalized); | |
| 537 | if (path) { | |
| 538 | byPath.set(path, normalized.trim()); | |
| 539 | } | |
| 540 | return byPath; | |
| 541 | } | |
| 542 | ||
| 543 | for (let index = 0; index < headerMatches.length; index += 1) { | |
| 544 | const match = headerMatches[index]; | |
| 545 | if (!match) continue; | |
| 546 | const start = match.index ?? 0; | |
| 547 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; | |
| 548 | const segment = normalized.slice(start, nextStart).trim(); | |
| 549 | const path = parsePathFromDiff(segment); | |
| 550 | if (!path || segment.length === 0) continue; | |
| 551 | byPath.set(path, segment); | |
| 552 | } | |
| 553 | ||
| 554 | return byPath; | |
| 555 | } | |
| 556 | ||
| 557 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | |
| 558 | const fileDiffsByPath = splitUnifiedDiffByFile(diff); | |
| 559 | return Array.from(fileDiffsByPath.entries()) | |
| 560 | .map(([path, fileDiff]) => { | |
| 561 | const stat = countDiffStat(fileDiff); | |
| 562 | return { | |
| 563 | path, | |
| 564 | additions: stat.additions, | |
| 565 | deletions: stat.deletions, | |
| 137 | const ordered = [...activities].toSorted((left, right) => | |
| 138 | left.createdAt.localeCompare(right.createdAt), | |
| 139 | ); | |
| 140 | return ordered | |
| 141 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) | |
| 142 | .filter((activity) => activity.kind !== "tool.started") | |
| 143 | .filter((activity) => activity.summary !== "Checkpoint captured") | |
| 144 | .map((activity) => { | |
| 145 | const payload = | |
| 146 | activity.payload && typeof activity.payload === "object" | |
| 147 | ? (activity.payload as Record<string, unknown>) | |
| 148 | : null; | |
| 149 | const entry: WorkLogEntry = { | |
| 150 | id: activity.id, | |
| 151 | createdAt: activity.createdAt, | |
| 152 | label: activity.summary, | |
| 153 | tone: activity.tone === "approval" ? "info" : activity.tone, | |
| 566 | 154 | }; |
| 567 | }) | |
| 568 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 569 | } | |
| 570 | ||
| 571 | export function inferCheckpointTurnCountByTurnId( | |
| 572 | summaries: TurnDiffSummary[], | |
| 573 | ): Record<string, number> { | |
| 574 | if (summaries.length === 0) { | |
| 575 | return {}; | |
| 576 | } | |
| 577 | ||
| 578 | const sorted = [...summaries].toSorted((a, b) => { | |
| 579 | const aTime = Date.parse(a.completedAt); | |
| 580 | const bTime = Date.parse(b.completedAt); | |
| 581 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 582 | return a.completedAt.localeCompare(b.completedAt); | |
| 583 | } | |
| 584 | return aTime - bTime; | |
| 585 | }); | |
| 586 | ||
| 587 | const next: Record<string, number> = {}; | |
| 588 | let fallbackTurnCount = 1; | |
| 589 | for (const summary of sorted) { | |
| 590 | if (typeof summary.checkpointTurnCount === "number") { | |
| 591 | next[summary.turnId] = summary.checkpointTurnCount; | |
| 592 | fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); | |
| 593 | continue; | |
| 594 | } | |
| 595 | next[summary.turnId] = fallbackTurnCount; | |
| 596 | fallbackTurnCount += 1; | |
| 597 | } | |
| 598 | ||
| 599 | return next; | |
| 600 | } | |
| 601 | ||
| 602 | export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { | |
| 603 | if (!patch) { | |
| 604 | return { additions: 0, deletions: 0 }; | |
| 605 | } | |
| 606 | ||
| 607 | let additions = 0; | |
| 608 | let deletions = 0; | |
| 609 | for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { | |
| 610 | if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; | |
| 611 | if (line.startsWith("+")) { | |
| 612 | additions += 1; | |
| 613 | continue; | |
| 614 | } | |
| 615 | if (line.startsWith("-")) { | |
| 616 | deletions += 1; | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | return { additions, deletions }; | |
| 621 | } | |
| 622 | ||
| 623 | export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { | |
| 624 | const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); | |
| 625 | const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); | |
| 626 | const ordered = | |
| 627 | events.length < 2 || | |
| 628 | Number.isNaN(firstEventAt) || | |
| 629 | Number.isNaN(lastEventAt) || | |
| 630 | firstEventAt >= lastEventAt | |
| 631 | ? events | |
| 632 | : [...events].toReversed(); | |
| 633 | const byTurnId = new Map<string, MutableTurnDiffSummary>(); | |
| 634 | ||
| 635 | const ensureSummary = (turnId: string): MutableTurnDiffSummary => { | |
| 636 | const existing = byTurnId.get(turnId); | |
| 637 | if (existing) return existing; | |
| 638 | const next: MutableTurnDiffSummary = { | |
| 639 | turnId, | |
| 640 | completedAt: undefined, | |
| 641 | status: undefined, | |
| 642 | assistantMessageId: undefined, | |
| 643 | hasTurnDiffUpdate: false, | |
| 644 | filesByPath: new Map<string, TurnDiffFileChange>(), | |
| 645 | }; | |
| 646 | byTurnId.set(turnId, next); | |
| 647 | return next; | |
| 648 | }; | |
| 649 | ||
| 650 | for (const event of ordered) { | |
| 651 | const turnId = eventTurnId(event); | |
| 652 | if (!turnId) continue; | |
| 653 | const summary = ensureSummary(turnId); | |
| 654 | ||
| 655 | if (event.method === "turn/completed") { | |
| 656 | if (summary.completedAt === undefined) { | |
| 657 | summary.completedAt = event.createdAt; | |
| 658 | } | |
| 659 | const turn = asObject(asObject(event.payload)?.turn); | |
| 660 | if (summary.status === undefined) { | |
| 661 | summary.status = normalizeDetail(asString(turn?.status)); | |
| 155 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | |
| 156 | entry.detail = payload.detail; | |
| 662 | 157 | } |
| 663 | } | |
| 664 | ||
| 665 | if (event.method === "item/completed") { | |
| 666 | const item = asObject(asObject(event.payload)?.item); | |
| 667 | if (asString(item?.type) === "agentMessage") { | |
| 668 | const itemId = normalizeDetail(asString(item?.id)); | |
| 669 | if (itemId && summary.assistantMessageId === undefined) { | |
| 670 | summary.assistantMessageId = itemId; | |
| 671 | } | |
| 672 | } | |
| 673 | } | |
| 674 | ||
| 675 | if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { | |
| 676 | summary.hasTurnDiffUpdate = true; | |
| 677 | const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); | |
| 678 | if (diff) { | |
| 679 | for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { | |
| 680 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 681 | } | |
| 682 | } | |
| 683 | } | |
| 684 | ||
| 685 | for (const file of parseFileChangeEntriesFromEvent(event)) { | |
| 686 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 687 | } | |
| 688 | } | |
| 689 | ||
| 690 | const summaries: TurnDiffSummary[] = []; | |
| 691 | for (const summary of byTurnId.values()) { | |
| 692 | if (!summary.completedAt) continue; | |
| 693 | ||
| 694 | summaries.push({ | |
| 695 | turnId: summary.turnId, | |
| 696 | completedAt: summary.completedAt, | |
| 697 | ...(summary.status ? { status: summary.status } : {}), | |
| 698 | files: Array.from(summary.filesByPath.values()).toSorted((a, b) => | |
| 699 | a.path.localeCompare(b.path), | |
| 700 | ), | |
| 701 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 158 | return entry; | |
| 702 | 159 | }); |
| 703 | } | |
| 704 | ||
| 705 | summaries.sort((a, b) => { | |
| 706 | const aTime = Date.parse(a.completedAt); | |
| 707 | const bTime = Date.parse(b.completedAt); | |
| 708 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 709 | return b.completedAt.localeCompare(a.completedAt); | |
| 710 | } | |
| 711 | return bTime - aTime; | |
| 712 | }); | |
| 713 | ||
| 714 | return summaries; | |
| 715 | 160 | } |
| 716 | 161 | |
| 717 | function toTimestamp(isoDate: string): number { | |
| 718 | const parsed = Date.parse(isoDate); | |
| 719 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | |
| 162 | export function hasToolActivityForTurn( | |
| 163 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 164 | turnId: TurnId | null | undefined, | |
| 165 | ): boolean { | |
| 166 | if (!turnId) return false; | |
| 167 | return activities.some((activity) => activity.turnId === turnId && activity.tone === "tool"); | |
| 720 | 168 | } |
| 721 | 169 | |
| 722 | 170 | export function deriveTimelineEntries( |
| 723 | 171 | messages: ChatMessage[], |
| 724 | 172 | workEntries: WorkLogEntry[], |
| 725 | 173 | ): TimelineEntry[] { |
| 726 | const timeline: TimelineEntry[] = []; | |
| 727 | let messageIndex = 0; | |
| 728 | let workIndex = 0; | |
| 729 | ||
| 730 | while (messageIndex < messages.length || workIndex < workEntries.length) { | |
| 731 | const message = messages[messageIndex]; | |
| 732 | const workEntry = workEntries[workIndex]; | |
| 733 | ||
| 734 | if (!message && workEntry) { | |
| 735 | timeline.push({ | |
| 736 | id: `work:${workEntry.id}`, | |
| 737 | kind: "work", | |
| 738 | createdAt: workEntry.createdAt, | |
| 739 | entry: workEntry, | |
| 740 | }); | |
| 741 | workIndex += 1; | |
| 742 | continue; | |
| 743 | } | |
| 744 | ||
| 745 | if (!workEntry && message) { | |
| 746 | timeline.push({ | |
| 747 | id: `message:${message.id}`, | |
| 748 | kind: "message", | |
| 749 | createdAt: message.createdAt, | |
| 750 | message, | |
| 751 | }); | |
| 752 | messageIndex += 1; | |
| 753 | continue; | |
| 754 | } | |
| 755 | ||
| 756 | if (!message || !workEntry) { | |
| 757 | break; | |
| 758 | } | |
| 759 | ||
| 760 | const messageAt = toTimestamp(message.createdAt); | |
| 761 | const workAt = toTimestamp(workEntry.createdAt); | |
| 762 | ||
| 763 | if (workAt <= messageAt) { | |
| 764 | timeline.push({ | |
| 765 | id: `work:${workEntry.id}`, | |
| 766 | kind: "work", | |
| 767 | createdAt: workEntry.createdAt, | |
| 768 | entry: workEntry, | |
| 769 | }); | |
| 770 | workIndex += 1; | |
| 771 | continue; | |
| 772 | } | |
| 174 | const messageRows: TimelineEntry[] = messages.map((message) => ({ | |
| 175 | id: message.id, | |
| 176 | kind: "message", | |
| 177 | createdAt: message.createdAt, | |
| 178 | message, | |
| 179 | })); | |
| 180 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ | |
| 181 | id: entry.id, | |
| 182 | kind: "work", | |
| 183 | createdAt: entry.createdAt, | |
| 184 | entry, | |
| 185 | })); | |
| 186 | return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); | |
| 187 | } | |
| 773 | 188 | |
| 774 | timeline.push({ | |
| 775 | id: `message:${message.id}`, | |
| 776 | kind: "message", | |
| 777 | createdAt: message.createdAt, | |
| 778 | message, | |
| 779 | }); | |
| 780 | messageIndex += 1; | |
| 189 | export function inferCheckpointTurnCountByTurnId( | |
| 190 | summaries: TurnDiffSummary[], | |
| 191 | ): Record<TurnId, number> { | |
| 192 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); | |
| 193 | const result: Record<TurnId, number> = {}; | |
| 194 | for (let index = 0; index < sorted.length; index += 1) { | |
| 195 | const summary = sorted[index]; | |
| 196 | if (!summary) continue; | |
| 197 | result[summary.turnId] = index + 1; | |
| 781 | 198 | } |
| 782 | ||
| 783 | return timeline; | |
| 199 | return result; | |
| 784 | 200 | } |
| 785 | 201 | |
| 786 | export function derivePhase(session: ProviderSession | null): SessionPhase { | |
| 202 | export function derivePhase(session: ThreadSession | null): SessionPhase { | |
| 787 | 203 | if (!session || session.status === "closed") return "disconnected"; |
| 788 | 204 | if (session.status === "connecting") return "connecting"; |
| 789 | 205 | if (session.status === "running") return "running"; |
| 790 | 206 | return "ready"; |
| 791 | 207 | } |
| 792 | ||
| 793 | export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { | |
| 794 | const payload = asObject(event.payload); | |
| 795 | ||
| 796 | if (event.method === "thread/started") { | |
| 797 | const thread = asObject(payload?.thread); | |
| 798 | return { | |
| 799 | ...previous, | |
| 800 | threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, | |
| 801 | updatedAt: event.createdAt, | |
| 802 | }; | |
| 803 | } | |
| 804 | ||
| 805 | if (event.method === "turn/started") { | |
| 806 | const turn = asObject(payload?.turn); | |
| 807 | return { | |
| 808 | ...previous, | |
| 809 | status: "running", | |
| 810 | activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, | |
| 811 | updatedAt: event.createdAt, | |
| 812 | }; | |
| 813 | } | |
| 814 | ||
| 815 | if (event.method === "turn/completed") { | |
| 816 | const turn = asObject(payload?.turn); | |
| 817 | const status = asString(turn?.status); | |
| 818 | const turnError = asObject(turn?.error); | |
| 819 | return { | |
| 820 | ...previous, | |
| 821 | status: status === "failed" ? "error" : "ready", | |
| 822 | activeTurnId: undefined, | |
| 823 | lastError: asString(turnError?.message) ?? previous.lastError, | |
| 824 | updatedAt: event.createdAt, | |
| 825 | }; | |
| 826 | } | |
| 827 | ||
| 828 | if (event.kind === "error") { | |
| 829 | return { | |
| 830 | ...previous, | |
| 831 | status: "error", | |
| 832 | lastError: event.message ?? previous.lastError, | |
| 833 | updatedAt: event.createdAt, | |
| 834 | }; | |
| 835 | } | |
| 836 | ||
| 837 | if (event.method === "session/closed" || event.method === "session/exited") { | |
| 838 | return { | |
| 839 | ...previous, | |
| 840 | status: "closed", | |
| 841 | activeTurnId: undefined, | |
| 842 | lastError: event.message ?? previous.lastError, | |
| 843 | updatedAt: event.createdAt, | |
| 844 | }; | |
| 845 | } | |
| 846 | ||
| 847 | return { ...previous, updatedAt: event.createdAt }; | |
| 848 | } | |
| 849 | ||
| 850 | export function applyEventToMessages( | |
| 851 | previous: ChatMessage[], | |
| 852 | event: ProviderEvent, | |
| 853 | activeAssistantItemRef: { current: string | null }, | |
| 854 | ): ChatMessage[] { | |
| 855 | const payload = asObject(event.payload); | |
| 856 | ||
| 857 | if (event.method === "item/started") { | |
| 858 | const item = asObject(payload?.item); | |
| 859 | if (asString(item?.type) !== "agentMessage") return previous; | |
| 860 | const itemId = asString(item?.id); | |
| 861 | if (!itemId) return previous; | |
| 862 | ||
| 863 | activeAssistantItemRef.current = itemId; | |
| 864 | const seedText = asString(item?.text) ?? ""; | |
| 865 | const filtered = previous.filter((entry) => entry.id !== itemId); | |
| 866 | return [ | |
| 867 | ...filtered, | |
| 868 | { | |
| 869 | id: itemId, | |
| 870 | role: "assistant", | |
| 871 | text: seedText, | |
| 872 | createdAt: event.createdAt, | |
| 873 | streaming: true, | |
| 874 | }, | |
| 875 | ]; | |
| 876 | } | |
| 877 | ||
| 878 | if (event.method === "item/agentMessage/delta") { | |
| 879 | const itemId = event.itemId ?? asString(payload?.itemId); | |
| 880 | const delta = event.textDelta ?? asString(payload?.delta) ?? ""; | |
| 881 | if (!itemId || !delta) return previous; | |
| 882 | ||
| 883 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | |
| 884 | if (existingIndex === -1) { | |
| 885 | activeAssistantItemRef.current = itemId; | |
| 886 | return [ | |
| 887 | ...previous, | |
| 888 | { | |
| 889 | id: itemId, | |
| 890 | role: "assistant", | |
| 891 | text: delta, | |
| 892 | createdAt: event.createdAt, | |
| 893 | streaming: true, | |
| 894 | }, | |
| 895 | ]; | |
| 896 | } | |
| 897 | ||
| 898 | const updated = [...previous]; | |
| 899 | const existing = updated[existingIndex]; | |
| 900 | if (!existing) return previous; | |
| 901 | updated[existingIndex] = { | |
| 902 | ...existing, | |
| 903 | text: `${existing.text}${delta}`, | |
| 904 | streaming: true, | |
| 905 | }; | |
| 906 | return updated; | |
| 907 | } | |
| 908 | ||
| 909 | if (event.method === "item/completed") { | |
| 910 | const item = asObject(payload?.item); | |
| 911 | if (asString(item?.type) !== "agentMessage") return previous; | |
| 912 | const itemId = asString(item?.id); | |
| 913 | if (!itemId) return previous; | |
| 914 | ||
| 915 | const fullText = asString(item?.text); | |
| 916 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | |
| 917 | if (existingIndex === -1) { | |
| 918 | return [ | |
| 919 | ...previous, | |
| 920 | { | |
| 921 | id: itemId, | |
| 922 | role: "assistant", | |
| 923 | text: fullText ?? "", | |
| 924 | createdAt: event.createdAt, | |
| 925 | streaming: false, | |
| 926 | }, | |
| 927 | ]; | |
| 928 | } | |
| 929 | ||
| 930 | const updated = [...previous]; | |
| 931 | const existing = updated[existingIndex]; | |
| 932 | if (!existing) return previous; | |
| 933 | updated[existingIndex] = { | |
| 934 | ...existing, | |
| 935 | text: fullText ?? existing.text, | |
| 936 | streaming: false, | |
| 937 | }; | |
| 938 | ||
| 939 | if (activeAssistantItemRef.current === itemId) { | |
| 940 | activeAssistantItemRef.current = null; | |
| 941 | } | |
| 942 | return updated; | |
| 943 | } | |
| 944 | ||
| 945 | if (event.method === "turn/completed") { | |
| 946 | return previous.map((entry) => ({ ...entry, streaming: false })); | |
| 947 | } | |
| 948 | ||
| 949 | return previous; | |
| 950 | } |
apps/web/src/store.test.ts+65-887| ... | ... | @@ -1,47 +1,16 @@ |
| 1 | import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { type AppState, reducer } from "./store"; | |
| 5 | import { | |
| 6 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 7 | DEFAULT_THREAD_TERMINAL_ID, | |
| 8 | MAX_THREAD_TERMINAL_COUNT, | |
| 9 | } from "./types"; | |
| 10 | import type { Thread } from "./types"; | |
| 11 | ||
| 12 | type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>; | |
| 13 | type TerminalActivityEvent = Extract<TerminalEvent, { type: "activity" }>; | |
| 14 | ||
| 15 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | |
| 16 | return { | |
| 17 | sessionId: "sess-1", | |
| 18 | provider: "codex", | |
| 19 | status: "ready", | |
| 20 | createdAt: "2026-02-09T00:00:00.000Z", | |
| 21 | updatedAt: "2026-02-09T00:00:00.000Z", | |
| 22 | ...overrides, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | function makeEvent(overrides: Partial<ProviderEvent> = {}): ProviderEvent { | |
| 27 | return { | |
| 28 | id: "evt-1", | |
| 29 | kind: "notification", | |
| 30 | provider: "codex", | |
| 31 | sessionId: "sess-1", | |
| 32 | createdAt: "2026-02-09T00:00:01.000Z", | |
| 33 | method: "thread/started", | |
| 34 | ...overrides, | |
| 35 | }; | |
| 36 | } | |
| 4 | import { reducer, type AppState } from "./store"; | |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | |
| 37 | 6 | |
| 38 | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 39 | 8 | return { |
| 40 | id: "thread-local-1", | |
| 9 | id: ThreadId.makeUnsafe("thread-1"), | |
| 41 | 10 | codexThreadId: null, |
| 42 | projectId: "project-1", | |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 43 | 12 | title: "Thread", |
| 44 | model: "gpt-5.3-codex", | |
| 13 | model: "gpt-5-codex", | |
| 45 | 14 | terminalOpen: false, |
| 46 | 15 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 47 | 16 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| ... | ... | @@ -54,12 +23,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 54 | 23 | }, |
| 55 | 24 | ], |
| 56 | 25 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 57 | session: makeSession(), | |
| 26 | session: null, | |
| 58 | 27 | messages: [], |
| 59 | events: [], | |
| 60 | 28 | turnDiffSummaries: [], |
| 29 | activities: [], | |
| 61 | 30 | error: null, |
| 62 | createdAt: "2026-02-09T00:00:00.000Z", | |
| 31 | createdAt: "2026-02-13T00:00:00.000Z", | |
| 32 | latestTurn: null, | |
| 63 | 33 | branch: null, |
| 64 | 34 | worktreePath: null, |
| 65 | 35 | ...overrides, |
| ... | ... | @@ -70,10 +40,10 @@ function makeState(thread: Thread): AppState { |
| 70 | 40 | return { |
| 71 | 41 | projects: [ |
| 72 | 42 | { |
| 73 | id: "project-1", | |
| 43 | id: ProjectId.makeUnsafe("project-1"), | |
| 74 | 44 | name: "Project", |
| 75 | 45 | cwd: "/tmp/project", |
| 76 | model: "gpt-5.3-codex", | |
| 46 | model: "gpt-5-codex", | |
| 77 | 47 | expanded: true, |
| 78 | 48 | scripts: [], |
| 79 | 49 | }, |
| ... | ... | @@ -84,900 +54,108 @@ function makeState(thread: Thread): AppState { |
| 84 | 54 | }; |
| 85 | 55 | } |
| 86 | 56 | |
| 87 | function makeTerminalStartedEvent( | |
| 88 | overrides: Partial<TerminalStartedEvent> = {}, | |
| 89 | ): TerminalStartedEvent { | |
| 90 | return { | |
| 91 | type: "started", | |
| 92 | threadId: "thread-local-1", | |
| 93 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 94 | createdAt: "2026-02-09T00:00:01.000Z", | |
| 95 | snapshot: { | |
| 96 | threadId: "thread-local-1", | |
| 97 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 98 | cwd: "/tmp/project", | |
| 99 | status: "running", | |
| 100 | pid: 1234, | |
| 101 | history: "", | |
| 102 | exitCode: null, | |
| 103 | exitSignal: null, | |
| 104 | updatedAt: "2026-02-09T00:00:01.000Z", | |
| 105 | }, | |
| 106 | ...overrides, | |
| 107 | }; | |
| 108 | } | |
| 109 | ||
| 110 | function makeTerminalActivityEvent( | |
| 111 | overrides: Partial<TerminalActivityEvent> = {}, | |
| 112 | ): TerminalActivityEvent { | |
| 113 | return { | |
| 114 | type: "activity", | |
| 115 | threadId: "thread-local-1", | |
| 116 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 117 | createdAt: "2026-02-09T00:00:02.000Z", | |
| 118 | hasRunningSubprocess: true, | |
| 119 | ...overrides, | |
| 120 | }; | |
| 121 | } | |
| 122 | ||
| 123 | describe("store reducer thread continuity", () => { | |
| 124 | it("stores codexThreadId from UPDATE_SESSION", () => { | |
| 125 | const state = makeState( | |
| 57 | describe("store reducer", () => { | |
| 58 | it("marks a completed thread as unread by moving lastVisitedAt before completion", () => { | |
| 59 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; | |
| 60 | const initialState = makeState( | |
| 126 | 61 | makeThread({ |
| 127 | session: null, | |
| 62 | latestTurn: { | |
| 63 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 64 | state: "completed", | |
| 65 | requestedAt: "2026-02-25T12:28:00.000Z", | |
| 66 | startedAt: "2026-02-25T12:28:30.000Z", | |
| 67 | completedAt: latestTurnCompletedAt, | |
| 68 | assistantMessageId: null, | |
| 69 | }, | |
| 70 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 128 | 71 | }), |
| 129 | 72 | ); |
| 130 | const next = reducer(state, { | |
| 131 | type: "UPDATE_SESSION", | |
| 132 | threadId: "thread-local-1", | |
| 133 | session: makeSession({ threadId: "thr_123" }), | |
| 134 | }); | |
| 135 | ||
| 136 | expect(next.threads[0]?.codexThreadId).toBe("thr_123"); | |
| 137 | }); | |
| 138 | ||
| 139 | it("toggles terminal open state per thread", () => { | |
| 140 | const state = makeState(makeThread({ terminalOpen: false })); | |
| 141 | const next = reducer(state, { | |
| 142 | type: "TOGGLE_THREAD_TERMINAL", | |
| 143 | threadId: "thread-local-1", | |
| 144 | }); | |
| 145 | expect(next.threads[0]?.terminalOpen).toBe(true); | |
| 146 | }); | |
| 147 | ||
| 148 | it("sets terminal open state per thread", () => { | |
| 149 | const state = makeState(makeThread({ terminalOpen: true })); | |
| 150 | const next = reducer(state, { | |
| 151 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 152 | threadId: "thread-local-1", | |
| 153 | open: false, | |
| 154 | }); | |
| 155 | expect(next.threads[0]?.terminalOpen).toBe(false); | |
| 156 | }); | |
| 157 | ||
| 158 | it("sets terminal height per thread", () => { | |
| 159 | const state = makeState(makeThread({ terminalHeight: 280 })); | |
| 160 | const next = reducer(state, { | |
| 161 | type: "SET_THREAD_TERMINAL_HEIGHT", | |
| 162 | threadId: "thread-local-1", | |
| 163 | height: 360, | |
| 164 | }); | |
| 165 | expect(next.threads[0]?.terminalHeight).toBe(360); | |
| 166 | }); | |
| 167 | 73 | |
| 168 | it("splits the active terminal into side-by-side mode", () => { | |
| 169 | const state = makeState(makeThread()); | |
| 170 | const next = reducer(state, { | |
| 171 | type: "SPLIT_THREAD_TERMINAL", | |
| 172 | threadId: "thread-local-1", | |
| 173 | terminalId: "term-2", | |
| 74 | const next = reducer(initialState, { | |
| 75 | type: "MARK_THREAD_UNREAD", | |
| 76 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 174 | 77 | }); |
| 175 | 78 | |
| 176 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | |
| 177 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | |
| 178 | expect(next.threads[0]?.terminalGroups).toEqual([ | |
| 179 | { | |
| 180 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 181 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 182 | }, | |
| 183 | ]); | |
| 184 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 185 | }); | |
| 186 | ||
| 187 | it("creates a new full-width terminal and switches to tab mode", () => { | |
| 188 | const state = makeState(makeThread()); | |
| 189 | const next = reducer(state, { | |
| 190 | type: "NEW_THREAD_TERMINAL", | |
| 191 | threadId: "thread-local-1", | |
| 192 | terminalId: "term-2", | |
| 193 | }); | |
| 194 | ||
| 195 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); | |
| 196 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | |
| 197 | expect(next.threads[0]?.terminalGroups).toEqual([ | |
| 198 | { | |
| 199 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 200 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 201 | }, | |
| 202 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 203 | ]); | |
| 204 | expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); | |
| 205 | }); | |
| 206 | ||
| 207 | it("switches the active terminal and restores its owning group", () => { | |
| 208 | const state = makeState( | |
| 209 | makeThread({ | |
| 210 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | |
| 211 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 212 | terminalGroups: [ | |
| 213 | { | |
| 214 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 215 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 216 | }, | |
| 217 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 218 | ], | |
| 219 | activeTerminalGroupId: "group-term-3", | |
| 220 | }), | |
| 79 | const updatedThread = next.threads[0]; | |
| 80 | expect(updatedThread).toBeDefined(); | |
| 81 | expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z"); | |
| 82 | expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan( | |
| 83 | Date.parse(latestTurnCompletedAt), | |
| 221 | 84 | ); |
| 222 | const next = reducer(state, { | |
| 223 | type: "SET_THREAD_ACTIVE_TERMINAL", | |
| 224 | threadId: "thread-local-1", | |
| 225 | terminalId: "term-2", | |
| 226 | }); | |
| 227 | ||
| 228 | expect(next.threads[0]?.activeTerminalId).toBe("term-2"); | |
| 229 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 230 | 85 | }); |
| 231 | 86 | |
| 232 | it("supports splitting beyond two terminals in the same group", () => { | |
| 233 | const state = makeState( | |
| 87 | it("does not change a thread without a completed turn", () => { | |
| 88 | const initialState = makeState( | |
| 234 | 89 | makeThread({ |
| 235 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 236 | activeTerminalId: "term-2", | |
| 237 | terminalGroups: [ | |
| 238 | { | |
| 239 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 240 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 241 | }, | |
| 242 | ], | |
| 243 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 90 | latestTurn: null, | |
| 91 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | |
| 244 | 92 | }), |
| 245 | 93 | ); |
| 246 | const next = reducer(state, { | |
| 247 | type: "SPLIT_THREAD_TERMINAL", | |
| 248 | threadId: "thread-local-1", | |
| 249 | terminalId: "term-3", | |
| 250 | }); | |
| 251 | 94 | |
| 252 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"]); | |
| 253 | expect(next.threads[0]?.terminalGroups).toEqual([ | |
| 254 | { | |
| 255 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 256 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | |
| 257 | }, | |
| 258 | ]); | |
| 259 | expect(next.threads[0]?.activeTerminalId).toBe("term-3"); | |
| 260 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); | |
| 261 | }); | |
| 262 | ||
| 263 | it("caps split terminals at four per thread", () => { | |
| 264 | const state = makeState( | |
| 265 | makeThread({ | |
| 266 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 267 | activeTerminalId: "term-4", | |
| 268 | terminalGroups: [ | |
| 269 | { | |
| 270 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 271 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 272 | }, | |
| 273 | ], | |
| 274 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 275 | }), | |
| 276 | ); | |
| 277 | const next = reducer(state, { | |
| 278 | type: "SPLIT_THREAD_TERMINAL", | |
| 279 | threadId: "thread-local-1", | |
| 280 | terminalId: "term-5", | |
| 95 | const next = reducer(initialState, { | |
| 96 | type: "MARK_THREAD_UNREAD", | |
| 97 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 281 | 98 | }); |
| 282 | 99 | |
| 283 | expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); | |
| 284 | expect(next.threads[0]?.terminalIds).toEqual([ | |
| 285 | DEFAULT_THREAD_TERMINAL_ID, | |
| 286 | "term-2", | |
| 287 | "term-3", | |
| 288 | "term-4", | |
| 289 | ]); | |
| 290 | expect(next.threads[0]?.activeTerminalId).toBe("term-4"); | |
| 100 | expect(next).toEqual(initialState); | |
| 291 | 101 | }); |
| 102 | }); | |
| 292 | 103 | |
| 293 | it("caps new terminals at four per thread", () => { | |
| 104 | describe("store terminal activity reducer", () => { | |
| 105 | it("adds a terminal to runningTerminalIds when subprocess activity starts", () => { | |
| 294 | 106 | const state = makeState( |
| 295 | 107 | makeThread({ |
| 296 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 297 | activeTerminalId: "term-4", | |
| 108 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], | |
| 298 | 109 | terminalGroups: [ |
| 299 | 110 | { |
| 300 | 111 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 301 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 112 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], | |
| 302 | 113 | }, |
| 303 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 304 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 305 | { id: "group-term-4", terminalIds: ["term-4"] }, | |
| 306 | 114 | ], |
| 307 | activeTerminalGroupId: "group-term-4", | |
| 308 | 115 | }), |
| 309 | 116 | ); |
| 310 | 117 | const next = reducer(state, { |
| 311 | type: "NEW_THREAD_TERMINAL", | |
| 312 | threadId: "thread-local-1", | |
| 313 | terminalId: "term-5", | |
| 118 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 119 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 120 | terminalId: "alt", | |
| 121 | hasRunningSubprocess: true, | |
| 314 | 122 | }); |
| 315 | 123 | |
| 316 | expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); | |
| 317 | expect(next.threads[0]?.terminalIds).toEqual([ | |
| 318 | DEFAULT_THREAD_TERMINAL_ID, | |
| 319 | "term-2", | |
| 320 | "term-3", | |
| 321 | "term-4", | |
| 322 | ]); | |
| 323 | expect(next.threads[0]?.activeTerminalId).toBe("term-4"); | |
| 324 | expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4"); | |
| 124 | expect(next.threads[0]?.runningTerminalIds).toEqual(["alt"]); | |
| 325 | 125 | }); |
| 326 | 126 | |
| 327 | it("closes a terminal and keeps grouped layout coherent", () => { | |
| 127 | it("removes a terminal from runningTerminalIds when subprocess activity stops", () => { | |
| 328 | 128 | const state = makeState( |
| 329 | 129 | makeThread({ |
| 330 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], | |
| 331 | activeTerminalId: "term-2", | |
| 130 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], | |
| 332 | 131 | terminalGroups: [ |
| 333 | 132 | { |
| 334 | 133 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 335 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 134 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"], | |
| 336 | 135 | }, |
| 337 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 338 | 136 | ], |
| 339 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 340 | }), | |
| 341 | ); | |
| 342 | const next = reducer(state, { | |
| 343 | type: "CLOSE_THREAD_TERMINAL", | |
| 344 | threadId: "thread-local-1", | |
| 345 | terminalId: "term-2", | |
| 346 | }); | |
| 347 | ||
| 348 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); | |
| 349 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | |
| 350 | expect(next.threads[0]?.terminalGroups).toEqual([ | |
| 351 | { | |
| 352 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 353 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 354 | }, | |
| 355 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 356 | ]); | |
| 357 | }); | |
| 358 | ||
| 359 | it("closes the final terminal and hides the drawer", () => { | |
| 360 | const state = makeState( | |
| 361 | makeThread({ | |
| 362 | terminalOpen: true, | |
| 363 | runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 137 | runningTerminalIds: ["alt"], | |
| 364 | 138 | }), |
| 365 | 139 | ); |
| 366 | 140 | const next = reducer(state, { |
| 367 | type: "CLOSE_THREAD_TERMINAL", | |
| 368 | threadId: "thread-local-1", | |
| 369 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 141 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 142 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 143 | terminalId: "alt", | |
| 144 | hasRunningSubprocess: false, | |
| 370 | 145 | }); |
| 371 | 146 | |
| 372 | expect(next.threads[0]?.terminalOpen).toBe(false); | |
| 373 | expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 374 | 147 | expect(next.threads[0]?.runningTerminalIds).toEqual([]); |
| 375 | expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); | |
| 376 | expect(next.threads[0]?.terminalGroups).toEqual([ | |
| 377 | { | |
| 378 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 379 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 380 | }, | |
| 381 | ]); | |
| 382 | 148 | }); |
| 383 | 149 | |
| 384 | it("tracks running terminals from subprocess activity events", () => { | |
| 150 | it("ignores activity events for unknown terminal ids", () => { | |
| 385 | 151 | const state = makeState(makeThread()); |
| 386 | const started = reducer(state, { | |
| 387 | type: "APPLY_TERMINAL_EVENT", | |
| 388 | event: makeTerminalStartedEvent(), | |
| 389 | }); | |
| 390 | expect(started.threads[0]?.runningTerminalIds).toEqual([]); | |
| 391 | ||
| 392 | const active = reducer(started, { | |
| 393 | type: "APPLY_TERMINAL_EVENT", | |
| 394 | event: makeTerminalActivityEvent(), | |
| 395 | }); | |
| 396 | expect(active.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); | |
| 397 | ||
| 398 | const idle = reducer(active, { | |
| 399 | type: "APPLY_TERMINAL_EVENT", | |
| 400 | event: makeTerminalActivityEvent({ hasRunningSubprocess: false }), | |
| 401 | }); | |
| 402 | expect(idle.threads[0]?.runningTerminalIds).toEqual([]); | |
| 403 | ||
| 404 | const exited = reducer(active, { | |
| 405 | type: "APPLY_TERMINAL_EVENT", | |
| 406 | event: { | |
| 407 | type: "exited", | |
| 408 | threadId: "thread-local-1", | |
| 409 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 410 | createdAt: "2026-02-09T00:00:05.000Z", | |
| 411 | exitCode: 0, | |
| 412 | exitSignal: null, | |
| 413 | }, | |
| 414 | }); | |
| 415 | expect(exited.threads[0]?.runningTerminalIds).toEqual([]); | |
| 416 | }); | |
| 417 | ||
| 418 | it("keeps running status when another terminal in the thread is still running", () => { | |
| 419 | const state = makeState( | |
| 420 | makeThread({ | |
| 421 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 422 | runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 423 | activeTerminalId: "term-2", | |
| 424 | terminalGroups: [ | |
| 425 | { | |
| 426 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 427 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], | |
| 428 | }, | |
| 429 | ], | |
| 430 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 431 | }), | |
| 432 | ); | |
| 433 | ||
| 434 | 152 | const next = reducer(state, { |
| 435 | type: "APPLY_TERMINAL_EVENT", | |
| 436 | event: { | |
| 437 | type: "exited", | |
| 438 | threadId: "thread-local-1", | |
| 439 | terminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 440 | createdAt: "2026-02-09T00:00:07.000Z", | |
| 441 | exitCode: 0, | |
| 442 | exitSignal: null, | |
| 443 | }, | |
| 444 | }); | |
| 445 | ||
| 446 | expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]); | |
| 447 | }); | |
| 448 | ||
| 449 | it("backfills codexThreadId from routed provider events", () => { | |
| 450 | const state = makeState(makeThread({ codexThreadId: null })); | |
| 451 | const next = reducer(state, { | |
| 452 | type: "APPLY_EVENT", | |
| 453 | event: makeEvent({ | |
| 454 | method: "thread/started", | |
| 455 | payload: { thread: { id: "thr_backfilled" } }, | |
| 456 | }), | |
| 457 | activeAssistantItemRef: { current: null }, | |
| 458 | }); | |
| 459 | ||
| 460 | expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); | |
| 461 | }); | |
| 462 | ||
| 463 | it("ignores events from a foreign thread within the same session", () => { | |
| 464 | const state = makeState(makeThread({ codexThreadId: "thr_expected" })); | |
| 465 | const next = reducer(state, { | |
| 466 | type: "APPLY_EVENT", | |
| 467 | event: makeEvent({ | |
| 468 | method: "turn/started", | |
| 469 | threadId: "thr_unexpected", | |
| 470 | payload: { turn: { id: "turn-1" } }, | |
| 471 | }), | |
| 472 | activeAssistantItemRef: { current: null }, | |
| 473 | }); | |
| 474 | ||
| 475 | expect(next).toBe(state); | |
| 476 | }); | |
| 477 | ||
| 478 | it("rebases thread identity on thread/started during connect", () => { | |
| 479 | const state = makeState( | |
| 480 | makeThread({ | |
| 481 | codexThreadId: "thr_old", | |
| 482 | session: makeSession({ | |
| 483 | status: "connecting", | |
| 484 | threadId: "thr_old", | |
| 485 | }), | |
| 486 | }), | |
| 487 | ); | |
| 488 | const next = reducer(state, { | |
| 489 | type: "APPLY_EVENT", | |
| 490 | event: makeEvent({ | |
| 491 | method: "thread/started", | |
| 492 | threadId: "thr_new", | |
| 493 | payload: { thread: { id: "thr_new" } }, | |
| 494 | }), | |
| 495 | activeAssistantItemRef: { current: null }, | |
| 153 | type: "SET_THREAD_TERMINAL_ACTIVITY", | |
| 154 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 155 | terminalId: "missing", | |
| 156 | hasRunningSubprocess: true, | |
| 496 | 157 | }); |
| 497 | 158 | |
| 498 | expect(next.threads[0]?.codexThreadId).toBe("thr_new"); | |
| 499 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); | |
| 500 | }); | |
| 501 | ||
| 502 | it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => { | |
| 503 | const state = makeState( | |
| 504 | makeThread({ | |
| 505 | events: [], | |
| 506 | turnDiffSummaries: [ | |
| 507 | { | |
| 508 | turnId: "turn-1", | |
| 509 | completedAt: "2026-02-09T00:00:01.000Z", | |
| 510 | files: [{ path: "src/existing.ts", kind: "modified" }], | |
| 511 | }, | |
| 512 | ], | |
| 513 | }), | |
| 514 | ); | |
| 515 | ||
| 516 | const withFileChange = reducer(state, { | |
| 517 | type: "APPLY_EVENT", | |
| 518 | event: makeEvent({ | |
| 519 | method: "item/completed", | |
| 520 | turnId: "turn-2", | |
| 521 | createdAt: "2026-02-09T00:00:02.000Z", | |
| 522 | payload: { | |
| 523 | item: { | |
| 524 | type: "fileChange", | |
| 525 | changes: [{ path: "src/new.ts", kind: "added" }], | |
| 526 | }, | |
| 527 | }, | |
| 528 | }), | |
| 529 | activeAssistantItemRef: { current: null }, | |
| 530 | }); | |
| 531 | ||
| 532 | expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | |
| 533 | "turn-1", | |
| 534 | ]); | |
| 535 | ||
| 536 | const completed = reducer(withFileChange, { | |
| 537 | type: "APPLY_EVENT", | |
| 538 | event: makeEvent({ | |
| 539 | method: "turn/completed", | |
| 540 | turnId: "turn-2", | |
| 541 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 542 | payload: { turn: { id: "turn-2", status: "completed" } }, | |
| 543 | }), | |
| 544 | activeAssistantItemRef: { current: null }, | |
| 545 | }); | |
| 546 | ||
| 547 | expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | |
| 548 | "turn-2", | |
| 549 | "turn-1", | |
| 550 | ]); | |
| 551 | }); | |
| 552 | ||
| 553 | it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { | |
| 554 | const state = makeState(makeThread({ turnDiffSummaries: [] })); | |
| 555 | const next = reducer(state, { | |
| 556 | type: "APPLY_EVENT", | |
| 557 | event: makeEvent({ | |
| 558 | method: "turn/completed", | |
| 559 | turnId: "turn-1", | |
| 560 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 561 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 562 | }), | |
| 563 | activeAssistantItemRef: { current: null }, | |
| 564 | }); | |
| 565 | ||
| 566 | expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); | |
| 567 | expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); | |
| 568 | }); | |
| 569 | ||
| 570 | it("reconciles project ids by cwd when syncing backend projects", () => { | |
| 571 | const state: AppState = { | |
| 572 | projects: [ | |
| 573 | { | |
| 574 | id: "project-old-a", | |
| 575 | name: "A", | |
| 576 | cwd: "/tmp/a", | |
| 577 | model: "gpt-5.3-codex", | |
| 578 | expanded: false, | |
| 579 | scripts: [], | |
| 580 | }, | |
| 581 | { | |
| 582 | id: "project-old-b", | |
| 583 | name: "B", | |
| 584 | cwd: "/tmp/b", | |
| 585 | model: "gpt-5.3-codex", | |
| 586 | expanded: true, | |
| 587 | scripts: [], | |
| 588 | }, | |
| 589 | ], | |
| 590 | threads: [ | |
| 591 | makeThread({ | |
| 592 | id: "thread-a", | |
| 593 | projectId: "project-old-a", | |
| 594 | }), | |
| 595 | makeThread({ | |
| 596 | id: "thread-b", | |
| 597 | projectId: "project-old-b", | |
| 598 | }), | |
| 599 | ], | |
| 600 | threadsHydrated: true, | |
| 601 | runtimeMode: "full-access", | |
| 602 | }; | |
| 603 | ||
| 604 | const next = reducer(state, { | |
| 605 | type: "SYNC_PROJECTS", | |
| 606 | projects: [ | |
| 607 | { | |
| 608 | id: "project-new-a", | |
| 609 | name: "A", | |
| 610 | cwd: "/tmp/a", | |
| 611 | model: "gpt-5.3-codex", | |
| 612 | expanded: true, | |
| 613 | scripts: [], | |
| 614 | }, | |
| 615 | ], | |
| 616 | }); | |
| 617 | ||
| 618 | expect(next.projects).toHaveLength(1); | |
| 619 | expect(next.projects[0]?.id).toBe("project-new-a"); | |
| 620 | // Preserve existing project UI preferences by cwd | |
| 621 | expect(next.projects[0]?.expanded).toBe(false); | |
| 622 | expect(next.threads).toHaveLength(1); | |
| 623 | expect(next.threads[0]?.id).toBe("thread-a"); | |
| 624 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | |
| 625 | }); | |
| 626 | ||
| 627 | it("treats empty scripts from sync as authoritative", () => { | |
| 628 | const state: AppState = { | |
| 629 | projects: [ | |
| 630 | { | |
| 631 | id: "project-old-a", | |
| 632 | name: "A", | |
| 633 | cwd: "/tmp/a", | |
| 634 | model: "gpt-5.3-codex", | |
| 635 | expanded: true, | |
| 636 | scripts: [ | |
| 637 | { | |
| 638 | id: "test", | |
| 639 | name: "Test", | |
| 640 | command: "bun test", | |
| 641 | icon: "test", | |
| 642 | runOnWorktreeCreate: false, | |
| 643 | }, | |
| 644 | ], | |
| 645 | }, | |
| 646 | ], | |
| 647 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], | |
| 648 | threadsHydrated: true, | |
| 649 | runtimeMode: "full-access", | |
| 650 | }; | |
| 651 | ||
| 652 | const next = reducer(state, { | |
| 653 | type: "SYNC_PROJECTS", | |
| 654 | projects: [ | |
| 655 | { | |
| 656 | id: "project-new-a", | |
| 657 | name: "A", | |
| 658 | cwd: "/tmp/a", | |
| 659 | model: "gpt-5.3-codex", | |
| 660 | expanded: false, | |
| 661 | scripts: [], | |
| 662 | }, | |
| 663 | ], | |
| 664 | }); | |
| 665 | ||
| 666 | expect(next.projects[0]?.scripts).toEqual([]); | |
| 667 | }); | |
| 668 | ||
| 669 | it("updates project scripts", () => { | |
| 670 | const state = makeState(makeThread()); | |
| 671 | const next = reducer(state, { | |
| 672 | type: "SET_PROJECT_SCRIPTS", | |
| 673 | projectId: "project-1", | |
| 674 | scripts: [ | |
| 675 | { | |
| 676 | id: "test", | |
| 677 | name: "Test", | |
| 678 | command: "bun test", | |
| 679 | icon: "test", | |
| 680 | runOnWorktreeCreate: false, | |
| 681 | }, | |
| 682 | ], | |
| 683 | }); | |
| 684 | ||
| 685 | expect(next.projects[0]?.scripts).toEqual([ | |
| 686 | { | |
| 687 | id: "test", | |
| 688 | name: "Test", | |
| 689 | command: "bun test", | |
| 690 | icon: "test", | |
| 691 | runOnWorktreeCreate: false, | |
| 692 | }, | |
| 693 | ]); | |
| 694 | }); | |
| 695 | ||
| 696 | it("marks threads hydration state", () => { | |
| 697 | const state = makeState(makeThread()); | |
| 698 | const next = reducer(state, { | |
| 699 | type: "SET_THREADS_HYDRATED", | |
| 700 | hydrated: false, | |
| 701 | }); | |
| 702 | ||
| 703 | expect(next.threadsHydrated).toBe(false); | |
| 704 | }); | |
| 705 | ||
| 706 | it("deletes a project and all of its threads", () => { | |
| 707 | const state: AppState = { | |
| 708 | projects: [ | |
| 709 | { | |
| 710 | id: "project-1", | |
| 711 | name: "One", | |
| 712 | cwd: "/tmp/one", | |
| 713 | model: "gpt-5.3-codex", | |
| 714 | expanded: true, | |
| 715 | scripts: [], | |
| 716 | }, | |
| 717 | { | |
| 718 | id: "project-2", | |
| 719 | name: "Two", | |
| 720 | cwd: "/tmp/two", | |
| 721 | model: "gpt-5.3-codex", | |
| 722 | expanded: true, | |
| 723 | scripts: [], | |
| 724 | }, | |
| 725 | ], | |
| 726 | threads: [ | |
| 727 | makeThread({ | |
| 728 | id: "thread-a", | |
| 729 | projectId: "project-1", | |
| 730 | }), | |
| 731 | makeThread({ | |
| 732 | id: "thread-b", | |
| 733 | projectId: "project-2", | |
| 734 | }), | |
| 735 | ], | |
| 736 | threadsHydrated: true, | |
| 737 | runtimeMode: "full-access", | |
| 738 | }; | |
| 739 | ||
| 740 | const next = reducer(state, { | |
| 741 | type: "DELETE_PROJECT", | |
| 742 | projectId: "project-1", | |
| 743 | }); | |
| 744 | ||
| 745 | expect(next.projects).toHaveLength(1); | |
| 746 | expect(next.projects[0]?.id).toBe("project-2"); | |
| 747 | expect(next.threads).toHaveLength(1); | |
| 748 | expect(next.threads[0]?.id).toBe("thread-b"); | |
| 749 | }); | |
| 750 | ||
| 751 | it("marks completion as seen immediately for the active thread", () => { | |
| 752 | const state = makeState( | |
| 753 | makeThread({ | |
| 754 | session: makeSession({ | |
| 755 | status: "running", | |
| 756 | activeTurnId: "turn-1", | |
| 757 | }), | |
| 758 | lastVisitedAt: "2026-02-08T10:00:00.000Z", | |
| 759 | }), | |
| 760 | ); | |
| 761 | ||
| 762 | const completedAt = "2026-02-08T10:00:10.000Z"; | |
| 763 | const next = reducer(state, { | |
| 764 | type: "APPLY_EVENT", | |
| 765 | event: makeEvent({ | |
| 766 | method: "turn/completed", | |
| 767 | turnId: "turn-1", | |
| 768 | createdAt: completedAt, | |
| 769 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 770 | }), | |
| 771 | activeAssistantItemRef: { current: null }, | |
| 772 | activeThreadId: "thread-local-1", | |
| 773 | }); | |
| 774 | ||
| 775 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | |
| 776 | expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); | |
| 777 | }); | |
| 778 | ||
| 779 | it("marks a thread as visited when selected", () => { | |
| 780 | const state = makeState( | |
| 781 | makeThread({ | |
| 782 | latestTurnCompletedAt: "2026-02-08T10:00:10.000Z", | |
| 783 | lastVisitedAt: "2026-02-08T10:00:00.000Z", | |
| 784 | }), | |
| 785 | ); | |
| 786 | ||
| 787 | const nextVisitedAt = "2026-02-08T10:00:20.000Z"; | |
| 788 | const next = reducer(state, { | |
| 789 | type: "MARK_THREAD_VISITED", | |
| 790 | threadId: "thread-local-1", | |
| 791 | visitedAt: nextVisitedAt, | |
| 792 | }); | |
| 793 | ||
| 794 | expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt); | |
| 795 | }); | |
| 796 | ||
| 797 | it("reverts thread state to a checkpoint snapshot", () => { | |
| 798 | const state = makeState( | |
| 799 | makeThread({ | |
| 800 | codexThreadId: "thr_before", | |
| 801 | session: makeSession({ | |
| 802 | status: "running", | |
| 803 | threadId: "thr_before", | |
| 804 | activeTurnId: "turn-live", | |
| 805 | }), | |
| 806 | messages: [ | |
| 807 | { | |
| 808 | id: "m-1", | |
| 809 | role: "user", | |
| 810 | text: "First", | |
| 811 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 812 | streaming: false, | |
| 813 | }, | |
| 814 | { | |
| 815 | id: "m-2", | |
| 816 | role: "assistant", | |
| 817 | text: "First reply", | |
| 818 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 819 | streaming: false, | |
| 820 | }, | |
| 821 | { | |
| 822 | id: "m-3", | |
| 823 | role: "user", | |
| 824 | text: "Second", | |
| 825 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 826 | streaming: false, | |
| 827 | }, | |
| 828 | ], | |
| 829 | events: [ | |
| 830 | makeEvent({ | |
| 831 | method: "turn/started", | |
| 832 | turnId: "turn-live", | |
| 833 | }), | |
| 834 | ], | |
| 835 | turnDiffSummaries: [ | |
| 836 | { | |
| 837 | turnId: "turn_1", | |
| 838 | completedAt: "2026-02-08T10:00:01.000Z", | |
| 839 | files: [{ path: "src/first.ts", kind: "modified" }], | |
| 840 | checkpointTurnCount: 1, | |
| 841 | }, | |
| 842 | { | |
| 843 | turnId: "turn_2", | |
| 844 | completedAt: "2026-02-08T10:00:03.000Z", | |
| 845 | files: [{ path: "src/second.ts", kind: "modified" }], | |
| 846 | checkpointTurnCount: 2, | |
| 847 | }, | |
| 848 | ], | |
| 849 | error: "temporary failure", | |
| 850 | latestTurnId: "turn-live", | |
| 851 | latestTurnStartedAt: "2026-02-08T10:00:03.000Z", | |
| 852 | }), | |
| 853 | ); | |
| 854 | ||
| 855 | const next = reducer(state, { | |
| 856 | type: "REVERT_TO_CHECKPOINT", | |
| 857 | threadId: "thread-local-1", | |
| 858 | sessionId: "sess-1", | |
| 859 | threadRuntimeId: "thr_after", | |
| 860 | turnCount: 1, | |
| 861 | messageCount: 2, | |
| 862 | }); | |
| 863 | ||
| 864 | expect(next.threads[0]?.codexThreadId).toBe("thr_after"); | |
| 865 | expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]); | |
| 866 | expect(next.threads[0]?.events).toEqual([]); | |
| 867 | expect(next.threads[0]?.error).toBeNull(); | |
| 868 | expect(next.threads[0]?.session?.status).toBe("ready"); | |
| 869 | expect(next.threads[0]?.session?.activeTurnId).toBeUndefined(); | |
| 870 | expect(next.threads[0]?.session?.threadId).toBe("thr_after"); | |
| 871 | expect(next.threads[0]?.latestTurnId).toBeUndefined(); | |
| 872 | expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); | |
| 873 | }); | |
| 874 | ||
| 875 | it("keeps existing turn file metadata when later events for the same turn arrive", () => { | |
| 876 | const state = makeState( | |
| 877 | makeThread({ | |
| 878 | turnDiffSummaries: [ | |
| 879 | { | |
| 880 | turnId: "turn-1", | |
| 881 | completedAt: "2026-02-09T00:00:03.000Z", | |
| 882 | files: [ | |
| 883 | { | |
| 884 | path: "src/from-checkpoint.ts", | |
| 885 | additions: 1, | |
| 886 | deletions: 1, | |
| 887 | }, | |
| 888 | ], | |
| 889 | }, | |
| 890 | ], | |
| 891 | }), | |
| 892 | ); | |
| 893 | ||
| 894 | const next = reducer(state, { | |
| 895 | type: "APPLY_EVENT", | |
| 896 | event: makeEvent({ | |
| 897 | method: "turn/completed", | |
| 898 | turnId: "turn-1", | |
| 899 | createdAt: "2026-02-09T00:00:04.000Z", | |
| 900 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 901 | }), | |
| 902 | activeAssistantItemRef: { current: null }, | |
| 903 | }); | |
| 904 | ||
| 905 | expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ | |
| 906 | "src/from-checkpoint.ts", | |
| 907 | ]); | |
| 908 | expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1); | |
| 909 | expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1); | |
| 910 | }); | |
| 911 | ||
| 912 | it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { | |
| 913 | const state = makeState( | |
| 914 | makeThread({ | |
| 915 | turnDiffSummaries: [ | |
| 916 | { | |
| 917 | turnId: "turn-1", | |
| 918 | completedAt: "2026-02-09T00:00:03.000Z", | |
| 919 | files: [], | |
| 920 | checkpointTurnCount: 1, | |
| 921 | }, | |
| 922 | { | |
| 923 | turnId: "turn-2", | |
| 924 | completedAt: "2026-02-09T00:00:04.000Z", | |
| 925 | files: [], | |
| 926 | checkpointTurnCount: 2, | |
| 927 | }, | |
| 928 | ], | |
| 929 | }), | |
| 930 | ); | |
| 931 | ||
| 932 | const next = reducer(state, { | |
| 933 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 934 | threadId: "thread-local-1", | |
| 935 | checkpointTurnCountByTurnId: { | |
| 936 | "turn-1": 2, | |
| 937 | "turn-2": 3, | |
| 938 | }, | |
| 939 | }); | |
| 940 | ||
| 941 | expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2); | |
| 942 | expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3); | |
| 943 | }); | |
| 944 | ||
| 945 | it("does not rederive completed turn summaries from late turn diff events", () => { | |
| 946 | const completed = reducer(makeState(makeThread()), { | |
| 947 | type: "APPLY_EVENT", | |
| 948 | event: makeEvent({ | |
| 949 | method: "turn/completed", | |
| 950 | turnId: "turn-1", | |
| 951 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 952 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 953 | }), | |
| 954 | activeAssistantItemRef: { current: null }, | |
| 955 | }); | |
| 956 | ||
| 957 | expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); | |
| 958 | ||
| 959 | const withLateDiff = reducer(completed, { | |
| 960 | type: "APPLY_EVENT", | |
| 961 | event: makeEvent({ | |
| 962 | method: "turn/diff/updated", | |
| 963 | turnId: "turn-1", | |
| 964 | createdAt: "2026-02-09T00:00:04.000Z", | |
| 965 | payload: { | |
| 966 | diff: [ | |
| 967 | "diff --git a/src/a.ts b/src/a.ts", | |
| 968 | "@@ -1 +1 @@", | |
| 969 | "-old", | |
| 970 | "+new", | |
| 971 | "diff --git a/src/b.ts b/src/b.ts", | |
| 972 | "@@ -1 +1 @@", | |
| 973 | "-old", | |
| 974 | "+new", | |
| 975 | ].join("\n"), | |
| 976 | }, | |
| 977 | }), | |
| 978 | activeAssistantItemRef: { current: null }, | |
| 979 | }); | |
| 980 | ||
| 981 | expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); | |
| 159 | expect(next.threads[0]?.runningTerminalIds).toEqual([]); | |
| 982 | 160 | }); |
| 983 | 161 | }); |
apps/web/src/store.ts+244-506| ... | ... | @@ -8,23 +8,20 @@ import { |
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; | |
| 12 | import { resolveModelSlug } from "./model-logic"; | |
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | |
| 14 | 11 | import { |
| 15 | applyEventToMessages, | |
| 16 | asObject, | |
| 17 | asString, | |
| 18 | deriveTurnDiffSummaries, | |
| 19 | inferCheckpointTurnCountByTurnId, | |
| 20 | evolveSession, | |
| 21 | } from "./session-logic"; | |
| 12 | DEFAULT_MODEL, | |
| 13 | ProviderSessionId, | |
| 14 | ThreadId, | |
| 15 | type OrchestrationReadModel, | |
| 16 | type OrchestrationSessionStatus, | |
| 17 | resolveModelSlug, | |
| 18 | } from "@t3tools/contracts"; | |
| 22 | 19 | import { |
| 23 | type ChatAttachment, | |
| 20 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 24 | 21 | DEFAULT_THREAD_TERMINAL_ID, |
| 25 | 22 | DEFAULT_RUNTIME_MODE, |
| 26 | 23 | MAX_THREAD_TERMINAL_COUNT, |
| 27 | type ProjectScript, | |
| 24 | type ChatMessage, | |
| 28 | 25 | type Project, |
| 29 | 26 | type RuntimeMode, |
| 30 | 27 | type Thread, |
| ... | ... | @@ -34,60 +31,32 @@ import { |
| 34 | 31 | // ── Actions ────────────────────────────────────────────────────────── |
| 35 | 32 | |
| 36 | 33 | type Action = |
| 37 | | { type: "ADD_PROJECT"; project: Project } | |
| 38 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | |
| 39 | | { type: "SYNC_PROJECTS"; projects: Project[] } | |
| 40 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | |
| 41 | | { type: "TOGGLE_PROJECT"; projectId: string } | |
| 42 | | { type: "DELETE_PROJECT"; projectId: string } | |
| 43 | | { type: "ADD_THREAD"; thread: Thread } | |
| 44 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | |
| 45 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | |
| 46 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | |
| 47 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 48 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 49 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | |
| 50 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | |
| 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | |
| 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } | |
| 36 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | |
| 37 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } | |
| 51 | 38 | | { |
| 52 | type: "APPLY_EVENT"; | |
| 53 | event: ProviderEvent; | |
| 54 | activeAssistantItemRef: { current: string | null }; | |
| 55 | activeThreadId?: string | null; | |
| 56 | } | |
| 57 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | |
| 58 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | |
| 59 | | { | |
| 60 | type: "PUSH_USER_MESSAGE"; | |
| 61 | threadId: string; | |
| 62 | id: string; | |
| 63 | text: string; | |
| 64 | attachments?: ChatAttachment[]; | |
| 65 | } | |
| 66 | | { type: "SET_ERROR"; threadId: string; error: string | null } | |
| 67 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | |
| 68 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | |
| 69 | | { | |
| 70 | type: "REVERT_TO_CHECKPOINT"; | |
| 71 | threadId: string; | |
| 72 | sessionId: string; | |
| 73 | threadRuntimeId: string; | |
| 74 | turnCount: number; | |
| 75 | messageCount: number; | |
| 76 | } | |
| 77 | | { | |
| 78 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS"; | |
| 79 | threadId: string; | |
| 80 | checkpointTurnCountByTurnId: Record<string, number>; | |
| 39 | type: "SET_THREAD_TERMINAL_ACTIVITY"; | |
| 40 | threadId: ThreadId; | |
| 41 | terminalId: string; | |
| 42 | hasRunningSubprocess: boolean; | |
| 81 | 43 | } |
| 44 | | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean } | |
| 45 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | |
| 46 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | |
| 47 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } | |
| 48 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 49 | | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 50 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 51 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } | |
| 52 | | { type: "SET_ERROR"; threadId: ThreadId; error: string | null } | |
| 82 | 53 | | { |
| 83 | 54 | type: "SET_THREAD_BRANCH"; |
| 84 | threadId: string; | |
| 55 | threadId: ThreadId; | |
| 85 | 56 | branch: string | null; |
| 86 | 57 | worktreePath: string | null; |
| 87 | 58 | } |
| 88 | | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | |
| 89 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | |
| 90 | | { type: "DELETE_THREAD"; threadId: string }; | |
| 59 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | |
| 91 | 60 | |
| 92 | 61 | // ── State ──────────────────────────────────────────────────────────── |
| 93 | 62 | |
| ... | ... | @@ -116,6 +85,7 @@ const initialState: AppState = { |
| 116 | 85 | threadsHydrated: false, |
| 117 | 86 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 118 | 87 | }; |
| 88 | const persistedExpandedProjectCwds = new Set<string>(); | |
| 119 | 89 | |
| 120 | 90 | // ── Helpers ────────────────────────────────────────────────────────── |
| 121 | 91 | |
| ... | ... | @@ -123,21 +93,24 @@ function readPersistedState(): AppState { |
| 123 | 93 | if (typeof window === "undefined") return initialState; |
| 124 | 94 | |
| 125 | 95 | try { |
| 126 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); | |
| 127 | const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key)); | |
| 128 | const rawLegacy = legacyValues.find((value) => value !== null) ?? null; | |
| 129 | const raw = rawCurrent ?? rawLegacy; | |
| 96 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); | |
| 130 | 97 | if (!raw) return initialState; |
| 131 | const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); | |
| 132 | const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); | |
| 133 | if (!hydrated) return initialState; | |
| 134 | ||
| 135 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); | |
| 136 | ||
| 98 | const parsed = JSON.parse(raw) as { | |
| 99 | runtimeMode?: RuntimeMode; | |
| 100 | expandedProjectCwds?: string[]; | |
| 101 | }; | |
| 102 | persistedExpandedProjectCwds.clear(); | |
| 103 | for (const cwd of parsed.expandedProjectCwds ?? []) { | |
| 104 | if (typeof cwd === "string" && cwd.length > 0) { | |
| 105 | persistedExpandedProjectCwds.add(cwd); | |
| 106 | } | |
| 107 | } | |
| 137 | 108 | return { |
| 138 | ...hydrated, | |
| 139 | threads, | |
| 140 | threadsHydrated: threads.length > 0, | |
| 109 | ...initialState, | |
| 110 | runtimeMode: | |
| 111 | parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" | |
| 112 | ? parsed.runtimeMode | |
| 113 | : DEFAULT_RUNTIME_MODE, | |
| 141 | 114 | }; |
| 142 | 115 | } catch { |
| 143 | 116 | return initialState; |
| ... | ... | @@ -148,7 +121,15 @@ function persistState(state: AppState): void { |
| 148 | 121 | if (typeof window === "undefined") return; |
| 149 | 122 | |
| 150 | 123 | try { |
| 151 | window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); | |
| 124 | window.localStorage.setItem( | |
| 125 | PERSISTED_STATE_KEY, | |
| 126 | JSON.stringify({ | |
| 127 | runtimeMode: state.runtimeMode, | |
| 128 | expandedProjectCwds: state.projects | |
| 129 | .filter((project) => project.expanded) | |
| 130 | .map((project) => project.cwd), | |
| 131 | }), | |
| 132 | ); | |
| 152 | 133 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 153 | 134 | window.localStorage.removeItem(legacyKey); |
| 154 | 135 | } |
| ... | ... | @@ -159,68 +140,56 @@ function persistState(state: AppState): void { |
| 159 | 140 | |
| 160 | 141 | function updateThread( |
| 161 | 142 | threads: Thread[], |
| 162 | threadId: string, | |
| 143 | threadId: ThreadId, | |
| 163 | 144 | updater: (t: Thread) => Thread, |
| 164 | 145 | ): Thread[] { |
| 165 | 146 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 166 | 147 | } |
| 167 | 148 | |
| 168 | function mergeTurnDiffSummaries( | |
| 169 | existing: Thread["turnDiffSummaries"], | |
| 170 | next: Thread["turnDiffSummaries"], | |
| 171 | ): Thread["turnDiffSummaries"] { | |
| 172 | if (next.length === 0) return existing; | |
| 173 | ||
| 174 | const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); | |
| 175 | const merged = next.map((summary) => { | |
| 176 | const previous = existingByTurnId.get(summary.turnId); | |
| 177 | if (!previous) { | |
| 178 | return summary; | |
| 179 | } | |
| 180 | ||
| 181 | const files = | |
| 182 | summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files; | |
| 183 | ||
| 149 | function mapProjectsFromReadModel( | |
| 150 | incoming: OrchestrationReadModel["projects"], | |
| 151 | previous: Project[], | |
| 152 | ): Project[] { | |
| 153 | return incoming.map((project) => { | |
| 154 | const existing = | |
| 155 | previous.find((entry) => entry.id === project.id) ?? | |
| 156 | previous.find((entry) => entry.cwd === project.workspaceRoot); | |
| 184 | 157 | return { |
| 185 | ...summary, | |
| 186 | files, | |
| 187 | ...(summary.assistantMessageId | |
| 188 | ? {} | |
| 189 | : previous.assistantMessageId | |
| 190 | ? { assistantMessageId: previous.assistantMessageId } | |
| 191 | : {}), | |
| 192 | ...(typeof summary.checkpointTurnCount === "number" | |
| 193 | ? {} | |
| 194 | : typeof previous.checkpointTurnCount === "number" | |
| 195 | ? { checkpointTurnCount: previous.checkpointTurnCount } | |
| 196 | : {}), | |
| 158 | id: project.id, | |
| 159 | name: project.title, | |
| 160 | cwd: project.workspaceRoot, | |
| 161 | model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), | |
| 162 | expanded: | |
| 163 | existing?.expanded ?? | |
| 164 | (persistedExpandedProjectCwds.size > 0 | |
| 165 | ? persistedExpandedProjectCwds.has(project.workspaceRoot) | |
| 166 | : true), | |
| 167 | scripts: project.scripts.map((script) => ({ ...script })), | |
| 197 | 168 | }; |
| 198 | 169 | }); |
| 170 | } | |
| 199 | 171 | |
| 200 | const mergedTurnIds = new Set(merged.map((summary) => summary.turnId)); | |
| 201 | for (const summary of existing) { | |
| 202 | if (!mergedTurnIds.has(summary.turnId)) { | |
| 203 | merged.push(summary); | |
| 204 | } | |
| 172 | function toLegacySessionStatus( | |
| 173 | status: OrchestrationSessionStatus, | |
| 174 | ): "connecting" | "ready" | "running" | "error" | "closed" { | |
| 175 | switch (status) { | |
| 176 | case "starting": | |
| 177 | return "connecting"; | |
| 178 | case "running": | |
| 179 | return "running"; | |
| 180 | case "error": | |
| 181 | return "error"; | |
| 182 | case "ready": | |
| 183 | case "interrupted": | |
| 184 | return "ready"; | |
| 185 | case "idle": | |
| 186 | case "stopped": | |
| 187 | return "closed"; | |
| 205 | 188 | } |
| 189 | } | |
| 206 | 190 | |
| 207 | const sorted = merged.toSorted((a, b) => { | |
| 208 | const aTime = Date.parse(a.completedAt); | |
| 209 | const bTime = Date.parse(b.completedAt); | |
| 210 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 211 | return b.completedAt.localeCompare(a.completedAt); | |
| 212 | } | |
| 213 | return bTime - aTime; | |
| 214 | }); | |
| 215 | ||
| 216 | const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted); | |
| 217 | return sorted.map((summary) => | |
| 218 | typeof summary.checkpointTurnCount === "number" | |
| 219 | ? summary | |
| 220 | : Object.assign({}, summary, { | |
| 221 | checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId], | |
| 222 | }), | |
| 223 | ); | |
| 191 | function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { | |
| 192 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | |
| 224 | 193 | } |
| 225 | 194 | |
| 226 | 195 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| ... | ... | @@ -243,7 +212,6 @@ function normalizeRunningTerminalIds( |
| 243 | 212 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); |
| 244 | 213 | } |
| 245 | 214 | |
| 246 | ||
| 247 | 215 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 248 | 216 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 249 | 217 | } |
| ... | ... | @@ -409,165 +377,166 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 409 | 377 | }); |
| 410 | 378 | } |
| 411 | 379 | |
| 412 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { | |
| 413 | return threads.find((t) => t.session?.sessionId === sessionId); | |
| 414 | } | |
| 415 | ||
| 416 | function getEventTurnId(event: ProviderEvent): string | undefined { | |
| 417 | if (event.turnId) return event.turnId; | |
| 418 | const payload = asObject(event.payload); | |
| 419 | const turn = asObject(payload?.turn); | |
| 420 | return asString(turn?.id); | |
| 421 | } | |
| 422 | ||
| 423 | function getEventThreadId(event: ProviderEvent): string | undefined { | |
| 424 | if (event.threadId) return event.threadId; | |
| 425 | const payload = asObject(event.payload); | |
| 426 | const payloadThread = asObject(payload?.thread); | |
| 427 | const payloadMessage = asObject(payload?.msg); | |
| 428 | return ( | |
| 429 | asString(payload?.threadId) ?? | |
| 430 | asString(payloadThread?.id) ?? | |
| 431 | asString(payload?.conversationId) ?? | |
| 432 | asString(payload?.thread_id) ?? | |
| 433 | asString(payloadMessage?.thread_id) | |
| 434 | ); | |
| 435 | } | |
| 436 | ||
| 437 | function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { | |
| 438 | const eventThreadId = getEventThreadId(event); | |
| 439 | if (!eventThreadId) { | |
| 440 | return false; | |
| 441 | } | |
| 442 | ||
| 443 | const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; | |
| 444 | if (!expectedThreadId || eventThreadId === expectedThreadId) { | |
| 445 | return false; | |
| 446 | } | |
| 447 | ||
| 448 | // During connect, accept a thread/started notification as an identity rebind. | |
| 449 | if (event.method === "thread/started" && thread.session?.status === "connecting") { | |
| 450 | return false; | |
| 451 | } | |
| 452 | ||
| 453 | return true; | |
| 454 | } | |
| 455 | ||
| 456 | function durationMs(startIso: string, endIso: string): number | undefined { | |
| 457 | const start = Date.parse(startIso); | |
| 458 | const end = Date.parse(endIso); | |
| 459 | if (Number.isNaN(start) || Number.isNaN(end) || end < start) { | |
| 460 | return undefined; | |
| 380 | function setThreadTerminalActivity( | |
| 381 | thread: Thread, | |
| 382 | terminalId: string, | |
| 383 | hasRunningSubprocess: boolean, | |
| 384 | ): Thread { | |
| 385 | const normalizedThread = normalizeThreadTerminals(thread); | |
| 386 | if (!normalizedThread.terminalIds.includes(terminalId)) { | |
| 387 | return normalizedThread; | |
| 461 | 388 | } |
| 462 | ||
| 463 | return end - start; | |
| 464 | } | |
| 465 | ||
| 466 | function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { | |
| 467 | if (event.method === "turn/started") { | |
| 468 | return { | |
| 469 | latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, | |
| 470 | latestTurnStartedAt: event.createdAt, | |
| 471 | latestTurnCompletedAt: undefined, | |
| 472 | latestTurnDurationMs: undefined, | |
| 473 | }; | |
| 389 | const runningTerminalIds = new Set(normalizedThread.runningTerminalIds); | |
| 390 | if (hasRunningSubprocess) { | |
| 391 | runningTerminalIds.add(terminalId); | |
| 392 | } else { | |
| 393 | runningTerminalIds.delete(terminalId); | |
| 474 | 394 | } |
| 475 | ||
| 476 | if (event.method === "turn/completed") { | |
| 477 | const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; | |
| 478 | const startedAt = | |
| 479 | completedTurnId && completedTurnId === thread.latestTurnId | |
| 480 | ? thread.latestTurnStartedAt | |
| 481 | : undefined; | |
| 482 | const elapsed = | |
| 483 | startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; | |
| 484 | ||
| 485 | return { | |
| 486 | latestTurnId: completedTurnId ?? thread.latestTurnId, | |
| 487 | latestTurnCompletedAt: event.createdAt, | |
| 488 | latestTurnDurationMs: elapsed, | |
| 489 | }; | |
| 490 | } | |
| 491 | ||
| 492 | return {}; | |
| 395 | return normalizeThreadTerminals({ | |
| 396 | ...normalizedThread, | |
| 397 | runningTerminalIds: [...runningTerminalIds], | |
| 398 | }); | |
| 493 | 399 | } |
| 494 | 400 | |
| 495 | 401 | // ── Reducer ────────────────────────────────────────────────────────── |
| 496 | 402 | |
| 497 | 403 | export function reducer(state: AppState, action: Action): AppState { |
| 498 | 404 | switch (action.type) { |
| 499 | case "ADD_PROJECT": | |
| 500 | if (state.projects.some((project) => project.cwd === action.project.cwd)) { | |
| 501 | return state; | |
| 502 | } | |
| 503 | return { | |
| 504 | ...state, | |
| 505 | projects: [ | |
| 506 | ...state.projects, | |
| 507 | { | |
| 508 | ...action.project, | |
| 509 | model: resolveModelSlug(action.project.model), | |
| 510 | scripts: normalizeProjectScripts(action.project.scripts), | |
| 511 | }, | |
| 512 | ], | |
| 513 | }; | |
| 405 | case "SYNC_SERVER_READ_MODEL": { | |
| 406 | const projects = mapProjectsFromReadModel( | |
| 407 | action.readModel.projects.filter((project) => project.deletedAt === null), | |
| 408 | state.projects, | |
| 409 | ); | |
| 410 | const existingThreadById = new Map( | |
| 411 | state.threads.map((thread) => [thread.id, thread] as const), | |
| 412 | ); | |
| 413 | const threads = action.readModel.threads | |
| 414 | .filter((thread) => thread.deletedAt === null) | |
| 415 | .map((thread) => { | |
| 416 | const existing = existingThreadById.get(thread.id); | |
| 514 | 417 | |
| 515 | case "SET_PROJECT_SCRIPTS": | |
| 418 | return normalizeThreadTerminals({ | |
| 419 | id: thread.id, | |
| 420 | codexThreadId: thread.session?.providerThreadId ?? null, | |
| 421 | projectId: thread.projectId, | |
| 422 | title: thread.title, | |
| 423 | model: resolveModelSlug(thread.model), | |
| 424 | terminalOpen: existing?.terminalOpen ?? false, | |
| 425 | terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 426 | terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], | |
| 427 | runningTerminalIds: existing?.runningTerminalIds ?? [], | |
| 428 | activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, | |
| 429 | terminalGroups: existing?.terminalGroups ?? [ | |
| 430 | { | |
| 431 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 432 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 433 | }, | |
| 434 | ], | |
| 435 | activeTerminalGroupId: | |
| 436 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 437 | session: thread.session | |
| 438 | ? { | |
| 439 | sessionId: | |
| 440 | thread.session.providerSessionId ?? | |
| 441 | ProviderSessionId.makeUnsafe(`thread:${thread.id}`), | |
| 442 | provider: toLegacyProvider(thread.session.providerName), | |
| 443 | status: toLegacySessionStatus(thread.session.status), | |
| 444 | orchestrationStatus: thread.session.status, | |
| 445 | threadId: thread.session.providerThreadId, | |
| 446 | activeTurnId: thread.session.activeTurnId ?? undefined, | |
| 447 | createdAt: thread.session.updatedAt, | |
| 448 | updatedAt: thread.session.updatedAt, | |
| 449 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), | |
| 450 | } | |
| 451 | : null, | |
| 452 | messages: thread.messages.map((message) => { | |
| 453 | const attachments = message.attachments?.map((attachment, index) => ({ | |
| 454 | type: "image" as const, | |
| 455 | id: `${message.id}:${index}`, | |
| 456 | name: attachment.name, | |
| 457 | mimeType: attachment.mimeType, | |
| 458 | sizeBytes: attachment.sizeBytes, | |
| 459 | previewUrl: attachment.dataUrl, | |
| 460 | })); | |
| 461 | const normalizedMessage: ChatMessage = { | |
| 462 | id: message.id, | |
| 463 | role: message.role, | |
| 464 | text: message.text, | |
| 465 | createdAt: message.createdAt, | |
| 466 | streaming: message.streaming, | |
| 467 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | |
| 468 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | |
| 469 | }; | |
| 470 | return normalizedMessage; | |
| 471 | }), | |
| 472 | error: thread.session?.lastError ?? null, | |
| 473 | createdAt: thread.createdAt, | |
| 474 | latestTurn: thread.latestTurn, | |
| 475 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, | |
| 476 | branch: thread.branch, | |
| 477 | worktreePath: thread.worktreePath, | |
| 478 | turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ | |
| 479 | turnId: checkpoint.turnId, | |
| 480 | completedAt: checkpoint.completedAt, | |
| 481 | status: checkpoint.status, | |
| 482 | assistantMessageId: checkpoint.assistantMessageId ?? undefined, | |
| 483 | checkpointTurnCount: checkpoint.checkpointTurnCount, | |
| 484 | checkpointRef: checkpoint.checkpointRef, | |
| 485 | files: checkpoint.files.map((file) => ({ ...file })), | |
| 486 | })), | |
| 487 | activities: thread.activities.map((activity) => ({ ...activity })), | |
| 488 | }); | |
| 489 | }); | |
| 516 | 490 | return { |
| 517 | 491 | ...state, |
| 518 | projects: state.projects.map((project) => | |
| 519 | project.id === action.projectId | |
| 520 | ? { ...project, scripts: normalizeProjectScripts(action.scripts) } | |
| 521 | : project, | |
| 522 | ), | |
| 492 | projects, | |
| 493 | threads, | |
| 494 | threadsHydrated: true, | |
| 523 | 495 | }; |
| 496 | } | |
| 524 | 497 | |
| 525 | case "SET_THREADS_HYDRATED": | |
| 526 | if (state.threadsHydrated === action.hydrated) { | |
| 527 | return state; | |
| 528 | } | |
| 498 | case "MARK_THREAD_VISITED": { | |
| 499 | const visitedAt = action.visitedAt ?? new Date().toISOString(); | |
| 500 | const visitedAtMs = Date.parse(visitedAt); | |
| 529 | 501 | return { |
| 530 | 502 | ...state, |
| 531 | threadsHydrated: action.hydrated, | |
| 532 | }; | |
| 533 | ||
| 534 | case "SYNC_PROJECTS": { | |
| 535 | const previousByCwd = new Map( | |
| 536 | state.projects.map((project) => [project.cwd, project] as const), | |
| 537 | ); | |
| 538 | const nextProjects = action.projects.map((project) => { | |
| 539 | const previous = previousByCwd.get(project.cwd); | |
| 540 | const scripts = normalizeProjectScripts(project.scripts); | |
| 541 | return { | |
| 542 | ...project, | |
| 543 | model: resolveModelSlug(previous?.model ?? project.model), | |
| 544 | expanded: previous?.expanded ?? project.expanded, | |
| 545 | scripts, | |
| 546 | }; | |
| 547 | }); | |
| 548 | const previousProjectById = new Map( | |
| 549 | state.projects.map((project) => [project.id, project] as const), | |
| 550 | ); | |
| 551 | const nextProjectIdByCwd = new Map( | |
| 552 | nextProjects.map((project) => [project.cwd, project.id] as const), | |
| 553 | ); | |
| 554 | const nextThreads = state.threads | |
| 555 | .map((thread) => { | |
| 556 | const previousProject = previousProjectById.get(thread.projectId); | |
| 557 | if (!previousProject) return null; | |
| 558 | const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); | |
| 559 | if (!mappedProjectId) return null; | |
| 560 | return normalizeThreadTerminals({ | |
| 503 | threads: updateThread(state.threads, action.threadId, (thread) => { | |
| 504 | const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; | |
| 505 | if ( | |
| 506 | Number.isFinite(previousVisitedAtMs) && | |
| 507 | Number.isFinite(visitedAtMs) && | |
| 508 | previousVisitedAtMs >= visitedAtMs | |
| 509 | ) { | |
| 510 | return thread; | |
| 511 | } | |
| 512 | return { | |
| 561 | 513 | ...thread, |
| 562 | projectId: mappedProjectId, | |
| 563 | }); | |
| 564 | }) | |
| 565 | .filter((thread): thread is Thread => thread !== null); | |
| 514 | lastVisitedAt: visitedAt, | |
| 515 | }; | |
| 516 | }), | |
| 517 | }; | |
| 518 | } | |
| 566 | 519 | |
| 520 | case "MARK_THREAD_UNREAD": { | |
| 567 | 521 | return { |
| 568 | 522 | ...state, |
| 569 | projects: nextProjects, | |
| 570 | threads: nextThreads, | |
| 523 | threads: updateThread(state.threads, action.threadId, (thread) => { | |
| 524 | if (!thread.latestTurn?.completedAt) { | |
| 525 | return thread; | |
| 526 | } | |
| 527 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); | |
| 528 | if (Number.isNaN(latestTurnCompletedAtMs)) { | |
| 529 | return thread; | |
| 530 | } | |
| 531 | const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); | |
| 532 | if (thread.lastVisitedAt === unreadVisitedAt) { | |
| 533 | return thread; | |
| 534 | } | |
| 535 | return { | |
| 536 | ...thread, | |
| 537 | lastVisitedAt: unreadVisitedAt, | |
| 538 | }; | |
| 539 | }), | |
| 571 | 540 | }; |
| 572 | 541 | } |
| 573 | 542 | |
| ... | ... | @@ -579,33 +548,21 @@ export function reducer(state: AppState, action: Action): AppState { |
| 579 | 548 | ), |
| 580 | 549 | }; |
| 581 | 550 | |
| 582 | case "DELETE_PROJECT": { | |
| 583 | const projects = state.projects.filter((project) => project.id !== action.projectId); | |
| 584 | if (projects.length === state.projects.length) { | |
| 585 | return state; | |
| 586 | } | |
| 587 | ||
| 588 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); | |
| 589 | ||
| 551 | case "SET_THREAD_TERMINAL_ACTIVITY": | |
| 590 | 552 | return { |
| 591 | 553 | ...state, |
| 592 | projects, | |
| 593 | threads, | |
| 554 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 555 | setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess), | |
| 556 | ), | |
| 594 | 557 | }; |
| 595 | } | |
| 596 | 558 | |
| 597 | case "ADD_THREAD": { | |
| 598 | const nextThread = normalizeThreadTerminals({ | |
| 599 | ...action.thread, | |
| 600 | model: resolveModelSlug(action.thread.model), | |
| 601 | lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, | |
| 602 | turnDiffSummaries: action.thread.turnDiffSummaries ?? [], | |
| 603 | }); | |
| 559 | case "SET_PROJECT_EXPANDED": | |
| 604 | 560 | return { |
| 605 | 561 | ...state, |
| 606 | threads: [...state.threads, nextThread], | |
| 562 | projects: state.projects.map((p) => | |
| 563 | p.id === action.projectId ? { ...p, expanded: action.expanded } : p, | |
| 564 | ), | |
| 607 | 565 | }; |
| 608 | } | |
| 609 | 566 | |
| 610 | 567 | case "TOGGLE_THREAD_TERMINAL": |
| 611 | 568 | return { |
| ... | ... | @@ -640,10 +597,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 640 | 597 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 641 | 598 | const normalizedThread = normalizeThreadTerminals(thread); |
| 642 | 599 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); |
| 643 | if ( | |
| 644 | isNewTerminal && | |
| 645 | normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT | |
| 646 | ) { | |
| 600 | if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 647 | 601 | return normalizedThread; |
| 648 | 602 | } |
| 649 | 603 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| ... | ... | @@ -713,10 +667,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 713 | 667 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 714 | 668 | const normalizedThread = normalizeThreadTerminals(thread); |
| 715 | 669 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); |
| 716 | if ( | |
| 717 | isNewTerminal && | |
| 718 | normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT | |
| 719 | ) { | |
| 670 | if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 720 | 671 | return normalizedThread; |
| 721 | 672 | } |
| 722 | 673 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| ... | ... | @@ -774,118 +725,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 774 | 725 | ), |
| 775 | 726 | }; |
| 776 | 727 | |
| 777 | case "APPLY_TERMINAL_EVENT": | |
| 778 | if (!state.threads.some((thread) => thread.id === action.event.threadId)) { | |
| 779 | return state; | |
| 780 | } | |
| 781 | return { | |
| 782 | ...state, | |
| 783 | threads: updateThread(state.threads, action.event.threadId, (thread) => { | |
| 784 | const normalizedThread = normalizeThreadTerminals(thread); | |
| 785 | const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds); | |
| 786 | if (action.event.type === "started" || action.event.type === "restarted") { | |
| 787 | runningTerminalIdSet.delete(action.event.terminalId); | |
| 788 | } else if (action.event.type === "activity") { | |
| 789 | if (action.event.hasRunningSubprocess) { | |
| 790 | runningTerminalIdSet.add(action.event.terminalId); | |
| 791 | } else { | |
| 792 | runningTerminalIdSet.delete(action.event.terminalId); | |
| 793 | } | |
| 794 | } else if (action.event.type === "exited" || action.event.type === "error") { | |
| 795 | runningTerminalIdSet.delete(action.event.terminalId); | |
| 796 | } | |
| 797 | ||
| 798 | return normalizeThreadTerminals({ | |
| 799 | ...normalizedThread, | |
| 800 | runningTerminalIds: [...runningTerminalIdSet], | |
| 801 | }); | |
| 802 | }), | |
| 803 | }; | |
| 804 | ||
| 805 | case "APPLY_EVENT": { | |
| 806 | const { event, activeAssistantItemRef, activeThreadId } = action; | |
| 807 | const target = findThreadBySessionId(state.threads, event.sessionId); | |
| 808 | if (!target) return state; | |
| 809 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | |
| 810 | ||
| 811 | return { | |
| 812 | ...state, | |
| 813 | threads: updateThread(state.threads, target.id, (t) => { | |
| 814 | const nextEvents = [event, ...t.events]; | |
| 815 | const eventTurnId = getEventTurnId(event); | |
| 816 | const hasCompletedSummaryForTurn = Boolean( | |
| 817 | eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId), | |
| 818 | ); | |
| 819 | const itemType = asString(asObject(asObject(event.payload)?.item)?.type); | |
| 820 | const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); | |
| 821 | const isMetadataItemCompleted = | |
| 822 | event.method === "item/completed" && | |
| 823 | (normalizedItemType === "agentmessage" || normalizedItemType === "filechange"); | |
| 824 | const shouldRederiveDiffs = | |
| 825 | event.method === "turn/completed" || | |
| 826 | (hasCompletedSummaryForTurn && isMetadataItemCompleted); | |
| 827 | const turnDiffSummaries = shouldRederiveDiffs | |
| 828 | ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) | |
| 829 | : t.turnDiffSummaries; | |
| 830 | const eventThreadId = getEventThreadId(event); | |
| 831 | const shouldRebindIdentity = | |
| 832 | event.method === "thread/started" && t.session?.status === "connecting"; | |
| 833 | return { | |
| 834 | ...t, | |
| 835 | codexThreadId: shouldRebindIdentity | |
| 836 | ? (eventThreadId ?? t.codexThreadId) | |
| 837 | : (t.codexThreadId ?? eventThreadId ?? null), | |
| 838 | error: event.kind === "error" && event.message ? event.message : t.error, | |
| 839 | session: t.session ? evolveSession(t.session, event) : t.session, | |
| 840 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 841 | events: nextEvents, | |
| 842 | turnDiffSummaries, | |
| 843 | ...updateTurnFields(t, event), | |
| 844 | ...(event.method === "turn/completed" && t.id === activeThreadId | |
| 845 | ? { lastVisitedAt: event.createdAt } | |
| 846 | : {}), | |
| 847 | }; | |
| 848 | }), | |
| 849 | }; | |
| 850 | } | |
| 851 | ||
| 852 | case "UPDATE_SESSION": | |
| 853 | return { | |
| 854 | ...state, | |
| 855 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 856 | ...t, | |
| 857 | session: action.session, | |
| 858 | codexThreadId: action.session.threadId ?? t.codexThreadId, | |
| 859 | events: [], | |
| 860 | error: null, | |
| 861 | latestTurnId: undefined, | |
| 862 | latestTurnStartedAt: undefined, | |
| 863 | latestTurnCompletedAt: undefined, | |
| 864 | latestTurnDurationMs: undefined, | |
| 865 | })), | |
| 866 | }; | |
| 867 | ||
| 868 | case "PUSH_USER_MESSAGE": | |
| 869 | return { | |
| 870 | ...state, | |
| 871 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 872 | ...t, | |
| 873 | messages: [ | |
| 874 | ...t.messages, | |
| 875 | { | |
| 876 | id: action.id, | |
| 877 | role: "user" as const, | |
| 878 | text: action.text, | |
| 879 | ...(action.attachments && action.attachments.length > 0 | |
| 880 | ? { attachments: action.attachments } | |
| 881 | : {}), | |
| 882 | createdAt: new Date().toISOString(), | |
| 883 | streaming: false, | |
| 884 | }, | |
| 885 | ], | |
| 886 | })), | |
| 887 | }; | |
| 888 | ||
| 889 | 728 | case "SET_ERROR": |
| 890 | 729 | return { |
| 891 | 730 | ...state, |
| ... | ... | @@ -895,90 +734,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 895 | 734 | })), |
| 896 | 735 | }; |
| 897 | 736 | |
| 898 | case "SET_THREAD_TITLE": | |
| 899 | return { | |
| 900 | ...state, | |
| 901 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 902 | ...t, | |
| 903 | title: action.title, | |
| 904 | })), | |
| 905 | }; | |
| 906 | ||
| 907 | case "SET_THREAD_MODEL": | |
| 908 | return { | |
| 909 | ...state, | |
| 910 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 911 | ...t, | |
| 912 | model: resolveModelSlug(action.model), | |
| 913 | })), | |
| 914 | }; | |
| 915 | ||
| 916 | case "REVERT_TO_CHECKPOINT": | |
| 917 | return { | |
| 918 | ...state, | |
| 919 | threads: updateThread(state.threads, action.threadId, (t) => { | |
| 920 | const nextMessageCount = Math.max(0, Math.floor(action.messageCount)); | |
| 921 | const nextTurnCount = Math.max(0, Math.floor(action.turnCount)); | |
| 922 | const now = new Date().toISOString(); | |
| 923 | return { | |
| 924 | ...t, | |
| 925 | codexThreadId: action.threadRuntimeId, | |
| 926 | session: | |
| 927 | t.session?.sessionId === action.sessionId | |
| 928 | ? { | |
| 929 | ...t.session, | |
| 930 | status: "ready", | |
| 931 | threadId: action.threadRuntimeId, | |
| 932 | activeTurnId: undefined, | |
| 933 | updatedAt: now, | |
| 934 | lastError: undefined, | |
| 935 | } | |
| 936 | : t.session, | |
| 937 | messages: t.messages.slice(0, nextMessageCount), | |
| 938 | events: [], | |
| 939 | turnDiffSummaries: t.turnDiffSummaries.filter( | |
| 940 | (summary) => | |
| 941 | typeof summary.checkpointTurnCount === "number" && | |
| 942 | summary.checkpointTurnCount <= nextTurnCount, | |
| 943 | ), | |
| 944 | error: null, | |
| 945 | latestTurnId: undefined, | |
| 946 | latestTurnStartedAt: undefined, | |
| 947 | latestTurnCompletedAt: undefined, | |
| 948 | latestTurnDurationMs: undefined, | |
| 949 | lastVisitedAt: now, | |
| 950 | }; | |
| 951 | }), | |
| 952 | }; | |
| 953 | ||
| 954 | case "SET_THREAD_TURN_CHECKPOINT_COUNTS": | |
| 955 | return { | |
| 956 | ...state, | |
| 957 | threads: updateThread(state.threads, action.threadId, (t) => { | |
| 958 | const hasUpdates = t.turnDiffSummaries.some( | |
| 959 | (summary) => | |
| 960 | action.checkpointTurnCountByTurnId[summary.turnId] !== undefined && | |
| 961 | action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount, | |
| 962 | ); | |
| 963 | if (!hasUpdates) { | |
| 964 | return t; | |
| 965 | } | |
| 966 | return { | |
| 967 | ...t, | |
| 968 | turnDiffSummaries: t.turnDiffSummaries.map((summary) => { | |
| 969 | const turnCount = action.checkpointTurnCountByTurnId[summary.turnId]; | |
| 970 | if (turnCount === undefined) { | |
| 971 | return summary; | |
| 972 | } | |
| 973 | return { | |
| 974 | ...summary, | |
| 975 | checkpointTurnCount: turnCount, | |
| 976 | }; | |
| 977 | }), | |
| 978 | }; | |
| 979 | }), | |
| 980 | }; | |
| 981 | ||
| 982 | 737 | case "SET_THREAD_BRANCH": { |
| 983 | 738 | return { |
| 984 | 739 | ...state, |
| ... | ... | @@ -997,29 +752,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 997 | 752 | }; |
| 998 | 753 | } |
| 999 | 754 | |
| 1000 | case "MARK_THREAD_VISITED": { | |
| 1001 | const visitedAt = action.visitedAt ?? new Date().toISOString(); | |
| 1002 | return { | |
| 1003 | ...state, | |
| 1004 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 1005 | ...t, | |
| 1006 | lastVisitedAt: visitedAt, | |
| 1007 | })), | |
| 1008 | }; | |
| 1009 | } | |
| 1010 | ||
| 1011 | 755 | case "SET_RUNTIME_MODE": |
| 1012 | 756 | return { |
| 1013 | 757 | ...state, |
| 1014 | 758 | runtimeMode: action.mode, |
| 1015 | 759 | }; |
| 1016 | 760 | |
| 1017 | case "DELETE_THREAD": | |
| 1018 | return { | |
| 1019 | ...state, | |
| 1020 | threads: state.threads.filter((t) => t.id !== action.threadId), | |
| 1021 | }; | |
| 1022 | ||
| 1023 | 761 | default: |
| 1024 | 762 | return state; |
| 1025 | 763 | } |
apps/web/src/terminalActivity.test.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; | |
| 4 | import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; | |
| 5 | ||
| 6 | const snapshot: TerminalSessionSnapshot = { | |
| 7 | threadId: "thread-1", | |
| 8 | terminalId: "default", | |
| 9 | cwd: "/tmp", | |
| 10 | status: "running", | |
| 11 | pid: 1234, | |
| 12 | history: "", | |
| 13 | exitCode: null, | |
| 14 | exitSignal: null, | |
| 15 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 16 | }; | |
| 17 | ||
| 18 | function eventBase() { | |
| 19 | return { | |
| 20 | threadId: "thread-1", | |
| 21 | terminalId: "default", | |
| 22 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | describe("terminalRunningSubprocessFromEvent", () => { | |
| 27 | it("returns the subprocess flag for terminal activity events", () => { | |
| 28 | const active = terminalRunningSubprocessFromEvent({ | |
| 29 | ...eventBase(), | |
| 30 | type: "activity", | |
| 31 | hasRunningSubprocess: true, | |
| 32 | }); | |
| 33 | const idle = terminalRunningSubprocessFromEvent({ | |
| 34 | ...eventBase(), | |
| 35 | type: "activity", | |
| 36 | hasRunningSubprocess: false, | |
| 37 | }); | |
| 38 | ||
| 39 | expect(active).toBe(true); | |
| 40 | expect(idle).toBe(false); | |
| 41 | }); | |
| 42 | ||
| 43 | it("clears running state when a terminal session starts/restarts/exits", () => { | |
| 44 | const events: TerminalEvent[] = [ | |
| 45 | { ...eventBase(), type: "started", snapshot }, | |
| 46 | { ...eventBase(), type: "restarted", snapshot }, | |
| 47 | { ...eventBase(), type: "exited", exitCode: 0, exitSignal: null }, | |
| 48 | ]; | |
| 49 | ||
| 50 | for (const event of events) { | |
| 51 | expect(terminalRunningSubprocessFromEvent(event)).toBe(false); | |
| 52 | } | |
| 53 | }); | |
| 54 | ||
| 55 | it("ignores non-activity terminal events", () => { | |
| 56 | expect( | |
| 57 | terminalRunningSubprocessFromEvent({ | |
| 58 | ...eventBase(), | |
| 59 | type: "output", | |
| 60 | data: "hello", | |
| 61 | }), | |
| 62 | ).toBeNull(); | |
| 63 | expect( | |
| 64 | terminalRunningSubprocessFromEvent({ | |
| 65 | ...eventBase(), | |
| 66 | type: "error", | |
| 67 | message: "oops", | |
| 68 | }), | |
| 69 | ).toBeNull(); | |
| 70 | }); | |
| 71 | }); |
apps/web/src/terminalActivity.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import type { TerminalEvent } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export function terminalRunningSubprocessFromEvent(event: TerminalEvent): boolean | null { | |
| 4 | switch (event.type) { | |
| 5 | case "activity": | |
| 6 | return event.hasRunningSubprocess; | |
| 7 | case "started": | |
| 8 | case "restarted": | |
| 9 | case "exited": | |
| 10 | return false; | |
| 11 | default: | |
| 12 | return null; | |
| 13 | } | |
| 14 | } |
apps/web/src/threadFactory.ts deleted-42| ... | ... | @@ -1,42 +0,0 @@ |
| 1 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 2 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | |
| 3 | ||
| 4 | interface CreateThreadOptions { | |
| 5 | branch?: string | null; | |
| 6 | createdAt?: string; | |
| 7 | model?: string; | |
| 8 | title?: string; | |
| 9 | worktreePath?: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread { | |
| 13 | const createdAt = options.createdAt ?? new Date().toISOString(); | |
| 14 | ||
| 15 | return { | |
| 16 | id: crypto.randomUUID(), | |
| 17 | codexThreadId: null, | |
| 18 | projectId, | |
| 19 | title: options.title ?? "New thread", | |
| 20 | model: options.model ?? DEFAULT_MODEL, | |
| 21 | terminalOpen: false, | |
| 22 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 23 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 24 | runningTerminalIds: [], | |
| 25 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 26 | terminalGroups: [ | |
| 27 | { | |
| 28 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 29 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 30 | }, | |
| 31 | ], | |
| 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 33 | session: null, | |
| 34 | messages: [], | |
| 35 | events: [], | |
| 36 | turnDiffSummaries: [], | |
| 37 | error: null, | |
| 38 | createdAt, | |
| 39 | branch: options.branch ?? null, | |
| 40 | worktreePath: options.worktreePath ?? null, | |
| 41 | }; | |
| 42 | } |
apps/web/src/types.ts+36-16| ... | ... | @@ -1,7 +1,16 @@ |
| 1 | 1 | import type { |
| 2 | OrchestrationLatestTurn, | |
| 3 | OrchestrationSessionStatus, | |
| 4 | OrchestrationThreadActivity, | |
| 2 | 5 | ProjectScript as ContractProjectScript, |
| 3 | ProviderEvent, | |
| 4 | ProviderSession, | |
| 6 | ThreadId, | |
| 7 | ProjectId, | |
| 8 | TurnId, | |
| 9 | MessageId, | |
| 10 | CheckpointRef, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderKind, | |
| 5 | 14 | } from "@t3tools/contracts"; |
| 6 | 15 | |
| 7 | 16 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| ... | ... | @@ -29,11 +38,12 @@ export interface ChatImageAttachment { |
| 29 | 38 | export type ChatAttachment = ChatImageAttachment; |
| 30 | 39 | |
| 31 | 40 | export interface ChatMessage { |
| 32 | id: string; | |
| 33 | role: "user" | "assistant"; | |
| 41 | id: MessageId; | |
| 42 | role: "user" | "assistant" | "system"; | |
| 34 | 43 | text: string; |
| 35 | 44 | attachments?: ChatAttachment[]; |
| 36 | 45 | createdAt: string; |
| 46 | completedAt?: string | undefined; | |
| 37 | 47 | streaming: boolean; |
| 38 | 48 | } |
| 39 | 49 | |
| ... | ... | @@ -45,16 +55,17 @@ export interface TurnDiffFileChange { |
| 45 | 55 | } |
| 46 | 56 | |
| 47 | 57 | export interface TurnDiffSummary { |
| 48 | turnId: string; | |
| 58 | turnId: TurnId; | |
| 49 | 59 | completedAt: string; |
| 50 | 60 | status?: string | undefined; |
| 51 | 61 | files: TurnDiffFileChange[]; |
| 52 | assistantMessageId?: string | undefined; | |
| 62 | checkpointRef?: CheckpointRef | undefined; | |
| 63 | assistantMessageId?: MessageId | undefined; | |
| 53 | 64 | checkpointTurnCount?: number | undefined; |
| 54 | 65 | } |
| 55 | 66 | |
| 56 | 67 | export interface Project { |
| 57 | id: string; | |
| 68 | id: ProjectId; | |
| 58 | 69 | name: string; |
| 59 | 70 | cwd: string; |
| 60 | 71 | model: string; |
| ... | ... | @@ -63,9 +74,9 @@ export interface Project { |
| 63 | 74 | } |
| 64 | 75 | |
| 65 | 76 | export interface Thread { |
| 66 | id: string; | |
| 67 | codexThreadId: string | null; | |
| 68 | projectId: string; | |
| 77 | id: ThreadId; | |
| 78 | codexThreadId: ProviderThreadId | null; | |
| 79 | projectId: ProjectId; | |
| 69 | 80 | title: string; |
| 70 | 81 | model: string; |
| 71 | 82 | terminalOpen: boolean; |
| ... | ... | @@ -75,17 +86,26 @@ export interface Thread { |
| 75 | 86 | activeTerminalId: string; |
| 76 | 87 | terminalGroups: ThreadTerminalGroup[]; |
| 77 | 88 | activeTerminalGroupId: string; |
| 78 | session: ProviderSession | null; | |
| 89 | session: ThreadSession | null; | |
| 79 | 90 | messages: ChatMessage[]; |
| 80 | events: ProviderEvent[]; | |
| 81 | 91 | error: string | null; |
| 82 | 92 | createdAt: string; |
| 83 | latestTurnId?: string | undefined; | |
| 84 | latestTurnStartedAt?: string | undefined; | |
| 85 | latestTurnCompletedAt?: string | undefined; | |
| 86 | latestTurnDurationMs?: number | undefined; | |
| 93 | latestTurn: OrchestrationLatestTurn | null; | |
| 87 | 94 | lastVisitedAt?: string | undefined; |
| 88 | 95 | branch: string | null; |
| 89 | 96 | worktreePath: string | null; |
| 90 | 97 | turnDiffSummaries: TurnDiffSummary[]; |
| 98 | activities: OrchestrationThreadActivity[]; | |
| 99 | } | |
| 100 | ||
| 101 | export interface ThreadSession { | |
| 102 | sessionId: ProviderSessionId; | |
| 103 | provider: ProviderKind; | |
| 104 | status: SessionPhase | "error" | "closed"; | |
| 105 | threadId: ProviderThreadId | null; | |
| 106 | activeTurnId?: TurnId | undefined; | |
| 107 | createdAt: string; | |
| 108 | updatedAt: string; | |
| 109 | lastError?: string; | |
| 110 | orchestrationStatus: OrchestrationSessionStatus; | |
| 91 | 111 | } |
apps/web/src/vite-env.d.ts+1-10| ... | ... | @@ -1,15 +1,6 @@ |
| 1 | 1 | /// <reference types="vite/client" /> |
| 2 | 2 | |
| 3 | import type { NativeApi } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | interface DesktopBridge { | |
| 6 | getWsUrl: () => string | null; | |
| 7 | pickFolder: () => Promise<string | null>; | |
| 8 | confirm: (message: string) => Promise<boolean>; | |
| 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | |
| 10 | openExternal: (url: string) => Promise<boolean>; | |
| 11 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 12 | } | |
| 3 | import type { NativeApi, DesktopBridge } from "@t3tools/contracts"; | |
| 13 | 4 | |
| 14 | 5 | declare global { |
| 15 | 6 | interface Window { |
apps/web/src/vscode-icons-language-associations.json+481-1| ... | ... | @@ -1 +1,481 @@ |
| 1 | {"version":"12.17.0","source":"https://raw.githubusercontent.com/vscode-icons/vscode-icons/v12.17.0/src/iconsManifest/languages.ts","generatedAt":"2026-02-17T20:03:11.362Z","extensionToLanguageId":{"as":"actionscript","ada":"ada","prw":"advpl","affect":"affectscript","agda":"agda","al":"al","alloy":"alloy","ansible":"ansible","g4":"antlr","any":"anyscript","htaccess":"apacheconf","cls":"apex","apib":"apiblueprint","apl":"apl","applescript":"applescript","adoc":"asciidoc","asp":"asp","asm":"arm","astro":"astro","ats":"ats","ahk":"ahk","au3":"autoit","am":"automake","avcs":"avro","azcli":"azcli","azure-pipelines.yml":"azure-pipelines","bal":"ballerina","bat":"bat","bats":"bats","bzl":"bazel","bf":"befunge","bicep":"bicep","bib":"bibtex","biml":"biml","bin":"code-text-binary","rast":"biome_syntax_tree","blade.php":"blade","blitzbasic":"blitzbasic","bolt":"bolt","bsq":"bosque","bru":"bruno","buf.yaml":"buf","lockb":"bun.lockb","c":"c","c3":"c3","cal":"c-al","cabal":"cabal","capnp":"capnp","casc":"casc","cddl":"cddl","ceylon":"ceylon","cfc":"cfc","cfm":"cfmhtml","circom":"circom","clojure":"clojure","clojurescript":"clojurescript","yml":"manifest-yaml","cmake":"cmake","cbl":"cobol","ql":"ql","coffee":"coffeescript","cfml":"cfml","confluence":"confluence","ckbk":"cookbook","instructions.md":"instructions","cpp":"cpp","cr":"crystal","cs":"csharp","css":"css","wxss":"css","acss":"css","mcss":"css","pcss":"css","postcss":"css","nss":"css","ccss":"css","less":"css","scss":"css","sass":"css","csv":"csv","feature":"cucumber","cu":"cuda","cypher":"cypher","pyx":"cython","dal":"dal","dart":"dart","dhall":"dhall","html":"django-html","diff":"diff","d":"d","hcl":"dockerbake","dtx":"doctex","env":"dotenv","dot":"dotjs","dox":"doxygen","drawio":"drawio","drl":"drools","dtd":"dtd","dust":"dustjs","dylan":"dylan","earthfile":"earthfile","edge":"edge","eex":"eex","es":"es","ex":"elixir","elm":"elm","erb":"erb","erl":"erlang","yaml":"esphome","xlsx":"excel","falcon":"falcon","fql":"fql","f":"fortran","ftl":"ftl","fs":"fsharp","fthtml":"fthtml","gspec":"galen","gml":"gml-gms","gcode":"gcode","gen":"genstat","git":"git-commit","gleam":"gleam","gjs":"glimmer-js","glsl":"glsl","glyphs":"glyphs","gp":"gnuplot","go":"go","mod":"go.mod","work":"go.work","api":"goctl","gd":"gdscript","gradle.kts":"gradle-kotlin-dsl","gr":"grain","gql":"graphql","gv":"dot","grit":"grit","groovy":"groovy","haml":"haml","hbs":"handlebars","prg":"harbour","hs":"haskell","haxe":"haxe","helm.tpl":"helm","hjson":"hjson","hlsl":"hlsl","hosts":"hosts","http":"http","aff":"hunspell.aff","hy":"hy","hypr":"hypr","icl":"icl","imba":"imba","4gl":"4GL","ini":"ini","ink":"ink","iss":"innosetup","io":"io","janet":"janet","java":"java","js":"javascript","mjs":"javascript","cjs":"javascript","es6":"javascript","jake":"javascript","jsm":"javascript","jsx":"javascript","njs":"javascript","pac":"javascript","sjs":"javascript","ssjs":"javascript","xsjs":"javascript","xsjslib":"javascript","jekyll":"jekyll","jenkins":"jenkins","jest-snap":"jest-snapshot","jinja":"jinja","json":"json","jsonc":"jsonc","jsonl":"jsonl","jsonnet":"jsonnet","json5":"json5","jl":"julia","id":"iodine","k":"k","kv":"kivy","ks":"kos","kt":"kotlin","kusto":"kusto","tex":"latex","lark":"lark","lat":"latino","lean":"lean4","flex":"lex","ly":"lilypond","lisp":"lisp","lhs":"literate haskell","log":"log","lol":"lolcode","lsl":"lsl","lua":"lua","m4":"m4","mk":"makefile","md":"markdown","markdown":"markdown","mdown":"markdown","mkd":"markdown","marko":"marko","mat":"matlab","ms":"maxscript","mdx":"mdx","mediawiki":"mediawiki","mel":"mel","mmd":"mermaid","metal":"metal","mcfunction":"bc-mcfunction","mjml":"mjml","pq":"powerquery","mojo":"mojo","ep":"mojolicious","mongo":"mongo","mson":"mson","mv":"mv","mvt":"mvt","ne":"nearley","nginx":"nginx","nim":"nim","nimble":"nimble","nix":"nix","nsi":"nsis","nunjucks":"nunjucks","nu":"nushell","m":"objective-c","mm":"objective-cpp","ml":"ocaml","o3":"ogone","w":"abl","things":"openhab","org":"org","pas":"pascal","pddl":"pddl","plan":"plan","happenings":"happenings","pl":"perl","pl6":"perl6","pgsql":"pgsql","php":"php","php4":"php","php5":"php","php7":"php","phtml":"php","phps":"php","phpt":"php","phar":"php","aw":"php","ctp":"php","inc":"php","php3":"php","php8":"php","module":"php","theme":"php","tpl":"php","pine":"pine","txt":"plaintext","dbgasm":"platformio-debug.disassembly","ddl":"plsql","po":"po","dib":"polyglot-notebook","polymer":"polymer","pony":"pony","ps":"postscript","ps1":"powershell","prisma":"prisma","pde":"pde","pro":"prolog","rules":"prometheus","properties":"java-properties","proto":"proto3","pug":"jade","pp":"puppet","purs":"purescript","arr":"pyret","pyscript":"pyscript","py":"python","pyi":"python","pyw":"python","pyc":"python","pyd":"python","pyo":"python","pxd":"python","pxi":"python","rpy":"python","cpy":"python","ipy":"python","pyowo":"pythowo","qvs":"qlik","qml":"qml","qs":"qsharp","r":"r","rkt":"racket","raku":"raku","cshtml":"razor","raml":"raml","re":"reason","red":"red","res":"rescript","rst":"restructuredtext","rex":"rexx","tag":"riot","ripple":"ripple","rmd":"rmd","rnc":"rnc","robot":"robot","rb":"ruby","rbw":"ruby","rake":"ruby","gemspec":"ruby","ru":"ruby","rjs":"ruby","rxml":"ruby","rsel":"ruby","builder":"ruby","jbuilder":"ruby","rabl":"ruby","prawn":"ruby","thor":"ruby","rs":"rust","sl":"s-lang","san":"san","sas":"SAS","sbt":"sbt","scad":"scad","scala":"scala","sce":"scilab","sdl":"sdl","code-search":"search-result","shader":"shaderlab","sh":"shellscript","bash":"shellscript","zsh":"shellscript","ksh":"shellscript","fish":"shellscript","slang":"slang","ice":"slice","slim":"slim","slint":"slint","sln":"sln","ss":"silverstripe","sn":"sino","eskip":"eskip","smk":"snakemake","code-snippets":"snippets","snort":"snort","sol":"solidity","rq":"sparql","sqf":"sqf","sql":"sql","nut":"squirrel","stan":"stan","bazel":"starlark","do":"stata","stencil":"stencil","html.stencil":"stencil-html","st.css":"stylable","styled":"source.css.styled","styl":"stylus","svelte":"svelte","svg":"svg","swagger":"Swagger","swift":"swift","swig":"swig","link":"systemd-conf","sv":"systemverilog","tt":"t4","teal":"teal","templ":"templ","tt3":"tt","tera":"tera","tf":"terraform","sty":"tex","textile":"textile","json-tmlanguage":"json-tmlanguage","yaml-tmlanguage":"yaml-tmlanguage","toit":"toit","toml":"toml","toon":"toon","ttcn3":"ttcn","tuc":"tuc","twig":"twig","ts":"typescript","mts":"typescript","cts":"typescript","tsx":"typescriptreact","typoscript":"typoscript","ua":"uiua","u":"unison","vb":"vb","wsf":"vbscript","vm":"velocity","vto":"vento","v":"verilog","vhdl":"vhdl","vim":"viml","vitest-snap":"vitest-snapshot","volt":"volt","vroom":"vroom","vue":"vue","vue2":"vue","vue3":"vue","vues":"vue","vuex":"vue","vuerc":"vue","vy":"vyper","wai":"wai","wasm":"wasm","wat":"wasm","wast":"wasm","wsm":"wasm","wast.js":"wasm","wit":"wasm","witx":"wasm","wy":"wenyan","wgsl":"wgsl","wt":"wikitext","wl":"wolfram","wurst":"wurstlang","xcompose":"xcompose","xmake.lua":"xmake","xml":"xml","xquery":"xquery","xsl":"xsl","bison":"yacc","yang":"yang","zig":"zig","zip":"zip"},"fileNameToLanguageId":{"browserslist":"browserslist",".browserslistrc":"browserslist","caddyfile":"caddyfile","cmakelists.txt":"cmake","cmakecache.txt":"cmake-cache","docker-compose.yml":"dockercompose","docker-compose.yaml":"dockercompose","compose.yaml":"dockercompose","compose.yml":"dockercompose","dockerfile":"dockerfile","dockerfile.dev":"dockerfile","dockerfile.prod":"dockerfile","dockerfile.test":"dockerfile",".editorconfig":"editorconfig","justfile":"just","meson.build":"meson",".php-version":"php","artisan":"php","requirements.txt":"pip-requirements","requirements-dev.txt":"pip-requirements","requirements-test.txt":"pip-requirements","requirements-prod.txt":"pip-requirements","setup.py":"python","setup.cfg":"python",".python-version":"python","pipfile":"python","pipfile.lock":"python","robots.txt":"robots-txt","config.ru":"ruby",".bashrc":"shellscript",".zshrc":"shellscript",".profile":"shellscript",".bash_profile":"shellscript",".bash_aliases":"shellscript",".zprofile":"shellscript","tiltfile":"tiltfile",".vue-cli-rc":"vue","yarn.lock":"yarnlock"}} | |
| 1 | { | |
| 2 | "version": "12.17.0", | |
| 3 | "source": "https://raw.githubusercontent.com/vscode-icons/vscode-icons/v12.17.0/src/iconsManifest/languages.ts", | |
| 4 | "generatedAt": "2026-02-17T20:03:11.362Z", | |
| 5 | "extensionToLanguageId": { | |
| 6 | "as": "actionscript", | |
| 7 | "ada": "ada", | |
| 8 | "prw": "advpl", | |
| 9 | "affect": "affectscript", | |
| 10 | "agda": "agda", | |
| 11 | "al": "al", | |
| 12 | "alloy": "alloy", | |
| 13 | "ansible": "ansible", | |
| 14 | "g4": "antlr", | |
| 15 | "any": "anyscript", | |
| 16 | "htaccess": "apacheconf", | |
| 17 | "cls": "apex", | |
| 18 | "apib": "apiblueprint", | |
| 19 | "apl": "apl", | |
| 20 | "applescript": "applescript", | |
| 21 | "adoc": "asciidoc", | |
| 22 | "asp": "asp", | |
| 23 | "asm": "arm", | |
| 24 | "astro": "astro", | |
| 25 | "ats": "ats", | |
| 26 | "ahk": "ahk", | |
| 27 | "au3": "autoit", | |
| 28 | "am": "automake", | |
| 29 | "avcs": "avro", | |
| 30 | "azcli": "azcli", | |
| 31 | "azure-pipelines.yml": "azure-pipelines", | |
| 32 | "bal": "ballerina", | |
| 33 | "bat": "bat", | |
| 34 | "bats": "bats", | |
| 35 | "bzl": "bazel", | |
| 36 | "bf": "befunge", | |
| 37 | "bicep": "bicep", | |
| 38 | "bib": "bibtex", | |
| 39 | "biml": "biml", | |
| 40 | "bin": "code-text-binary", | |
| 41 | "rast": "biome_syntax_tree", | |
| 42 | "blade.php": "blade", | |
| 43 | "blitzbasic": "blitzbasic", | |
| 44 | "bolt": "bolt", | |
| 45 | "bsq": "bosque", | |
| 46 | "bru": "bruno", | |
| 47 | "buf.yaml": "buf", | |
| 48 | "lockb": "bun.lockb", | |
| 49 | "c": "c", | |
| 50 | "c3": "c3", | |
| 51 | "cal": "c-al", | |
| 52 | "cabal": "cabal", | |
| 53 | "capnp": "capnp", | |
| 54 | "casc": "casc", | |
| 55 | "cddl": "cddl", | |
| 56 | "ceylon": "ceylon", | |
| 57 | "cfc": "cfc", | |
| 58 | "cfm": "cfmhtml", | |
| 59 | "circom": "circom", | |
| 60 | "clojure": "clojure", | |
| 61 | "clojurescript": "clojurescript", | |
| 62 | "yml": "manifest-yaml", | |
| 63 | "cmake": "cmake", | |
| 64 | "cbl": "cobol", | |
| 65 | "ql": "ql", | |
| 66 | "coffee": "coffeescript", | |
| 67 | "cfml": "cfml", | |
| 68 | "confluence": "confluence", | |
| 69 | "ckbk": "cookbook", | |
| 70 | "instructions.md": "instructions", | |
| 71 | "cpp": "cpp", | |
| 72 | "cr": "crystal", | |
| 73 | "cs": "csharp", | |
| 74 | "css": "css", | |
| 75 | "wxss": "css", | |
| 76 | "acss": "css", | |
| 77 | "mcss": "css", | |
| 78 | "pcss": "css", | |
| 79 | "postcss": "css", | |
| 80 | "nss": "css", | |
| 81 | "ccss": "css", | |
| 82 | "less": "css", | |
| 83 | "scss": "css", | |
| 84 | "sass": "css", | |
| 85 | "csv": "csv", | |
| 86 | "feature": "cucumber", | |
| 87 | "cu": "cuda", | |
| 88 | "cypher": "cypher", | |
| 89 | "pyx": "cython", | |
| 90 | "dal": "dal", | |
| 91 | "dart": "dart", | |
| 92 | "dhall": "dhall", | |
| 93 | "html": "django-html", | |
| 94 | "diff": "diff", | |
| 95 | "d": "d", | |
| 96 | "hcl": "dockerbake", | |
| 97 | "dtx": "doctex", | |
| 98 | "env": "dotenv", | |
| 99 | "dot": "dotjs", | |
| 100 | "dox": "doxygen", | |
| 101 | "drawio": "drawio", | |
| 102 | "drl": "drools", | |
| 103 | "dtd": "dtd", | |
| 104 | "dust": "dustjs", | |
| 105 | "dylan": "dylan", | |
| 106 | "earthfile": "earthfile", | |
| 107 | "edge": "edge", | |
| 108 | "eex": "eex", | |
| 109 | "es": "es", | |
| 110 | "ex": "elixir", | |
| 111 | "elm": "elm", | |
| 112 | "erb": "erb", | |
| 113 | "erl": "erlang", | |
| 114 | "yaml": "esphome", | |
| 115 | "xlsx": "excel", | |
| 116 | "falcon": "falcon", | |
| 117 | "fql": "fql", | |
| 118 | "f": "fortran", | |
| 119 | "ftl": "ftl", | |
| 120 | "fs": "fsharp", | |
| 121 | "fthtml": "fthtml", | |
| 122 | "gspec": "galen", | |
| 123 | "gml": "gml-gms", | |
| 124 | "gcode": "gcode", | |
| 125 | "gen": "genstat", | |
| 126 | "git": "git-commit", | |
| 127 | "gleam": "gleam", | |
| 128 | "gjs": "glimmer-js", | |
| 129 | "glsl": "glsl", | |
| 130 | "glyphs": "glyphs", | |
| 131 | "gp": "gnuplot", | |
| 132 | "go": "go", | |
| 133 | "mod": "go.mod", | |
| 134 | "work": "go.work", | |
| 135 | "api": "goctl", | |
| 136 | "gd": "gdscript", | |
| 137 | "gradle.kts": "gradle-kotlin-dsl", | |
| 138 | "gr": "grain", | |
| 139 | "gql": "graphql", | |
| 140 | "gv": "dot", | |
| 141 | "grit": "grit", | |
| 142 | "groovy": "groovy", | |
| 143 | "haml": "haml", | |
| 144 | "hbs": "handlebars", | |
| 145 | "prg": "harbour", | |
| 146 | "hs": "haskell", | |
| 147 | "haxe": "haxe", | |
| 148 | "helm.tpl": "helm", | |
| 149 | "hjson": "hjson", | |
| 150 | "hlsl": "hlsl", | |
| 151 | "hosts": "hosts", | |
| 152 | "http": "http", | |
| 153 | "aff": "hunspell.aff", | |
| 154 | "hy": "hy", | |
| 155 | "hypr": "hypr", | |
| 156 | "icl": "icl", | |
| 157 | "imba": "imba", | |
| 158 | "4gl": "4GL", | |
| 159 | "ini": "ini", | |
| 160 | "ink": "ink", | |
| 161 | "iss": "innosetup", | |
| 162 | "io": "io", | |
| 163 | "janet": "janet", | |
| 164 | "java": "java", | |
| 165 | "js": "javascript", | |
| 166 | "mjs": "javascript", | |
| 167 | "cjs": "javascript", | |
| 168 | "es6": "javascript", | |
| 169 | "jake": "javascript", | |
| 170 | "jsm": "javascript", | |
| 171 | "jsx": "javascript", | |
| 172 | "njs": "javascript", | |
| 173 | "pac": "javascript", | |
| 174 | "sjs": "javascript", | |
| 175 | "ssjs": "javascript", | |
| 176 | "xsjs": "javascript", | |
| 177 | "xsjslib": "javascript", | |
| 178 | "jekyll": "jekyll", | |
| 179 | "jenkins": "jenkins", | |
| 180 | "jest-snap": "jest-snapshot", | |
| 181 | "jinja": "jinja", | |
| 182 | "json": "json", | |
| 183 | "jsonc": "jsonc", | |
| 184 | "jsonl": "jsonl", | |
| 185 | "jsonnet": "jsonnet", | |
| 186 | "json5": "json5", | |
| 187 | "jl": "julia", | |
| 188 | "id": "iodine", | |
| 189 | "k": "k", | |
| 190 | "kv": "kivy", | |
| 191 | "ks": "kos", | |
| 192 | "kt": "kotlin", | |
| 193 | "kusto": "kusto", | |
| 194 | "tex": "latex", | |
| 195 | "lark": "lark", | |
| 196 | "lat": "latino", | |
| 197 | "lean": "lean4", | |
| 198 | "flex": "lex", | |
| 199 | "ly": "lilypond", | |
| 200 | "lisp": "lisp", | |
| 201 | "lhs": "literate haskell", | |
| 202 | "log": "log", | |
| 203 | "lol": "lolcode", | |
| 204 | "lsl": "lsl", | |
| 205 | "lua": "lua", | |
| 206 | "m4": "m4", | |
| 207 | "mk": "makefile", | |
| 208 | "md": "markdown", | |
| 209 | "markdown": "markdown", | |
| 210 | "mdown": "markdown", | |
| 211 | "mkd": "markdown", | |
| 212 | "marko": "marko", | |
| 213 | "mat": "matlab", | |
| 214 | "ms": "maxscript", | |
| 215 | "mdx": "mdx", | |
| 216 | "mediawiki": "mediawiki", | |
| 217 | "mel": "mel", | |
| 218 | "mmd": "mermaid", | |
| 219 | "metal": "metal", | |
| 220 | "mcfunction": "bc-mcfunction", | |
| 221 | "mjml": "mjml", | |
| 222 | "pq": "powerquery", | |
| 223 | "mojo": "mojo", | |
| 224 | "ep": "mojolicious", | |
| 225 | "mongo": "mongo", | |
| 226 | "mson": "mson", | |
| 227 | "mv": "mv", | |
| 228 | "mvt": "mvt", | |
| 229 | "ne": "nearley", | |
| 230 | "nginx": "nginx", | |
| 231 | "nim": "nim", | |
| 232 | "nimble": "nimble", | |
| 233 | "nix": "nix", | |
| 234 | "nsi": "nsis", | |
| 235 | "nunjucks": "nunjucks", | |
| 236 | "nu": "nushell", | |
| 237 | "m": "objective-c", | |
| 238 | "mm": "objective-cpp", | |
| 239 | "ml": "ocaml", | |
| 240 | "o3": "ogone", | |
| 241 | "w": "abl", | |
| 242 | "things": "openhab", | |
| 243 | "org": "org", | |
| 244 | "pas": "pascal", | |
| 245 | "pddl": "pddl", | |
| 246 | "plan": "plan", | |
| 247 | "happenings": "happenings", | |
| 248 | "pl": "perl", | |
| 249 | "pl6": "perl6", | |
| 250 | "pgsql": "pgsql", | |
| 251 | "php": "php", | |
| 252 | "php4": "php", | |
| 253 | "php5": "php", | |
| 254 | "php7": "php", | |
| 255 | "phtml": "php", | |
| 256 | "phps": "php", | |
| 257 | "phpt": "php", | |
| 258 | "phar": "php", | |
| 259 | "aw": "php", | |
| 260 | "ctp": "php", | |
| 261 | "inc": "php", | |
| 262 | "php3": "php", | |
| 263 | "php8": "php", | |
| 264 | "module": "php", | |
| 265 | "theme": "php", | |
| 266 | "tpl": "php", | |
| 267 | "pine": "pine", | |
| 268 | "txt": "plaintext", | |
| 269 | "dbgasm": "platformio-debug.disassembly", | |
| 270 | "ddl": "plsql", | |
| 271 | "po": "po", | |
| 272 | "dib": "polyglot-notebook", | |
| 273 | "polymer": "polymer", | |
| 274 | "pony": "pony", | |
| 275 | "ps": "postscript", | |
| 276 | "ps1": "powershell", | |
| 277 | "prisma": "prisma", | |
| 278 | "pde": "pde", | |
| 279 | "pro": "prolog", | |
| 280 | "rules": "prometheus", | |
| 281 | "properties": "java-properties", | |
| 282 | "proto": "proto3", | |
| 283 | "pug": "jade", | |
| 284 | "pp": "puppet", | |
| 285 | "purs": "purescript", | |
| 286 | "arr": "pyret", | |
| 287 | "pyscript": "pyscript", | |
| 288 | "py": "python", | |
| 289 | "pyi": "python", | |
| 290 | "pyw": "python", | |
| 291 | "pyc": "python", | |
| 292 | "pyd": "python", | |
| 293 | "pyo": "python", | |
| 294 | "pxd": "python", | |
| 295 | "pxi": "python", | |
| 296 | "rpy": "python", | |
| 297 | "cpy": "python", | |
| 298 | "ipy": "python", | |
| 299 | "pyowo": "pythowo", | |
| 300 | "qvs": "qlik", | |
| 301 | "qml": "qml", | |
| 302 | "qs": "qsharp", | |
| 303 | "r": "r", | |
| 304 | "rkt": "racket", | |
| 305 | "raku": "raku", | |
| 306 | "cshtml": "razor", | |
| 307 | "raml": "raml", | |
| 308 | "re": "reason", | |
| 309 | "red": "red", | |
| 310 | "res": "rescript", | |
| 311 | "rst": "restructuredtext", | |
| 312 | "rex": "rexx", | |
| 313 | "tag": "riot", | |
| 314 | "ripple": "ripple", | |
| 315 | "rmd": "rmd", | |
| 316 | "rnc": "rnc", | |
| 317 | "robot": "robot", | |
| 318 | "rb": "ruby", | |
| 319 | "rbw": "ruby", | |
| 320 | "rake": "ruby", | |
| 321 | "gemspec": "ruby", | |
| 322 | "ru": "ruby", | |
| 323 | "rjs": "ruby", | |
| 324 | "rxml": "ruby", | |
| 325 | "rsel": "ruby", | |
| 326 | "builder": "ruby", | |
| 327 | "jbuilder": "ruby", | |
| 328 | "rabl": "ruby", | |
| 329 | "prawn": "ruby", | |
| 330 | "thor": "ruby", | |
| 331 | "rs": "rust", | |
| 332 | "sl": "s-lang", | |
| 333 | "san": "san", | |
| 334 | "sas": "SAS", | |
| 335 | "sbt": "sbt", | |
| 336 | "scad": "scad", | |
| 337 | "scala": "scala", | |
| 338 | "sce": "scilab", | |
| 339 | "sdl": "sdl", | |
| 340 | "code-search": "search-result", | |
| 341 | "shader": "shaderlab", | |
| 342 | "sh": "shellscript", | |
| 343 | "bash": "shellscript", | |
| 344 | "zsh": "shellscript", | |
| 345 | "ksh": "shellscript", | |
| 346 | "fish": "shellscript", | |
| 347 | "slang": "slang", | |
| 348 | "ice": "slice", | |
| 349 | "slim": "slim", | |
| 350 | "slint": "slint", | |
| 351 | "sln": "sln", | |
| 352 | "ss": "silverstripe", | |
| 353 | "sn": "sino", | |
| 354 | "eskip": "eskip", | |
| 355 | "smk": "snakemake", | |
| 356 | "code-snippets": "snippets", | |
| 357 | "snort": "snort", | |
| 358 | "sol": "solidity", | |
| 359 | "rq": "sparql", | |
| 360 | "sqf": "sqf", | |
| 361 | "sql": "sql", | |
| 362 | "nut": "squirrel", | |
| 363 | "stan": "stan", | |
| 364 | "bazel": "starlark", | |
| 365 | "do": "stata", | |
| 366 | "stencil": "stencil", | |
| 367 | "html.stencil": "stencil-html", | |
| 368 | "st.css": "stylable", | |
| 369 | "styled": "source.css.styled", | |
| 370 | "styl": "stylus", | |
| 371 | "svelte": "svelte", | |
| 372 | "svg": "svg", | |
| 373 | "swagger": "Swagger", | |
| 374 | "swift": "swift", | |
| 375 | "swig": "swig", | |
| 376 | "link": "systemd-conf", | |
| 377 | "sv": "systemverilog", | |
| 378 | "tt": "t4", | |
| 379 | "teal": "teal", | |
| 380 | "templ": "templ", | |
| 381 | "tt3": "tt", | |
| 382 | "tera": "tera", | |
| 383 | "tf": "terraform", | |
| 384 | "sty": "tex", | |
| 385 | "textile": "textile", | |
| 386 | "json-tmlanguage": "json-tmlanguage", | |
| 387 | "yaml-tmlanguage": "yaml-tmlanguage", | |
| 388 | "toit": "toit", | |
| 389 | "toml": "toml", | |
| 390 | "toon": "toon", | |
| 391 | "ttcn3": "ttcn", | |
| 392 | "tuc": "tuc", | |
| 393 | "twig": "twig", | |
| 394 | "ts": "typescript", | |
| 395 | "mts": "typescript", | |
| 396 | "cts": "typescript", | |
| 397 | "tsx": "typescriptreact", | |
| 398 | "typoscript": "typoscript", | |
| 399 | "ua": "uiua", | |
| 400 | "u": "unison", | |
| 401 | "vb": "vb", | |
| 402 | "wsf": "vbscript", | |
| 403 | "vm": "velocity", | |
| 404 | "vto": "vento", | |
| 405 | "v": "verilog", | |
| 406 | "vhdl": "vhdl", | |
| 407 | "vim": "viml", | |
| 408 | "vitest-snap": "vitest-snapshot", | |
| 409 | "volt": "volt", | |
| 410 | "vroom": "vroom", | |
| 411 | "vue": "vue", | |
| 412 | "vue2": "vue", | |
| 413 | "vue3": "vue", | |
| 414 | "vues": "vue", | |
| 415 | "vuex": "vue", | |
| 416 | "vuerc": "vue", | |
| 417 | "vy": "vyper", | |
| 418 | "wai": "wai", | |
| 419 | "wasm": "wasm", | |
| 420 | "wat": "wasm", | |
| 421 | "wast": "wasm", | |
| 422 | "wsm": "wasm", | |
| 423 | "wast.js": "wasm", | |
| 424 | "wit": "wasm", | |
| 425 | "witx": "wasm", | |
| 426 | "wy": "wenyan", | |
| 427 | "wgsl": "wgsl", | |
| 428 | "wt": "wikitext", | |
| 429 | "wl": "wolfram", | |
| 430 | "wurst": "wurstlang", | |
| 431 | "xcompose": "xcompose", | |
| 432 | "xmake.lua": "xmake", | |
| 433 | "xml": "xml", | |
| 434 | "xquery": "xquery", | |
| 435 | "xsl": "xsl", | |
| 436 | "bison": "yacc", | |
| 437 | "yang": "yang", | |
| 438 | "zig": "zig", | |
| 439 | "zip": "zip" | |
| 440 | }, | |
| 441 | "fileNameToLanguageId": { | |
| 442 | "browserslist": "browserslist", | |
| 443 | ".browserslistrc": "browserslist", | |
| 444 | "caddyfile": "caddyfile", | |
| 445 | "cmakelists.txt": "cmake", | |
| 446 | "cmakecache.txt": "cmake-cache", | |
| 447 | "docker-compose.yml": "dockercompose", | |
| 448 | "docker-compose.yaml": "dockercompose", | |
| 449 | "compose.yaml": "dockercompose", | |
| 450 | "compose.yml": "dockercompose", | |
| 451 | "dockerfile": "dockerfile", | |
| 452 | "dockerfile.dev": "dockerfile", | |
| 453 | "dockerfile.prod": "dockerfile", | |
| 454 | "dockerfile.test": "dockerfile", | |
| 455 | ".editorconfig": "editorconfig", | |
| 456 | "justfile": "just", | |
| 457 | "meson.build": "meson", | |
| 458 | ".php-version": "php", | |
| 459 | "artisan": "php", | |
| 460 | "requirements.txt": "pip-requirements", | |
| 461 | "requirements-dev.txt": "pip-requirements", | |
| 462 | "requirements-test.txt": "pip-requirements", | |
| 463 | "requirements-prod.txt": "pip-requirements", | |
| 464 | "setup.py": "python", | |
| 465 | "setup.cfg": "python", | |
| 466 | ".python-version": "python", | |
| 467 | "pipfile": "python", | |
| 468 | "pipfile.lock": "python", | |
| 469 | "robots.txt": "robots-txt", | |
| 470 | "config.ru": "ruby", | |
| 471 | ".bashrc": "shellscript", | |
| 472 | ".zshrc": "shellscript", | |
| 473 | ".profile": "shellscript", | |
| 474 | ".bash_profile": "shellscript", | |
| 475 | ".bash_aliases": "shellscript", | |
| 476 | ".zprofile": "shellscript", | |
| 477 | "tiltfile": "tiltfile", | |
| 478 | ".vue-cli-rc": "vue", | |
| 479 | "yarn.lock": "yarnlock" | |
| 480 | } | |
| 481 | } |
apps/web/src/vscode-icons-manifest.json+8351-1| ... | ... | @@ -1 +1,8351 @@ |
| 1 | {"iconDefinitions":{"_file":{"iconPath":"../../icons/default_file.svg"},"_folder":{"iconPath":"../../icons/default_folder.svg"},"_folder_open":{"iconPath":"../../icons/default_folder_opened.svg"},"_root_folder":{"iconPath":"../../icons/default_root_folder.svg"},"_root_folder_open":{"iconPath":"../../icons/default_root_folder_opened.svg"},"_file_light":{"iconPath":""},"_folder_light":{"iconPath":""},"_folder_light_open":{"iconPath":""},"_root_folder_light":{"iconPath":""},"_root_folder_light_open":{"iconPath":""},"_fd_android":{"iconPath":"../../icons/folder_type_android.svg"},"_fd_android_open":{"iconPath":"../../icons/folder_type_android_opened.svg"},"_fd_angular":{"iconPath":"../../icons/folder_type_angular.svg"},"_fd_angular_open":{"iconPath":"../../icons/folder_type_angular_opened.svg"},"_fd_apache":{"iconPath":"../../icons/folder_type_apache.svg"},"_fd_apache_open":{"iconPath":"../../icons/folder_type_apache_opened.svg"},"_fd_api":{"iconPath":"../../icons/folder_type_api.svg"},"_fd_api_open":{"iconPath":"../../icons/folder_type_api_opened.svg"},"_fd_app":{"iconPath":"../../icons/folder_type_app.svg"},"_fd_app_open":{"iconPath":"../../icons/folder_type_app_opened.svg"},"_fd_arangodb":{"iconPath":"../../icons/folder_type_arangodb.svg"},"_fd_arangodb_open":{"iconPath":"../../icons/folder_type_arangodb_opened.svg"},"_fd_asset":{"iconPath":"../../icons/folder_type_asset.svg"},"_fd_asset_open":{"iconPath":"../../icons/folder_type_asset_opened.svg"},"_fd_astro":{"iconPath":"../../icons/folder_type_astro.svg"},"_fd_astro_open":{"iconPath":"../../icons/folder_type_astro_opened.svg"},"_fd_audio":{"iconPath":"../../icons/folder_type_audio.svg"},"_fd_audio_open":{"iconPath":"../../icons/folder_type_audio_opened.svg"},"_fd_aurelia":{"iconPath":"../../icons/folder_type_aurelia.svg"},"_fd_aurelia_open":{"iconPath":"../../icons/folder_type_aurelia_opened.svg"},"_fd_aws":{"iconPath":"../../icons/folder_type_aws.svg"},"_fd_aws_open":{"iconPath":"../../icons/folder_type_aws_opened.svg"},"_fd_azure":{"iconPath":"../../icons/folder_type_azure.svg"},"_fd_azure_open":{"iconPath":"../../icons/folder_type_azure_opened.svg"},"_fd_azurepipelines":{"iconPath":"../../icons/folder_type_azurepipelines.svg"},"_fd_azurepipelines_open":{"iconPath":"../../icons/folder_type_azurepipelines_opened.svg"},"_fd_binary":{"iconPath":"../../icons/folder_type_binary.svg"},"_fd_binary_open":{"iconPath":"../../icons/folder_type_binary_opened.svg"},"_fd_bloc":{"iconPath":"../../icons/folder_type_bloc.svg"},"_fd_bloc_open":{"iconPath":"../../icons/folder_type_bloc_opened.svg"},"_fd_blueprint":{"iconPath":"../../icons/folder_type_blueprint.svg"},"_fd_blueprint_open":{"iconPath":"../../icons/folder_type_blueprint_opened.svg"},"_fd_bot":{"iconPath":"../../icons/folder_type_bot.svg"},"_fd_bot_open":{"iconPath":"../../icons/folder_type_bot_opened.svg"},"_fd_bower":{"iconPath":"../../icons/folder_type_bower.svg"},"_fd_bower_open":{"iconPath":"../../icons/folder_type_bower_opened.svg"},"_fd_buildkite":{"iconPath":"../../icons/folder_type_buildkite.svg"},"_fd_buildkite_open":{"iconPath":"../../icons/folder_type_buildkite_opened.svg"},"_fd_cake":{"iconPath":"../../icons/folder_type_cake.svg"},"_fd_cake_open":{"iconPath":"../../icons/folder_type_cake_opened.svg"},"_fd_cargo":{"iconPath":"../../icons/folder_type_cargo.svg"},"_fd_cargo_open":{"iconPath":"../../icons/folder_type_cargo_opened.svg"},"_fd_certificate":{"iconPath":"../../icons/folder_type_certificate.svg"},"_fd_certificate_open":{"iconPath":"../../icons/folder_type_certificate_opened.svg"},"_fd_changesets":{"iconPath":"../../icons/folder_type_changesets.svg"},"_fd_changesets_open":{"iconPath":"../../icons/folder_type_changesets_opened.svg"},"_fd_chef":{"iconPath":"../../icons/folder_type_chef.svg"},"_fd_chef_open":{"iconPath":"../../icons/folder_type_chef_opened.svg"},"_fd_circleci":{"iconPath":"../../icons/folder_type_circleci.svg"},"_fd_circleci_open":{"iconPath":"../../icons/folder_type_circleci_opened.svg"},"_fd_claude":{"iconPath":"../../icons/folder_type_claude.svg"},"_fd_claude_open":{"iconPath":"../../icons/folder_type_claude_opened.svg"},"_fd_cli":{"iconPath":"../../icons/folder_type_cli.svg"},"_fd_cli_open":{"iconPath":"../../icons/folder_type_cli_opened.svg"},"_fd_client":{"iconPath":"../../icons/folder_type_client.svg"},"_fd_client_open":{"iconPath":"../../icons/folder_type_client_opened.svg"},"_fd_cloudflare":{"iconPath":"../../icons/folder_type_cloudflare.svg"},"_fd_cloudflare_open":{"iconPath":"../../icons/folder_type_cloudflare_opened.svg"},"_fd_cmake":{"iconPath":"../../icons/folder_type_cmake.svg"},"_fd_cmake_open":{"iconPath":"../../icons/folder_type_cmake_opened.svg"},"_fd_common":{"iconPath":"../../icons/folder_type_common.svg"},"_fd_common_open":{"iconPath":"../../icons/folder_type_common_opened.svg"},"_fd_component":{"iconPath":"../../icons/folder_type_component.svg"},"_fd_component_open":{"iconPath":"../../icons/folder_type_component_opened.svg"},"_fd_composer":{"iconPath":"../../icons/folder_type_composer.svg"},"_fd_composer_open":{"iconPath":"../../icons/folder_type_composer_opened.svg"},"_fd_config":{"iconPath":"../../icons/folder_type_config.svg"},"_fd_config_open":{"iconPath":"../../icons/folder_type_config_opened.svg"},"_fd_controller":{"iconPath":"../../icons/folder_type_controller.svg"},"_fd_controller_open":{"iconPath":"../../icons/folder_type_controller_opened.svg"},"_fd_coverage":{"iconPath":"../../icons/folder_type_coverage.svg"},"_fd_coverage_open":{"iconPath":"../../icons/folder_type_coverage_opened.svg"},"_fd_css":{"iconPath":"../../icons/folder_type_css.svg"},"_fd_css_open":{"iconPath":"../../icons/folder_type_css_opened.svg"},"_fd_cubit":{"iconPath":"../../icons/folder_type_cubit.svg"},"_fd_cubit_open":{"iconPath":"../../icons/folder_type_cubit_opened.svg"},"_fd_cursor":{"iconPath":"../../icons/folder_type_cursor.svg"},"_fd_cursor_open":{"iconPath":"../../icons/folder_type_cursor_opened.svg"},"_fd_light_cursor":{"iconPath":"../../icons/folder_type_light_cursor.svg"},"_fd_light_cursor_open":{"iconPath":"../../icons/folder_type_light_cursor_opened.svg"},"_fd_cypress":{"iconPath":"../../icons/folder_type_cypress.svg"},"_fd_cypress_open":{"iconPath":"../../icons/folder_type_cypress_opened.svg"},"_fd_light_cypress":{"iconPath":"../../icons/folder_type_light_cypress.svg"},"_fd_light_cypress_open":{"iconPath":"../../icons/folder_type_light_cypress_opened.svg"},"_fd_dapr":{"iconPath":"../../icons/folder_type_dapr.svg"},"_fd_dapr_open":{"iconPath":"../../icons/folder_type_dapr_opened.svg"},"_fd_dart":{"iconPath":"../../icons/folder_type_dart.svg"},"_fd_dart_open":{"iconPath":"../../icons/folder_type_dart_opened.svg"},"_fd_datadog":{"iconPath":"../../icons/folder_type_datadog.svg"},"_fd_datadog_open":{"iconPath":"../../icons/folder_type_datadog_opened.svg"},"_fd_db":{"iconPath":"../../icons/folder_type_db.svg"},"_fd_db_open":{"iconPath":"../../icons/folder_type_db_opened.svg"},"_fd_debian":{"iconPath":"../../icons/folder_type_debian.svg"},"_fd_debian_open":{"iconPath":"../../icons/folder_type_debian_opened.svg"},"_fd_dependabot":{"iconPath":"../../icons/folder_type_dependabot.svg"},"_fd_dependabot_open":{"iconPath":"../../icons/folder_type_dependabot_opened.svg"},"_fd_devcontainer":{"iconPath":"../../icons/folder_type_devcontainer.svg"},"_fd_devcontainer_open":{"iconPath":"../../icons/folder_type_devcontainer_opened.svg"},"_fd_dist":{"iconPath":"../../icons/folder_type_dist.svg"},"_fd_dist_open":{"iconPath":"../../icons/folder_type_dist_opened.svg"},"_fd_docker":{"iconPath":"../../icons/folder_type_docker.svg"},"_fd_docker_open":{"iconPath":"../../icons/folder_type_docker_opened.svg"},"_fd_docs":{"iconPath":"../../icons/folder_type_docs.svg"},"_fd_docs_open":{"iconPath":"../../icons/folder_type_docs_opened.svg"},"_fd_e2e":{"iconPath":"../../icons/folder_type_e2e.svg"},"_fd_e2e_open":{"iconPath":"../../icons/folder_type_e2e_opened.svg"},"_fd_elasticbeanstalk":{"iconPath":"../../icons/folder_type_elasticbeanstalk.svg"},"_fd_elasticbeanstalk_open":{"iconPath":"../../icons/folder_type_elasticbeanstalk_opened.svg"},"_fd_electron":{"iconPath":"../../icons/folder_type_electron.svg"},"_fd_electron_open":{"iconPath":"../../icons/folder_type_electron_opened.svg"},"_fd_light_electron":{"iconPath":"../../icons/folder_type_light_electron.svg"},"_fd_light_electron_open":{"iconPath":"../../icons/folder_type_light_electron_opened.svg"},"_fd_expo":{"iconPath":"../../icons/folder_type_expo.svg"},"_fd_expo_open":{"iconPath":"../../icons/folder_type_expo_opened.svg"},"_fd_light_expo":{"iconPath":"../../icons/folder_type_light_expo.svg"},"_fd_light_expo_open":{"iconPath":"../../icons/folder_type_light_expo_opened.svg"},"_fd_favicon":{"iconPath":"../../icons/folder_type_favicon.svg"},"_fd_favicon_open":{"iconPath":"../../icons/folder_type_favicon_opened.svg"},"_fd_flow":{"iconPath":"../../icons/folder_type_flow.svg"},"_fd_flow_open":{"iconPath":"../../icons/folder_type_flow_opened.svg"},"_fd_flutter":{"iconPath":"../../icons/folder_type_flutter.svg"},"_fd_flutter_open":{"iconPath":"../../icons/folder_type_flutter_opened.svg"},"_fd_fonts":{"iconPath":"../../icons/folder_type_fonts.svg"},"_fd_fonts_open":{"iconPath":"../../icons/folder_type_fonts_opened.svg"},"_fd_light_fonts":{"iconPath":"../../icons/folder_type_light_fonts.svg"},"_fd_light_fonts_open":{"iconPath":"../../icons/folder_type_light_fonts_opened.svg"},"_fd_frontcommerce":{"iconPath":"../../icons/folder_type_frontcommerce.svg"},"_fd_frontcommerce_open":{"iconPath":"../../icons/folder_type_frontcommerce_opened.svg"},"_fd_gcp":{"iconPath":"../../icons/folder_type_gcp.svg"},"_fd_gcp_open":{"iconPath":"../../icons/folder_type_gcp_opened.svg"},"_fd_gemini":{"iconPath":"../../icons/folder_type_gemini.svg"},"_fd_gemini_open":{"iconPath":"../../icons/folder_type_gemini_opened.svg"},"_fd_git":{"iconPath":"../../icons/folder_type_git.svg"},"_fd_git_open":{"iconPath":"../../icons/folder_type_git_opened.svg"},"_fd_github":{"iconPath":"../../icons/folder_type_github.svg"},"_fd_github_open":{"iconPath":"../../icons/folder_type_github_opened.svg"},"_fd_gitlab":{"iconPath":"../../icons/folder_type_gitlab.svg"},"_fd_gitlab_open":{"iconPath":"../../icons/folder_type_gitlab_opened.svg"},"_fd_godot":{"iconPath":"../../icons/folder_type_godot.svg"},"_fd_godot_open":{"iconPath":"../../icons/folder_type_godot_opened.svg"},"_fd_gradle":{"iconPath":"../../icons/folder_type_gradle.svg"},"_fd_gradle_open":{"iconPath":"../../icons/folder_type_gradle_opened.svg"},"_fd_light_gradle":{"iconPath":"../../icons/folder_type_light_gradle.svg"},"_fd_light_gradle_open":{"iconPath":"../../icons/folder_type_light_gradle_opened.svg"},"_fd_graphql":{"iconPath":"../../icons/folder_type_graphql.svg"},"_fd_graphql_open":{"iconPath":"../../icons/folder_type_graphql_opened.svg"},"_fd_grunt":{"iconPath":"../../icons/folder_type_grunt.svg"},"_fd_grunt_open":{"iconPath":"../../icons/folder_type_grunt_opened.svg"},"_fd_gulp":{"iconPath":"../../icons/folder_type_gulp.svg"},"_fd_gulp_open":{"iconPath":"../../icons/folder_type_gulp_opened.svg"},"_fd_haxelib":{"iconPath":"../../icons/folder_type_haxelib.svg"},"_fd_haxelib_open":{"iconPath":"../../icons/folder_type_haxelib_opened.svg"},"_fd_helper":{"iconPath":"../../icons/folder_type_helper.svg"},"_fd_helper_open":{"iconPath":"../../icons/folder_type_helper_opened.svg"},"_fd_histoire":{"iconPath":"../../icons/folder_type_histoire.svg"},"_fd_histoire_open":{"iconPath":"../../icons/folder_type_histoire_opened.svg"},"_fd_hook":{"iconPath":"../../icons/folder_type_hook.svg"},"_fd_hook_open":{"iconPath":"../../icons/folder_type_hook_opened.svg"},"_fd_husky":{"iconPath":"../../icons/folder_type_husky.svg"},"_fd_husky_open":{"iconPath":"../../icons/folder_type_husky_opened.svg"},"_fd_idea":{"iconPath":"../../icons/folder_type_idea.svg"},"_fd_idea_open":{"iconPath":"../../icons/folder_type_idea_opened.svg"},"_fd_images":{"iconPath":"../../icons/folder_type_images.svg"},"_fd_images_open":{"iconPath":"../../icons/folder_type_images_opened.svg"},"_fd_include":{"iconPath":"../../icons/folder_type_include.svg"},"_fd_include_open":{"iconPath":"../../icons/folder_type_include_opened.svg"},"_fd_interfaces":{"iconPath":"../../icons/folder_type_interfaces.svg"},"_fd_interfaces_open":{"iconPath":"../../icons/folder_type_interfaces_opened.svg"},"_fd_ios":{"iconPath":"../../icons/folder_type_ios.svg"},"_fd_ios_open":{"iconPath":"../../icons/folder_type_ios_opened.svg"},"_fd_js":{"iconPath":"../../icons/folder_type_js.svg"},"_fd_js_open":{"iconPath":"../../icons/folder_type_js_opened.svg"},"_fd_json":{"iconPath":"../../icons/folder_type_json.svg"},"_fd_json_open":{"iconPath":"../../icons/folder_type_json_opened.svg"},"_fd_junie":{"iconPath":"../../icons/folder_type_junie.svg"},"_fd_junie_open":{"iconPath":"../../icons/folder_type_junie_opened.svg"},"_fd_kotlin":{"iconPath":"../../icons/folder_type_kotlin.svg"},"_fd_kotlin_open":{"iconPath":"../../icons/folder_type_kotlin_opened.svg"},"_fd_kubernetes":{"iconPath":"../../icons/folder_type_kubernetes.svg"},"_fd_kubernetes_open":{"iconPath":"../../icons/folder_type_kubernetes_opened.svg"},"_fd_less":{"iconPath":"../../icons/folder_type_less.svg"},"_fd_less_open":{"iconPath":"../../icons/folder_type_less_opened.svg"},"_fd_library":{"iconPath":"../../icons/folder_type_library.svg"},"_fd_library_open":{"iconPath":"../../icons/folder_type_library_opened.svg"},"_fd_linux":{"iconPath":"../../icons/folder_type_linux.svg"},"_fd_linux_open":{"iconPath":"../../icons/folder_type_linux_opened.svg"},"_fd_locale":{"iconPath":"../../icons/folder_type_locale.svg"},"_fd_locale_open":{"iconPath":"../../icons/folder_type_locale_opened.svg"},"_fd_log":{"iconPath":"../../icons/folder_type_log.svg"},"_fd_log_open":{"iconPath":"../../icons/folder_type_log_opened.svg"},"_fd_macos":{"iconPath":"../../icons/folder_type_macos.svg"},"_fd_macos_open":{"iconPath":"../../icons/folder_type_macos_opened.svg"},"_fd_mariadb":{"iconPath":"../../icons/folder_type_mariadb.svg"},"_fd_mariadb_open":{"iconPath":"../../icons/folder_type_mariadb_opened.svg"},"_fd_maven":{"iconPath":"../../icons/folder_type_maven.svg"},"_fd_maven_open":{"iconPath":"../../icons/folder_type_maven_opened.svg"},"_fd_mediawiki":{"iconPath":"../../icons/folder_type_mediawiki.svg"},"_fd_mediawiki_open":{"iconPath":"../../icons/folder_type_mediawiki_opened.svg"},"_fd_memcached":{"iconPath":"../../icons/folder_type_memcached.svg"},"_fd_memcached_open":{"iconPath":"../../icons/folder_type_memcached_opened.svg"},"_fd_meteor":{"iconPath":"../../icons/folder_type_meteor.svg"},"_fd_meteor_open":{"iconPath":"../../icons/folder_type_meteor_opened.svg"},"_fd_light_meteor":{"iconPath":"../../icons/folder_type_light_meteor.svg"},"_fd_light_meteor_open":{"iconPath":"../../icons/folder_type_light_meteor_opened.svg"},"_fd_middleware":{"iconPath":"../../icons/folder_type_middleware.svg"},"_fd_middleware_open":{"iconPath":"../../icons/folder_type_middleware_opened.svg"},"_fd_minecraft":{"iconPath":"../../icons/folder_type_minecraft.svg"},"_fd_minecraft_open":{"iconPath":"../../icons/folder_type_minecraft_opened.svg"},"_fd_minikube":{"iconPath":"../../icons/folder_type_minikube.svg"},"_fd_minikube_open":{"iconPath":"../../icons/folder_type_minikube_opened.svg"},"_fd_mjml":{"iconPath":"../../icons/folder_type_mjml.svg"},"_fd_mjml_open":{"iconPath":"../../icons/folder_type_mjml_opened.svg"},"_fd_mock":{"iconPath":"../../icons/folder_type_mock.svg"},"_fd_mock_open":{"iconPath":"../../icons/folder_type_mock_opened.svg"},"_fd_model":{"iconPath":"../../icons/folder_type_model.svg"},"_fd_model_open":{"iconPath":"../../icons/folder_type_model_opened.svg"},"_fd_module":{"iconPath":"../../icons/folder_type_module.svg"},"_fd_module_open":{"iconPath":"../../icons/folder_type_module_opened.svg"},"_fd_mojo":{"iconPath":"../../icons/folder_type_mojo.svg"},"_fd_mojo_open":{"iconPath":"../../icons/folder_type_mojo_opened.svg"},"_fd_mongodb":{"iconPath":"../../icons/folder_type_mongodb.svg"},"_fd_mongodb_open":{"iconPath":"../../icons/folder_type_mongodb_opened.svg"},"_fd_mypy":{"iconPath":"../../icons/folder_type_mypy.svg"},"_fd_mypy_open":{"iconPath":"../../icons/folder_type_mypy_opened.svg"},"_fd_light_mypy":{"iconPath":"../../icons/folder_type_light_mypy.svg"},"_fd_light_mypy_open":{"iconPath":"../../icons/folder_type_light_mypy_opened.svg"},"_fd_mysql":{"iconPath":"../../icons/folder_type_mysql.svg"},"_fd_mysql_open":{"iconPath":"../../icons/folder_type_mysql_opened.svg"},"_fd_light_mysql":{"iconPath":"../../icons/folder_type_light_mysql.svg"},"_fd_light_mysql_open":{"iconPath":"../../icons/folder_type_light_mysql_opened.svg"},"_fd_netlify":{"iconPath":"../../icons/folder_type_netlify.svg"},"_fd_netlify_open":{"iconPath":"../../icons/folder_type_netlify_opened.svg"},"_fd_next":{"iconPath":"../../icons/folder_type_next.svg"},"_fd_next_open":{"iconPath":"../../icons/folder_type_next_opened.svg"},"_fd_nginx":{"iconPath":"../../icons/folder_type_nginx.svg"},"_fd_nginx_open":{"iconPath":"../../icons/folder_type_nginx_opened.svg"},"_fd_nix":{"iconPath":"../../icons/folder_type_nix.svg"},"_fd_nix_open":{"iconPath":"../../icons/folder_type_nix_opened.svg"},"_fd_node":{"iconPath":"../../icons/folder_type_node.svg"},"_fd_node_open":{"iconPath":"../../icons/folder_type_node_opened.svg"},"_fd_light_node":{"iconPath":"../../icons/folder_type_light_node.svg"},"_fd_light_node_open":{"iconPath":"../../icons/folder_type_light_node_opened.svg"},"_fd_notebooks":{"iconPath":"../../icons/folder_type_notebooks.svg"},"_fd_notebooks_open":{"iconPath":"../../icons/folder_type_notebooks_opened.svg"},"_fd_notification":{"iconPath":"../../icons/folder_type_notification.svg"},"_fd_notification_open":{"iconPath":"../../icons/folder_type_notification_opened.svg"},"_fd_nuget":{"iconPath":"../../icons/folder_type_nuget.svg"},"_fd_nuget_open":{"iconPath":"../../icons/folder_type_nuget_opened.svg"},"_fd_nuxt":{"iconPath":"../../icons/folder_type_nuxt.svg"},"_fd_nuxt_open":{"iconPath":"../../icons/folder_type_nuxt_opened.svg"},"_fd_package":{"iconPath":"../../icons/folder_type_package.svg"},"_fd_package_open":{"iconPath":"../../icons/folder_type_package_opened.svg"},"_fd_paket":{"iconPath":"../../icons/folder_type_paket.svg"},"_fd_paket_open":{"iconPath":"../../icons/folder_type_paket_opened.svg"},"_fd_php":{"iconPath":"../../icons/folder_type_php.svg"},"_fd_php_open":{"iconPath":"../../icons/folder_type_php_opened.svg"},"_fd_platformio":{"iconPath":"../../icons/folder_type_platformio.svg"},"_fd_platformio_open":{"iconPath":"../../icons/folder_type_platformio_opened.svg"},"_fd_plugin":{"iconPath":"../../icons/folder_type_plugin.svg"},"_fd_plugin_open":{"iconPath":"../../icons/folder_type_plugin_opened.svg"},"_fd_prisma":{"iconPath":"../../icons/folder_type_prisma.svg"},"_fd_prisma_open":{"iconPath":"../../icons/folder_type_prisma_opened.svg"},"_fd_private":{"iconPath":"../../icons/folder_type_private.svg"},"_fd_private_open":{"iconPath":"../../icons/folder_type_private_opened.svg"},"_fd_public":{"iconPath":"../../icons/folder_type_public.svg"},"_fd_public_open":{"iconPath":"../../icons/folder_type_public_opened.svg"},"_fd_pytest":{"iconPath":"../../icons/folder_type_pytest.svg"},"_fd_pytest_open":{"iconPath":"../../icons/folder_type_pytest_opened.svg"},"_fd_python":{"iconPath":"../../icons/folder_type_python.svg"},"_fd_python_open":{"iconPath":"../../icons/folder_type_python_opened.svg"},"_fd_ravendb":{"iconPath":"../../icons/folder_type_ravendb.svg"},"_fd_ravendb_open":{"iconPath":"../../icons/folder_type_ravendb_opened.svg"},"_fd_redis":{"iconPath":"../../icons/folder_type_redis.svg"},"_fd_redis_open":{"iconPath":"../../icons/folder_type_redis_opened.svg"},"_fd_redux":{"iconPath":"../../icons/folder_type_redux.svg"},"_fd_redux_open":{"iconPath":"../../icons/folder_type_redux_opened.svg"},"_fd_light_redux":{"iconPath":"../../icons/folder_type_light_redux.svg"},"_fd_light_redux_open":{"iconPath":"../../icons/folder_type_light_redux_opened.svg"},"_fd_route":{"iconPath":"../../icons/folder_type_route.svg"},"_fd_route_open":{"iconPath":"../../icons/folder_type_route_opened.svg"},"_fd_sass":{"iconPath":"../../icons/folder_type_sass.svg"},"_fd_sass_open":{"iconPath":"../../icons/folder_type_sass_opened.svg"},"_fd_light_sass":{"iconPath":"../../icons/folder_type_light_sass.svg"},"_fd_light_sass_open":{"iconPath":"../../icons/folder_type_light_sass_opened.svg"},"_fd_script":{"iconPath":"../../icons/folder_type_script.svg"},"_fd_script_open":{"iconPath":"../../icons/folder_type_script_opened.svg"},"_fd_seedkit":{"iconPath":"../../icons/folder_type_seedkit.svg"},"_fd_seedkit_open":{"iconPath":"../../icons/folder_type_seedkit_opened.svg"},"_fd_server":{"iconPath":"../../icons/folder_type_server.svg"},"_fd_server_open":{"iconPath":"../../icons/folder_type_server_opened.svg"},"_fd_services":{"iconPath":"../../icons/folder_type_services.svg"},"_fd_services_open":{"iconPath":"../../icons/folder_type_services_opened.svg"},"_fd_shared":{"iconPath":"../../icons/folder_type_shared.svg"},"_fd_shared_open":{"iconPath":"../../icons/folder_type_shared_opened.svg"},"_fd_snaplet":{"iconPath":"../../icons/folder_type_snaplet.svg"},"_fd_snaplet_open":{"iconPath":"../../icons/folder_type_snaplet_opened.svg"},"_fd_spin":{"iconPath":"../../icons/folder_type_spin.svg"},"_fd_spin_open":{"iconPath":"../../icons/folder_type_spin_opened.svg"},"_fd_src":{"iconPath":"../../icons/folder_type_src.svg"},"_fd_src_open":{"iconPath":"../../icons/folder_type_src_opened.svg"},"_fd_sso":{"iconPath":"../../icons/folder_type_sso.svg"},"_fd_sso_open":{"iconPath":"../../icons/folder_type_sso_opened.svg"},"_fd_story":{"iconPath":"../../icons/folder_type_story.svg"},"_fd_story_open":{"iconPath":"../../icons/folder_type_story_opened.svg"},"_fd_style":{"iconPath":"../../icons/folder_type_style.svg"},"_fd_style_open":{"iconPath":"../../icons/folder_type_style_opened.svg"},"_fd_supabase":{"iconPath":"../../icons/folder_type_supabase.svg"},"_fd_supabase_open":{"iconPath":"../../icons/folder_type_supabase_opened.svg"},"_fd_svelte":{"iconPath":"../../icons/folder_type_svelte.svg"},"_fd_svelte_open":{"iconPath":"../../icons/folder_type_svelte_opened.svg"},"_fd_tauri":{"iconPath":"../../icons/folder_type_tauri.svg"},"_fd_tauri_open":{"iconPath":"../../icons/folder_type_tauri_opened.svg"},"_fd_temp":{"iconPath":"../../icons/folder_type_temp.svg"},"_fd_temp_open":{"iconPath":"../../icons/folder_type_temp_opened.svg"},"_fd_template":{"iconPath":"../../icons/folder_type_template.svg"},"_fd_template_open":{"iconPath":"../../icons/folder_type_template_opened.svg"},"_fd_test":{"iconPath":"../../icons/folder_type_test.svg"},"_fd_test_open":{"iconPath":"../../icons/folder_type_test_opened.svg"},"_fd_theme":{"iconPath":"../../icons/folder_type_theme.svg"},"_fd_theme_open":{"iconPath":"../../icons/folder_type_theme_opened.svg"},"_fd_tools":{"iconPath":"../../icons/folder_type_tools.svg"},"_fd_tools_open":{"iconPath":"../../icons/folder_type_tools_opened.svg"},"_fd_travis":{"iconPath":"../../icons/folder_type_travis.svg"},"_fd_travis_open":{"iconPath":"../../icons/folder_type_travis_opened.svg"},"_fd_trunk":{"iconPath":"../../icons/folder_type_trunk.svg"},"_fd_trunk_open":{"iconPath":"../../icons/folder_type_trunk_opened.svg"},"_fd_turbo":{"iconPath":"../../icons/folder_type_turbo.svg"},"_fd_turbo_open":{"iconPath":"../../icons/folder_type_turbo_opened.svg"},"_fd_typescript":{"iconPath":"../../icons/folder_type_typescript.svg"},"_fd_typescript_open":{"iconPath":"../../icons/folder_type_typescript_opened.svg"},"_fd_typings":{"iconPath":"../../icons/folder_type_typings.svg"},"_fd_typings_open":{"iconPath":"../../icons/folder_type_typings_opened.svg"},"_fd_vagrant":{"iconPath":"../../icons/folder_type_vagrant.svg"},"_fd_vagrant_open":{"iconPath":"../../icons/folder_type_vagrant_opened.svg"},"_fd_vercel":{"iconPath":"../../icons/folder_type_vercel.svg"},"_fd_vercel_open":{"iconPath":"../../icons/folder_type_vercel_opened.svg"},"_fd_video":{"iconPath":"../../icons/folder_type_video.svg"},"_fd_video_open":{"iconPath":"../../icons/folder_type_video_opened.svg"},"_fd_view":{"iconPath":"../../icons/folder_type_view.svg"},"_fd_view_open":{"iconPath":"../../icons/folder_type_view_opened.svg"},"_fd_vitepress":{"iconPath":"../../icons/folder_type_vitepress.svg"},"_fd_vitepress_open":{"iconPath":"../../icons/folder_type_vitepress_opened.svg"},"_fd_vs":{"iconPath":"../../icons/folder_type_vs.svg"},"_fd_vs_open":{"iconPath":"../../icons/folder_type_vs_opened.svg"},"_fd_vscode":{"iconPath":"../../icons/folder_type_vscode.svg"},"_fd_vscode_open":{"iconPath":"../../icons/folder_type_vscode_opened.svg"},"_fd_vscode_test":{"iconPath":"../../icons/folder_type_vscode_test.svg"},"_fd_vscode_test_open":{"iconPath":"../../icons/folder_type_vscode_test_opened.svg"},"_fd_wasm":{"iconPath":"../../icons/folder_type_wasm.svg"},"_fd_wasm_open":{"iconPath":"../../icons/folder_type_wasm_opened.svg"},"_fd_webpack":{"iconPath":"../../icons/folder_type_webpack.svg"},"_fd_webpack_open":{"iconPath":"../../icons/folder_type_webpack_opened.svg"},"_fd_windows":{"iconPath":"../../icons/folder_type_windows.svg"},"_fd_windows_open":{"iconPath":"../../icons/folder_type_windows_opened.svg"},"_fd_windsurf":{"iconPath":"../../icons/folder_type_windsurf.svg"},"_fd_windsurf_open":{"iconPath":"../../icons/folder_type_windsurf_opened.svg"},"_fd_wit":{"iconPath":"../../icons/folder_type_wit.svg"},"_fd_wit_open":{"iconPath":"../../icons/folder_type_wit_opened.svg"},"_fd_www":{"iconPath":"../../icons/folder_type_www.svg"},"_fd_www_open":{"iconPath":"../../icons/folder_type_www_opened.svg"},"_fd_yarn":{"iconPath":"../../icons/folder_type_yarn.svg"},"_fd_yarn_open":{"iconPath":"../../icons/folder_type_yarn_opened.svg"},"_fd_zed":{"iconPath":"../../icons/folder_type_zed.svg"},"_fd_zed_open":{"iconPath":"../../icons/folder_type_zed_opened.svg"},"_fd_light_zed":{"iconPath":"../../icons/folder_type_light_zed.svg"},"_fd_light_zed_open":{"iconPath":"../../icons/folder_type_light_zed_opened.svg"},"_f_access":{"iconPath":"../../icons/file_type_access.svg"},"_f_actionscript":{"iconPath":"../../icons/file_type_actionscript.svg"},"_f_ada":{"iconPath":"../../icons/file_type_ada.svg"},"_f_light_ada":{"iconPath":"../../icons/file_type_light_ada.svg"},"_f_advpl":{"iconPath":"../../icons/file_type_advpl.svg"},"_f_affectscript":{"iconPath":"../../icons/file_type_affectscript.svg"},"_f_affinity":{"iconPath":"../../icons/file_type_affinity.svg"},"_f_affinitydesigner":{"iconPath":"../../icons/file_type_affinitydesigner.svg"},"_f_affinityphoto":{"iconPath":"../../icons/file_type_affinityphoto.svg"},"_f_affinitypublisher":{"iconPath":"../../icons/file_type_affinitypublisher.svg"},"_f_agda":{"iconPath":"../../icons/file_type_agda.svg"},"_f_light_agda":{"iconPath":"../../icons/file_type_light_agda.svg"},"_f_agents":{"iconPath":"../../icons/file_type_agents.svg"},"_f_light_agents":{"iconPath":"../../icons/file_type_light_agents.svg"},"_f_ai":{"iconPath":"../../icons/file_type_ai.svg"},"_f_al":{"iconPath":"../../icons/file_type_al.svg"},"_f_al_dal":{"iconPath":"../../icons/file_type_al_dal.svg"},"_f_alchemy":{"iconPath":"../../icons/file_type_alchemy.svg"},"_f_allcontributors":{"iconPath":"../../icons/file_type_allcontributors.svg"},"_f_alloy":{"iconPath":"../../icons/file_type_alloy.svg"},"_f_allure":{"iconPath":"../../icons/file_type_allure.svg"},"_f_angular":{"iconPath":"../../icons/file_type_angular.svg"},"_f_ansible":{"iconPath":"../../icons/file_type_ansible.svg"},"_f_antlers_html":{"iconPath":"../../icons/file_type_antlers_html.svg"},"_f_antlr":{"iconPath":"../../icons/file_type_antlr.svg"},"_f_anyscript":{"iconPath":"../../icons/file_type_anyscript.svg"},"_f_apache":{"iconPath":"../../icons/file_type_apache.svg"},"_f_apex":{"iconPath":"../../icons/file_type_apex.svg"},"_f_api_extractor":{"iconPath":"../../icons/file_type_api_extractor.svg"},"_f_apib":{"iconPath":"../../icons/file_type_apib.svg"},"_f_apl":{"iconPath":"../../icons/file_type_apl.svg"},"_f_light_apl":{"iconPath":"../../icons/file_type_light_apl.svg"},"_f_applescript":{"iconPath":"../../icons/file_type_applescript.svg"},"_f_appscript":{"iconPath":"../../icons/file_type_appscript.svg"},"_f_appsemble":{"iconPath":"../../icons/file_type_appsemble.svg"},"_f_appveyor":{"iconPath":"../../icons/file_type_appveyor.svg"},"_f_arduino":{"iconPath":"../../icons/file_type_arduino.svg"},"_f_asciidoc":{"iconPath":"../../icons/file_type_asciidoc.svg"},"_f_aseprite":{"iconPath":"../../icons/file_type_aseprite.svg"},"_f_asp":{"iconPath":"../../icons/file_type_asp.svg"},"_f_aspx":{"iconPath":"../../icons/file_type_aspx.svg"},"_f_assembly":{"iconPath":"../../icons/file_type_assembly.svg"},"_f_assembly_script":{"iconPath":"../../icons/file_type_assembly_script.svg"},"_f_astro":{"iconPath":"../../icons/file_type_astro.svg"},"_f_light_astro":{"iconPath":"../../icons/file_type_light_astro.svg"},"_f_astroconfig":{"iconPath":"../../icons/file_type_astroconfig.svg"},"_f_light_astroconfig":{"iconPath":"../../icons/file_type_light_astroconfig.svg"},"_f_atom":{"iconPath":"../../icons/file_type_atom.svg"},"_f_ats":{"iconPath":"../../icons/file_type_ats.svg"},"_f_attw":{"iconPath":"../../icons/file_type_attw.svg"},"_f_audio":{"iconPath":"../../icons/file_type_audio.svg"},"_f_aurelia":{"iconPath":"../../icons/file_type_aurelia.svg"},"_f_autohotkey":{"iconPath":"../../icons/file_type_autohotkey.svg"},"_f_autoit":{"iconPath":"../../icons/file_type_autoit.svg"},"_f_avif":{"iconPath":"../../icons/file_type_avif.svg"},"_f_avro":{"iconPath":"../../icons/file_type_avro.svg"},"_f_awk":{"iconPath":"../../icons/file_type_awk.svg"},"_f_aws":{"iconPath":"../../icons/file_type_aws.svg"},"_f_azure":{"iconPath":"../../icons/file_type_azure.svg"},"_f_azurepipelines":{"iconPath":"../../icons/file_type_azurepipelines.svg"},"_f_babel":{"iconPath":"../../icons/file_type_babel.svg"},"_f_light_babel":{"iconPath":"../../icons/file_type_light_babel.svg"},"_f_bak":{"iconPath":"../../icons/file_type_bak.svg"},"_f_ballerina":{"iconPath":"../../icons/file_type_ballerina.svg"},"_f_bat":{"iconPath":"../../icons/file_type_bat.svg"},"_f_bats":{"iconPath":"../../icons/file_type_bats.svg"},"_f_bazaar":{"iconPath":"../../icons/file_type_bazaar.svg"},"_f_bazel":{"iconPath":"../../icons/file_type_bazel.svg"},"_f_bazel_ignore":{"iconPath":"../../icons/file_type_bazel_ignore.svg"},"_f_bazel_version":{"iconPath":"../../icons/file_type_bazel_version.svg"},"_f_befunge":{"iconPath":"../../icons/file_type_befunge.svg"},"_f_bicep":{"iconPath":"../../icons/file_type_bicep.svg"},"_f_biml":{"iconPath":"../../icons/file_type_biml.svg"},"_f_binary":{"iconPath":"../../icons/file_type_binary.svg"},"_f_biome":{"iconPath":"../../icons/file_type_biome.svg"},"_f_bitbucketpipeline":{"iconPath":"../../icons/file_type_bitbucketpipeline.svg"},"_f_bithound":{"iconPath":"../../icons/file_type_bithound.svg"},"_f_blade":{"iconPath":"../../icons/file_type_blade.svg"},"_f_blender":{"iconPath":"../../icons/file_type_blender.svg"},"_f_blitzbasic":{"iconPath":"../../icons/file_type_blitzbasic.svg"},"_f_bolt":{"iconPath":"../../icons/file_type_bolt.svg"},"_f_bosque":{"iconPath":"../../icons/file_type_bosque.svg"},"_f_bower":{"iconPath":"../../icons/file_type_bower.svg"},"_f_browserslist":{"iconPath":"../../icons/file_type_browserslist.svg"},"_f_bruno":{"iconPath":"../../icons/file_type_bruno.svg"},"_f_buckbuild":{"iconPath":"../../icons/file_type_buckbuild.svg"},"_f_buf":{"iconPath":"../../icons/file_type_buf.svg"},"_f_bun":{"iconPath":"../../icons/file_type_bun.svg"},"_f_bundlemon":{"iconPath":"../../icons/file_type_bundlemon.svg"},"_f_bundler":{"iconPath":"../../icons/file_type_bundler.svg"},"_f_bunfig":{"iconPath":"../../icons/file_type_bunfig.svg"},"_f_c":{"iconPath":"../../icons/file_type_c.svg"},"_f_c3":{"iconPath":"../../icons/file_type_c3.svg"},"_f_c_al":{"iconPath":"../../icons/file_type_c_al.svg"},"_f_cabal":{"iconPath":"../../icons/file_type_cabal.svg"},"_f_caddy":{"iconPath":"../../icons/file_type_caddy.svg"},"_f_cake":{"iconPath":"../../icons/file_type_cake.svg"},"_f_cakephp":{"iconPath":"../../icons/file_type_cakephp.svg"},"_f_capacitor":{"iconPath":"../../icons/file_type_capacitor.svg"},"_f_capnp":{"iconPath":"../../icons/file_type_capnp.svg"},"_f_cargo":{"iconPath":"../../icons/file_type_cargo.svg"},"_f_casc":{"iconPath":"../../icons/file_type_casc.svg"},"_f_cddl":{"iconPath":"../../icons/file_type_cddl.svg"},"_f_cert":{"iconPath":"../../icons/file_type_cert.svg"},"_f_ceylon":{"iconPath":"../../icons/file_type_ceylon.svg"},"_f_cf":{"iconPath":"../../icons/file_type_cf.svg"},"_f_cfc":{"iconPath":"../../icons/file_type_cfc.svg"},"_f_cfm":{"iconPath":"../../icons/file_type_cfm.svg"},"_f_changie":{"iconPath":"../../icons/file_type_changie.svg"},"_f_cheader":{"iconPath":"../../icons/file_type_cheader.svg"},"_f_chef":{"iconPath":"../../icons/file_type_chef.svg"},"_f_chef_cookbook":{"iconPath":"../../icons/file_type_chef_cookbook.svg"},"_f_circleci":{"iconPath":"../../icons/file_type_circleci.svg"},"_f_light_circleci":{"iconPath":"../../icons/file_type_light_circleci.svg"},"_f_circom":{"iconPath":"../../icons/file_type_circom.svg"},"_f_class":{"iconPath":"../../icons/file_type_class.svg"},"_f_claude":{"iconPath":"../../icons/file_type_claude.svg"},"_f_clojure":{"iconPath":"../../icons/file_type_clojure.svg"},"_f_clojurescript":{"iconPath":"../../icons/file_type_clojurescript.svg"},"_f_cloudflare":{"iconPath":"../../icons/file_type_cloudflare.svg"},"_f_cloudfoundry":{"iconPath":"../../icons/file_type_cloudfoundry.svg"},"_f_light_cloudfoundry":{"iconPath":"../../icons/file_type_light_cloudfoundry.svg"},"_f_cmake":{"iconPath":"../../icons/file_type_cmake.svg"},"_f_cobol":{"iconPath":"../../icons/file_type_cobol.svg"},"_f_codacy":{"iconPath":"../../icons/file_type_codacy.svg"},"_f_light_codacy":{"iconPath":"../../icons/file_type_light_codacy.svg"},"_f_codeclimate":{"iconPath":"../../icons/file_type_codeclimate.svg"},"_f_light_codeclimate":{"iconPath":"../../icons/file_type_light_codeclimate.svg"},"_f_codecov":{"iconPath":"../../icons/file_type_codecov.svg"},"_f_codekit":{"iconPath":"../../icons/file_type_codekit.svg"},"_f_codemagic":{"iconPath":"../../icons/file_type_codemagic.svg"},"_f_codeowners":{"iconPath":"../../icons/file_type_codeowners.svg"},"_f_light_codeowners":{"iconPath":"../../icons/file_type_light_codeowners.svg"},"_f_codeql":{"iconPath":"../../icons/file_type_codeql.svg"},"_f_coderabbit":{"iconPath":"../../icons/file_type_coderabbit.svg"},"_f_coffeelint":{"iconPath":"../../icons/file_type_coffeelint.svg"},"_f_coffeescript":{"iconPath":"../../icons/file_type_coffeescript.svg"},"_f_commitizen":{"iconPath":"../../icons/file_type_commitizen.svg"},"_f_commitlint":{"iconPath":"../../icons/file_type_commitlint.svg"},"_f_compass":{"iconPath":"../../icons/file_type_compass.svg"},"_f_composer":{"iconPath":"../../icons/file_type_composer.svg"},"_f_conan":{"iconPath":"../../icons/file_type_conan.svg"},"_f_conda":{"iconPath":"../../icons/file_type_conda.svg"},"_f_config":{"iconPath":"../../icons/file_type_config.svg"},"_f_light_config":{"iconPath":"../../icons/file_type_light_config.svg"},"_f_confluence":{"iconPath":"../../icons/file_type_confluence.svg"},"_f_copilot":{"iconPath":"../../icons/file_type_copilot.svg"},"_f_light_copilot":{"iconPath":"../../icons/file_type_light_copilot.svg"},"_f_coverage":{"iconPath":"../../icons/file_type_coverage.svg"},"_f_coveralls":{"iconPath":"../../icons/file_type_coveralls.svg"},"_f_cpp":{"iconPath":"../../icons/file_type_cpp.svg"},"_f_cppheader":{"iconPath":"../../icons/file_type_cppheader.svg"},"_f_craco":{"iconPath":"../../icons/file_type_craco.svg"},"_f_crowdin":{"iconPath":"../../icons/file_type_crowdin.svg"},"_f_crystal":{"iconPath":"../../icons/file_type_crystal.svg"},"_f_light_crystal":{"iconPath":"../../icons/file_type_light_crystal.svg"},"_f_csharp":{"iconPath":"../../icons/file_type_csharp.svg"},"_f_cspell":{"iconPath":"../../icons/file_type_cspell.svg"},"_f_csproj":{"iconPath":"../../icons/file_type_csproj.svg"},"_f_css":{"iconPath":"../../icons/file_type_css.svg"},"_f_csscomb":{"iconPath":"../../icons/file_type_csscomb.svg"},"_f_csslint":{"iconPath":"../../icons/file_type_csslint.svg"},"_f_cssmap":{"iconPath":"../../icons/file_type_cssmap.svg"},"_f_cucumber":{"iconPath":"../../icons/file_type_cucumber.svg"},"_f_cuda":{"iconPath":"../../icons/file_type_cuda.svg"},"_f_cursorrules":{"iconPath":"../../icons/file_type_cursorrules.svg"},"_f_cvs":{"iconPath":"../../icons/file_type_cvs.svg"},"_f_cypress":{"iconPath":"../../icons/file_type_cypress.svg"},"_f_light_cypress":{"iconPath":"../../icons/file_type_light_cypress.svg"},"_f_cypress_spec":{"iconPath":"../../icons/file_type_cypress_spec.svg"},"_f_light_cypress_spec":{"iconPath":"../../icons/file_type_light_cypress_spec.svg"},"_f_cython":{"iconPath":"../../icons/file_type_cython.svg"},"_f_dal":{"iconPath":"../../icons/file_type_dal.svg"},"_f_darcs":{"iconPath":"../../icons/file_type_darcs.svg"},"_f_dartlang":{"iconPath":"../../icons/file_type_dartlang.svg"},"_f_dartlang_generated":{"iconPath":"../../icons/file_type_dartlang_generated.svg"},"_f_dartlang_ignore":{"iconPath":"../../icons/file_type_dartlang_ignore.svg"},"_f_datadog":{"iconPath":"../../icons/file_type_datadog.svg"},"_f_db":{"iconPath":"../../icons/file_type_db.svg"},"_f_light_db":{"iconPath":"../../icons/file_type_light_db.svg"},"_f_debian":{"iconPath":"../../icons/file_type_debian.svg"},"_f_delphi":{"iconPath":"../../icons/file_type_delphi.svg"},"_f_deno":{"iconPath":"../../icons/file_type_deno.svg"},"_f_denoify":{"iconPath":"../../icons/file_type_denoify.svg"},"_f_dependabot":{"iconPath":"../../icons/file_type_dependabot.svg"},"_f_dependencies":{"iconPath":"../../icons/file_type_dependencies.svg"},"_f_design_tokens":{"iconPath":"../../icons/file_type_design_tokens.svg"},"_f_devcontainer":{"iconPath":"../../icons/file_type_devcontainer.svg"},"_f_devvit":{"iconPath":"../../icons/file_type_devvit.svg"},"_f_dhall":{"iconPath":"../../icons/file_type_dhall.svg"},"_f_light_dhall":{"iconPath":"../../icons/file_type_light_dhall.svg"},"_f_diff":{"iconPath":"../../icons/file_type_diff.svg"},"_f_django":{"iconPath":"../../icons/file_type_django.svg"},"_f_dlang":{"iconPath":"../../icons/file_type_dlang.svg"},"_f_docker":{"iconPath":"../../icons/file_type_docker.svg"},"_f_dockertest":{"iconPath":"../../icons/file_type_dockertest.svg"},"_f_docpad":{"iconPath":"../../icons/file_type_docpad.svg"},"_f_light_docpad":{"iconPath":"../../icons/file_type_light_docpad.svg"},"_f_docusaurus":{"iconPath":"../../icons/file_type_docusaurus.svg"},"_f_docz":{"iconPath":"../../icons/file_type_docz.svg"},"_f_dojo":{"iconPath":"../../icons/file_type_dojo.svg"},"_f_doppler":{"iconPath":"../../icons/file_type_doppler.svg"},"_f_dotenv":{"iconPath":"../../icons/file_type_dotenv.svg"},"_f_dotjs":{"iconPath":"../../icons/file_type_dotjs.svg"},"_f_doxygen":{"iconPath":"../../icons/file_type_doxygen.svg"},"_f_drawio":{"iconPath":"../../icons/file_type_drawio.svg"},"_f_drone":{"iconPath":"../../icons/file_type_drone.svg"},"_f_light_drone":{"iconPath":"../../icons/file_type_light_drone.svg"},"_f_drools":{"iconPath":"../../icons/file_type_drools.svg"},"_f_dtd":{"iconPath":"../../icons/file_type_dtd.svg"},"_f_dune":{"iconPath":"../../icons/file_type_dune.svg"},"_f_dustjs":{"iconPath":"../../icons/file_type_dustjs.svg"},"_f_dvc":{"iconPath":"../../icons/file_type_dvc.svg"},"_f_dylan":{"iconPath":"../../icons/file_type_dylan.svg"},"_f_earthly":{"iconPath":"../../icons/file_type_earthly.svg"},"_f_eas-metadata":{"iconPath":"../../icons/file_type_eas-metadata.svg"},"_f_light_eas-metadata":{"iconPath":"../../icons/file_type_light_eas-metadata.svg"},"_f_edge":{"iconPath":"../../icons/file_type_edge.svg"},"_f_editorconfig":{"iconPath":"../../icons/file_type_editorconfig.svg"},"_f_eex":{"iconPath":"../../icons/file_type_eex.svg"},"_f_ejs":{"iconPath":"../../icons/file_type_ejs.svg"},"_f_elastic":{"iconPath":"../../icons/file_type_elastic.svg"},"_f_elasticbeanstalk":{"iconPath":"../../icons/file_type_elasticbeanstalk.svg"},"_f_electron":{"iconPath":"../../icons/file_type_electron.svg"},"_f_eleventy":{"iconPath":"../../icons/file_type_eleventy.svg"},"_f_light_eleventy":{"iconPath":"../../icons/file_type_light_eleventy.svg"},"_f_elixir":{"iconPath":"../../icons/file_type_elixir.svg"},"_f_elm":{"iconPath":"../../icons/file_type_elm.svg"},"_f_emacs":{"iconPath":"../../icons/file_type_emacs.svg"},"_f_ember":{"iconPath":"../../icons/file_type_ember.svg"},"_f_ensime":{"iconPath":"../../icons/file_type_ensime.svg"},"_f_eps":{"iconPath":"../../icons/file_type_eps.svg"},"_f_epub":{"iconPath":"../../icons/file_type_epub.svg"},"_f_erb":{"iconPath":"../../icons/file_type_erb.svg"},"_f_erlang":{"iconPath":"../../icons/file_type_erlang.svg"},"_f_esbuild":{"iconPath":"../../icons/file_type_esbuild.svg"},"_f_eslint":{"iconPath":"../../icons/file_type_eslint.svg"},"_f_esphome":{"iconPath":"../../icons/file_type_esphome.svg"},"_f_light_esphome":{"iconPath":"../../icons/file_type_light_esphome.svg"},"_f_excalidraw":{"iconPath":"../../icons/file_type_excalidraw.svg"},"_f_excel":{"iconPath":"../../icons/file_type_excel.svg"},"_f_expo":{"iconPath":"../../icons/file_type_expo.svg"},"_f_light_expo":{"iconPath":"../../icons/file_type_light_expo.svg"},"_f_falcon":{"iconPath":"../../icons/file_type_falcon.svg"},"_f_fantasticon":{"iconPath":"../../icons/file_type_fantasticon.svg"},"_f_fastly":{"iconPath":"../../icons/file_type_fastly.svg"},"_f_fauna":{"iconPath":"../../icons/file_type_fauna.svg"},"_f_favicon":{"iconPath":"../../icons/file_type_favicon.svg"},"_f_fbx":{"iconPath":"../../icons/file_type_fbx.svg"},"_f_firebase":{"iconPath":"../../icons/file_type_firebase.svg"},"_f_firebasehosting":{"iconPath":"../../icons/file_type_firebasehosting.svg"},"_f_light_firebasehosting":{"iconPath":"../../icons/file_type_light_firebasehosting.svg"},"_f_firebasestorage":{"iconPath":"../../icons/file_type_firebasestorage.svg"},"_f_firestore":{"iconPath":"../../icons/file_type_firestore.svg"},"_f_fitbit":{"iconPath":"../../icons/file_type_fitbit.svg"},"_f_fla":{"iconPath":"../../icons/file_type_fla.svg"},"_f_light_fla":{"iconPath":"../../icons/file_type_light_fla.svg"},"_f_flareact":{"iconPath":"../../icons/file_type_flareact.svg"},"_f_flash":{"iconPath":"../../icons/file_type_flash.svg"},"_f_floobits":{"iconPath":"../../icons/file_type_floobits.svg"},"_f_flow":{"iconPath":"../../icons/file_type_flow.svg"},"_f_flutter":{"iconPath":"../../icons/file_type_flutter.svg"},"_f_flutter_package":{"iconPath":"../../icons/file_type_flutter_package.svg"},"_f_flyio":{"iconPath":"../../icons/file_type_flyio.svg"},"_f_font":{"iconPath":"../../icons/file_type_font.svg"},"_f_light_font":{"iconPath":"../../icons/file_type_light_font.svg"},"_f_formkit":{"iconPath":"../../icons/file_type_formkit.svg"},"_f_fortran":{"iconPath":"../../icons/file_type_fortran.svg"},"_f_fossa":{"iconPath":"../../icons/file_type_fossa.svg"},"_f_fossil":{"iconPath":"../../icons/file_type_fossil.svg"},"_f_freemarker":{"iconPath":"../../icons/file_type_freemarker.svg"},"_f_frontcommerce":{"iconPath":"../../icons/file_type_frontcommerce.svg"},"_f_fsharp":{"iconPath":"../../icons/file_type_fsharp.svg"},"_f_fsproj":{"iconPath":"../../icons/file_type_fsproj.svg"},"_f_fthtml":{"iconPath":"../../icons/file_type_fthtml.svg"},"_f_funding":{"iconPath":"../../icons/file_type_funding.svg"},"_f_fusebox":{"iconPath":"../../icons/file_type_fusebox.svg"},"_f_galen":{"iconPath":"../../icons/file_type_galen.svg"},"_f_gamemaker":{"iconPath":"../../icons/file_type_gamemaker.svg"},"_f_gamemaker2":{"iconPath":"../../icons/file_type_gamemaker2.svg"},"_f_light_gamemaker2":{"iconPath":"../../icons/file_type_light_gamemaker2.svg"},"_f_gamemaker81":{"iconPath":"../../icons/file_type_gamemaker81.svg"},"_f_gatsby":{"iconPath":"../../icons/file_type_gatsby.svg"},"_f_gcloud":{"iconPath":"../../icons/file_type_gcloud.svg"},"_f_gcode":{"iconPath":"../../icons/file_type_gcode.svg"},"_f_gdscript":{"iconPath":"../../icons/file_type_gdscript.svg"},"_f_gduid":{"iconPath":"../../icons/file_type_gduid.svg"},"_f_gemini":{"iconPath":"../../icons/file_type_gemini.svg"},"_f_genstat":{"iconPath":"../../icons/file_type_genstat.svg"},"_f_geojson":{"iconPath":"../../icons/file_type_geojson.svg"},"_f_gimp":{"iconPath":"../../icons/file_type_gimp.svg"},"_f_git":{"iconPath":"../../icons/file_type_git.svg"},"_f_gitlab":{"iconPath":"../../icons/file_type_gitlab.svg"},"_f_gitpod":{"iconPath":"../../icons/file_type_gitpod.svg"},"_f_gleam":{"iconPath":"../../icons/file_type_gleam.svg"},"_f_gleamconfig":{"iconPath":"../../icons/file_type_gleamconfig.svg"},"_f_glide":{"iconPath":"../../icons/file_type_glide.svg"},"_f_glimmer":{"iconPath":"../../icons/file_type_glimmer.svg"},"_f_glitter":{"iconPath":"../../icons/file_type_glitter.svg"},"_f_glsl":{"iconPath":"../../icons/file_type_glsl.svg"},"_f_gltf":{"iconPath":"../../icons/file_type_gltf.svg"},"_f_glyphs":{"iconPath":"../../icons/file_type_glyphs.svg"},"_f_gnu":{"iconPath":"../../icons/file_type_gnu.svg"},"_f_gnuplot":{"iconPath":"../../icons/file_type_gnuplot.svg"},"_f_go":{"iconPath":"../../icons/file_type_go.svg"},"_f_go_package":{"iconPath":"../../icons/file_type_go_package.svg"},"_f_go_work":{"iconPath":"../../icons/file_type_go_work.svg"},"_f_goctl":{"iconPath":"../../icons/file_type_goctl.svg"},"_f_godot":{"iconPath":"../../icons/file_type_godot.svg"},"_f_gpg":{"iconPath":"../../icons/file_type_gpg.svg"},"_f_gradle":{"iconPath":"../../icons/file_type_gradle.svg"},"_f_light_gradle":{"iconPath":"../../icons/file_type_light_gradle.svg"},"_f_grain":{"iconPath":"../../icons/file_type_grain.svg"},"_f_graphql":{"iconPath":"../../icons/file_type_graphql.svg"},"_f_graphql_config":{"iconPath":"../../icons/file_type_graphql_config.svg"},"_f_graphviz":{"iconPath":"../../icons/file_type_graphviz.svg"},"_f_greenkeeper":{"iconPath":"../../icons/file_type_greenkeeper.svg"},"_f_gridsome":{"iconPath":"../../icons/file_type_gridsome.svg"},"_f_grit":{"iconPath":"../../icons/file_type_grit.svg"},"_f_groovy":{"iconPath":"../../icons/file_type_groovy.svg"},"_f_grunt":{"iconPath":"../../icons/file_type_grunt.svg"},"_f_gulp":{"iconPath":"../../icons/file_type_gulp.svg"},"_f_haml":{"iconPath":"../../icons/file_type_haml.svg"},"_f_handlebars":{"iconPath":"../../icons/file_type_handlebars.svg"},"_f_harbour":{"iconPath":"../../icons/file_type_harbour.svg"},"_f_hardhat":{"iconPath":"../../icons/file_type_hardhat.svg"},"_f_hashicorp":{"iconPath":"../../icons/file_type_hashicorp.svg"},"_f_light_hashicorp":{"iconPath":"../../icons/file_type_light_hashicorp.svg"},"_f_haskell":{"iconPath":"../../icons/file_type_haskell.svg"},"_f_haxe":{"iconPath":"../../icons/file_type_haxe.svg"},"_f_haxecheckstyle":{"iconPath":"../../icons/file_type_haxecheckstyle.svg"},"_f_haxedevelop":{"iconPath":"../../icons/file_type_haxedevelop.svg"},"_f_helix":{"iconPath":"../../icons/file_type_helix.svg"},"_f_helm":{"iconPath":"../../icons/file_type_helm.svg"},"_f_histoire":{"iconPath":"../../icons/file_type_histoire.svg"},"_f_hjson":{"iconPath":"../../icons/file_type_hjson.svg"},"_f_light_hjson":{"iconPath":"../../icons/file_type_light_hjson.svg"},"_f_hlsl":{"iconPath":"../../icons/file_type_hlsl.svg"},"_f_homeassistant":{"iconPath":"../../icons/file_type_homeassistant.svg"},"_f_horusec":{"iconPath":"../../icons/file_type_horusec.svg"},"_f_host":{"iconPath":"../../icons/file_type_host.svg"},"_f_html":{"iconPath":"../../icons/file_type_html.svg"},"_f_htmlhint":{"iconPath":"../../icons/file_type_htmlhint.svg"},"_f_htmlvalidate":{"iconPath":"../../icons/file_type_htmlvalidate.svg"},"_f_http":{"iconPath":"../../icons/file_type_http.svg"},"_f_hugo":{"iconPath":"../../icons/file_type_hugo.svg"},"_f_humanstxt":{"iconPath":"../../icons/file_type_humanstxt.svg"},"_f_hunspell":{"iconPath":"../../icons/file_type_hunspell.svg"},"_f_husky":{"iconPath":"../../icons/file_type_husky.svg"},"_f_hy":{"iconPath":"../../icons/file_type_hy.svg"},"_f_hygen":{"iconPath":"../../icons/file_type_hygen.svg"},"_f_hypr":{"iconPath":"../../icons/file_type_hypr.svg"},"_f_icl":{"iconPath":"../../icons/file_type_icl.svg"},"_f_idris":{"iconPath":"../../icons/file_type_idris.svg"},"_f_idrisbin":{"iconPath":"../../icons/file_type_idrisbin.svg"},"_f_idrispkg":{"iconPath":"../../icons/file_type_idrispkg.svg"},"_f_image":{"iconPath":"../../icons/file_type_image.svg"},"_f_imba":{"iconPath":"../../icons/file_type_imba.svg"},"_f_inc":{"iconPath":"../../icons/file_type_inc.svg"},"_f_infopath":{"iconPath":"../../icons/file_type_infopath.svg"},"_f_informix":{"iconPath":"../../icons/file_type_informix.svg"},"_f_ini":{"iconPath":"../../icons/file_type_ini.svg"},"_f_light_ini":{"iconPath":"../../icons/file_type_light_ini.svg"},"_f_ink":{"iconPath":"../../icons/file_type_ink.svg"},"_f_innosetup":{"iconPath":"../../icons/file_type_innosetup.svg"},"_f_io":{"iconPath":"../../icons/file_type_io.svg"},"_f_light_io":{"iconPath":"../../icons/file_type_light_io.svg"},"_f_iodine":{"iconPath":"../../icons/file_type_iodine.svg"},"_f_ionic":{"iconPath":"../../icons/file_type_ionic.svg"},"_f_jake":{"iconPath":"../../icons/file_type_jake.svg"},"_f_janet":{"iconPath":"../../icons/file_type_janet.svg"},"_f_jar":{"iconPath":"../../icons/file_type_jar.svg"},"_f_jasmine":{"iconPath":"../../icons/file_type_jasmine.svg"},"_f_java":{"iconPath":"../../icons/file_type_java.svg"},"_f_jbuilder":{"iconPath":"../../icons/file_type_jbuilder.svg"},"_f_jekyll":{"iconPath":"../../icons/file_type_jekyll.svg"},"_f_jenkins":{"iconPath":"../../icons/file_type_jenkins.svg"},"_f_jest":{"iconPath":"../../icons/file_type_jest.svg"},"_f_jest_snapshot":{"iconPath":"../../icons/file_type_jest_snapshot.svg"},"_f_jetbrains":{"iconPath":"../../icons/file_type_jetbrains.svg"},"_f_jinja":{"iconPath":"../../icons/file_type_jinja.svg"},"_f_jpm":{"iconPath":"../../icons/file_type_jpm.svg"},"_f_js":{"iconPath":"../../icons/file_type_js.svg"},"_f_light_js":{"iconPath":"../../icons/file_type_light_js.svg"},"_f_jsbeautify":{"iconPath":"../../icons/file_type_jsbeautify.svg"},"_f_jsconfig":{"iconPath":"../../icons/file_type_jsconfig.svg"},"_f_light_jsconfig":{"iconPath":"../../icons/file_type_light_jsconfig.svg"},"_f_jscpd":{"iconPath":"../../icons/file_type_jscpd.svg"},"_f_jshint":{"iconPath":"../../icons/file_type_jshint.svg"},"_f_jsmap":{"iconPath":"../../icons/file_type_jsmap.svg"},"_f_light_jsmap":{"iconPath":"../../icons/file_type_light_jsmap.svg"},"_f_json":{"iconPath":"../../icons/file_type_json.svg"},"_f_light_json":{"iconPath":"../../icons/file_type_light_json.svg"},"_f_json5":{"iconPath":"../../icons/file_type_json5.svg"},"_f_light_json5":{"iconPath":"../../icons/file_type_light_json5.svg"},"_f_json_schema":{"iconPath":"../../icons/file_type_json_schema.svg"},"_f_light_json_schema":{"iconPath":"../../icons/file_type_light_json_schema.svg"},"_f_jsonld":{"iconPath":"../../icons/file_type_jsonld.svg"},"_f_light_jsonld":{"iconPath":"../../icons/file_type_light_jsonld.svg"},"_f_jsonnet":{"iconPath":"../../icons/file_type_jsonnet.svg"},"_f_jsp":{"iconPath":"../../icons/file_type_jsp.svg"},"_f_jsr":{"iconPath":"../../icons/file_type_jsr.svg"},"_f_jss":{"iconPath":"../../icons/file_type_jss.svg"},"_f_juice":{"iconPath":"../../icons/file_type_juice.svg"},"_f_julia":{"iconPath":"../../icons/file_type_julia.svg"},"_f_jupyter":{"iconPath":"../../icons/file_type_jupyter.svg"},"_f_just":{"iconPath":"../../icons/file_type_just.svg"},"_f_k":{"iconPath":"../../icons/file_type_k.svg"},"_f_karma":{"iconPath":"../../icons/file_type_karma.svg"},"_f_key":{"iconPath":"../../icons/file_type_key.svg"},"_f_kitchenci":{"iconPath":"../../icons/file_type_kitchenci.svg"},"_f_kite":{"iconPath":"../../icons/file_type_kite.svg"},"_f_light_kite":{"iconPath":"../../icons/file_type_light_kite.svg"},"_f_kivy":{"iconPath":"../../icons/file_type_kivy.svg"},"_f_knip":{"iconPath":"../../icons/file_type_knip.svg"},"_f_kos":{"iconPath":"../../icons/file_type_kos.svg"},"_f_kotlin":{"iconPath":"../../icons/file_type_kotlin.svg"},"_f_kusto":{"iconPath":"../../icons/file_type_kusto.svg"},"_f_language_configuration":{"iconPath":"../../icons/file_type_language_configuration.svg"},"_f_lark":{"iconPath":"../../icons/file_type_lark.svg"},"_f_light_lark":{"iconPath":"../../icons/file_type_light_lark.svg"},"_f_latino":{"iconPath":"../../icons/file_type_latino.svg"},"_f_layout":{"iconPath":"../../icons/file_type_layout.svg"},"_f_lean":{"iconPath":"../../icons/file_type_lean.svg"},"_f_light_lean":{"iconPath":"../../icons/file_type_light_lean.svg"},"_f_leanconfig":{"iconPath":"../../icons/file_type_leanconfig.svg"},"_f_light_leanconfig":{"iconPath":"../../icons/file_type_light_leanconfig.svg"},"_f_lefthook":{"iconPath":"../../icons/file_type_lefthook.svg"},"_f_lerna":{"iconPath":"../../icons/file_type_lerna.svg"},"_f_light_lerna":{"iconPath":"../../icons/file_type_light_lerna.svg"},"_f_less":{"iconPath":"../../icons/file_type_less.svg"},"_f_lex":{"iconPath":"../../icons/file_type_lex.svg"},"_f_liara":{"iconPath":"../../icons/file_type_liara.svg"},"_f_libreoffice_base":{"iconPath":"../../icons/file_type_libreoffice_base.svg"},"_f_libreoffice_calc":{"iconPath":"../../icons/file_type_libreoffice_calc.svg"},"_f_libreoffice_draw":{"iconPath":"../../icons/file_type_libreoffice_draw.svg"},"_f_libreoffice_impress":{"iconPath":"../../icons/file_type_libreoffice_impress.svg"},"_f_libreoffice_math":{"iconPath":"../../icons/file_type_libreoffice_math.svg"},"_f_libreoffice_writer":{"iconPath":"../../icons/file_type_libreoffice_writer.svg"},"_f_license":{"iconPath":"../../icons/file_type_license.svg"},"_f_licensebat":{"iconPath":"../../icons/file_type_licensebat.svg"},"_f_lighthouse":{"iconPath":"../../icons/file_type_lighthouse.svg"},"_f_lilypond":{"iconPath":"../../icons/file_type_lilypond.svg"},"_f_lime":{"iconPath":"../../icons/file_type_lime.svg"},"_f_lintstagedrc":{"iconPath":"../../icons/file_type_lintstagedrc.svg"},"_f_liquid":{"iconPath":"../../icons/file_type_liquid.svg"},"_f_lisp":{"iconPath":"../../icons/file_type_lisp.svg"},"_f_livescript":{"iconPath":"../../icons/file_type_livescript.svg"},"_f_lnk":{"iconPath":"../../icons/file_type_lnk.svg"},"_f_locale":{"iconPath":"../../icons/file_type_locale.svg"},"_f_log":{"iconPath":"../../icons/file_type_log.svg"},"_f_lolcode":{"iconPath":"../../icons/file_type_lolcode.svg"},"_f_lsl":{"iconPath":"../../icons/file_type_lsl.svg"},"_f_lua":{"iconPath":"../../icons/file_type_lua.svg"},"_f_luau":{"iconPath":"../../icons/file_type_luau.svg"},"_f_lync":{"iconPath":"../../icons/file_type_lync.svg"},"_f_mailing":{"iconPath":"../../icons/file_type_mailing.svg"},"_f_light_mailing":{"iconPath":"../../icons/file_type_light_mailing.svg"},"_f_manifest":{"iconPath":"../../icons/file_type_manifest.svg"},"_f_manifest_bak":{"iconPath":"../../icons/file_type_manifest_bak.svg"},"_f_manifest_skip":{"iconPath":"../../icons/file_type_manifest_skip.svg"},"_f_map":{"iconPath":"../../icons/file_type_map.svg"},"_f_markdown":{"iconPath":"../../icons/file_type_markdown.svg"},"_f_markdownlint":{"iconPath":"../../icons/file_type_markdownlint.svg"},"_f_markdownlint_ignore":{"iconPath":"../../icons/file_type_markdownlint_ignore.svg"},"_f_marko":{"iconPath":"../../icons/file_type_marko.svg"},"_f_markojs":{"iconPath":"../../icons/file_type_markojs.svg"},"_f_markuplint":{"iconPath":"../../icons/file_type_markuplint.svg"},"_f_light_markuplint":{"iconPath":"../../icons/file_type_light_markuplint.svg"},"_f_master-co":{"iconPath":"../../icons/file_type_master-co.svg"},"_f_matlab":{"iconPath":"../../icons/file_type_matlab.svg"},"_f_maven":{"iconPath":"../../icons/file_type_maven.svg"},"_f_maxscript":{"iconPath":"../../icons/file_type_maxscript.svg"},"_f_maya":{"iconPath":"../../icons/file_type_maya.svg"},"_f_mcp":{"iconPath":"../../icons/file_type_mcp.svg"},"_f_light_mcp":{"iconPath":"../../icons/file_type_light_mcp.svg"},"_f_mdx":{"iconPath":"../../icons/file_type_mdx.svg"},"_f_light_mdx":{"iconPath":"../../icons/file_type_light_mdx.svg"},"_f_mdx-components":{"iconPath":"../../icons/file_type_mdx-components.svg"},"_f_light_mdx-components":{"iconPath":"../../icons/file_type_light_mdx-components.svg"},"_f_mdxlint":{"iconPath":"../../icons/file_type_mdxlint.svg"},"_f_mediawiki":{"iconPath":"../../icons/file_type_mediawiki.svg"},"_f_mercurial":{"iconPath":"../../icons/file_type_mercurial.svg"},"_f_mermaid":{"iconPath":"../../icons/file_type_mermaid.svg"},"_f_meson":{"iconPath":"../../icons/file_type_meson.svg"},"_f_metal":{"iconPath":"../../icons/file_type_metal.svg"},"_f_meteor":{"iconPath":"../../icons/file_type_meteor.svg"},"_f_minecraft":{"iconPath":"../../icons/file_type_minecraft.svg"},"_f_mivascript":{"iconPath":"../../icons/file_type_mivascript.svg"},"_f_mjml":{"iconPath":"../../icons/file_type_mjml.svg"},"_f_mlang":{"iconPath":"../../icons/file_type_mlang.svg"},"_f_light_mlang":{"iconPath":"../../icons/file_type_light_mlang.svg"},"_f_mocha":{"iconPath":"../../icons/file_type_mocha.svg"},"_f_modernizr":{"iconPath":"../../icons/file_type_modernizr.svg"},"_f_mojo":{"iconPath":"../../icons/file_type_mojo.svg"},"_f_mojolicious":{"iconPath":"../../icons/file_type_mojolicious.svg"},"_f_moleculer":{"iconPath":"../../icons/file_type_moleculer.svg"},"_f_mondoo":{"iconPath":"../../icons/file_type_mondoo.svg"},"_f_mongo":{"iconPath":"../../icons/file_type_mongo.svg"},"_f_monotone":{"iconPath":"../../icons/file_type_monotone.svg"},"_f_motif":{"iconPath":"../../icons/file_type_motif.svg"},"_f_mson":{"iconPath":"../../icons/file_type_mson.svg"},"_f_mustache":{"iconPath":"../../icons/file_type_mustache.svg"},"_f_light_mustache":{"iconPath":"../../icons/file_type_light_mustache.svg"},"_f_mvt":{"iconPath":"../../icons/file_type_mvt.svg"},"_f_mvtcss":{"iconPath":"../../icons/file_type_mvtcss.svg"},"_f_mvtjs":{"iconPath":"../../icons/file_type_mvtjs.svg"},"_f_mypy":{"iconPath":"../../icons/file_type_mypy.svg"},"_f_light_mypy":{"iconPath":"../../icons/file_type_light_mypy.svg"},"_f_nanostaged":{"iconPath":"../../icons/file_type_nanostaged.svg"},"_f_ndst":{"iconPath":"../../icons/file_type_ndst.svg"},"_f_nearly":{"iconPath":"../../icons/file_type_nearly.svg"},"_f_neo4j":{"iconPath":"../../icons/file_type_neo4j.svg"},"_f_light_neo4j":{"iconPath":"../../icons/file_type_light_neo4j.svg"},"_f_nestjs":{"iconPath":"../../icons/file_type_nestjs.svg"},"_f_netlify":{"iconPath":"../../icons/file_type_netlify.svg"},"_f_light_netlify":{"iconPath":"../../icons/file_type_light_netlify.svg"},"_f_next":{"iconPath":"../../icons/file_type_next.svg"},"_f_light_next":{"iconPath":"../../icons/file_type_light_next.svg"},"_f_nextflow":{"iconPath":"../../icons/file_type_nextflow.svg"},"_f_ng_tailwind":{"iconPath":"../../icons/file_type_ng_tailwind.svg"},"_f_nginx":{"iconPath":"../../icons/file_type_nginx.svg"},"_f_nim":{"iconPath":"../../icons/file_type_nim.svg"},"_f_nimble":{"iconPath":"../../icons/file_type_nimble.svg"},"_f_ninja":{"iconPath":"../../icons/file_type_ninja.svg"},"_f_nix":{"iconPath":"../../icons/file_type_nix.svg"},"_f_njsproj":{"iconPath":"../../icons/file_type_njsproj.svg"},"_f_noc":{"iconPath":"../../icons/file_type_noc.svg"},"_f_node":{"iconPath":"../../icons/file_type_node.svg"},"_f_nodemon":{"iconPath":"../../icons/file_type_nodemon.svg"},"_f_npm":{"iconPath":"../../icons/file_type_npm.svg"},"_f_npmpackagejsonlint":{"iconPath":"../../icons/file_type_npmpackagejsonlint.svg"},"_f_nsi":{"iconPath":"../../icons/file_type_nsi.svg"},"_f_nsri":{"iconPath":"../../icons/file_type_nsri.svg"},"_f_nsri-integrity":{"iconPath":"../../icons/file_type_nsri-integrity.svg"},"_f_nuget":{"iconPath":"../../icons/file_type_nuget.svg"},"_f_numpy":{"iconPath":"../../icons/file_type_numpy.svg"},"_f_nunjucks":{"iconPath":"../../icons/file_type_nunjucks.svg"},"_f_nushell":{"iconPath":"../../icons/file_type_nushell.svg"},"_f_nuxt":{"iconPath":"../../icons/file_type_nuxt.svg"},"_f_nx":{"iconPath":"../../icons/file_type_nx.svg"},"_f_light_nx":{"iconPath":"../../icons/file_type_light_nx.svg"},"_f_nyc":{"iconPath":"../../icons/file_type_nyc.svg"},"_f_objectivec":{"iconPath":"../../icons/file_type_objectivec.svg"},"_f_objectivecpp":{"iconPath":"../../icons/file_type_objectivecpp.svg"},"_f_objidconfig":{"iconPath":"../../icons/file_type_objidconfig.svg"},"_f_light_objidconfig":{"iconPath":"../../icons/file_type_light_objidconfig.svg"},"_f_ocaml":{"iconPath":"../../icons/file_type_ocaml.svg"},"_f_ocaml_intf":{"iconPath":"../../icons/file_type_ocaml_intf.svg"},"_f_ogone":{"iconPath":"../../icons/file_type_ogone.svg"},"_f_onenote":{"iconPath":"../../icons/file_type_onenote.svg"},"_f_opam":{"iconPath":"../../icons/file_type_opam.svg"},"_f_openHAB":{"iconPath":"../../icons/file_type_openHAB.svg"},"_f_light_openHAB":{"iconPath":"../../icons/file_type_light_openHAB.svg"},"_f_opencl":{"iconPath":"../../icons/file_type_opencl.svg"},"_f_openscad":{"iconPath":"../../icons/file_type_openscad.svg"},"_f_opentofu":{"iconPath":"../../icons/file_type_opentofu.svg"},"_f_org":{"iconPath":"../../icons/file_type_org.svg"},"_f_outlook":{"iconPath":"../../icons/file_type_outlook.svg"},"_f_ovpn":{"iconPath":"../../icons/file_type_ovpn.svg"},"_f_oxc":{"iconPath":"../../icons/file_type_oxc.svg"},"_f_light_oxc":{"iconPath":"../../icons/file_type_light_oxc.svg"},"_f_package":{"iconPath":"../../icons/file_type_package.svg"},"_f_packship":{"iconPath":"../../icons/file_type_packship.svg"},"_f_light_packship":{"iconPath":"../../icons/file_type_light_packship.svg"},"_f_paket":{"iconPath":"../../icons/file_type_paket.svg"},"_f_pandacss":{"iconPath":"../../icons/file_type_pandacss.svg"},"_f_patch":{"iconPath":"../../icons/file_type_patch.svg"},"_f_pcl":{"iconPath":"../../icons/file_type_pcl.svg"},"_f_light_pcl":{"iconPath":"../../icons/file_type_light_pcl.svg"},"_f_pddl":{"iconPath":"../../icons/file_type_pddl.svg"},"_f_pddl_happenings":{"iconPath":"../../icons/file_type_pddl_happenings.svg"},"_f_pddl_plan":{"iconPath":"../../icons/file_type_pddl_plan.svg"},"_f_pdf":{"iconPath":"../../icons/file_type_pdf.svg"},"_f_pdm":{"iconPath":"../../icons/file_type_pdm.svg"},"_f_peeky":{"iconPath":"../../icons/file_type_peeky.svg"},"_f_perl":{"iconPath":"../../icons/file_type_perl.svg"},"_f_perl6":{"iconPath":"../../icons/file_type_perl6.svg"},"_f_pgsql":{"iconPath":"../../icons/file_type_pgsql.svg"},"_f_photoshop":{"iconPath":"../../icons/file_type_photoshop.svg"},"_f_php":{"iconPath":"../../icons/file_type_php.svg"},"_f_phpcsfixer":{"iconPath":"../../icons/file_type_phpcsfixer.svg"},"_f_phpstan":{"iconPath":"../../icons/file_type_phpstan.svg"},"_f_phpunit":{"iconPath":"../../icons/file_type_phpunit.svg"},"_f_phraseapp":{"iconPath":"../../icons/file_type_phraseapp.svg"},"_f_pine":{"iconPath":"../../icons/file_type_pine.svg"},"_f_pip":{"iconPath":"../../icons/file_type_pip.svg"},"_f_pipeline":{"iconPath":"../../icons/file_type_pipeline.svg"},"_f_plantuml":{"iconPath":"../../icons/file_type_plantuml.svg"},"_f_platformio":{"iconPath":"../../icons/file_type_platformio.svg"},"_f_playwright":{"iconPath":"../../icons/file_type_playwright.svg"},"_f_plsql":{"iconPath":"../../icons/file_type_plsql.svg"},"_f_plsql_package":{"iconPath":"../../icons/file_type_plsql_package.svg"},"_f_plsql_package_body":{"iconPath":"../../icons/file_type_plsql_package_body.svg"},"_f_plsql_package_header":{"iconPath":"../../icons/file_type_plsql_package_header.svg"},"_f_plsql_package_spec":{"iconPath":"../../icons/file_type_plsql_package_spec.svg"},"_f_pm2":{"iconPath":"../../icons/file_type_pm2.svg"},"_f_pnpm":{"iconPath":"../../icons/file_type_pnpm.svg"},"_f_light_pnpm":{"iconPath":"../../icons/file_type_light_pnpm.svg"},"_f_poedit":{"iconPath":"../../icons/file_type_poedit.svg"},"_f_poetry":{"iconPath":"../../icons/file_type_poetry.svg"},"_f_polyglot":{"iconPath":"../../icons/file_type_polyglot.svg"},"_f_polymer":{"iconPath":"../../icons/file_type_polymer.svg"},"_f_pony":{"iconPath":"../../icons/file_type_pony.svg"},"_f_postcss":{"iconPath":"../../icons/file_type_postcss.svg"},"_f_postcssconfig":{"iconPath":"../../icons/file_type_postcssconfig.svg"},"_f_postman":{"iconPath":"../../icons/file_type_postman.svg"},"_f_postscript":{"iconPath":"../../icons/file_type_postscript.svg"},"_f_powerpoint":{"iconPath":"../../icons/file_type_powerpoint.svg"},"_f_powershell":{"iconPath":"../../icons/file_type_powershell.svg"},"_f_powershell_format":{"iconPath":"../../icons/file_type_powershell_format.svg"},"_f_powershell_psd":{"iconPath":"../../icons/file_type_powershell_psd.svg"},"_f_powershell_psm":{"iconPath":"../../icons/file_type_powershell_psm.svg"},"_f_powershell_types":{"iconPath":"../../icons/file_type_powershell_types.svg"},"_f_preact":{"iconPath":"../../icons/file_type_preact.svg"},"_f_precommit":{"iconPath":"../../icons/file_type_precommit.svg"},"_f_prettier":{"iconPath":"../../icons/file_type_prettier.svg"},"_f_light_prettier":{"iconPath":"../../icons/file_type_light_prettier.svg"},"_f_prisma":{"iconPath":"../../icons/file_type_prisma.svg"},"_f_light_prisma":{"iconPath":"../../icons/file_type_light_prisma.svg"},"_f_prismaconfig":{"iconPath":"../../icons/file_type_prismaconfig.svg"},"_f_processinglang":{"iconPath":"../../icons/file_type_processinglang.svg"},"_f_procfile":{"iconPath":"../../icons/file_type_procfile.svg"},"_f_progress":{"iconPath":"../../icons/file_type_progress.svg"},"_f_prolog":{"iconPath":"../../icons/file_type_prolog.svg"},"_f_prometheus":{"iconPath":"../../icons/file_type_prometheus.svg"},"_f_protobuf":{"iconPath":"../../icons/file_type_protobuf.svg"},"_f_protractor":{"iconPath":"../../icons/file_type_protractor.svg"},"_f_publiccode":{"iconPath":"../../icons/file_type_publiccode.svg"},"_f_publisher":{"iconPath":"../../icons/file_type_publisher.svg"},"_f_pug":{"iconPath":"../../icons/file_type_pug.svg"},"_f_pulumi":{"iconPath":"../../icons/file_type_pulumi.svg"},"_f_puppet":{"iconPath":"../../icons/file_type_puppet.svg"},"_f_purescript":{"iconPath":"../../icons/file_type_purescript.svg"},"_f_light_purescript":{"iconPath":"../../icons/file_type_light_purescript.svg"},"_f_purgecss":{"iconPath":"../../icons/file_type_purgecss.svg"},"_f_pyenv":{"iconPath":"../../icons/file_type_pyenv.svg"},"_f_pypi":{"iconPath":"../../icons/file_type_pypi.svg"},"_f_pyret":{"iconPath":"../../icons/file_type_pyret.svg"},"_f_pyscript":{"iconPath":"../../icons/file_type_pyscript.svg"},"_f_pytest":{"iconPath":"../../icons/file_type_pytest.svg"},"_f_python":{"iconPath":"../../icons/file_type_python.svg"},"_f_pythowo":{"iconPath":"../../icons/file_type_pythowo.svg"},"_f_pytyped":{"iconPath":"../../icons/file_type_pytyped.svg"},"_f_pyup":{"iconPath":"../../icons/file_type_pyup.svg"},"_f_q":{"iconPath":"../../icons/file_type_q.svg"},"_f_qbs":{"iconPath":"../../icons/file_type_qbs.svg"},"_f_qlikview":{"iconPath":"../../icons/file_type_qlikview.svg"},"_f_qml":{"iconPath":"../../icons/file_type_qml.svg"},"_f_qmldir":{"iconPath":"../../icons/file_type_qmldir.svg"},"_f_qsharp":{"iconPath":"../../icons/file_type_qsharp.svg"},"_f_quasar":{"iconPath":"../../icons/file_type_quasar.svg"},"_f_light_quasar":{"iconPath":"../../icons/file_type_light_quasar.svg"},"_f_r":{"iconPath":"../../icons/file_type_r.svg"},"_f_ra_syntax_tree":{"iconPath":"../../icons/file_type_ra_syntax_tree.svg"},"_f_racket":{"iconPath":"../../icons/file_type_racket.svg"},"_f_rails":{"iconPath":"../../icons/file_type_rails.svg"},"_f_rake":{"iconPath":"../../icons/file_type_rake.svg"},"_f_raku":{"iconPath":"../../icons/file_type_raku.svg"},"_f_light_raku":{"iconPath":"../../icons/file_type_light_raku.svg"},"_f_raml":{"iconPath":"../../icons/file_type_raml.svg"},"_f_razor":{"iconPath":"../../icons/file_type_razor.svg"},"_f_razzle":{"iconPath":"../../icons/file_type_razzle.svg"},"_f_light_razzle":{"iconPath":"../../icons/file_type_light_razzle.svg"},"_f_reactjs":{"iconPath":"../../icons/file_type_reactjs.svg"},"_f_reactrouter":{"iconPath":"../../icons/file_type_reactrouter.svg"},"_f_light_reactrouter":{"iconPath":"../../icons/file_type_light_reactrouter.svg"},"_f_reacttemplate":{"iconPath":"../../icons/file_type_reacttemplate.svg"},"_f_reactts":{"iconPath":"../../icons/file_type_reactts.svg"},"_f_reason":{"iconPath":"../../icons/file_type_reason.svg"},"_f_red":{"iconPath":"../../icons/file_type_red.svg"},"_f_registry":{"iconPath":"../../icons/file_type_registry.svg"},"_f_rego":{"iconPath":"../../icons/file_type_rego.svg"},"_f_rehype":{"iconPath":"../../icons/file_type_rehype.svg"},"_f_light_rehype":{"iconPath":"../../icons/file_type_light_rehype.svg"},"_f_remark":{"iconPath":"../../icons/file_type_remark.svg"},"_f_light_remark":{"iconPath":"../../icons/file_type_light_remark.svg"},"_f_renovate":{"iconPath":"../../icons/file_type_renovate.svg"},"_f_replit":{"iconPath":"../../icons/file_type_replit.svg"},"_f_light_replit":{"iconPath":"../../icons/file_type_light_replit.svg"},"_f_rescript":{"iconPath":"../../icons/file_type_rescript.svg"},"_f_rest":{"iconPath":"../../icons/file_type_rest.svg"},"_f_retext":{"iconPath":"../../icons/file_type_retext.svg"},"_f_light_retext":{"iconPath":"../../icons/file_type_light_retext.svg"},"_f_rexx":{"iconPath":"../../icons/file_type_rexx.svg"},"_f_riot":{"iconPath":"../../icons/file_type_riot.svg"},"_f_ripple":{"iconPath":"../../icons/file_type_ripple.svg"},"_f_rmd":{"iconPath":"../../icons/file_type_rmd.svg"},"_f_rnc":{"iconPath":"../../icons/file_type_rnc.svg"},"_f_robotframework":{"iconPath":"../../icons/file_type_robotframework.svg"},"_f_robots":{"iconPath":"../../icons/file_type_robots.svg"},"_f_rolldown":{"iconPath":"../../icons/file_type_rolldown.svg"},"_f_light_rolldown":{"iconPath":"../../icons/file_type_light_rolldown.svg"},"_f_rollup":{"iconPath":"../../icons/file_type_rollup.svg"},"_f_rome":{"iconPath":"../../icons/file_type_rome.svg"},"_f_light_rome":{"iconPath":"../../icons/file_type_light_rome.svg"},"_f_ron":{"iconPath":"../../icons/file_type_ron.svg"},"_f_rproj":{"iconPath":"../../icons/file_type_rproj.svg"},"_f_rspec":{"iconPath":"../../icons/file_type_rspec.svg"},"_f_rss":{"iconPath":"../../icons/file_type_rss.svg"},"_f_rubocop":{"iconPath":"../../icons/file_type_rubocop.svg"},"_f_light_rubocop":{"iconPath":"../../icons/file_type_light_rubocop.svg"},"_f_ruby":{"iconPath":"../../icons/file_type_ruby.svg"},"_f_rust":{"iconPath":"../../icons/file_type_rust.svg"},"_f_light_rust":{"iconPath":"../../icons/file_type_light_rust.svg"},"_f_rust_toolchain":{"iconPath":"../../icons/file_type_rust_toolchain.svg"},"_f_light_rust_toolchain":{"iconPath":"../../icons/file_type_light_rust_toolchain.svg"},"_f_s-lang":{"iconPath":"../../icons/file_type_s-lang.svg"},"_f_safetensors":{"iconPath":"../../icons/file_type_safetensors.svg"},"_f_light_safetensors":{"iconPath":"../../icons/file_type_light_safetensors.svg"},"_f_sails":{"iconPath":"../../icons/file_type_sails.svg"},"_f_saltstack":{"iconPath":"../../icons/file_type_saltstack.svg"},"_f_san":{"iconPath":"../../icons/file_type_san.svg"},"_f_sapphire_framework_cli":{"iconPath":"../../icons/file_type_sapphire_framework_cli.svg"},"_f_sas":{"iconPath":"../../icons/file_type_sas.svg"},"_f_sass":{"iconPath":"../../icons/file_type_sass.svg"},"_f_sbt":{"iconPath":"../../icons/file_type_sbt.svg"},"_f_scala":{"iconPath":"../../icons/file_type_scala.svg"},"_f_scilab":{"iconPath":"../../icons/file_type_scilab.svg"},"_f_script":{"iconPath":"../../icons/file_type_script.svg"},"_f_scss":{"iconPath":"../../icons/file_type_scss.svg"},"_f_sdlang":{"iconPath":"../../icons/file_type_sdlang.svg"},"_f_search_result":{"iconPath":"../../icons/file_type_search_result.svg"},"_f_seedkit":{"iconPath":"../../icons/file_type_seedkit.svg"},"_f_sentry":{"iconPath":"../../icons/file_type_sentry.svg"},"_f_sequelize":{"iconPath":"../../icons/file_type_sequelize.svg"},"_f_serverless":{"iconPath":"../../icons/file_type_serverless.svg"},"_f_shadcn":{"iconPath":"../../icons/file_type_shadcn.svg"},"_f_light_shadcn":{"iconPath":"../../icons/file_type_light_shadcn.svg"},"_f_shaderlab":{"iconPath":"../../icons/file_type_shaderlab.svg"},"_f_light_shaderlab":{"iconPath":"../../icons/file_type_light_shaderlab.svg"},"_f_shell":{"iconPath":"../../icons/file_type_shell.svg"},"_f_shellcheck":{"iconPath":"../../icons/file_type_shellcheck.svg"},"_f_shuttle":{"iconPath":"../../icons/file_type_shuttle.svg"},"_f_silverstripe":{"iconPath":"../../icons/file_type_silverstripe.svg"},"_f_sino":{"iconPath":"../../icons/file_type_sino.svg"},"_f_sitemap":{"iconPath":"../../icons/file_type_sitemap.svg"},"_f_siyuan":{"iconPath":"../../icons/file_type_siyuan.svg"},"_f_sketch":{"iconPath":"../../icons/file_type_sketch.svg"},"_f_skipper":{"iconPath":"../../icons/file_type_skipper.svg"},"_f_slang":{"iconPath":"../../icons/file_type_slang.svg"},"_f_slashup":{"iconPath":"../../icons/file_type_slashup.svg"},"_f_slice":{"iconPath":"../../icons/file_type_slice.svg"},"_f_slim":{"iconPath":"../../icons/file_type_slim.svg"},"_f_slint":{"iconPath":"../../icons/file_type_slint.svg"},"_f_sln":{"iconPath":"../../icons/file_type_sln.svg"},"_f_smarty":{"iconPath":"../../icons/file_type_smarty.svg"},"_f_smithery":{"iconPath":"../../icons/file_type_smithery.svg"},"_f_snakemake":{"iconPath":"../../icons/file_type_snakemake.svg"},"_f_snapcraft":{"iconPath":"../../icons/file_type_snapcraft.svg"},"_f_snaplet":{"iconPath":"../../icons/file_type_snaplet.svg"},"_f_snort":{"iconPath":"../../icons/file_type_snort.svg"},"_f_snyk":{"iconPath":"../../icons/file_type_snyk.svg"},"_f_solidarity":{"iconPath":"../../icons/file_type_solidarity.svg"},"_f_solidity":{"iconPath":"../../icons/file_type_solidity.svg"},"_f_light_solidity":{"iconPath":"../../icons/file_type_light_solidity.svg"},"_f_source":{"iconPath":"../../icons/file_type_source.svg"},"_f_spacengine":{"iconPath":"../../icons/file_type_spacengine.svg"},"_f_sparql":{"iconPath":"../../icons/file_type_sparql.svg"},"_f_spin":{"iconPath":"../../icons/file_type_spin.svg"},"_f_light_spin":{"iconPath":"../../icons/file_type_light_spin.svg"},"_f_sqf":{"iconPath":"../../icons/file_type_sqf.svg"},"_f_sql":{"iconPath":"../../icons/file_type_sql.svg"},"_f_sqlite":{"iconPath":"../../icons/file_type_sqlite.svg"},"_f_squirrel":{"iconPath":"../../icons/file_type_squirrel.svg"},"_f_sss":{"iconPath":"../../icons/file_type_sss.svg"},"_f_sst":{"iconPath":"../../icons/file_type_sst.svg"},"_f_stackblitz":{"iconPath":"../../icons/file_type_stackblitz.svg"},"_f_stan":{"iconPath":"../../icons/file_type_stan.svg"},"_f_stata":{"iconPath":"../../icons/file_type_stata.svg"},"_f_stencil":{"iconPath":"../../icons/file_type_stencil.svg"},"_f_storyboard":{"iconPath":"../../icons/file_type_storyboard.svg"},"_f_storybook":{"iconPath":"../../icons/file_type_storybook.svg"},"_f_stryker":{"iconPath":"../../icons/file_type_stryker.svg"},"_f_stylable":{"iconPath":"../../icons/file_type_stylable.svg"},"_f_style":{"iconPath":"../../icons/file_type_style.svg"},"_f_styled":{"iconPath":"../../icons/file_type_styled.svg"},"_f_stylelint":{"iconPath":"../../icons/file_type_stylelint.svg"},"_f_light_stylelint":{"iconPath":"../../icons/file_type_light_stylelint.svg"},"_f_stylish_haskell":{"iconPath":"../../icons/file_type_stylish_haskell.svg"},"_f_stylus":{"iconPath":"../../icons/file_type_stylus.svg"},"_f_light_stylus":{"iconPath":"../../icons/file_type_light_stylus.svg"},"_f_sublime":{"iconPath":"../../icons/file_type_sublime.svg"},"_f_subversion":{"iconPath":"../../icons/file_type_subversion.svg"},"_f_svelte":{"iconPath":"../../icons/file_type_svelte.svg"},"_f_svelteconfig":{"iconPath":"../../icons/file_type_svelteconfig.svg"},"_f_svg":{"iconPath":"../../icons/file_type_svg.svg"},"_f_svgo":{"iconPath":"../../icons/file_type_svgo.svg"},"_f_swagger":{"iconPath":"../../icons/file_type_swagger.svg"},"_f_swc":{"iconPath":"../../icons/file_type_swc.svg"},"_f_swift":{"iconPath":"../../icons/file_type_swift.svg"},"_f_swig":{"iconPath":"../../icons/file_type_swig.svg"},"_f_symfony":{"iconPath":"../../icons/file_type_symfony.svg"},"_f_light_symfony":{"iconPath":"../../icons/file_type_light_symfony.svg"},"_f_syncpack":{"iconPath":"../../icons/file_type_syncpack.svg"},"_f_systemd":{"iconPath":"../../icons/file_type_systemd.svg"},"_f_light_systemd":{"iconPath":"../../icons/file_type_light_systemd.svg"},"_f_systemverilog":{"iconPath":"../../icons/file_type_systemverilog.svg"},"_f_light_systemverilog":{"iconPath":"../../icons/file_type_light_systemverilog.svg"},"_f_t4tt":{"iconPath":"../../icons/file_type_t4tt.svg"},"_f_tailwind":{"iconPath":"../../icons/file_type_tailwind.svg"},"_f_tamagui":{"iconPath":"../../icons/file_type_tamagui.svg"},"_f_taplo":{"iconPath":"../../icons/file_type_taplo.svg"},"_f_taskfile":{"iconPath":"../../icons/file_type_taskfile.svg"},"_f_tauri":{"iconPath":"../../icons/file_type_tauri.svg"},"_f_tcl":{"iconPath":"../../icons/file_type_tcl.svg"},"_f_teal":{"iconPath":"../../icons/file_type_teal.svg"},"_f_templ":{"iconPath":"../../icons/file_type_templ.svg"},"_f_tera":{"iconPath":"../../icons/file_type_tera.svg"},"_f_terraform":{"iconPath":"../../icons/file_type_terraform.svg"},"_f_test":{"iconPath":"../../icons/file_type_test.svg"},"_f_testcafe":{"iconPath":"../../icons/file_type_testcafe.svg"},"_f_light_testcafe":{"iconPath":"../../icons/file_type_light_testcafe.svg"},"_f_testjs":{"iconPath":"../../icons/file_type_testjs.svg"},"_f_light_testjs":{"iconPath":"../../icons/file_type_light_testjs.svg"},"_f_testplane":{"iconPath":"../../icons/file_type_testplane.svg"},"_f_testts":{"iconPath":"../../icons/file_type_testts.svg"},"_f_tex":{"iconPath":"../../icons/file_type_tex.svg"},"_f_light_tex":{"iconPath":"../../icons/file_type_light_tex.svg"},"_f_text":{"iconPath":"../../icons/file_type_text.svg"},"_f_textile":{"iconPath":"../../icons/file_type_textile.svg"},"_f_tfs":{"iconPath":"../../icons/file_type_tfs.svg"},"_f_tiltfile":{"iconPath":"../../icons/file_type_tiltfile.svg"},"_f_tm":{"iconPath":"../../icons/file_type_tm.svg"},"_f_light_tm":{"iconPath":"../../icons/file_type_light_tm.svg"},"_f_tmux":{"iconPath":"../../icons/file_type_tmux.svg"},"_f_light_tmux":{"iconPath":"../../icons/file_type_light_tmux.svg"},"_f_todo":{"iconPath":"../../icons/file_type_todo.svg"},"_f_light_todo":{"iconPath":"../../icons/file_type_light_todo.svg"},"_f_toit":{"iconPath":"../../icons/file_type_toit.svg"},"_f_light_toit":{"iconPath":"../../icons/file_type_light_toit.svg"},"_f_toml":{"iconPath":"../../icons/file_type_toml.svg"},"_f_light_toml":{"iconPath":"../../icons/file_type_light_toml.svg"},"_f_toon":{"iconPath":"../../icons/file_type_toon.svg"},"_f_light_toon":{"iconPath":"../../icons/file_type_light_toon.svg"},"_f_tox":{"iconPath":"../../icons/file_type_tox.svg"},"_f_travis":{"iconPath":"../../icons/file_type_travis.svg"},"_f_tree":{"iconPath":"../../icons/file_type_tree.svg"},"_f_light_tree":{"iconPath":"../../icons/file_type_light_tree.svg"},"_f_tres":{"iconPath":"../../icons/file_type_tres.svg"},"_f_truffle":{"iconPath":"../../icons/file_type_truffle.svg"},"_f_trunk":{"iconPath":"../../icons/file_type_trunk.svg"},"_f_tsbuildinfo":{"iconPath":"../../icons/file_type_tsbuildinfo.svg"},"_f_tscn":{"iconPath":"../../icons/file_type_tscn.svg"},"_f_tsconfig":{"iconPath":"../../icons/file_type_tsconfig.svg"},"_f_tsdoc":{"iconPath":"../../icons/file_type_tsdoc.svg"},"_f_tsdown":{"iconPath":"../../icons/file_type_tsdown.svg"},"_f_tslint":{"iconPath":"../../icons/file_type_tslint.svg"},"_f_tt":{"iconPath":"../../icons/file_type_tt.svg"},"_f_ttcn":{"iconPath":"../../icons/file_type_ttcn.svg"},"_f_tuc":{"iconPath":"../../icons/file_type_tuc.svg"},"_f_turbo":{"iconPath":"../../icons/file_type_turbo.svg"},"_f_light_turbo":{"iconPath":"../../icons/file_type_light_turbo.svg"},"_f_twig":{"iconPath":"../../icons/file_type_twig.svg"},"_f_typedoc":{"iconPath":"../../icons/file_type_typedoc.svg"},"_f_typescript":{"iconPath":"../../icons/file_type_typescript.svg"},"_f_typescriptdef":{"iconPath":"../../icons/file_type_typescriptdef.svg"},"_f_typo3":{"iconPath":"../../icons/file_type_typo3.svg"},"_f_uiua":{"iconPath":"../../icons/file_type_uiua.svg"},"_f_unibeautify":{"iconPath":"../../icons/file_type_unibeautify.svg"},"_f_light_unibeautify":{"iconPath":"../../icons/file_type_light_unibeautify.svg"},"_f_unison":{"iconPath":"../../icons/file_type_unison.svg"},"_f_unlicense":{"iconPath":"../../icons/file_type_unlicense.svg"},"_f_unocss":{"iconPath":"../../icons/file_type_unocss.svg"},"_f_uv":{"iconPath":"../../icons/file_type_uv.svg"},"_f_vagrant":{"iconPath":"../../icons/file_type_vagrant.svg"},"_f_vala":{"iconPath":"../../icons/file_type_vala.svg"},"_f_vanilla_extract":{"iconPath":"../../icons/file_type_vanilla_extract.svg"},"_f_vapi":{"iconPath":"../../icons/file_type_vapi.svg"},"_f_vapor":{"iconPath":"../../icons/file_type_vapor.svg"},"_f_vash":{"iconPath":"../../icons/file_type_vash.svg"},"_f_light_vash":{"iconPath":"../../icons/file_type_light_vash.svg"},"_f_vb":{"iconPath":"../../icons/file_type_vb.svg"},"_f_vba":{"iconPath":"../../icons/file_type_vba.svg"},"_f_vbhtml":{"iconPath":"../../icons/file_type_vbhtml.svg"},"_f_vbproj":{"iconPath":"../../icons/file_type_vbproj.svg"},"_f_vcxproj":{"iconPath":"../../icons/file_type_vcxproj.svg"},"_f_velocity":{"iconPath":"../../icons/file_type_velocity.svg"},"_f_vento":{"iconPath":"../../icons/file_type_vento.svg"},"_f_vercel":{"iconPath":"../../icons/file_type_vercel.svg"},"_f_light_vercel":{"iconPath":"../../icons/file_type_light_vercel.svg"},"_f_verilog":{"iconPath":"../../icons/file_type_verilog.svg"},"_f_vhdl":{"iconPath":"../../icons/file_type_vhdl.svg"},"_f_video":{"iconPath":"../../icons/file_type_video.svg"},"_f_view":{"iconPath":"../../icons/file_type_view.svg"},"_f_vim":{"iconPath":"../../icons/file_type_vim.svg"},"_f_vite":{"iconPath":"../../icons/file_type_vite.svg"},"_f_light_vite":{"iconPath":"../../icons/file_type_light_vite.svg"},"_f_vitest":{"iconPath":"../../icons/file_type_vitest.svg"},"_f_light_vitest":{"iconPath":"../../icons/file_type_light_vitest.svg"},"_f_vlang":{"iconPath":"../../icons/file_type_vlang.svg"},"_f_volt":{"iconPath":"../../icons/file_type_volt.svg"},"_f_vroom":{"iconPath":"../../icons/file_type_vroom.svg"},"_f_vscode":{"iconPath":"../../icons/file_type_vscode.svg"},"_f_vscode_test":{"iconPath":"../../icons/file_type_vscode_test.svg"},"_f_vsix":{"iconPath":"../../icons/file_type_vsix.svg"},"_f_light_vsix":{"iconPath":"../../icons/file_type_light_vsix.svg"},"_f_vsixmanifest":{"iconPath":"../../icons/file_type_vsixmanifest.svg"},"_f_light_vsixmanifest":{"iconPath":"../../icons/file_type_light_vsixmanifest.svg"},"_f_vue":{"iconPath":"../../icons/file_type_vue.svg"},"_f_vueconfig":{"iconPath":"../../icons/file_type_vueconfig.svg"},"_f_vvvvvv":{"iconPath":"../../icons/file_type_vvvvvv.svg"},"_f_vyper":{"iconPath":"../../icons/file_type_vyper.svg"},"_f_wallaby":{"iconPath":"../../icons/file_type_wallaby.svg"},"_f_wally":{"iconPath":"../../icons/file_type_wally.svg"},"_f_wasm":{"iconPath":"../../icons/file_type_wasm.svg"},"_f_watchmanconfig":{"iconPath":"../../icons/file_type_watchmanconfig.svg"},"_f_wdio":{"iconPath":"../../icons/file_type_wdio.svg"},"_f_weblate":{"iconPath":"../../icons/file_type_weblate.svg"},"_f_webpack":{"iconPath":"../../icons/file_type_webpack.svg"},"_f_wenyan":{"iconPath":"../../icons/file_type_wenyan.svg"},"_f_wercker":{"iconPath":"../../icons/file_type_wercker.svg"},"_f_wgsl":{"iconPath":"../../icons/file_type_wgsl.svg"},"_f_wikitext":{"iconPath":"../../icons/file_type_wikitext.svg"},"_f_windi":{"iconPath":"../../icons/file_type_windi.svg"},"_f_wit":{"iconPath":"../../icons/file_type_wit.svg"},"_f_wolfram":{"iconPath":"../../icons/file_type_wolfram.svg"},"_f_word":{"iconPath":"../../icons/file_type_word.svg"},"_f_wpml":{"iconPath":"../../icons/file_type_wpml.svg"},"_f_wurst":{"iconPath":"../../icons/file_type_wurst.svg"},"_f_wxml":{"iconPath":"../../icons/file_type_wxml.svg"},"_f_wxss":{"iconPath":"../../icons/file_type_wxss.svg"},"_f_wxt":{"iconPath":"../../icons/file_type_wxt.svg"},"_f_xaml":{"iconPath":"../../icons/file_type_xaml.svg"},"_f_xcode":{"iconPath":"../../icons/file_type_xcode.svg"},"_f_xfl":{"iconPath":"../../icons/file_type_xfl.svg"},"_f_light_xfl":{"iconPath":"../../icons/file_type_light_xfl.svg"},"_f_xib":{"iconPath":"../../icons/file_type_xib.svg"},"_f_xliff":{"iconPath":"../../icons/file_type_xliff.svg"},"_f_xmake":{"iconPath":"../../icons/file_type_xmake.svg"},"_f_xml":{"iconPath":"../../icons/file_type_xml.svg"},"_f_xo":{"iconPath":"../../icons/file_type_xo.svg"},"_f_xorg":{"iconPath":"../../icons/file_type_xorg.svg"},"_f_light_xorg":{"iconPath":"../../icons/file_type_light_xorg.svg"},"_f_xquery":{"iconPath":"../../icons/file_type_xquery.svg"},"_f_xsl":{"iconPath":"../../icons/file_type_xsl.svg"},"_f_yacc":{"iconPath":"../../icons/file_type_yacc.svg"},"_f_yaml":{"iconPath":"../../icons/file_type_yaml.svg"},"_f_light_yaml":{"iconPath":"../../icons/file_type_light_yaml.svg"},"_f_yamllint":{"iconPath":"../../icons/file_type_yamllint.svg"},"_f_yandex":{"iconPath":"../../icons/file_type_yandex.svg"},"_f_yang":{"iconPath":"../../icons/file_type_yang.svg"},"_f_yarn":{"iconPath":"../../icons/file_type_yarn.svg"},"_f_yeoman":{"iconPath":"../../icons/file_type_yeoman.svg"},"_f_zig":{"iconPath":"../../icons/file_type_zig.svg"},"_f_zip":{"iconPath":"../../icons/file_type_zip.svg"}},"file":"_file","folder":"_folder","folderExpanded":"_folder_open","rootFolder":"_root_folder","rootFolderExpanded":"_root_folder_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_cursor","cypress":"_fd_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_electron",".expo":"_fd_expo",".expo-shared":"_fd_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_fonts","font":"_fd_fonts","fnt":"_fd_fonts","webfonts":"_fd_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_gradle",".gradle":"_fd_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_mypy","mysqldb":"_fd_mysql","mysql":"_fd_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_sass","scss":"_fd_sass","_sass":"_fd_sass","_scss":"_fd_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_cursor_open","cypress":"_fd_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_electron_open",".expo":"_fd_expo_open",".expo-shared":"_fd_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_fonts_open","font":"_fd_fonts_open","fnt":"_fd_fonts_open","webfonts":"_fd_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_gradle_open",".gradle":"_fd_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_mypy_open","mysqldb":"_fd_mysql_open","mysql":"_fd_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_sass_open","scss":"_fd_sass_open","_sass":"_fd_sass_open","_scss":"_fd_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_cypress_spec","cy.mjs":"_f_cypress_spec","cy.cjs":"_f_cypress_spec","cy.coffee":"_f_cypress_spec","cy.ts":"_f_cypress_spec","cy.tsx":"_f_cypress_spec","cy.jsx":"_f_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_font","woff2":"_f_font","ttf":"_f_font","otf":"_f_libreoffice_math","eot":"_f_font","pfa":"_f_font","pfb":"_f_font","sfd":"_f_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_gamemaker2","yyp":"_f_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_gradle","gr":"_f_grain","hcl2":"_f_hashicorp","sentinel":"_f_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_jsmap","cjs.map":"_f_jsmap","mjs.map":"_f_jsmap","jsonl":"_f_json","ndjson":"_f_json","json5":"_f_json5","schema.json":"_f_json_schema","jsonld":"_f_jsonld","json-ld":"_f_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_mustache","mst":"_f_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_testjs","test.jsx":"_f_testjs","test.mjs":"_f_testjs","spec.js":"_f_testjs","spec.jsx":"_f_testjs","spec.mjs":"_f_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_tex","tikz":"_f_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_tm","tm.hcl":"_f_tm","todo":"_f_todo","tree":"_f_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_vsix","vsixmanifest":"_f_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_astroconfig","astro.config.cjs":"_f_astroconfig","astro.config.mjs":"_f_astroconfig","astro.config.ts":"_f_astroconfig","astro.config.cts":"_f_astroconfig","astro.config.mts":"_f_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_babel",".babelignore":"_f_babel",".babelrc.js":"_f_babel",".babelrc.cjs":"_f_babel",".babelrc.mjs":"_f_babel",".babelrc.json":"_f_babel","babel.config.js":"_f_babel","babel.config.cjs":"_f_babel","babel.config.mjs":"_f_babel","babel.config.json":"_f_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_cloudfoundry",".codacy.yml":"_f_codacy",".codacy.yaml":"_f_codacy",".codeclimate.yml":"_f_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_config","copilot-instructions.md":"_f_copilot","github-copilot.xml":"_f_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_cypress","cypress.env.json":"_f_cypress","cypress.config.js":"_f_cypress","cypress.config.ts":"_f_cypress","cypress.config.cjs":"_f_cypress","cypress.config.mjs":"_f_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_drone",".drone.yml.sig":"_f_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_eleventy","eleventy.config.js":"_f_eleventy","eleventy.config.cjs":"_f_eleventy","eleventy.config.mjs":"_f_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_expo","app.config.js":"_f_expo","app.config.json":"_f_expo","app.config.json5":"_f_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_leanconfig","lakefile.toml":"_f_leanconfig","lake-manifest.json":"_f_leanconfig","lean-toolchain":"_f_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_markuplint",".markuplintrc.json":"_f_markuplint",".markuplintrc.yaml":"_f_markuplint",".markuplintrc.yml":"_f_markuplint",".markuplintrc.js":"_f_markuplint",".markuplintrc.cjs":"_f_markuplint",".markuplintrc.mjs":"_f_markuplint",".markuplintrc.ts":"_f_markuplint","markuplint.config.json":"_f_markuplint","markuplint.config.yaml":"_f_markuplint","markuplint.config.yml":"_f_markuplint","markuplint.config.js":"_f_markuplint","markuplint.config.cjs":"_f_markuplint","markuplint.config.mjs":"_f_markuplint","markuplint.config.ts":"_f_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_mcp","mdx-components.cjs":"_f_mdx-components","mdx-components.cts":"_f_mdx-components","mdx-components.js":"_f_mdx-components","mdx-components.jsx":"_f_mdx-components","mdx-components.mjs":"_f_mdx-components","mdx-components.mts":"_f_mdx-components","mdx-components.ts":"_f_mdx-components","mdx-components.tsx":"_f_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_mypy",".mypy.ini":"_f_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_netlify","next.config.js":"_f_next","next.config.cjs":"_f_next","next.config.mjs":"_f_next","next.config.ts":"_f_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_oxc",".oxlintrc.json":"_f_oxc",".oxlintrc.jsonc":"_f_oxc",".oxfmtrc.json":"_f_oxc",".oxfmtrc.jsonc":"_f_oxc",".packshiprc":"_f_packship",".packshiprc.json":"_f_packship",".packshiprc.js":"_f_packship",".packshiprc.ts":"_f_packship","packship.config.js":"_f_packship","packship.config.ts":"_f_packship","packship.config.mjs":"_f_packship","packship.config.mts":"_f_packship","packship.config.json":"_f_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_pnpm","pnpmfile.js":"_f_pnpm","pnpm-lock.yaml":"_f_pnpm","pnpm-workspace.yaml":"_f_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_prettier",".prettierignore":"_f_prettier","prettier.config.js":"_f_prettier","prettier.config.cjs":"_f_prettier","prettier.config.mjs":"_f_prettier","prettier.config.ts":"_f_prettier","prettier.config.coffee":"_f_prettier",".prettierrc.js":"_f_prettier",".prettierrc.cjs":"_f_prettier",".prettierrc.mjs":"_f_prettier",".prettierrc.json":"_f_prettier",".prettierrc.json5":"_f_prettier",".prettierrc.yml":"_f_prettier",".prettierrc.yaml":"_f_prettier",".prettierrc.toml":"_f_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_quasar","quasar.config.js":"_f_quasar","quasar.config.mjs":"_f_quasar","quasar.config.cjs":"_f_quasar","quasar.config.ts":"_f_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_razzle","react-router.config.js":"_f_reactrouter","react-router.config.ts":"_f_reactrouter",".rehyperc":"_f_rehype",".rehypeignore":"_f_rehype",".rehyperc.cjs":"_f_rehype",".rehyperc.js":"_f_rehype",".rehyperc.json":"_f_rehype",".rehyperc.mjs":"_f_rehype",".rehyperc.yml":"_f_rehype",".rehyperc.yaml":"_f_rehype",".remarkrc":"_f_remark",".remarkignore":"_f_remark",".remarkrc.cjs":"_f_remark",".remarkrc.js":"_f_remark",".remarkrc.json":"_f_remark",".remarkrc.mjs":"_f_remark",".remarkrc.yml":"_f_remark",".remarkrc.yaml":"_f_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_replit","replit.nix":"_f_replit",".retextrc":"_f_retext",".retextignore":"_f_retext",".retextrc.cjs":"_f_retext",".retextrc.js":"_f_retext",".retextrc.json":"_f_retext",".retextrc.mjs":"_f_retext",".retextrc.yml":"_f_retext",".retextrc.yaml":"_f_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_rolldown","rolldown.config.cjs":"_f_rolldown","rolldown.config.mjs":"_f_rolldown","rolldown.config.coffee":"_f_rolldown","rolldown.config.ts":"_f_rolldown","rolldown.config.mts":"_f_rolldown","rolldown.config.cts":"_f_rolldown","rolldown.config.common.js":"_f_rolldown","rolldown.config.common.cjs":"_f_rolldown","rolldown.config.common.mjs":"_f_rolldown","rolldown.config.common.coffee":"_f_rolldown","rolldown.config.common.ts":"_f_rolldown","rolldown.config.common.mts":"_f_rolldown","rolldown.config.common.cts":"_f_rolldown","rolldown.config.dev.js":"_f_rolldown","rolldown.config.dev.cjs":"_f_rolldown","rolldown.config.dev.mjs":"_f_rolldown","rolldown.config.dev.coffee":"_f_rolldown","rolldown.config.dev.ts":"_f_rolldown","rolldown.config.dev.mts":"_f_rolldown","rolldown.config.dev.cts":"_f_rolldown","rolldown.config.prod.js":"_f_rolldown","rolldown.config.prod.cjs":"_f_rolldown","rolldown.config.prod.mjs":"_f_rolldown","rolldown.config.prod.coffee":"_f_rolldown","rolldown.config.prod.ts":"_f_rolldown","rolldown.config.prod.mts":"_f_rolldown","rolldown.config.prod.cts":"_f_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_rubocop",".rubocop_todo.yml":"_f_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_rust_toolchain","rust-toolchain.toml":"_f_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_stylelint",".stylelintignore":"_f_stylelint",".stylelintcache":"_f_stylelint","stylelint.config.js":"_f_stylelint","stylelint.config.json":"_f_stylelint","stylelint.config.yaml":"_f_stylelint","stylelint.config.yml":"_f_stylelint","stylelint.config.ts":"_f_stylelint","stylelint.config.cjs":"_f_stylelint","stylelint.config.mjs":"_f_stylelint",".stylelintrc.js":"_f_stylelint",".stylelintrc.json":"_f_stylelint",".stylelintrc.yaml":"_f_stylelint",".stylelintrc.yml":"_f_stylelint",".stylelintrc.ts":"_f_stylelint",".stylelintrc.cjs":"_f_stylelint",".stylelintrc.mjs":"_f_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_turbo","turbo.jsonc":"_f_turbo","microfrontends.json":"_f_turbo","microfrontends.jsonc":"_f_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_unibeautify","unibeautify.config.js":"_f_unibeautify",".unibeautifyrc.js":"_f_unibeautify",".unibeautifyrc.json":"_f_unibeautify",".unibeautifyrc.yaml":"_f_unibeautify",".unibeautifyrc.yml":"_f_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_vercel",".nowignore":"_f_vercel","vercel.json":"_f_vercel",".vercelignore":"_f_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_vite","vite.config.mjs":"_f_vite","vite.config.ts":"_f_vite","vite.config.cjs":"_f_vite","vite.config.mts":"_f_vite","vite.config.cts":"_f_vite","vitest.config.js":"_f_vitest","vitest.config.mjs":"_f_vitest","vitest.config.ts":"_f_vitest","vitest.config.cjs":"_f_vitest","vitest.config.mts":"_f_vitest","vitest.config.cts":"_f_vitest","vitest.workspace.json":"_f_vitest","vitest.workspace.js":"_f_vitest","vitest.workspace.mjs":"_f_vitest","vitest.workspace.ts":"_f_vitest","vitest.workspace.cjs":"_f_vitest","vitest.workspace.mts":"_f_vitest","vitest.workspace.cts":"_f_vitest","vitest.projects.json":"_f_vitest","vitest.projects.js":"_f_vitest","vitest.projects.mjs":"_f_vitest","vitest.projects.ts":"_f_vitest","vitest.projects.cjs":"_f_vitest","vitest.projects.mts":"_f_vitest","vitest.projects.cts":"_f_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_agda","lagda-md":"_f_agda","lagda-rst":"_f_agda","lagda-tex":"_f_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_config","properties":"_f_config","spring-boot-properties":"_f_config","confluence":"_f_confluence","instructions":"_f_copilot","prompt":"_f_copilot","cpp":"_f_cpp","crystal":"_f_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_js","json":"_f_json","json-tmlanguage":"_f_json","jsonc":"_f_json","jsonl":"_f_json","json5":"_f_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_lark","latino":"_f_latino","lean4":"_f_lean","lean4markdown":"_f_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_systemd","systemd-unit-file":"_f_systemd","systemverilog":"_f_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_tex","latex":"_f_tex","bibtex":"_f_tex","doctex":"_f_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_toit","toml":"_f_toml","toon":"_f_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_yaml","yaml-tmlanguage":"_f_yaml","spring-boot-properties-yaml":"_f_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"},"light":{"file":"_file_light","folder":"_folder_light","folderExpanded":"_folder_light_open","rootFolder":"_root_folder_light","rootFolderExpanded":"_root_folder_light_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_light_cursor","cypress":"_fd_light_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_light_electron",".expo":"_fd_light_expo",".expo-shared":"_fd_light_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_light_fonts","font":"_fd_light_fonts","fnt":"_fd_light_fonts","webfonts":"_fd_light_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_light_gradle",".gradle":"_fd_light_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_light_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_light_mypy","mysqldb":"_fd_light_mysql","mysql":"_fd_light_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_light_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_light_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_light_sass","scss":"_fd_light_sass","_sass":"_fd_light_sass","_scss":"_fd_light_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_light_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_light_cursor_open","cypress":"_fd_light_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_light_electron_open",".expo":"_fd_light_expo_open",".expo-shared":"_fd_light_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_light_fonts_open","font":"_fd_light_fonts_open","fnt":"_fd_light_fonts_open","webfonts":"_fd_light_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_light_gradle_open",".gradle":"_fd_light_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_light_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_light_mypy_open","mysqldb":"_fd_light_mysql_open","mysql":"_fd_light_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_light_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_light_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_light_sass_open","scss":"_fd_light_sass_open","_sass":"_fd_light_sass_open","_scss":"_fd_light_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_light_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_light_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_light_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_light_cypress_spec","cy.mjs":"_f_light_cypress_spec","cy.cjs":"_f_light_cypress_spec","cy.coffee":"_f_light_cypress_spec","cy.ts":"_f_light_cypress_spec","cy.tsx":"_f_light_cypress_spec","cy.jsx":"_f_light_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_light_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_light_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_light_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_light_font","woff2":"_f_light_font","ttf":"_f_light_font","otf":"_f_libreoffice_math","eot":"_f_light_font","pfa":"_f_light_font","pfb":"_f_light_font","sfd":"_f_light_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_light_gamemaker2","yyp":"_f_light_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_light_gradle","gr":"_f_grain","hcl2":"_f_light_hashicorp","sentinel":"_f_light_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_light_jsmap","cjs.map":"_f_light_jsmap","mjs.map":"_f_light_jsmap","jsonl":"_f_light_json","ndjson":"_f_light_json","json5":"_f_light_json5","schema.json":"_f_light_json_schema","jsonld":"_f_light_jsonld","json-ld":"_f_light_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_light_mustache","mst":"_f_light_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_light_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_light_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_light_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_light_testjs","test.jsx":"_f_light_testjs","test.mjs":"_f_light_testjs","spec.js":"_f_light_testjs","spec.jsx":"_f_light_testjs","spec.mjs":"_f_light_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_light_tex","tikz":"_f_light_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_light_tm","tm.hcl":"_f_light_tm","todo":"_f_light_todo","tree":"_f_light_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_light_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_light_vsix","vsixmanifest":"_f_light_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_light_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_light_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_light_astroconfig","astro.config.cjs":"_f_light_astroconfig","astro.config.mjs":"_f_light_astroconfig","astro.config.ts":"_f_light_astroconfig","astro.config.cts":"_f_light_astroconfig","astro.config.mts":"_f_light_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_light_babel",".babelignore":"_f_light_babel",".babelrc.js":"_f_light_babel",".babelrc.cjs":"_f_light_babel",".babelrc.mjs":"_f_light_babel",".babelrc.json":"_f_light_babel","babel.config.js":"_f_light_babel","babel.config.cjs":"_f_light_babel","babel.config.mjs":"_f_light_babel","babel.config.json":"_f_light_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_light_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_light_cloudfoundry",".codacy.yml":"_f_light_codacy",".codacy.yaml":"_f_light_codacy",".codeclimate.yml":"_f_light_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_light_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_light_config","copilot-instructions.md":"_f_light_copilot","github-copilot.xml":"_f_light_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_light_cypress","cypress.env.json":"_f_light_cypress","cypress.config.js":"_f_light_cypress","cypress.config.ts":"_f_light_cypress","cypress.config.cjs":"_f_light_cypress","cypress.config.mjs":"_f_light_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_light_drone",".drone.yml.sig":"_f_light_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_light_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_light_eleventy","eleventy.config.js":"_f_light_eleventy","eleventy.config.cjs":"_f_light_eleventy","eleventy.config.mjs":"_f_light_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_light_expo","app.config.js":"_f_light_expo","app.config.json":"_f_light_expo","app.config.json5":"_f_light_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_light_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_light_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_light_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_light_leanconfig","lakefile.toml":"_f_light_leanconfig","lake-manifest.json":"_f_light_leanconfig","lean-toolchain":"_f_light_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_light_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_light_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_light_markuplint",".markuplintrc.json":"_f_light_markuplint",".markuplintrc.yaml":"_f_light_markuplint",".markuplintrc.yml":"_f_light_markuplint",".markuplintrc.js":"_f_light_markuplint",".markuplintrc.cjs":"_f_light_markuplint",".markuplintrc.mjs":"_f_light_markuplint",".markuplintrc.ts":"_f_light_markuplint","markuplint.config.json":"_f_light_markuplint","markuplint.config.yaml":"_f_light_markuplint","markuplint.config.yml":"_f_light_markuplint","markuplint.config.js":"_f_light_markuplint","markuplint.config.cjs":"_f_light_markuplint","markuplint.config.mjs":"_f_light_markuplint","markuplint.config.ts":"_f_light_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_light_mcp","mdx-components.cjs":"_f_light_mdx-components","mdx-components.cts":"_f_light_mdx-components","mdx-components.js":"_f_light_mdx-components","mdx-components.jsx":"_f_light_mdx-components","mdx-components.mjs":"_f_light_mdx-components","mdx-components.mts":"_f_light_mdx-components","mdx-components.ts":"_f_light_mdx-components","mdx-components.tsx":"_f_light_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_light_mypy",".mypy.ini":"_f_light_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_light_netlify","next.config.js":"_f_light_next","next.config.cjs":"_f_light_next","next.config.mjs":"_f_light_next","next.config.ts":"_f_light_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_light_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_light_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_light_oxc",".oxlintrc.json":"_f_light_oxc",".oxlintrc.jsonc":"_f_light_oxc",".oxfmtrc.json":"_f_light_oxc",".oxfmtrc.jsonc":"_f_light_oxc",".packshiprc":"_f_light_packship",".packshiprc.json":"_f_light_packship",".packshiprc.js":"_f_light_packship",".packshiprc.ts":"_f_light_packship","packship.config.js":"_f_light_packship","packship.config.ts":"_f_light_packship","packship.config.mjs":"_f_light_packship","packship.config.mts":"_f_light_packship","packship.config.json":"_f_light_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_light_pnpm","pnpmfile.js":"_f_light_pnpm","pnpm-lock.yaml":"_f_light_pnpm","pnpm-workspace.yaml":"_f_light_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_light_prettier",".prettierignore":"_f_light_prettier","prettier.config.js":"_f_light_prettier","prettier.config.cjs":"_f_light_prettier","prettier.config.mjs":"_f_light_prettier","prettier.config.ts":"_f_light_prettier","prettier.config.coffee":"_f_light_prettier",".prettierrc.js":"_f_light_prettier",".prettierrc.cjs":"_f_light_prettier",".prettierrc.mjs":"_f_light_prettier",".prettierrc.json":"_f_light_prettier",".prettierrc.json5":"_f_light_prettier",".prettierrc.yml":"_f_light_prettier",".prettierrc.yaml":"_f_light_prettier",".prettierrc.toml":"_f_light_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_light_quasar","quasar.config.js":"_f_light_quasar","quasar.config.mjs":"_f_light_quasar","quasar.config.cjs":"_f_light_quasar","quasar.config.ts":"_f_light_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_light_razzle","react-router.config.js":"_f_light_reactrouter","react-router.config.ts":"_f_light_reactrouter",".rehyperc":"_f_light_rehype",".rehypeignore":"_f_light_rehype",".rehyperc.cjs":"_f_light_rehype",".rehyperc.js":"_f_light_rehype",".rehyperc.json":"_f_light_rehype",".rehyperc.mjs":"_f_light_rehype",".rehyperc.yml":"_f_light_rehype",".rehyperc.yaml":"_f_light_rehype",".remarkrc":"_f_light_remark",".remarkignore":"_f_light_remark",".remarkrc.cjs":"_f_light_remark",".remarkrc.js":"_f_light_remark",".remarkrc.json":"_f_light_remark",".remarkrc.mjs":"_f_light_remark",".remarkrc.yml":"_f_light_remark",".remarkrc.yaml":"_f_light_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_light_replit","replit.nix":"_f_light_replit",".retextrc":"_f_light_retext",".retextignore":"_f_light_retext",".retextrc.cjs":"_f_light_retext",".retextrc.js":"_f_light_retext",".retextrc.json":"_f_light_retext",".retextrc.mjs":"_f_light_retext",".retextrc.yml":"_f_light_retext",".retextrc.yaml":"_f_light_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_light_rolldown","rolldown.config.cjs":"_f_light_rolldown","rolldown.config.mjs":"_f_light_rolldown","rolldown.config.coffee":"_f_light_rolldown","rolldown.config.ts":"_f_light_rolldown","rolldown.config.mts":"_f_light_rolldown","rolldown.config.cts":"_f_light_rolldown","rolldown.config.common.js":"_f_light_rolldown","rolldown.config.common.cjs":"_f_light_rolldown","rolldown.config.common.mjs":"_f_light_rolldown","rolldown.config.common.coffee":"_f_light_rolldown","rolldown.config.common.ts":"_f_light_rolldown","rolldown.config.common.mts":"_f_light_rolldown","rolldown.config.common.cts":"_f_light_rolldown","rolldown.config.dev.js":"_f_light_rolldown","rolldown.config.dev.cjs":"_f_light_rolldown","rolldown.config.dev.mjs":"_f_light_rolldown","rolldown.config.dev.coffee":"_f_light_rolldown","rolldown.config.dev.ts":"_f_light_rolldown","rolldown.config.dev.mts":"_f_light_rolldown","rolldown.config.dev.cts":"_f_light_rolldown","rolldown.config.prod.js":"_f_light_rolldown","rolldown.config.prod.cjs":"_f_light_rolldown","rolldown.config.prod.mjs":"_f_light_rolldown","rolldown.config.prod.coffee":"_f_light_rolldown","rolldown.config.prod.ts":"_f_light_rolldown","rolldown.config.prod.mts":"_f_light_rolldown","rolldown.config.prod.cts":"_f_light_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_light_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_light_rubocop",".rubocop_todo.yml":"_f_light_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_light_rust_toolchain","rust-toolchain.toml":"_f_light_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_light_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_light_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_light_stylelint",".stylelintignore":"_f_light_stylelint",".stylelintcache":"_f_light_stylelint","stylelint.config.js":"_f_light_stylelint","stylelint.config.json":"_f_light_stylelint","stylelint.config.yaml":"_f_light_stylelint","stylelint.config.yml":"_f_light_stylelint","stylelint.config.ts":"_f_light_stylelint","stylelint.config.cjs":"_f_light_stylelint","stylelint.config.mjs":"_f_light_stylelint",".stylelintrc.js":"_f_light_stylelint",".stylelintrc.json":"_f_light_stylelint",".stylelintrc.yaml":"_f_light_stylelint",".stylelintrc.yml":"_f_light_stylelint",".stylelintrc.ts":"_f_light_stylelint",".stylelintrc.cjs":"_f_light_stylelint",".stylelintrc.mjs":"_f_light_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_light_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_light_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_light_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_light_turbo","turbo.jsonc":"_f_light_turbo","microfrontends.json":"_f_light_turbo","microfrontends.jsonc":"_f_light_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_light_unibeautify","unibeautify.config.js":"_f_light_unibeautify",".unibeautifyrc.js":"_f_light_unibeautify",".unibeautifyrc.json":"_f_light_unibeautify",".unibeautifyrc.yaml":"_f_light_unibeautify",".unibeautifyrc.yml":"_f_light_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_light_vercel",".nowignore":"_f_light_vercel","vercel.json":"_f_light_vercel",".vercelignore":"_f_light_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_light_vite","vite.config.mjs":"_f_light_vite","vite.config.ts":"_f_light_vite","vite.config.cjs":"_f_light_vite","vite.config.mts":"_f_light_vite","vite.config.cts":"_f_light_vite","vitest.config.js":"_f_light_vitest","vitest.config.mjs":"_f_light_vitest","vitest.config.ts":"_f_light_vitest","vitest.config.cjs":"_f_light_vitest","vitest.config.mts":"_f_light_vitest","vitest.config.cts":"_f_light_vitest","vitest.workspace.json":"_f_light_vitest","vitest.workspace.js":"_f_light_vitest","vitest.workspace.mjs":"_f_light_vitest","vitest.workspace.ts":"_f_light_vitest","vitest.workspace.cjs":"_f_light_vitest","vitest.workspace.mts":"_f_light_vitest","vitest.workspace.cts":"_f_light_vitest","vitest.projects.json":"_f_light_vitest","vitest.projects.js":"_f_light_vitest","vitest.projects.mjs":"_f_light_vitest","vitest.projects.ts":"_f_light_vitest","vitest.projects.cjs":"_f_light_vitest","vitest.projects.mts":"_f_light_vitest","vitest.projects.cts":"_f_light_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_light_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_light_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_light_agda","lagda-md":"_f_light_agda","lagda-rst":"_f_light_agda","lagda-tex":"_f_light_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_light_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_light_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_light_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_light_config","properties":"_f_light_config","spring-boot-properties":"_f_light_config","confluence":"_f_confluence","instructions":"_f_light_copilot","prompt":"_f_light_copilot","cpp":"_f_cpp","crystal":"_f_light_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_light_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_light_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_light_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_light_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_light_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_light_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_light_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_light_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_light_js","json":"_f_light_json","json-tmlanguage":"_f_light_json","jsonc":"_f_light_json","jsonl":"_f_light_json","json5":"_f_light_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_light_lark","latino":"_f_latino","lean4":"_f_light_lean","lean4markdown":"_f_light_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_light_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_light_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_light_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_light_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_light_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_light_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_light_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_light_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_light_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_light_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_light_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_light_systemd","systemd-unit-file":"_f_light_systemd","systemverilog":"_f_light_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_light_tex","latex":"_f_light_tex","bibtex":"_f_light_tex","doctex":"_f_light_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_light_toit","toml":"_f_light_toml","toon":"_f_light_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_light_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_light_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_light_yaml","yaml-tmlanguage":"_f_light_yaml","spring-boot-properties-yaml":"_f_light_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"}},"hidesExplorerArrows":false} | |
| 1 | { | |
| 2 | "iconDefinitions": { | |
| 3 | "_file": { "iconPath": "../../icons/default_file.svg" }, | |
| 4 | "_folder": { "iconPath": "../../icons/default_folder.svg" }, | |
| 5 | "_folder_open": { "iconPath": "../../icons/default_folder_opened.svg" }, | |
| 6 | "_root_folder": { "iconPath": "../../icons/default_root_folder.svg" }, | |
| 7 | "_root_folder_open": { "iconPath": "../../icons/default_root_folder_opened.svg" }, | |
| 8 | "_file_light": { "iconPath": "" }, | |
| 9 | "_folder_light": { "iconPath": "" }, | |
| 10 | "_folder_light_open": { "iconPath": "" }, | |
| 11 | "_root_folder_light": { "iconPath": "" }, | |
| 12 | "_root_folder_light_open": { "iconPath": "" }, | |
| 13 | "_fd_android": { "iconPath": "../../icons/folder_type_android.svg" }, | |
| 14 | "_fd_android_open": { "iconPath": "../../icons/folder_type_android_opened.svg" }, | |
| 15 | "_fd_angular": { "iconPath": "../../icons/folder_type_angular.svg" }, | |
| 16 | "_fd_angular_open": { "iconPath": "../../icons/folder_type_angular_opened.svg" }, | |
| 17 | "_fd_apache": { "iconPath": "../../icons/folder_type_apache.svg" }, | |
| 18 | "_fd_apache_open": { "iconPath": "../../icons/folder_type_apache_opened.svg" }, | |
| 19 | "_fd_api": { "iconPath": "../../icons/folder_type_api.svg" }, | |
| 20 | "_fd_api_open": { "iconPath": "../../icons/folder_type_api_opened.svg" }, | |
| 21 | "_fd_app": { "iconPath": "../../icons/folder_type_app.svg" }, | |
| 22 | "_fd_app_open": { "iconPath": "../../icons/folder_type_app_opened.svg" }, | |
| 23 | "_fd_arangodb": { "iconPath": "../../icons/folder_type_arangodb.svg" }, | |
| 24 | "_fd_arangodb_open": { "iconPath": "../../icons/folder_type_arangodb_opened.svg" }, | |
| 25 | "_fd_asset": { "iconPath": "../../icons/folder_type_asset.svg" }, | |
| 26 | "_fd_asset_open": { "iconPath": "../../icons/folder_type_asset_opened.svg" }, | |
| 27 | "_fd_astro": { "iconPath": "../../icons/folder_type_astro.svg" }, | |
| 28 | "_fd_astro_open": { "iconPath": "../../icons/folder_type_astro_opened.svg" }, | |
| 29 | "_fd_audio": { "iconPath": "../../icons/folder_type_audio.svg" }, | |
| 30 | "_fd_audio_open": { "iconPath": "../../icons/folder_type_audio_opened.svg" }, | |
| 31 | "_fd_aurelia": { "iconPath": "../../icons/folder_type_aurelia.svg" }, | |
| 32 | "_fd_aurelia_open": { "iconPath": "../../icons/folder_type_aurelia_opened.svg" }, | |
| 33 | "_fd_aws": { "iconPath": "../../icons/folder_type_aws.svg" }, | |
| 34 | "_fd_aws_open": { "iconPath": "../../icons/folder_type_aws_opened.svg" }, | |
| 35 | "_fd_azure": { "iconPath": "../../icons/folder_type_azure.svg" }, | |
| 36 | "_fd_azure_open": { "iconPath": "../../icons/folder_type_azure_opened.svg" }, | |
| 37 | "_fd_azurepipelines": { "iconPath": "../../icons/folder_type_azurepipelines.svg" }, | |
| 38 | "_fd_azurepipelines_open": { "iconPath": "../../icons/folder_type_azurepipelines_opened.svg" }, | |
| 39 | "_fd_binary": { "iconPath": "../../icons/folder_type_binary.svg" }, | |
| 40 | "_fd_binary_open": { "iconPath": "../../icons/folder_type_binary_opened.svg" }, | |
| 41 | "_fd_bloc": { "iconPath": "../../icons/folder_type_bloc.svg" }, | |
| 42 | "_fd_bloc_open": { "iconPath": "../../icons/folder_type_bloc_opened.svg" }, | |
| 43 | "_fd_blueprint": { "iconPath": "../../icons/folder_type_blueprint.svg" }, | |
| 44 | "_fd_blueprint_open": { "iconPath": "../../icons/folder_type_blueprint_opened.svg" }, | |
| 45 | "_fd_bot": { "iconPath": "../../icons/folder_type_bot.svg" }, | |
| 46 | "_fd_bot_open": { "iconPath": "../../icons/folder_type_bot_opened.svg" }, | |
| 47 | "_fd_bower": { "iconPath": "../../icons/folder_type_bower.svg" }, | |
| 48 | "_fd_bower_open": { "iconPath": "../../icons/folder_type_bower_opened.svg" }, | |
| 49 | "_fd_buildkite": { "iconPath": "../../icons/folder_type_buildkite.svg" }, | |
| 50 | "_fd_buildkite_open": { "iconPath": "../../icons/folder_type_buildkite_opened.svg" }, | |
| 51 | "_fd_cake": { "iconPath": "../../icons/folder_type_cake.svg" }, | |
| 52 | "_fd_cake_open": { "iconPath": "../../icons/folder_type_cake_opened.svg" }, | |
| 53 | "_fd_cargo": { "iconPath": "../../icons/folder_type_cargo.svg" }, | |
| 54 | "_fd_cargo_open": { "iconPath": "../../icons/folder_type_cargo_opened.svg" }, | |
| 55 | "_fd_certificate": { "iconPath": "../../icons/folder_type_certificate.svg" }, | |
| 56 | "_fd_certificate_open": { "iconPath": "../../icons/folder_type_certificate_opened.svg" }, | |
| 57 | "_fd_changesets": { "iconPath": "../../icons/folder_type_changesets.svg" }, | |
| 58 | "_fd_changesets_open": { "iconPath": "../../icons/folder_type_changesets_opened.svg" }, | |
| 59 | "_fd_chef": { "iconPath": "../../icons/folder_type_chef.svg" }, | |
| 60 | "_fd_chef_open": { "iconPath": "../../icons/folder_type_chef_opened.svg" }, | |
| 61 | "_fd_circleci": { "iconPath": "../../icons/folder_type_circleci.svg" }, | |
| 62 | "_fd_circleci_open": { "iconPath": "../../icons/folder_type_circleci_opened.svg" }, | |
| 63 | "_fd_claude": { "iconPath": "../../icons/folder_type_claude.svg" }, | |
| 64 | "_fd_claude_open": { "iconPath": "../../icons/folder_type_claude_opened.svg" }, | |
| 65 | "_fd_cli": { "iconPath": "../../icons/folder_type_cli.svg" }, | |
| 66 | "_fd_cli_open": { "iconPath": "../../icons/folder_type_cli_opened.svg" }, | |
| 67 | "_fd_client": { "iconPath": "../../icons/folder_type_client.svg" }, | |
| 68 | "_fd_client_open": { "iconPath": "../../icons/folder_type_client_opened.svg" }, | |
| 69 | "_fd_cloudflare": { "iconPath": "../../icons/folder_type_cloudflare.svg" }, | |
| 70 | "_fd_cloudflare_open": { "iconPath": "../../icons/folder_type_cloudflare_opened.svg" }, | |
| 71 | "_fd_cmake": { "iconPath": "../../icons/folder_type_cmake.svg" }, | |
| 72 | "_fd_cmake_open": { "iconPath": "../../icons/folder_type_cmake_opened.svg" }, | |
| 73 | "_fd_common": { "iconPath": "../../icons/folder_type_common.svg" }, | |
| 74 | "_fd_common_open": { "iconPath": "../../icons/folder_type_common_opened.svg" }, | |
| 75 | "_fd_component": { "iconPath": "../../icons/folder_type_component.svg" }, | |
| 76 | "_fd_component_open": { "iconPath": "../../icons/folder_type_component_opened.svg" }, | |
| 77 | "_fd_composer": { "iconPath": "../../icons/folder_type_composer.svg" }, | |
| 78 | "_fd_composer_open": { "iconPath": "../../icons/folder_type_composer_opened.svg" }, | |
| 79 | "_fd_config": { "iconPath": "../../icons/folder_type_config.svg" }, | |
| 80 | "_fd_config_open": { "iconPath": "../../icons/folder_type_config_opened.svg" }, | |
| 81 | "_fd_controller": { "iconPath": "../../icons/folder_type_controller.svg" }, | |
| 82 | "_fd_controller_open": { "iconPath": "../../icons/folder_type_controller_opened.svg" }, | |
| 83 | "_fd_coverage": { "iconPath": "../../icons/folder_type_coverage.svg" }, | |
| 84 | "_fd_coverage_open": { "iconPath": "../../icons/folder_type_coverage_opened.svg" }, | |
| 85 | "_fd_css": { "iconPath": "../../icons/folder_type_css.svg" }, | |
| 86 | "_fd_css_open": { "iconPath": "../../icons/folder_type_css_opened.svg" }, | |
| 87 | "_fd_cubit": { "iconPath": "../../icons/folder_type_cubit.svg" }, | |
| 88 | "_fd_cubit_open": { "iconPath": "../../icons/folder_type_cubit_opened.svg" }, | |
| 89 | "_fd_cursor": { "iconPath": "../../icons/folder_type_cursor.svg" }, | |
| 90 | "_fd_cursor_open": { "iconPath": "../../icons/folder_type_cursor_opened.svg" }, | |
| 91 | "_fd_light_cursor": { "iconPath": "../../icons/folder_type_light_cursor.svg" }, | |
| 92 | "_fd_light_cursor_open": { "iconPath": "../../icons/folder_type_light_cursor_opened.svg" }, | |
| 93 | "_fd_cypress": { "iconPath": "../../icons/folder_type_cypress.svg" }, | |
| 94 | "_fd_cypress_open": { "iconPath": "../../icons/folder_type_cypress_opened.svg" }, | |
| 95 | "_fd_light_cypress": { "iconPath": "../../icons/folder_type_light_cypress.svg" }, | |
| 96 | "_fd_light_cypress_open": { "iconPath": "../../icons/folder_type_light_cypress_opened.svg" }, | |
| 97 | "_fd_dapr": { "iconPath": "../../icons/folder_type_dapr.svg" }, | |
| 98 | "_fd_dapr_open": { "iconPath": "../../icons/folder_type_dapr_opened.svg" }, | |
| 99 | "_fd_dart": { "iconPath": "../../icons/folder_type_dart.svg" }, | |
| 100 | "_fd_dart_open": { "iconPath": "../../icons/folder_type_dart_opened.svg" }, | |
| 101 | "_fd_datadog": { "iconPath": "../../icons/folder_type_datadog.svg" }, | |
| 102 | "_fd_datadog_open": { "iconPath": "../../icons/folder_type_datadog_opened.svg" }, | |
| 103 | "_fd_db": { "iconPath": "../../icons/folder_type_db.svg" }, | |
| 104 | "_fd_db_open": { "iconPath": "../../icons/folder_type_db_opened.svg" }, | |
| 105 | "_fd_debian": { "iconPath": "../../icons/folder_type_debian.svg" }, | |
| 106 | "_fd_debian_open": { "iconPath": "../../icons/folder_type_debian_opened.svg" }, | |
| 107 | "_fd_dependabot": { "iconPath": "../../icons/folder_type_dependabot.svg" }, | |
| 108 | "_fd_dependabot_open": { "iconPath": "../../icons/folder_type_dependabot_opened.svg" }, | |
| 109 | "_fd_devcontainer": { "iconPath": "../../icons/folder_type_devcontainer.svg" }, | |
| 110 | "_fd_devcontainer_open": { "iconPath": "../../icons/folder_type_devcontainer_opened.svg" }, | |
| 111 | "_fd_dist": { "iconPath": "../../icons/folder_type_dist.svg" }, | |
| 112 | "_fd_dist_open": { "iconPath": "../../icons/folder_type_dist_opened.svg" }, | |
| 113 | "_fd_docker": { "iconPath": "../../icons/folder_type_docker.svg" }, | |
| 114 | "_fd_docker_open": { "iconPath": "../../icons/folder_type_docker_opened.svg" }, | |
| 115 | "_fd_docs": { "iconPath": "../../icons/folder_type_docs.svg" }, | |
| 116 | "_fd_docs_open": { "iconPath": "../../icons/folder_type_docs_opened.svg" }, | |
| 117 | "_fd_e2e": { "iconPath": "../../icons/folder_type_e2e.svg" }, | |
| 118 | "_fd_e2e_open": { "iconPath": "../../icons/folder_type_e2e_opened.svg" }, | |
| 119 | "_fd_elasticbeanstalk": { "iconPath": "../../icons/folder_type_elasticbeanstalk.svg" }, | |
| 120 | "_fd_elasticbeanstalk_open": { | |
| 121 | "iconPath": "../../icons/folder_type_elasticbeanstalk_opened.svg" | |
| 122 | }, | |
| 123 | "_fd_electron": { "iconPath": "../../icons/folder_type_electron.svg" }, | |
| 124 | "_fd_electron_open": { "iconPath": "../../icons/folder_type_electron_opened.svg" }, | |
| 125 | "_fd_light_electron": { "iconPath": "../../icons/folder_type_light_electron.svg" }, | |
| 126 | "_fd_light_electron_open": { "iconPath": "../../icons/folder_type_light_electron_opened.svg" }, | |
| 127 | "_fd_expo": { "iconPath": "../../icons/folder_type_expo.svg" }, | |
| 128 | "_fd_expo_open": { "iconPath": "../../icons/folder_type_expo_opened.svg" }, | |
| 129 | "_fd_light_expo": { "iconPath": "../../icons/folder_type_light_expo.svg" }, | |
| 130 | "_fd_light_expo_open": { "iconPath": "../../icons/folder_type_light_expo_opened.svg" }, | |
| 131 | "_fd_favicon": { "iconPath": "../../icons/folder_type_favicon.svg" }, | |
| 132 | "_fd_favicon_open": { "iconPath": "../../icons/folder_type_favicon_opened.svg" }, | |
| 133 | "_fd_flow": { "iconPath": "../../icons/folder_type_flow.svg" }, | |
| 134 | "_fd_flow_open": { "iconPath": "../../icons/folder_type_flow_opened.svg" }, | |
| 135 | "_fd_flutter": { "iconPath": "../../icons/folder_type_flutter.svg" }, | |
| 136 | "_fd_flutter_open": { "iconPath": "../../icons/folder_type_flutter_opened.svg" }, | |
| 137 | "_fd_fonts": { "iconPath": "../../icons/folder_type_fonts.svg" }, | |
| 138 | "_fd_fonts_open": { "iconPath": "../../icons/folder_type_fonts_opened.svg" }, | |
| 139 | "_fd_light_fonts": { "iconPath": "../../icons/folder_type_light_fonts.svg" }, | |
| 140 | "_fd_light_fonts_open": { "iconPath": "../../icons/folder_type_light_fonts_opened.svg" }, | |
| 141 | "_fd_frontcommerce": { "iconPath": "../../icons/folder_type_frontcommerce.svg" }, | |
| 142 | "_fd_frontcommerce_open": { "iconPath": "../../icons/folder_type_frontcommerce_opened.svg" }, | |
| 143 | "_fd_gcp": { "iconPath": "../../icons/folder_type_gcp.svg" }, | |
| 144 | "_fd_gcp_open": { "iconPath": "../../icons/folder_type_gcp_opened.svg" }, | |
| 145 | "_fd_gemini": { "iconPath": "../../icons/folder_type_gemini.svg" }, | |
| 146 | "_fd_gemini_open": { "iconPath": "../../icons/folder_type_gemini_opened.svg" }, | |
| 147 | "_fd_git": { "iconPath": "../../icons/folder_type_git.svg" }, | |
| 148 | "_fd_git_open": { "iconPath": "../../icons/folder_type_git_opened.svg" }, | |
| 149 | "_fd_github": { "iconPath": "../../icons/folder_type_github.svg" }, | |
| 150 | "_fd_github_open": { "iconPath": "../../icons/folder_type_github_opened.svg" }, | |
| 151 | "_fd_gitlab": { "iconPath": "../../icons/folder_type_gitlab.svg" }, | |
| 152 | "_fd_gitlab_open": { "iconPath": "../../icons/folder_type_gitlab_opened.svg" }, | |
| 153 | "_fd_godot": { "iconPath": "../../icons/folder_type_godot.svg" }, | |
| 154 | "_fd_godot_open": { "iconPath": "../../icons/folder_type_godot_opened.svg" }, | |
| 155 | "_fd_gradle": { "iconPath": "../../icons/folder_type_gradle.svg" }, | |
| 156 | "_fd_gradle_open": { "iconPath": "../../icons/folder_type_gradle_opened.svg" }, | |
| 157 | "_fd_light_gradle": { "iconPath": "../../icons/folder_type_light_gradle.svg" }, | |
| 158 | "_fd_light_gradle_open": { "iconPath": "../../icons/folder_type_light_gradle_opened.svg" }, | |
| 159 | "_fd_graphql": { "iconPath": "../../icons/folder_type_graphql.svg" }, | |
| 160 | "_fd_graphql_open": { "iconPath": "../../icons/folder_type_graphql_opened.svg" }, | |
| 161 | "_fd_grunt": { "iconPath": "../../icons/folder_type_grunt.svg" }, | |
| 162 | "_fd_grunt_open": { "iconPath": "../../icons/folder_type_grunt_opened.svg" }, | |
| 163 | "_fd_gulp": { "iconPath": "../../icons/folder_type_gulp.svg" }, | |
| 164 | "_fd_gulp_open": { "iconPath": "../../icons/folder_type_gulp_opened.svg" }, | |
| 165 | "_fd_haxelib": { "iconPath": "../../icons/folder_type_haxelib.svg" }, | |
| 166 | "_fd_haxelib_open": { "iconPath": "../../icons/folder_type_haxelib_opened.svg" }, | |
| 167 | "_fd_helper": { "iconPath": "../../icons/folder_type_helper.svg" }, | |
| 168 | "_fd_helper_open": { "iconPath": "../../icons/folder_type_helper_opened.svg" }, | |
| 169 | "_fd_histoire": { "iconPath": "../../icons/folder_type_histoire.svg" }, | |
| 170 | "_fd_histoire_open": { "iconPath": "../../icons/folder_type_histoire_opened.svg" }, | |
| 171 | "_fd_hook": { "iconPath": "../../icons/folder_type_hook.svg" }, | |
| 172 | "_fd_hook_open": { "iconPath": "../../icons/folder_type_hook_opened.svg" }, | |
| 173 | "_fd_husky": { "iconPath": "../../icons/folder_type_husky.svg" }, | |
| 174 | "_fd_husky_open": { "iconPath": "../../icons/folder_type_husky_opened.svg" }, | |
| 175 | "_fd_idea": { "iconPath": "../../icons/folder_type_idea.svg" }, | |
| 176 | "_fd_idea_open": { "iconPath": "../../icons/folder_type_idea_opened.svg" }, | |
| 177 | "_fd_images": { "iconPath": "../../icons/folder_type_images.svg" }, | |
| 178 | "_fd_images_open": { "iconPath": "../../icons/folder_type_images_opened.svg" }, | |
| 179 | "_fd_include": { "iconPath": "../../icons/folder_type_include.svg" }, | |
| 180 | "_fd_include_open": { "iconPath": "../../icons/folder_type_include_opened.svg" }, | |
| 181 | "_fd_interfaces": { "iconPath": "../../icons/folder_type_interfaces.svg" }, | |
| 182 | "_fd_interfaces_open": { "iconPath": "../../icons/folder_type_interfaces_opened.svg" }, | |
| 183 | "_fd_ios": { "iconPath": "../../icons/folder_type_ios.svg" }, | |
| 184 | "_fd_ios_open": { "iconPath": "../../icons/folder_type_ios_opened.svg" }, | |
| 185 | "_fd_js": { "iconPath": "../../icons/folder_type_js.svg" }, | |
| 186 | "_fd_js_open": { "iconPath": "../../icons/folder_type_js_opened.svg" }, | |
| 187 | "_fd_json": { "iconPath": "../../icons/folder_type_json.svg" }, | |
| 188 | "_fd_json_open": { "iconPath": "../../icons/folder_type_json_opened.svg" }, | |
| 189 | "_fd_junie": { "iconPath": "../../icons/folder_type_junie.svg" }, | |
| 190 | "_fd_junie_open": { "iconPath": "../../icons/folder_type_junie_opened.svg" }, | |
| 191 | "_fd_kotlin": { "iconPath": "../../icons/folder_type_kotlin.svg" }, | |
| 192 | "_fd_kotlin_open": { "iconPath": "../../icons/folder_type_kotlin_opened.svg" }, | |
| 193 | "_fd_kubernetes": { "iconPath": "../../icons/folder_type_kubernetes.svg" }, | |
| 194 | "_fd_kubernetes_open": { "iconPath": "../../icons/folder_type_kubernetes_opened.svg" }, | |
| 195 | "_fd_less": { "iconPath": "../../icons/folder_type_less.svg" }, | |
| 196 | "_fd_less_open": { "iconPath": "../../icons/folder_type_less_opened.svg" }, | |
| 197 | "_fd_library": { "iconPath": "../../icons/folder_type_library.svg" }, | |
| 198 | "_fd_library_open": { "iconPath": "../../icons/folder_type_library_opened.svg" }, | |
| 199 | "_fd_linux": { "iconPath": "../../icons/folder_type_linux.svg" }, | |
| 200 | "_fd_linux_open": { "iconPath": "../../icons/folder_type_linux_opened.svg" }, | |
| 201 | "_fd_locale": { "iconPath": "../../icons/folder_type_locale.svg" }, | |
| 202 | "_fd_locale_open": { "iconPath": "../../icons/folder_type_locale_opened.svg" }, | |
| 203 | "_fd_log": { "iconPath": "../../icons/folder_type_log.svg" }, | |
| 204 | "_fd_log_open": { "iconPath": "../../icons/folder_type_log_opened.svg" }, | |
| 205 | "_fd_macos": { "iconPath": "../../icons/folder_type_macos.svg" }, | |
| 206 | "_fd_macos_open": { "iconPath": "../../icons/folder_type_macos_opened.svg" }, | |
| 207 | "_fd_mariadb": { "iconPath": "../../icons/folder_type_mariadb.svg" }, | |
| 208 | "_fd_mariadb_open": { "iconPath": "../../icons/folder_type_mariadb_opened.svg" }, | |
| 209 | "_fd_maven": { "iconPath": "../../icons/folder_type_maven.svg" }, | |
| 210 | "_fd_maven_open": { "iconPath": "../../icons/folder_type_maven_opened.svg" }, | |
| 211 | "_fd_mediawiki": { "iconPath": "../../icons/folder_type_mediawiki.svg" }, | |
| 212 | "_fd_mediawiki_open": { "iconPath": "../../icons/folder_type_mediawiki_opened.svg" }, | |
| 213 | "_fd_memcached": { "iconPath": "../../icons/folder_type_memcached.svg" }, | |
| 214 | "_fd_memcached_open": { "iconPath": "../../icons/folder_type_memcached_opened.svg" }, | |
| 215 | "_fd_meteor": { "iconPath": "../../icons/folder_type_meteor.svg" }, | |
| 216 | "_fd_meteor_open": { "iconPath": "../../icons/folder_type_meteor_opened.svg" }, | |
| 217 | "_fd_light_meteor": { "iconPath": "../../icons/folder_type_light_meteor.svg" }, | |
| 218 | "_fd_light_meteor_open": { "iconPath": "../../icons/folder_type_light_meteor_opened.svg" }, | |
| 219 | "_fd_middleware": { "iconPath": "../../icons/folder_type_middleware.svg" }, | |
| 220 | "_fd_middleware_open": { "iconPath": "../../icons/folder_type_middleware_opened.svg" }, | |
| 221 | "_fd_minecraft": { "iconPath": "../../icons/folder_type_minecraft.svg" }, | |
| 222 | "_fd_minecraft_open": { "iconPath": "../../icons/folder_type_minecraft_opened.svg" }, | |
| 223 | "_fd_minikube": { "iconPath": "../../icons/folder_type_minikube.svg" }, | |
| 224 | "_fd_minikube_open": { "iconPath": "../../icons/folder_type_minikube_opened.svg" }, | |
| 225 | "_fd_mjml": { "iconPath": "../../icons/folder_type_mjml.svg" }, | |
| 226 | "_fd_mjml_open": { "iconPath": "../../icons/folder_type_mjml_opened.svg" }, | |
| 227 | "_fd_mock": { "iconPath": "../../icons/folder_type_mock.svg" }, | |
| 228 | "_fd_mock_open": { "iconPath": "../../icons/folder_type_mock_opened.svg" }, | |
| 229 | "_fd_model": { "iconPath": "../../icons/folder_type_model.svg" }, | |
| 230 | "_fd_model_open": { "iconPath": "../../icons/folder_type_model_opened.svg" }, | |
| 231 | "_fd_module": { "iconPath": "../../icons/folder_type_module.svg" }, | |
| 232 | "_fd_module_open": { "iconPath": "../../icons/folder_type_module_opened.svg" }, | |
| 233 | "_fd_mojo": { "iconPath": "../../icons/folder_type_mojo.svg" }, | |
| 234 | "_fd_mojo_open": { "iconPath": "../../icons/folder_type_mojo_opened.svg" }, | |
| 235 | "_fd_mongodb": { "iconPath": "../../icons/folder_type_mongodb.svg" }, | |
| 236 | "_fd_mongodb_open": { "iconPath": "../../icons/folder_type_mongodb_opened.svg" }, | |
| 237 | "_fd_mypy": { "iconPath": "../../icons/folder_type_mypy.svg" }, | |
| 238 | "_fd_mypy_open": { "iconPath": "../../icons/folder_type_mypy_opened.svg" }, | |
| 239 | "_fd_light_mypy": { "iconPath": "../../icons/folder_type_light_mypy.svg" }, | |
| 240 | "_fd_light_mypy_open": { "iconPath": "../../icons/folder_type_light_mypy_opened.svg" }, | |
| 241 | "_fd_mysql": { "iconPath": "../../icons/folder_type_mysql.svg" }, | |
| 242 | "_fd_mysql_open": { "iconPath": "../../icons/folder_type_mysql_opened.svg" }, | |
| 243 | "_fd_light_mysql": { "iconPath": "../../icons/folder_type_light_mysql.svg" }, | |
| 244 | "_fd_light_mysql_open": { "iconPath": "../../icons/folder_type_light_mysql_opened.svg" }, | |
| 245 | "_fd_netlify": { "iconPath": "../../icons/folder_type_netlify.svg" }, | |
| 246 | "_fd_netlify_open": { "iconPath": "../../icons/folder_type_netlify_opened.svg" }, | |
| 247 | "_fd_next": { "iconPath": "../../icons/folder_type_next.svg" }, | |
| 248 | "_fd_next_open": { "iconPath": "../../icons/folder_type_next_opened.svg" }, | |
| 249 | "_fd_nginx": { "iconPath": "../../icons/folder_type_nginx.svg" }, | |
| 250 | "_fd_nginx_open": { "iconPath": "../../icons/folder_type_nginx_opened.svg" }, | |
| 251 | "_fd_nix": { "iconPath": "../../icons/folder_type_nix.svg" }, | |
| 252 | "_fd_nix_open": { "iconPath": "../../icons/folder_type_nix_opened.svg" }, | |
| 253 | "_fd_node": { "iconPath": "../../icons/folder_type_node.svg" }, | |
| 254 | "_fd_node_open": { "iconPath": "../../icons/folder_type_node_opened.svg" }, | |
| 255 | "_fd_light_node": { "iconPath": "../../icons/folder_type_light_node.svg" }, | |
| 256 | "_fd_light_node_open": { "iconPath": "../../icons/folder_type_light_node_opened.svg" }, | |
| 257 | "_fd_notebooks": { "iconPath": "../../icons/folder_type_notebooks.svg" }, | |
| 258 | "_fd_notebooks_open": { "iconPath": "../../icons/folder_type_notebooks_opened.svg" }, | |
| 259 | "_fd_notification": { "iconPath": "../../icons/folder_type_notification.svg" }, | |
| 260 | "_fd_notification_open": { "iconPath": "../../icons/folder_type_notification_opened.svg" }, | |
| 261 | "_fd_nuget": { "iconPath": "../../icons/folder_type_nuget.svg" }, | |
| 262 | "_fd_nuget_open": { "iconPath": "../../icons/folder_type_nuget_opened.svg" }, | |
| 263 | "_fd_nuxt": { "iconPath": "../../icons/folder_type_nuxt.svg" }, | |
| 264 | "_fd_nuxt_open": { "iconPath": "../../icons/folder_type_nuxt_opened.svg" }, | |
| 265 | "_fd_package": { "iconPath": "../../icons/folder_type_package.svg" }, | |
| 266 | "_fd_package_open": { "iconPath": "../../icons/folder_type_package_opened.svg" }, | |
| 267 | "_fd_paket": { "iconPath": "../../icons/folder_type_paket.svg" }, | |
| 268 | "_fd_paket_open": { "iconPath": "../../icons/folder_type_paket_opened.svg" }, | |
| 269 | "_fd_php": { "iconPath": "../../icons/folder_type_php.svg" }, | |
| 270 | "_fd_php_open": { "iconPath": "../../icons/folder_type_php_opened.svg" }, | |
| 271 | "_fd_platformio": { "iconPath": "../../icons/folder_type_platformio.svg" }, | |
| 272 | "_fd_platformio_open": { "iconPath": "../../icons/folder_type_platformio_opened.svg" }, | |
| 273 | "_fd_plugin": { "iconPath": "../../icons/folder_type_plugin.svg" }, | |
| 274 | "_fd_plugin_open": { "iconPath": "../../icons/folder_type_plugin_opened.svg" }, | |
| 275 | "_fd_prisma": { "iconPath": "../../icons/folder_type_prisma.svg" }, | |
| 276 | "_fd_prisma_open": { "iconPath": "../../icons/folder_type_prisma_opened.svg" }, | |
| 277 | "_fd_private": { "iconPath": "../../icons/folder_type_private.svg" }, | |
| 278 | "_fd_private_open": { "iconPath": "../../icons/folder_type_private_opened.svg" }, | |
| 279 | "_fd_public": { "iconPath": "../../icons/folder_type_public.svg" }, | |
| 280 | "_fd_public_open": { "iconPath": "../../icons/folder_type_public_opened.svg" }, | |
| 281 | "_fd_pytest": { "iconPath": "../../icons/folder_type_pytest.svg" }, | |
| 282 | "_fd_pytest_open": { "iconPath": "../../icons/folder_type_pytest_opened.svg" }, | |
| 283 | "_fd_python": { "iconPath": "../../icons/folder_type_python.svg" }, | |
| 284 | "_fd_python_open": { "iconPath": "../../icons/folder_type_python_opened.svg" }, | |
| 285 | "_fd_ravendb": { "iconPath": "../../icons/folder_type_ravendb.svg" }, | |
| 286 | "_fd_ravendb_open": { "iconPath": "../../icons/folder_type_ravendb_opened.svg" }, | |
| 287 | "_fd_redis": { "iconPath": "../../icons/folder_type_redis.svg" }, | |
| 288 | "_fd_redis_open": { "iconPath": "../../icons/folder_type_redis_opened.svg" }, | |
| 289 | "_fd_redux": { "iconPath": "../../icons/folder_type_redux.svg" }, | |
| 290 | "_fd_redux_open": { "iconPath": "../../icons/folder_type_redux_opened.svg" }, | |
| 291 | "_fd_light_redux": { "iconPath": "../../icons/folder_type_light_redux.svg" }, | |
| 292 | "_fd_light_redux_open": { "iconPath": "../../icons/folder_type_light_redux_opened.svg" }, | |
| 293 | "_fd_route": { "iconPath": "../../icons/folder_type_route.svg" }, | |
| 294 | "_fd_route_open": { "iconPath": "../../icons/folder_type_route_opened.svg" }, | |
| 295 | "_fd_sass": { "iconPath": "../../icons/folder_type_sass.svg" }, | |
| 296 | "_fd_sass_open": { "iconPath": "../../icons/folder_type_sass_opened.svg" }, | |
| 297 | "_fd_light_sass": { "iconPath": "../../icons/folder_type_light_sass.svg" }, | |
| 298 | "_fd_light_sass_open": { "iconPath": "../../icons/folder_type_light_sass_opened.svg" }, | |
| 299 | "_fd_script": { "iconPath": "../../icons/folder_type_script.svg" }, | |
| 300 | "_fd_script_open": { "iconPath": "../../icons/folder_type_script_opened.svg" }, | |
| 301 | "_fd_seedkit": { "iconPath": "../../icons/folder_type_seedkit.svg" }, | |
| 302 | "_fd_seedkit_open": { "iconPath": "../../icons/folder_type_seedkit_opened.svg" }, | |
| 303 | "_fd_server": { "iconPath": "../../icons/folder_type_server.svg" }, | |
| 304 | "_fd_server_open": { "iconPath": "../../icons/folder_type_server_opened.svg" }, | |
| 305 | "_fd_services": { "iconPath": "../../icons/folder_type_services.svg" }, | |
| 306 | "_fd_services_open": { "iconPath": "../../icons/folder_type_services_opened.svg" }, | |
| 307 | "_fd_shared": { "iconPath": "../../icons/folder_type_shared.svg" }, | |
| 308 | "_fd_shared_open": { "iconPath": "../../icons/folder_type_shared_opened.svg" }, | |
| 309 | "_fd_snaplet": { "iconPath": "../../icons/folder_type_snaplet.svg" }, | |
| 310 | "_fd_snaplet_open": { "iconPath": "../../icons/folder_type_snaplet_opened.svg" }, | |
| 311 | "_fd_spin": { "iconPath": "../../icons/folder_type_spin.svg" }, | |
| 312 | "_fd_spin_open": { "iconPath": "../../icons/folder_type_spin_opened.svg" }, | |
| 313 | "_fd_src": { "iconPath": "../../icons/folder_type_src.svg" }, | |
| 314 | "_fd_src_open": { "iconPath": "../../icons/folder_type_src_opened.svg" }, | |
| 315 | "_fd_sso": { "iconPath": "../../icons/folder_type_sso.svg" }, | |
| 316 | "_fd_sso_open": { "iconPath": "../../icons/folder_type_sso_opened.svg" }, | |
| 317 | "_fd_story": { "iconPath": "../../icons/folder_type_story.svg" }, | |
| 318 | "_fd_story_open": { "iconPath": "../../icons/folder_type_story_opened.svg" }, | |
| 319 | "_fd_style": { "iconPath": "../../icons/folder_type_style.svg" }, | |
| 320 | "_fd_style_open": { "iconPath": "../../icons/folder_type_style_opened.svg" }, | |
| 321 | "_fd_supabase": { "iconPath": "../../icons/folder_type_supabase.svg" }, | |
| 322 | "_fd_supabase_open": { "iconPath": "../../icons/folder_type_supabase_opened.svg" }, | |
| 323 | "_fd_svelte": { "iconPath": "../../icons/folder_type_svelte.svg" }, | |
| 324 | "_fd_svelte_open": { "iconPath": "../../icons/folder_type_svelte_opened.svg" }, | |
| 325 | "_fd_tauri": { "iconPath": "../../icons/folder_type_tauri.svg" }, | |
| 326 | "_fd_tauri_open": { "iconPath": "../../icons/folder_type_tauri_opened.svg" }, | |
| 327 | "_fd_temp": { "iconPath": "../../icons/folder_type_temp.svg" }, | |
| 328 | "_fd_temp_open": { "iconPath": "../../icons/folder_type_temp_opened.svg" }, | |
| 329 | "_fd_template": { "iconPath": "../../icons/folder_type_template.svg" }, | |
| 330 | "_fd_template_open": { "iconPath": "../../icons/folder_type_template_opened.svg" }, | |
| 331 | "_fd_test": { "iconPath": "../../icons/folder_type_test.svg" }, | |
| 332 | "_fd_test_open": { "iconPath": "../../icons/folder_type_test_opened.svg" }, | |
| 333 | "_fd_theme": { "iconPath": "../../icons/folder_type_theme.svg" }, | |
| 334 | "_fd_theme_open": { "iconPath": "../../icons/folder_type_theme_opened.svg" }, | |
| 335 | "_fd_tools": { "iconPath": "../../icons/folder_type_tools.svg" }, | |
| 336 | "_fd_tools_open": { "iconPath": "../../icons/folder_type_tools_opened.svg" }, | |
| 337 | "_fd_travis": { "iconPath": "../../icons/folder_type_travis.svg" }, | |
| 338 | "_fd_travis_open": { "iconPath": "../../icons/folder_type_travis_opened.svg" }, | |
| 339 | "_fd_trunk": { "iconPath": "../../icons/folder_type_trunk.svg" }, | |
| 340 | "_fd_trunk_open": { "iconPath": "../../icons/folder_type_trunk_opened.svg" }, | |
| 341 | "_fd_turbo": { "iconPath": "../../icons/folder_type_turbo.svg" }, | |
| 342 | "_fd_turbo_open": { "iconPath": "../../icons/folder_type_turbo_opened.svg" }, | |
| 343 | "_fd_typescript": { "iconPath": "../../icons/folder_type_typescript.svg" }, | |
| 344 | "_fd_typescript_open": { "iconPath": "../../icons/folder_type_typescript_opened.svg" }, | |
| 345 | "_fd_typings": { "iconPath": "../../icons/folder_type_typings.svg" }, | |
| 346 | "_fd_typings_open": { "iconPath": "../../icons/folder_type_typings_opened.svg" }, | |
| 347 | "_fd_vagrant": { "iconPath": "../../icons/folder_type_vagrant.svg" }, | |
| 348 | "_fd_vagrant_open": { "iconPath": "../../icons/folder_type_vagrant_opened.svg" }, | |
| 349 | "_fd_vercel": { "iconPath": "../../icons/folder_type_vercel.svg" }, | |
| 350 | "_fd_vercel_open": { "iconPath": "../../icons/folder_type_vercel_opened.svg" }, | |
| 351 | "_fd_video": { "iconPath": "../../icons/folder_type_video.svg" }, | |
| 352 | "_fd_video_open": { "iconPath": "../../icons/folder_type_video_opened.svg" }, | |
| 353 | "_fd_view": { "iconPath": "../../icons/folder_type_view.svg" }, | |
| 354 | "_fd_view_open": { "iconPath": "../../icons/folder_type_view_opened.svg" }, | |
| 355 | "_fd_vitepress": { "iconPath": "../../icons/folder_type_vitepress.svg" }, | |
| 356 | "_fd_vitepress_open": { "iconPath": "../../icons/folder_type_vitepress_opened.svg" }, | |
| 357 | "_fd_vs": { "iconPath": "../../icons/folder_type_vs.svg" }, | |
| 358 | "_fd_vs_open": { "iconPath": "../../icons/folder_type_vs_opened.svg" }, | |
| 359 | "_fd_vscode": { "iconPath": "../../icons/folder_type_vscode.svg" }, | |
| 360 | "_fd_vscode_open": { "iconPath": "../../icons/folder_type_vscode_opened.svg" }, | |
| 361 | "_fd_vscode_test": { "iconPath": "../../icons/folder_type_vscode_test.svg" }, | |
| 362 | "_fd_vscode_test_open": { "iconPath": "../../icons/folder_type_vscode_test_opened.svg" }, | |
| 363 | "_fd_wasm": { "iconPath": "../../icons/folder_type_wasm.svg" }, | |
| 364 | "_fd_wasm_open": { "iconPath": "../../icons/folder_type_wasm_opened.svg" }, | |
| 365 | "_fd_webpack": { "iconPath": "../../icons/folder_type_webpack.svg" }, | |
| 366 | "_fd_webpack_open": { "iconPath": "../../icons/folder_type_webpack_opened.svg" }, | |
| 367 | "_fd_windows": { "iconPath": "../../icons/folder_type_windows.svg" }, | |
| 368 | "_fd_windows_open": { "iconPath": "../../icons/folder_type_windows_opened.svg" }, | |
| 369 | "_fd_windsurf": { "iconPath": "../../icons/folder_type_windsurf.svg" }, | |
| 370 | "_fd_windsurf_open": { "iconPath": "../../icons/folder_type_windsurf_opened.svg" }, | |
| 371 | "_fd_wit": { "iconPath": "../../icons/folder_type_wit.svg" }, | |
| 372 | "_fd_wit_open": { "iconPath": "../../icons/folder_type_wit_opened.svg" }, | |
| 373 | "_fd_www": { "iconPath": "../../icons/folder_type_www.svg" }, | |
| 374 | "_fd_www_open": { "iconPath": "../../icons/folder_type_www_opened.svg" }, | |
| 375 | "_fd_yarn": { "iconPath": "../../icons/folder_type_yarn.svg" }, | |
| 376 | "_fd_yarn_open": { "iconPath": "../../icons/folder_type_yarn_opened.svg" }, | |
| 377 | "_fd_zed": { "iconPath": "../../icons/folder_type_zed.svg" }, | |
| 378 | "_fd_zed_open": { "iconPath": "../../icons/folder_type_zed_opened.svg" }, | |
| 379 | "_fd_light_zed": { "iconPath": "../../icons/folder_type_light_zed.svg" }, | |
| 380 | "_fd_light_zed_open": { "iconPath": "../../icons/folder_type_light_zed_opened.svg" }, | |
| 381 | "_f_access": { "iconPath": "../../icons/file_type_access.svg" }, | |
| 382 | "_f_actionscript": { "iconPath": "../../icons/file_type_actionscript.svg" }, | |
| 383 | "_f_ada": { "iconPath": "../../icons/file_type_ada.svg" }, | |
| 384 | "_f_light_ada": { "iconPath": "../../icons/file_type_light_ada.svg" }, | |
| 385 | "_f_advpl": { "iconPath": "../../icons/file_type_advpl.svg" }, | |
| 386 | "_f_affectscript": { "iconPath": "../../icons/file_type_affectscript.svg" }, | |
| 387 | "_f_affinity": { "iconPath": "../../icons/file_type_affinity.svg" }, | |
| 388 | "_f_affinitydesigner": { "iconPath": "../../icons/file_type_affinitydesigner.svg" }, | |
| 389 | "_f_affinityphoto": { "iconPath": "../../icons/file_type_affinityphoto.svg" }, | |
| 390 | "_f_affinitypublisher": { "iconPath": "../../icons/file_type_affinitypublisher.svg" }, | |
| 391 | "_f_agda": { "iconPath": "../../icons/file_type_agda.svg" }, | |
| 392 | "_f_light_agda": { "iconPath": "../../icons/file_type_light_agda.svg" }, | |
| 393 | "_f_agents": { "iconPath": "../../icons/file_type_agents.svg" }, | |
| 394 | "_f_light_agents": { "iconPath": "../../icons/file_type_light_agents.svg" }, | |
| 395 | "_f_ai": { "iconPath": "../../icons/file_type_ai.svg" }, | |
| 396 | "_f_al": { "iconPath": "../../icons/file_type_al.svg" }, | |
| 397 | "_f_al_dal": { "iconPath": "../../icons/file_type_al_dal.svg" }, | |
| 398 | "_f_alchemy": { "iconPath": "../../icons/file_type_alchemy.svg" }, | |
| 399 | "_f_allcontributors": { "iconPath": "../../icons/file_type_allcontributors.svg" }, | |
| 400 | "_f_alloy": { "iconPath": "../../icons/file_type_alloy.svg" }, | |
| 401 | "_f_allure": { "iconPath": "../../icons/file_type_allure.svg" }, | |
| 402 | "_f_angular": { "iconPath": "../../icons/file_type_angular.svg" }, | |
| 403 | "_f_ansible": { "iconPath": "../../icons/file_type_ansible.svg" }, | |
| 404 | "_f_antlers_html": { "iconPath": "../../icons/file_type_antlers_html.svg" }, | |
| 405 | "_f_antlr": { "iconPath": "../../icons/file_type_antlr.svg" }, | |
| 406 | "_f_anyscript": { "iconPath": "../../icons/file_type_anyscript.svg" }, | |
| 407 | "_f_apache": { "iconPath": "../../icons/file_type_apache.svg" }, | |
| 408 | "_f_apex": { "iconPath": "../../icons/file_type_apex.svg" }, | |
| 409 | "_f_api_extractor": { "iconPath": "../../icons/file_type_api_extractor.svg" }, | |
| 410 | "_f_apib": { "iconPath": "../../icons/file_type_apib.svg" }, | |
| 411 | "_f_apl": { "iconPath": "../../icons/file_type_apl.svg" }, | |
| 412 | "_f_light_apl": { "iconPath": "../../icons/file_type_light_apl.svg" }, | |
| 413 | "_f_applescript": { "iconPath": "../../icons/file_type_applescript.svg" }, | |
| 414 | "_f_appscript": { "iconPath": "../../icons/file_type_appscript.svg" }, | |
| 415 | "_f_appsemble": { "iconPath": "../../icons/file_type_appsemble.svg" }, | |
| 416 | "_f_appveyor": { "iconPath": "../../icons/file_type_appveyor.svg" }, | |
| 417 | "_f_arduino": { "iconPath": "../../icons/file_type_arduino.svg" }, | |
| 418 | "_f_asciidoc": { "iconPath": "../../icons/file_type_asciidoc.svg" }, | |
| 419 | "_f_aseprite": { "iconPath": "../../icons/file_type_aseprite.svg" }, | |
| 420 | "_f_asp": { "iconPath": "../../icons/file_type_asp.svg" }, | |
| 421 | "_f_aspx": { "iconPath": "../../icons/file_type_aspx.svg" }, | |
| 422 | "_f_assembly": { "iconPath": "../../icons/file_type_assembly.svg" }, | |
| 423 | "_f_assembly_script": { "iconPath": "../../icons/file_type_assembly_script.svg" }, | |
| 424 | "_f_astro": { "iconPath": "../../icons/file_type_astro.svg" }, | |
| 425 | "_f_light_astro": { "iconPath": "../../icons/file_type_light_astro.svg" }, | |
| 426 | "_f_astroconfig": { "iconPath": "../../icons/file_type_astroconfig.svg" }, | |
| 427 | "_f_light_astroconfig": { "iconPath": "../../icons/file_type_light_astroconfig.svg" }, | |
| 428 | "_f_atom": { "iconPath": "../../icons/file_type_atom.svg" }, | |
| 429 | "_f_ats": { "iconPath": "../../icons/file_type_ats.svg" }, | |
| 430 | "_f_attw": { "iconPath": "../../icons/file_type_attw.svg" }, | |
| 431 | "_f_audio": { "iconPath": "../../icons/file_type_audio.svg" }, | |
| 432 | "_f_aurelia": { "iconPath": "../../icons/file_type_aurelia.svg" }, | |
| 433 | "_f_autohotkey": { "iconPath": "../../icons/file_type_autohotkey.svg" }, | |
| 434 | "_f_autoit": { "iconPath": "../../icons/file_type_autoit.svg" }, | |
| 435 | "_f_avif": { "iconPath": "../../icons/file_type_avif.svg" }, | |
| 436 | "_f_avro": { "iconPath": "../../icons/file_type_avro.svg" }, | |
| 437 | "_f_awk": { "iconPath": "../../icons/file_type_awk.svg" }, | |
| 438 | "_f_aws": { "iconPath": "../../icons/file_type_aws.svg" }, | |
| 439 | "_f_azure": { "iconPath": "../../icons/file_type_azure.svg" }, | |
| 440 | "_f_azurepipelines": { "iconPath": "../../icons/file_type_azurepipelines.svg" }, | |
| 441 | "_f_babel": { "iconPath": "../../icons/file_type_babel.svg" }, | |
| 442 | "_f_light_babel": { "iconPath": "../../icons/file_type_light_babel.svg" }, | |
| 443 | "_f_bak": { "iconPath": "../../icons/file_type_bak.svg" }, | |
| 444 | "_f_ballerina": { "iconPath": "../../icons/file_type_ballerina.svg" }, | |
| 445 | "_f_bat": { "iconPath": "../../icons/file_type_bat.svg" }, | |
| 446 | "_f_bats": { "iconPath": "../../icons/file_type_bats.svg" }, | |
| 447 | "_f_bazaar": { "iconPath": "../../icons/file_type_bazaar.svg" }, | |
| 448 | "_f_bazel": { "iconPath": "../../icons/file_type_bazel.svg" }, | |
| 449 | "_f_bazel_ignore": { "iconPath": "../../icons/file_type_bazel_ignore.svg" }, | |
| 450 | "_f_bazel_version": { "iconPath": "../../icons/file_type_bazel_version.svg" }, | |
| 451 | "_f_befunge": { "iconPath": "../../icons/file_type_befunge.svg" }, | |
| 452 | "_f_bicep": { "iconPath": "../../icons/file_type_bicep.svg" }, | |
| 453 | "_f_biml": { "iconPath": "../../icons/file_type_biml.svg" }, | |
| 454 | "_f_binary": { "iconPath": "../../icons/file_type_binary.svg" }, | |
| 455 | "_f_biome": { "iconPath": "../../icons/file_type_biome.svg" }, | |
| 456 | "_f_bitbucketpipeline": { "iconPath": "../../icons/file_type_bitbucketpipeline.svg" }, | |
| 457 | "_f_bithound": { "iconPath": "../../icons/file_type_bithound.svg" }, | |
| 458 | "_f_blade": { "iconPath": "../../icons/file_type_blade.svg" }, | |
| 459 | "_f_blender": { "iconPath": "../../icons/file_type_blender.svg" }, | |
| 460 | "_f_blitzbasic": { "iconPath": "../../icons/file_type_blitzbasic.svg" }, | |
| 461 | "_f_bolt": { "iconPath": "../../icons/file_type_bolt.svg" }, | |
| 462 | "_f_bosque": { "iconPath": "../../icons/file_type_bosque.svg" }, | |
| 463 | "_f_bower": { "iconPath": "../../icons/file_type_bower.svg" }, | |
| 464 | "_f_browserslist": { "iconPath": "../../icons/file_type_browserslist.svg" }, | |
| 465 | "_f_bruno": { "iconPath": "../../icons/file_type_bruno.svg" }, | |
| 466 | "_f_buckbuild": { "iconPath": "../../icons/file_type_buckbuild.svg" }, | |
| 467 | "_f_buf": { "iconPath": "../../icons/file_type_buf.svg" }, | |
| 468 | "_f_bun": { "iconPath": "../../icons/file_type_bun.svg" }, | |
| 469 | "_f_bundlemon": { "iconPath": "../../icons/file_type_bundlemon.svg" }, | |
| 470 | "_f_bundler": { "iconPath": "../../icons/file_type_bundler.svg" }, | |
| 471 | "_f_bunfig": { "iconPath": "../../icons/file_type_bunfig.svg" }, | |
| 472 | "_f_c": { "iconPath": "../../icons/file_type_c.svg" }, | |
| 473 | "_f_c3": { "iconPath": "../../icons/file_type_c3.svg" }, | |
| 474 | "_f_c_al": { "iconPath": "../../icons/file_type_c_al.svg" }, | |
| 475 | "_f_cabal": { "iconPath": "../../icons/file_type_cabal.svg" }, | |
| 476 | "_f_caddy": { "iconPath": "../../icons/file_type_caddy.svg" }, | |
| 477 | "_f_cake": { "iconPath": "../../icons/file_type_cake.svg" }, | |
| 478 | "_f_cakephp": { "iconPath": "../../icons/file_type_cakephp.svg" }, | |
| 479 | "_f_capacitor": { "iconPath": "../../icons/file_type_capacitor.svg" }, | |
| 480 | "_f_capnp": { "iconPath": "../../icons/file_type_capnp.svg" }, | |
| 481 | "_f_cargo": { "iconPath": "../../icons/file_type_cargo.svg" }, | |
| 482 | "_f_casc": { "iconPath": "../../icons/file_type_casc.svg" }, | |
| 483 | "_f_cddl": { "iconPath": "../../icons/file_type_cddl.svg" }, | |
| 484 | "_f_cert": { "iconPath": "../../icons/file_type_cert.svg" }, | |
| 485 | "_f_ceylon": { "iconPath": "../../icons/file_type_ceylon.svg" }, | |
| 486 | "_f_cf": { "iconPath": "../../icons/file_type_cf.svg" }, | |
| 487 | "_f_cfc": { "iconPath": "../../icons/file_type_cfc.svg" }, | |
| 488 | "_f_cfm": { "iconPath": "../../icons/file_type_cfm.svg" }, | |
| 489 | "_f_changie": { "iconPath": "../../icons/file_type_changie.svg" }, | |
| 490 | "_f_cheader": { "iconPath": "../../icons/file_type_cheader.svg" }, | |
| 491 | "_f_chef": { "iconPath": "../../icons/file_type_chef.svg" }, | |
| 492 | "_f_chef_cookbook": { "iconPath": "../../icons/file_type_chef_cookbook.svg" }, | |
| 493 | "_f_circleci": { "iconPath": "../../icons/file_type_circleci.svg" }, | |
| 494 | "_f_light_circleci": { "iconPath": "../../icons/file_type_light_circleci.svg" }, | |
| 495 | "_f_circom": { "iconPath": "../../icons/file_type_circom.svg" }, | |
| 496 | "_f_class": { "iconPath": "../../icons/file_type_class.svg" }, | |
| 497 | "_f_claude": { "iconPath": "../../icons/file_type_claude.svg" }, | |
| 498 | "_f_clojure": { "iconPath": "../../icons/file_type_clojure.svg" }, | |
| 499 | "_f_clojurescript": { "iconPath": "../../icons/file_type_clojurescript.svg" }, | |
| 500 | "_f_cloudflare": { "iconPath": "../../icons/file_type_cloudflare.svg" }, | |
| 501 | "_f_cloudfoundry": { "iconPath": "../../icons/file_type_cloudfoundry.svg" }, | |
| 502 | "_f_light_cloudfoundry": { "iconPath": "../../icons/file_type_light_cloudfoundry.svg" }, | |
| 503 | "_f_cmake": { "iconPath": "../../icons/file_type_cmake.svg" }, | |
| 504 | "_f_cobol": { "iconPath": "../../icons/file_type_cobol.svg" }, | |
| 505 | "_f_codacy": { "iconPath": "../../icons/file_type_codacy.svg" }, | |
| 506 | "_f_light_codacy": { "iconPath": "../../icons/file_type_light_codacy.svg" }, | |
| 507 | "_f_codeclimate": { "iconPath": "../../icons/file_type_codeclimate.svg" }, | |
| 508 | "_f_light_codeclimate": { "iconPath": "../../icons/file_type_light_codeclimate.svg" }, | |
| 509 | "_f_codecov": { "iconPath": "../../icons/file_type_codecov.svg" }, | |
| 510 | "_f_codekit": { "iconPath": "../../icons/file_type_codekit.svg" }, | |
| 511 | "_f_codemagic": { "iconPath": "../../icons/file_type_codemagic.svg" }, | |
| 512 | "_f_codeowners": { "iconPath": "../../icons/file_type_codeowners.svg" }, | |
| 513 | "_f_light_codeowners": { "iconPath": "../../icons/file_type_light_codeowners.svg" }, | |
| 514 | "_f_codeql": { "iconPath": "../../icons/file_type_codeql.svg" }, | |
| 515 | "_f_coderabbit": { "iconPath": "../../icons/file_type_coderabbit.svg" }, | |
| 516 | "_f_coffeelint": { "iconPath": "../../icons/file_type_coffeelint.svg" }, | |
| 517 | "_f_coffeescript": { "iconPath": "../../icons/file_type_coffeescript.svg" }, | |
| 518 | "_f_commitizen": { "iconPath": "../../icons/file_type_commitizen.svg" }, | |
| 519 | "_f_commitlint": { "iconPath": "../../icons/file_type_commitlint.svg" }, | |
| 520 | "_f_compass": { "iconPath": "../../icons/file_type_compass.svg" }, | |
| 521 | "_f_composer": { "iconPath": "../../icons/file_type_composer.svg" }, | |
| 522 | "_f_conan": { "iconPath": "../../icons/file_type_conan.svg" }, | |
| 523 | "_f_conda": { "iconPath": "../../icons/file_type_conda.svg" }, | |
| 524 | "_f_config": { "iconPath": "../../icons/file_type_config.svg" }, | |
| 525 | "_f_light_config": { "iconPath": "../../icons/file_type_light_config.svg" }, | |
| 526 | "_f_confluence": { "iconPath": "../../icons/file_type_confluence.svg" }, | |
| 527 | "_f_copilot": { "iconPath": "../../icons/file_type_copilot.svg" }, | |
| 528 | "_f_light_copilot": { "iconPath": "../../icons/file_type_light_copilot.svg" }, | |
| 529 | "_f_coverage": { "iconPath": "../../icons/file_type_coverage.svg" }, | |
| 530 | "_f_coveralls": { "iconPath": "../../icons/file_type_coveralls.svg" }, | |
| 531 | "_f_cpp": { "iconPath": "../../icons/file_type_cpp.svg" }, | |
| 532 | "_f_cppheader": { "iconPath": "../../icons/file_type_cppheader.svg" }, | |
| 533 | "_f_craco": { "iconPath": "../../icons/file_type_craco.svg" }, | |
| 534 | "_f_crowdin": { "iconPath": "../../icons/file_type_crowdin.svg" }, | |
| 535 | "_f_crystal": { "iconPath": "../../icons/file_type_crystal.svg" }, | |
| 536 | "_f_light_crystal": { "iconPath": "../../icons/file_type_light_crystal.svg" }, | |
| 537 | "_f_csharp": { "iconPath": "../../icons/file_type_csharp.svg" }, | |
| 538 | "_f_cspell": { "iconPath": "../../icons/file_type_cspell.svg" }, | |
| 539 | "_f_csproj": { "iconPath": "../../icons/file_type_csproj.svg" }, | |
| 540 | "_f_css": { "iconPath": "../../icons/file_type_css.svg" }, | |
| 541 | "_f_csscomb": { "iconPath": "../../icons/file_type_csscomb.svg" }, | |
| 542 | "_f_csslint": { "iconPath": "../../icons/file_type_csslint.svg" }, | |
| 543 | "_f_cssmap": { "iconPath": "../../icons/file_type_cssmap.svg" }, | |
| 544 | "_f_cucumber": { "iconPath": "../../icons/file_type_cucumber.svg" }, | |
| 545 | "_f_cuda": { "iconPath": "../../icons/file_type_cuda.svg" }, | |
| 546 | "_f_cursorrules": { "iconPath": "../../icons/file_type_cursorrules.svg" }, | |
| 547 | "_f_cvs": { "iconPath": "../../icons/file_type_cvs.svg" }, | |
| 548 | "_f_cypress": { "iconPath": "../../icons/file_type_cypress.svg" }, | |
| 549 | "_f_light_cypress": { "iconPath": "../../icons/file_type_light_cypress.svg" }, | |
| 550 | "_f_cypress_spec": { "iconPath": "../../icons/file_type_cypress_spec.svg" }, | |
| 551 | "_f_light_cypress_spec": { "iconPath": "../../icons/file_type_light_cypress_spec.svg" }, | |
| 552 | "_f_cython": { "iconPath": "../../icons/file_type_cython.svg" }, | |
| 553 | "_f_dal": { "iconPath": "../../icons/file_type_dal.svg" }, | |
| 554 | "_f_darcs": { "iconPath": "../../icons/file_type_darcs.svg" }, | |
| 555 | "_f_dartlang": { "iconPath": "../../icons/file_type_dartlang.svg" }, | |
| 556 | "_f_dartlang_generated": { "iconPath": "../../icons/file_type_dartlang_generated.svg" }, | |
| 557 | "_f_dartlang_ignore": { "iconPath": "../../icons/file_type_dartlang_ignore.svg" }, | |
| 558 | "_f_datadog": { "iconPath": "../../icons/file_type_datadog.svg" }, | |
| 559 | "_f_db": { "iconPath": "../../icons/file_type_db.svg" }, | |
| 560 | "_f_light_db": { "iconPath": "../../icons/file_type_light_db.svg" }, | |
| 561 | "_f_debian": { "iconPath": "../../icons/file_type_debian.svg" }, | |
| 562 | "_f_delphi": { "iconPath": "../../icons/file_type_delphi.svg" }, | |
| 563 | "_f_deno": { "iconPath": "../../icons/file_type_deno.svg" }, | |
| 564 | "_f_denoify": { "iconPath": "../../icons/file_type_denoify.svg" }, | |
| 565 | "_f_dependabot": { "iconPath": "../../icons/file_type_dependabot.svg" }, | |
| 566 | "_f_dependencies": { "iconPath": "../../icons/file_type_dependencies.svg" }, | |
| 567 | "_f_design_tokens": { "iconPath": "../../icons/file_type_design_tokens.svg" }, | |
| 568 | "_f_devcontainer": { "iconPath": "../../icons/file_type_devcontainer.svg" }, | |
| 569 | "_f_devvit": { "iconPath": "../../icons/file_type_devvit.svg" }, | |
| 570 | "_f_dhall": { "iconPath": "../../icons/file_type_dhall.svg" }, | |
| 571 | "_f_light_dhall": { "iconPath": "../../icons/file_type_light_dhall.svg" }, | |
| 572 | "_f_diff": { "iconPath": "../../icons/file_type_diff.svg" }, | |
| 573 | "_f_django": { "iconPath": "../../icons/file_type_django.svg" }, | |
| 574 | "_f_dlang": { "iconPath": "../../icons/file_type_dlang.svg" }, | |
| 575 | "_f_docker": { "iconPath": "../../icons/file_type_docker.svg" }, | |
| 576 | "_f_dockertest": { "iconPath": "../../icons/file_type_dockertest.svg" }, | |
| 577 | "_f_docpad": { "iconPath": "../../icons/file_type_docpad.svg" }, | |
| 578 | "_f_light_docpad": { "iconPath": "../../icons/file_type_light_docpad.svg" }, | |
| 579 | "_f_docusaurus": { "iconPath": "../../icons/file_type_docusaurus.svg" }, | |
| 580 | "_f_docz": { "iconPath": "../../icons/file_type_docz.svg" }, | |
| 581 | "_f_dojo": { "iconPath": "../../icons/file_type_dojo.svg" }, | |
| 582 | "_f_doppler": { "iconPath": "../../icons/file_type_doppler.svg" }, | |
| 583 | "_f_dotenv": { "iconPath": "../../icons/file_type_dotenv.svg" }, | |
| 584 | "_f_dotjs": { "iconPath": "../../icons/file_type_dotjs.svg" }, | |
| 585 | "_f_doxygen": { "iconPath": "../../icons/file_type_doxygen.svg" }, | |
| 586 | "_f_drawio": { "iconPath": "../../icons/file_type_drawio.svg" }, | |
| 587 | "_f_drone": { "iconPath": "../../icons/file_type_drone.svg" }, | |
| 588 | "_f_light_drone": { "iconPath": "../../icons/file_type_light_drone.svg" }, | |
| 589 | "_f_drools": { "iconPath": "../../icons/file_type_drools.svg" }, | |
| 590 | "_f_dtd": { "iconPath": "../../icons/file_type_dtd.svg" }, | |
| 591 | "_f_dune": { "iconPath": "../../icons/file_type_dune.svg" }, | |
| 592 | "_f_dustjs": { "iconPath": "../../icons/file_type_dustjs.svg" }, | |
| 593 | "_f_dvc": { "iconPath": "../../icons/file_type_dvc.svg" }, | |
| 594 | "_f_dylan": { "iconPath": "../../icons/file_type_dylan.svg" }, | |
| 595 | "_f_earthly": { "iconPath": "../../icons/file_type_earthly.svg" }, | |
| 596 | "_f_eas-metadata": { "iconPath": "../../icons/file_type_eas-metadata.svg" }, | |
| 597 | "_f_light_eas-metadata": { "iconPath": "../../icons/file_type_light_eas-metadata.svg" }, | |
| 598 | "_f_edge": { "iconPath": "../../icons/file_type_edge.svg" }, | |
| 599 | "_f_editorconfig": { "iconPath": "../../icons/file_type_editorconfig.svg" }, | |
| 600 | "_f_eex": { "iconPath": "../../icons/file_type_eex.svg" }, | |
| 601 | "_f_ejs": { "iconPath": "../../icons/file_type_ejs.svg" }, | |
| 602 | "_f_elastic": { "iconPath": "../../icons/file_type_elastic.svg" }, | |
| 603 | "_f_elasticbeanstalk": { "iconPath": "../../icons/file_type_elasticbeanstalk.svg" }, | |
| 604 | "_f_electron": { "iconPath": "../../icons/file_type_electron.svg" }, | |
| 605 | "_f_eleventy": { "iconPath": "../../icons/file_type_eleventy.svg" }, | |
| 606 | "_f_light_eleventy": { "iconPath": "../../icons/file_type_light_eleventy.svg" }, | |
| 607 | "_f_elixir": { "iconPath": "../../icons/file_type_elixir.svg" }, | |
| 608 | "_f_elm": { "iconPath": "../../icons/file_type_elm.svg" }, | |
| 609 | "_f_emacs": { "iconPath": "../../icons/file_type_emacs.svg" }, | |
| 610 | "_f_ember": { "iconPath": "../../icons/file_type_ember.svg" }, | |
| 611 | "_f_ensime": { "iconPath": "../../icons/file_type_ensime.svg" }, | |
| 612 | "_f_eps": { "iconPath": "../../icons/file_type_eps.svg" }, | |
| 613 | "_f_epub": { "iconPath": "../../icons/file_type_epub.svg" }, | |
| 614 | "_f_erb": { "iconPath": "../../icons/file_type_erb.svg" }, | |
| 615 | "_f_erlang": { "iconPath": "../../icons/file_type_erlang.svg" }, | |
| 616 | "_f_esbuild": { "iconPath": "../../icons/file_type_esbuild.svg" }, | |
| 617 | "_f_eslint": { "iconPath": "../../icons/file_type_eslint.svg" }, | |
| 618 | "_f_esphome": { "iconPath": "../../icons/file_type_esphome.svg" }, | |
| 619 | "_f_light_esphome": { "iconPath": "../../icons/file_type_light_esphome.svg" }, | |
| 620 | "_f_excalidraw": { "iconPath": "../../icons/file_type_excalidraw.svg" }, | |
| 621 | "_f_excel": { "iconPath": "../../icons/file_type_excel.svg" }, | |
| 622 | "_f_expo": { "iconPath": "../../icons/file_type_expo.svg" }, | |
| 623 | "_f_light_expo": { "iconPath": "../../icons/file_type_light_expo.svg" }, | |
| 624 | "_f_falcon": { "iconPath": "../../icons/file_type_falcon.svg" }, | |
| 625 | "_f_fantasticon": { "iconPath": "../../icons/file_type_fantasticon.svg" }, | |
| 626 | "_f_fastly": { "iconPath": "../../icons/file_type_fastly.svg" }, | |
| 627 | "_f_fauna": { "iconPath": "../../icons/file_type_fauna.svg" }, | |
| 628 | "_f_favicon": { "iconPath": "../../icons/file_type_favicon.svg" }, | |
| 629 | "_f_fbx": { "iconPath": "../../icons/file_type_fbx.svg" }, | |
| 630 | "_f_firebase": { "iconPath": "../../icons/file_type_firebase.svg" }, | |
| 631 | "_f_firebasehosting": { "iconPath": "../../icons/file_type_firebasehosting.svg" }, | |
| 632 | "_f_light_firebasehosting": { "iconPath": "../../icons/file_type_light_firebasehosting.svg" }, | |
| 633 | "_f_firebasestorage": { "iconPath": "../../icons/file_type_firebasestorage.svg" }, | |
| 634 | "_f_firestore": { "iconPath": "../../icons/file_type_firestore.svg" }, | |
| 635 | "_f_fitbit": { "iconPath": "../../icons/file_type_fitbit.svg" }, | |
| 636 | "_f_fla": { "iconPath": "../../icons/file_type_fla.svg" }, | |
| 637 | "_f_light_fla": { "iconPath": "../../icons/file_type_light_fla.svg" }, | |
| 638 | "_f_flareact": { "iconPath": "../../icons/file_type_flareact.svg" }, | |
| 639 | "_f_flash": { "iconPath": "../../icons/file_type_flash.svg" }, | |
| 640 | "_f_floobits": { "iconPath": "../../icons/file_type_floobits.svg" }, | |
| 641 | "_f_flow": { "iconPath": "../../icons/file_type_flow.svg" }, | |
| 642 | "_f_flutter": { "iconPath": "../../icons/file_type_flutter.svg" }, | |
| 643 | "_f_flutter_package": { "iconPath": "../../icons/file_type_flutter_package.svg" }, | |
| 644 | "_f_flyio": { "iconPath": "../../icons/file_type_flyio.svg" }, | |
| 645 | "_f_font": { "iconPath": "../../icons/file_type_font.svg" }, | |
| 646 | "_f_light_font": { "iconPath": "../../icons/file_type_light_font.svg" }, | |
| 647 | "_f_formkit": { "iconPath": "../../icons/file_type_formkit.svg" }, | |
| 648 | "_f_fortran": { "iconPath": "../../icons/file_type_fortran.svg" }, | |
| 649 | "_f_fossa": { "iconPath": "../../icons/file_type_fossa.svg" }, | |
| 650 | "_f_fossil": { "iconPath": "../../icons/file_type_fossil.svg" }, | |
| 651 | "_f_freemarker": { "iconPath": "../../icons/file_type_freemarker.svg" }, | |
| 652 | "_f_frontcommerce": { "iconPath": "../../icons/file_type_frontcommerce.svg" }, | |
| 653 | "_f_fsharp": { "iconPath": "../../icons/file_type_fsharp.svg" }, | |
| 654 | "_f_fsproj": { "iconPath": "../../icons/file_type_fsproj.svg" }, | |
| 655 | "_f_fthtml": { "iconPath": "../../icons/file_type_fthtml.svg" }, | |
| 656 | "_f_funding": { "iconPath": "../../icons/file_type_funding.svg" }, | |
| 657 | "_f_fusebox": { "iconPath": "../../icons/file_type_fusebox.svg" }, | |
| 658 | "_f_galen": { "iconPath": "../../icons/file_type_galen.svg" }, | |
| 659 | "_f_gamemaker": { "iconPath": "../../icons/file_type_gamemaker.svg" }, | |
| 660 | "_f_gamemaker2": { "iconPath": "../../icons/file_type_gamemaker2.svg" }, | |
| 661 | "_f_light_gamemaker2": { "iconPath": "../../icons/file_type_light_gamemaker2.svg" }, | |
| 662 | "_f_gamemaker81": { "iconPath": "../../icons/file_type_gamemaker81.svg" }, | |
| 663 | "_f_gatsby": { "iconPath": "../../icons/file_type_gatsby.svg" }, | |
| 664 | "_f_gcloud": { "iconPath": "../../icons/file_type_gcloud.svg" }, | |
| 665 | "_f_gcode": { "iconPath": "../../icons/file_type_gcode.svg" }, | |
| 666 | "_f_gdscript": { "iconPath": "../../icons/file_type_gdscript.svg" }, | |
| 667 | "_f_gduid": { "iconPath": "../../icons/file_type_gduid.svg" }, | |
| 668 | "_f_gemini": { "iconPath": "../../icons/file_type_gemini.svg" }, | |
| 669 | "_f_genstat": { "iconPath": "../../icons/file_type_genstat.svg" }, | |
| 670 | "_f_geojson": { "iconPath": "../../icons/file_type_geojson.svg" }, | |
| 671 | "_f_gimp": { "iconPath": "../../icons/file_type_gimp.svg" }, | |
| 672 | "_f_git": { "iconPath": "../../icons/file_type_git.svg" }, | |
| 673 | "_f_gitlab": { "iconPath": "../../icons/file_type_gitlab.svg" }, | |
| 674 | "_f_gitpod": { "iconPath": "../../icons/file_type_gitpod.svg" }, | |
| 675 | "_f_gleam": { "iconPath": "../../icons/file_type_gleam.svg" }, | |
| 676 | "_f_gleamconfig": { "iconPath": "../../icons/file_type_gleamconfig.svg" }, | |
| 677 | "_f_glide": { "iconPath": "../../icons/file_type_glide.svg" }, | |
| 678 | "_f_glimmer": { "iconPath": "../../icons/file_type_glimmer.svg" }, | |
| 679 | "_f_glitter": { "iconPath": "../../icons/file_type_glitter.svg" }, | |
| 680 | "_f_glsl": { "iconPath": "../../icons/file_type_glsl.svg" }, | |
| 681 | "_f_gltf": { "iconPath": "../../icons/file_type_gltf.svg" }, | |
| 682 | "_f_glyphs": { "iconPath": "../../icons/file_type_glyphs.svg" }, | |
| 683 | "_f_gnu": { "iconPath": "../../icons/file_type_gnu.svg" }, | |
| 684 | "_f_gnuplot": { "iconPath": "../../icons/file_type_gnuplot.svg" }, | |
| 685 | "_f_go": { "iconPath": "../../icons/file_type_go.svg" }, | |
| 686 | "_f_go_package": { "iconPath": "../../icons/file_type_go_package.svg" }, | |
| 687 | "_f_go_work": { "iconPath": "../../icons/file_type_go_work.svg" }, | |
| 688 | "_f_goctl": { "iconPath": "../../icons/file_type_goctl.svg" }, | |
| 689 | "_f_godot": { "iconPath": "../../icons/file_type_godot.svg" }, | |
| 690 | "_f_gpg": { "iconPath": "../../icons/file_type_gpg.svg" }, | |
| 691 | "_f_gradle": { "iconPath": "../../icons/file_type_gradle.svg" }, | |
| 692 | "_f_light_gradle": { "iconPath": "../../icons/file_type_light_gradle.svg" }, | |
| 693 | "_f_grain": { "iconPath": "../../icons/file_type_grain.svg" }, | |
| 694 | "_f_graphql": { "iconPath": "../../icons/file_type_graphql.svg" }, | |
| 695 | "_f_graphql_config": { "iconPath": "../../icons/file_type_graphql_config.svg" }, | |
| 696 | "_f_graphviz": { "iconPath": "../../icons/file_type_graphviz.svg" }, | |
| 697 | "_f_greenkeeper": { "iconPath": "../../icons/file_type_greenkeeper.svg" }, | |
| 698 | "_f_gridsome": { "iconPath": "../../icons/file_type_gridsome.svg" }, | |
| 699 | "_f_grit": { "iconPath": "../../icons/file_type_grit.svg" }, | |
| 700 | "_f_groovy": { "iconPath": "../../icons/file_type_groovy.svg" }, | |
| 701 | "_f_grunt": { "iconPath": "../../icons/file_type_grunt.svg" }, | |
| 702 | "_f_gulp": { "iconPath": "../../icons/file_type_gulp.svg" }, | |
| 703 | "_f_haml": { "iconPath": "../../icons/file_type_haml.svg" }, | |
| 704 | "_f_handlebars": { "iconPath": "../../icons/file_type_handlebars.svg" }, | |
| 705 | "_f_harbour": { "iconPath": "../../icons/file_type_harbour.svg" }, | |
| 706 | "_f_hardhat": { "iconPath": "../../icons/file_type_hardhat.svg" }, | |
| 707 | "_f_hashicorp": { "iconPath": "../../icons/file_type_hashicorp.svg" }, | |
| 708 | "_f_light_hashicorp": { "iconPath": "../../icons/file_type_light_hashicorp.svg" }, | |
| 709 | "_f_haskell": { "iconPath": "../../icons/file_type_haskell.svg" }, | |
| 710 | "_f_haxe": { "iconPath": "../../icons/file_type_haxe.svg" }, | |
| 711 | "_f_haxecheckstyle": { "iconPath": "../../icons/file_type_haxecheckstyle.svg" }, | |
| 712 | "_f_haxedevelop": { "iconPath": "../../icons/file_type_haxedevelop.svg" }, | |
| 713 | "_f_helix": { "iconPath": "../../icons/file_type_helix.svg" }, | |
| 714 | "_f_helm": { "iconPath": "../../icons/file_type_helm.svg" }, | |
| 715 | "_f_histoire": { "iconPath": "../../icons/file_type_histoire.svg" }, | |
| 716 | "_f_hjson": { "iconPath": "../../icons/file_type_hjson.svg" }, | |
| 717 | "_f_light_hjson": { "iconPath": "../../icons/file_type_light_hjson.svg" }, | |
| 718 | "_f_hlsl": { "iconPath": "../../icons/file_type_hlsl.svg" }, | |
| 719 | "_f_homeassistant": { "iconPath": "../../icons/file_type_homeassistant.svg" }, | |
| 720 | "_f_horusec": { "iconPath": "../../icons/file_type_horusec.svg" }, | |
| 721 | "_f_host": { "iconPath": "../../icons/file_type_host.svg" }, | |
| 722 | "_f_html": { "iconPath": "../../icons/file_type_html.svg" }, | |
| 723 | "_f_htmlhint": { "iconPath": "../../icons/file_type_htmlhint.svg" }, | |
| 724 | "_f_htmlvalidate": { "iconPath": "../../icons/file_type_htmlvalidate.svg" }, | |
| 725 | "_f_http": { "iconPath": "../../icons/file_type_http.svg" }, | |
| 726 | "_f_hugo": { "iconPath": "../../icons/file_type_hugo.svg" }, | |
| 727 | "_f_humanstxt": { "iconPath": "../../icons/file_type_humanstxt.svg" }, | |
| 728 | "_f_hunspell": { "iconPath": "../../icons/file_type_hunspell.svg" }, | |
| 729 | "_f_husky": { "iconPath": "../../icons/file_type_husky.svg" }, | |
| 730 | "_f_hy": { "iconPath": "../../icons/file_type_hy.svg" }, | |
| 731 | "_f_hygen": { "iconPath": "../../icons/file_type_hygen.svg" }, | |
| 732 | "_f_hypr": { "iconPath": "../../icons/file_type_hypr.svg" }, | |
| 733 | "_f_icl": { "iconPath": "../../icons/file_type_icl.svg" }, | |
| 734 | "_f_idris": { "iconPath": "../../icons/file_type_idris.svg" }, | |
| 735 | "_f_idrisbin": { "iconPath": "../../icons/file_type_idrisbin.svg" }, | |
| 736 | "_f_idrispkg": { "iconPath": "../../icons/file_type_idrispkg.svg" }, | |
| 737 | "_f_image": { "iconPath": "../../icons/file_type_image.svg" }, | |
| 738 | "_f_imba": { "iconPath": "../../icons/file_type_imba.svg" }, | |
| 739 | "_f_inc": { "iconPath": "../../icons/file_type_inc.svg" }, | |
| 740 | "_f_infopath": { "iconPath": "../../icons/file_type_infopath.svg" }, | |
| 741 | "_f_informix": { "iconPath": "../../icons/file_type_informix.svg" }, | |
| 742 | "_f_ini": { "iconPath": "../../icons/file_type_ini.svg" }, | |
| 743 | "_f_light_ini": { "iconPath": "../../icons/file_type_light_ini.svg" }, | |
| 744 | "_f_ink": { "iconPath": "../../icons/file_type_ink.svg" }, | |
| 745 | "_f_innosetup": { "iconPath": "../../icons/file_type_innosetup.svg" }, | |
| 746 | "_f_io": { "iconPath": "../../icons/file_type_io.svg" }, | |
| 747 | "_f_light_io": { "iconPath": "../../icons/file_type_light_io.svg" }, | |
| 748 | "_f_iodine": { "iconPath": "../../icons/file_type_iodine.svg" }, | |
| 749 | "_f_ionic": { "iconPath": "../../icons/file_type_ionic.svg" }, | |
| 750 | "_f_jake": { "iconPath": "../../icons/file_type_jake.svg" }, | |
| 751 | "_f_janet": { "iconPath": "../../icons/file_type_janet.svg" }, | |
| 752 | "_f_jar": { "iconPath": "../../icons/file_type_jar.svg" }, | |
| 753 | "_f_jasmine": { "iconPath": "../../icons/file_type_jasmine.svg" }, | |
| 754 | "_f_java": { "iconPath": "../../icons/file_type_java.svg" }, | |
| 755 | "_f_jbuilder": { "iconPath": "../../icons/file_type_jbuilder.svg" }, | |
| 756 | "_f_jekyll": { "iconPath": "../../icons/file_type_jekyll.svg" }, | |
| 757 | "_f_jenkins": { "iconPath": "../../icons/file_type_jenkins.svg" }, | |
| 758 | "_f_jest": { "iconPath": "../../icons/file_type_jest.svg" }, | |
| 759 | "_f_jest_snapshot": { "iconPath": "../../icons/file_type_jest_snapshot.svg" }, | |
| 760 | "_f_jetbrains": { "iconPath": "../../icons/file_type_jetbrains.svg" }, | |
| 761 | "_f_jinja": { "iconPath": "../../icons/file_type_jinja.svg" }, | |
| 762 | "_f_jpm": { "iconPath": "../../icons/file_type_jpm.svg" }, | |
| 763 | "_f_js": { "iconPath": "../../icons/file_type_js.svg" }, | |
| 764 | "_f_light_js": { "iconPath": "../../icons/file_type_light_js.svg" }, | |
| 765 | "_f_jsbeautify": { "iconPath": "../../icons/file_type_jsbeautify.svg" }, | |
| 766 | "_f_jsconfig": { "iconPath": "../../icons/file_type_jsconfig.svg" }, | |
| 767 | "_f_light_jsconfig": { "iconPath": "../../icons/file_type_light_jsconfig.svg" }, | |
| 768 | "_f_jscpd": { "iconPath": "../../icons/file_type_jscpd.svg" }, | |
| 769 | "_f_jshint": { "iconPath": "../../icons/file_type_jshint.svg" }, | |
| 770 | "_f_jsmap": { "iconPath": "../../icons/file_type_jsmap.svg" }, | |
| 771 | "_f_light_jsmap": { "iconPath": "../../icons/file_type_light_jsmap.svg" }, | |
| 772 | "_f_json": { "iconPath": "../../icons/file_type_json.svg" }, | |
| 773 | "_f_light_json": { "iconPath": "../../icons/file_type_light_json.svg" }, | |
| 774 | "_f_json5": { "iconPath": "../../icons/file_type_json5.svg" }, | |
| 775 | "_f_light_json5": { "iconPath": "../../icons/file_type_light_json5.svg" }, | |
| 776 | "_f_json_schema": { "iconPath": "../../icons/file_type_json_schema.svg" }, | |
| 777 | "_f_light_json_schema": { "iconPath": "../../icons/file_type_light_json_schema.svg" }, | |
| 778 | "_f_jsonld": { "iconPath": "../../icons/file_type_jsonld.svg" }, | |
| 779 | "_f_light_jsonld": { "iconPath": "../../icons/file_type_light_jsonld.svg" }, | |
| 780 | "_f_jsonnet": { "iconPath": "../../icons/file_type_jsonnet.svg" }, | |
| 781 | "_f_jsp": { "iconPath": "../../icons/file_type_jsp.svg" }, | |
| 782 | "_f_jsr": { "iconPath": "../../icons/file_type_jsr.svg" }, | |
| 783 | "_f_jss": { "iconPath": "../../icons/file_type_jss.svg" }, | |
| 784 | "_f_juice": { "iconPath": "../../icons/file_type_juice.svg" }, | |
| 785 | "_f_julia": { "iconPath": "../../icons/file_type_julia.svg" }, | |
| 786 | "_f_jupyter": { "iconPath": "../../icons/file_type_jupyter.svg" }, | |
| 787 | "_f_just": { "iconPath": "../../icons/file_type_just.svg" }, | |
| 788 | "_f_k": { "iconPath": "../../icons/file_type_k.svg" }, | |
| 789 | "_f_karma": { "iconPath": "../../icons/file_type_karma.svg" }, | |
| 790 | "_f_key": { "iconPath": "../../icons/file_type_key.svg" }, | |
| 791 | "_f_kitchenci": { "iconPath": "../../icons/file_type_kitchenci.svg" }, | |
| 792 | "_f_kite": { "iconPath": "../../icons/file_type_kite.svg" }, | |
| 793 | "_f_light_kite": { "iconPath": "../../icons/file_type_light_kite.svg" }, | |
| 794 | "_f_kivy": { "iconPath": "../../icons/file_type_kivy.svg" }, | |
| 795 | "_f_knip": { "iconPath": "../../icons/file_type_knip.svg" }, | |
| 796 | "_f_kos": { "iconPath": "../../icons/file_type_kos.svg" }, | |
| 797 | "_f_kotlin": { "iconPath": "../../icons/file_type_kotlin.svg" }, | |
| 798 | "_f_kusto": { "iconPath": "../../icons/file_type_kusto.svg" }, | |
| 799 | "_f_language_configuration": { "iconPath": "../../icons/file_type_language_configuration.svg" }, | |
| 800 | "_f_lark": { "iconPath": "../../icons/file_type_lark.svg" }, | |
| 801 | "_f_light_lark": { "iconPath": "../../icons/file_type_light_lark.svg" }, | |
| 802 | "_f_latino": { "iconPath": "../../icons/file_type_latino.svg" }, | |
| 803 | "_f_layout": { "iconPath": "../../icons/file_type_layout.svg" }, | |
| 804 | "_f_lean": { "iconPath": "../../icons/file_type_lean.svg" }, | |
| 805 | "_f_light_lean": { "iconPath": "../../icons/file_type_light_lean.svg" }, | |
| 806 | "_f_leanconfig": { "iconPath": "../../icons/file_type_leanconfig.svg" }, | |
| 807 | "_f_light_leanconfig": { "iconPath": "../../icons/file_type_light_leanconfig.svg" }, | |
| 808 | "_f_lefthook": { "iconPath": "../../icons/file_type_lefthook.svg" }, | |
| 809 | "_f_lerna": { "iconPath": "../../icons/file_type_lerna.svg" }, | |
| 810 | "_f_light_lerna": { "iconPath": "../../icons/file_type_light_lerna.svg" }, | |
| 811 | "_f_less": { "iconPath": "../../icons/file_type_less.svg" }, | |
| 812 | "_f_lex": { "iconPath": "../../icons/file_type_lex.svg" }, | |
| 813 | "_f_liara": { "iconPath": "../../icons/file_type_liara.svg" }, | |
| 814 | "_f_libreoffice_base": { "iconPath": "../../icons/file_type_libreoffice_base.svg" }, | |
| 815 | "_f_libreoffice_calc": { "iconPath": "../../icons/file_type_libreoffice_calc.svg" }, | |
| 816 | "_f_libreoffice_draw": { "iconPath": "../../icons/file_type_libreoffice_draw.svg" }, | |
| 817 | "_f_libreoffice_impress": { "iconPath": "../../icons/file_type_libreoffice_impress.svg" }, | |
| 818 | "_f_libreoffice_math": { "iconPath": "../../icons/file_type_libreoffice_math.svg" }, | |
| 819 | "_f_libreoffice_writer": { "iconPath": "../../icons/file_type_libreoffice_writer.svg" }, | |
| 820 | "_f_license": { "iconPath": "../../icons/file_type_license.svg" }, | |
| 821 | "_f_licensebat": { "iconPath": "../../icons/file_type_licensebat.svg" }, | |
| 822 | "_f_lighthouse": { "iconPath": "../../icons/file_type_lighthouse.svg" }, | |
| 823 | "_f_lilypond": { "iconPath": "../../icons/file_type_lilypond.svg" }, | |
| 824 | "_f_lime": { "iconPath": "../../icons/file_type_lime.svg" }, | |
| 825 | "_f_lintstagedrc": { "iconPath": "../../icons/file_type_lintstagedrc.svg" }, | |
| 826 | "_f_liquid": { "iconPath": "../../icons/file_type_liquid.svg" }, | |
| 827 | "_f_lisp": { "iconPath": "../../icons/file_type_lisp.svg" }, | |
| 828 | "_f_livescript": { "iconPath": "../../icons/file_type_livescript.svg" }, | |
| 829 | "_f_lnk": { "iconPath": "../../icons/file_type_lnk.svg" }, | |
| 830 | "_f_locale": { "iconPath": "../../icons/file_type_locale.svg" }, | |
| 831 | "_f_log": { "iconPath": "../../icons/file_type_log.svg" }, | |
| 832 | "_f_lolcode": { "iconPath": "../../icons/file_type_lolcode.svg" }, | |
| 833 | "_f_lsl": { "iconPath": "../../icons/file_type_lsl.svg" }, | |
| 834 | "_f_lua": { "iconPath": "../../icons/file_type_lua.svg" }, | |
| 835 | "_f_luau": { "iconPath": "../../icons/file_type_luau.svg" }, | |
| 836 | "_f_lync": { "iconPath": "../../icons/file_type_lync.svg" }, | |
| 837 | "_f_mailing": { "iconPath": "../../icons/file_type_mailing.svg" }, | |
| 838 | "_f_light_mailing": { "iconPath": "../../icons/file_type_light_mailing.svg" }, | |
| 839 | "_f_manifest": { "iconPath": "../../icons/file_type_manifest.svg" }, | |
| 840 | "_f_manifest_bak": { "iconPath": "../../icons/file_type_manifest_bak.svg" }, | |
| 841 | "_f_manifest_skip": { "iconPath": "../../icons/file_type_manifest_skip.svg" }, | |
| 842 | "_f_map": { "iconPath": "../../icons/file_type_map.svg" }, | |
| 843 | "_f_markdown": { "iconPath": "../../icons/file_type_markdown.svg" }, | |
| 844 | "_f_markdownlint": { "iconPath": "../../icons/file_type_markdownlint.svg" }, | |
| 845 | "_f_markdownlint_ignore": { "iconPath": "../../icons/file_type_markdownlint_ignore.svg" }, | |
| 846 | "_f_marko": { "iconPath": "../../icons/file_type_marko.svg" }, | |
| 847 | "_f_markojs": { "iconPath": "../../icons/file_type_markojs.svg" }, | |
| 848 | "_f_markuplint": { "iconPath": "../../icons/file_type_markuplint.svg" }, | |
| 849 | "_f_light_markuplint": { "iconPath": "../../icons/file_type_light_markuplint.svg" }, | |
| 850 | "_f_master-co": { "iconPath": "../../icons/file_type_master-co.svg" }, | |
| 851 | "_f_matlab": { "iconPath": "../../icons/file_type_matlab.svg" }, | |
| 852 | "_f_maven": { "iconPath": "../../icons/file_type_maven.svg" }, | |
| 853 | "_f_maxscript": { "iconPath": "../../icons/file_type_maxscript.svg" }, | |
| 854 | "_f_maya": { "iconPath": "../../icons/file_type_maya.svg" }, | |
| 855 | "_f_mcp": { "iconPath": "../../icons/file_type_mcp.svg" }, | |
| 856 | "_f_light_mcp": { "iconPath": "../../icons/file_type_light_mcp.svg" }, | |
| 857 | "_f_mdx": { "iconPath": "../../icons/file_type_mdx.svg" }, | |
| 858 | "_f_light_mdx": { "iconPath": "../../icons/file_type_light_mdx.svg" }, | |
| 859 | "_f_mdx-components": { "iconPath": "../../icons/file_type_mdx-components.svg" }, | |
| 860 | "_f_light_mdx-components": { "iconPath": "../../icons/file_type_light_mdx-components.svg" }, | |
| 861 | "_f_mdxlint": { "iconPath": "../../icons/file_type_mdxlint.svg" }, | |
| 862 | "_f_mediawiki": { "iconPath": "../../icons/file_type_mediawiki.svg" }, | |
| 863 | "_f_mercurial": { "iconPath": "../../icons/file_type_mercurial.svg" }, | |
| 864 | "_f_mermaid": { "iconPath": "../../icons/file_type_mermaid.svg" }, | |
| 865 | "_f_meson": { "iconPath": "../../icons/file_type_meson.svg" }, | |
| 866 | "_f_metal": { "iconPath": "../../icons/file_type_metal.svg" }, | |
| 867 | "_f_meteor": { "iconPath": "../../icons/file_type_meteor.svg" }, | |
| 868 | "_f_minecraft": { "iconPath": "../../icons/file_type_minecraft.svg" }, | |
| 869 | "_f_mivascript": { "iconPath": "../../icons/file_type_mivascript.svg" }, | |
| 870 | "_f_mjml": { "iconPath": "../../icons/file_type_mjml.svg" }, | |
| 871 | "_f_mlang": { "iconPath": "../../icons/file_type_mlang.svg" }, | |
| 872 | "_f_light_mlang": { "iconPath": "../../icons/file_type_light_mlang.svg" }, | |
| 873 | "_f_mocha": { "iconPath": "../../icons/file_type_mocha.svg" }, | |
| 874 | "_f_modernizr": { "iconPath": "../../icons/file_type_modernizr.svg" }, | |
| 875 | "_f_mojo": { "iconPath": "../../icons/file_type_mojo.svg" }, | |
| 876 | "_f_mojolicious": { "iconPath": "../../icons/file_type_mojolicious.svg" }, | |
| 877 | "_f_moleculer": { "iconPath": "../../icons/file_type_moleculer.svg" }, | |
| 878 | "_f_mondoo": { "iconPath": "../../icons/file_type_mondoo.svg" }, | |
| 879 | "_f_mongo": { "iconPath": "../../icons/file_type_mongo.svg" }, | |
| 880 | "_f_monotone": { "iconPath": "../../icons/file_type_monotone.svg" }, | |
| 881 | "_f_motif": { "iconPath": "../../icons/file_type_motif.svg" }, | |
| 882 | "_f_mson": { "iconPath": "../../icons/file_type_mson.svg" }, | |
| 883 | "_f_mustache": { "iconPath": "../../icons/file_type_mustache.svg" }, | |
| 884 | "_f_light_mustache": { "iconPath": "../../icons/file_type_light_mustache.svg" }, | |
| 885 | "_f_mvt": { "iconPath": "../../icons/file_type_mvt.svg" }, | |
| 886 | "_f_mvtcss": { "iconPath": "../../icons/file_type_mvtcss.svg" }, | |
| 887 | "_f_mvtjs": { "iconPath": "../../icons/file_type_mvtjs.svg" }, | |
| 888 | "_f_mypy": { "iconPath": "../../icons/file_type_mypy.svg" }, | |
| 889 | "_f_light_mypy": { "iconPath": "../../icons/file_type_light_mypy.svg" }, | |
| 890 | "_f_nanostaged": { "iconPath": "../../icons/file_type_nanostaged.svg" }, | |
| 891 | "_f_ndst": { "iconPath": "../../icons/file_type_ndst.svg" }, | |
| 892 | "_f_nearly": { "iconPath": "../../icons/file_type_nearly.svg" }, | |
| 893 | "_f_neo4j": { "iconPath": "../../icons/file_type_neo4j.svg" }, | |
| 894 | "_f_light_neo4j": { "iconPath": "../../icons/file_type_light_neo4j.svg" }, | |
| 895 | "_f_nestjs": { "iconPath": "../../icons/file_type_nestjs.svg" }, | |
| 896 | "_f_netlify": { "iconPath": "../../icons/file_type_netlify.svg" }, | |
| 897 | "_f_light_netlify": { "iconPath": "../../icons/file_type_light_netlify.svg" }, | |
| 898 | "_f_next": { "iconPath": "../../icons/file_type_next.svg" }, | |
| 899 | "_f_light_next": { "iconPath": "../../icons/file_type_light_next.svg" }, | |
| 900 | "_f_nextflow": { "iconPath": "../../icons/file_type_nextflow.svg" }, | |
| 901 | "_f_ng_tailwind": { "iconPath": "../../icons/file_type_ng_tailwind.svg" }, | |
| 902 | "_f_nginx": { "iconPath": "../../icons/file_type_nginx.svg" }, | |
| 903 | "_f_nim": { "iconPath": "../../icons/file_type_nim.svg" }, | |
| 904 | "_f_nimble": { "iconPath": "../../icons/file_type_nimble.svg" }, | |
| 905 | "_f_ninja": { "iconPath": "../../icons/file_type_ninja.svg" }, | |
| 906 | "_f_nix": { "iconPath": "../../icons/file_type_nix.svg" }, | |
| 907 | "_f_njsproj": { "iconPath": "../../icons/file_type_njsproj.svg" }, | |
| 908 | "_f_noc": { "iconPath": "../../icons/file_type_noc.svg" }, | |
| 909 | "_f_node": { "iconPath": "../../icons/file_type_node.svg" }, | |
| 910 | "_f_nodemon": { "iconPath": "../../icons/file_type_nodemon.svg" }, | |
| 911 | "_f_npm": { "iconPath": "../../icons/file_type_npm.svg" }, | |
| 912 | "_f_npmpackagejsonlint": { "iconPath": "../../icons/file_type_npmpackagejsonlint.svg" }, | |
| 913 | "_f_nsi": { "iconPath": "../../icons/file_type_nsi.svg" }, | |
| 914 | "_f_nsri": { "iconPath": "../../icons/file_type_nsri.svg" }, | |
| 915 | "_f_nsri-integrity": { "iconPath": "../../icons/file_type_nsri-integrity.svg" }, | |
| 916 | "_f_nuget": { "iconPath": "../../icons/file_type_nuget.svg" }, | |
| 917 | "_f_numpy": { "iconPath": "../../icons/file_type_numpy.svg" }, | |
| 918 | "_f_nunjucks": { "iconPath": "../../icons/file_type_nunjucks.svg" }, | |
| 919 | "_f_nushell": { "iconPath": "../../icons/file_type_nushell.svg" }, | |
| 920 | "_f_nuxt": { "iconPath": "../../icons/file_type_nuxt.svg" }, | |
| 921 | "_f_nx": { "iconPath": "../../icons/file_type_nx.svg" }, | |
| 922 | "_f_light_nx": { "iconPath": "../../icons/file_type_light_nx.svg" }, | |
| 923 | "_f_nyc": { "iconPath": "../../icons/file_type_nyc.svg" }, | |
| 924 | "_f_objectivec": { "iconPath": "../../icons/file_type_objectivec.svg" }, | |
| 925 | "_f_objectivecpp": { "iconPath": "../../icons/file_type_objectivecpp.svg" }, | |
| 926 | "_f_objidconfig": { "iconPath": "../../icons/file_type_objidconfig.svg" }, | |
| 927 | "_f_light_objidconfig": { "iconPath": "../../icons/file_type_light_objidconfig.svg" }, | |
| 928 | "_f_ocaml": { "iconPath": "../../icons/file_type_ocaml.svg" }, | |
| 929 | "_f_ocaml_intf": { "iconPath": "../../icons/file_type_ocaml_intf.svg" }, | |
| 930 | "_f_ogone": { "iconPath": "../../icons/file_type_ogone.svg" }, | |
| 931 | "_f_onenote": { "iconPath": "../../icons/file_type_onenote.svg" }, | |
| 932 | "_f_opam": { "iconPath": "../../icons/file_type_opam.svg" }, | |
| 933 | "_f_openHAB": { "iconPath": "../../icons/file_type_openHAB.svg" }, | |
| 934 | "_f_light_openHAB": { "iconPath": "../../icons/file_type_light_openHAB.svg" }, | |
| 935 | "_f_opencl": { "iconPath": "../../icons/file_type_opencl.svg" }, | |
| 936 | "_f_openscad": { "iconPath": "../../icons/file_type_openscad.svg" }, | |
| 937 | "_f_opentofu": { "iconPath": "../../icons/file_type_opentofu.svg" }, | |
| 938 | "_f_org": { "iconPath": "../../icons/file_type_org.svg" }, | |
| 939 | "_f_outlook": { "iconPath": "../../icons/file_type_outlook.svg" }, | |
| 940 | "_f_ovpn": { "iconPath": "../../icons/file_type_ovpn.svg" }, | |
| 941 | "_f_oxc": { "iconPath": "../../icons/file_type_oxc.svg" }, | |
| 942 | "_f_light_oxc": { "iconPath": "../../icons/file_type_light_oxc.svg" }, | |
| 943 | "_f_package": { "iconPath": "../../icons/file_type_package.svg" }, | |
| 944 | "_f_packship": { "iconPath": "../../icons/file_type_packship.svg" }, | |
| 945 | "_f_light_packship": { "iconPath": "../../icons/file_type_light_packship.svg" }, | |
| 946 | "_f_paket": { "iconPath": "../../icons/file_type_paket.svg" }, | |
| 947 | "_f_pandacss": { "iconPath": "../../icons/file_type_pandacss.svg" }, | |
| 948 | "_f_patch": { "iconPath": "../../icons/file_type_patch.svg" }, | |
| 949 | "_f_pcl": { "iconPath": "../../icons/file_type_pcl.svg" }, | |
| 950 | "_f_light_pcl": { "iconPath": "../../icons/file_type_light_pcl.svg" }, | |
| 951 | "_f_pddl": { "iconPath": "../../icons/file_type_pddl.svg" }, | |
| 952 | "_f_pddl_happenings": { "iconPath": "../../icons/file_type_pddl_happenings.svg" }, | |
| 953 | "_f_pddl_plan": { "iconPath": "../../icons/file_type_pddl_plan.svg" }, | |
| 954 | "_f_pdf": { "iconPath": "../../icons/file_type_pdf.svg" }, | |
| 955 | "_f_pdm": { "iconPath": "../../icons/file_type_pdm.svg" }, | |
| 956 | "_f_peeky": { "iconPath": "../../icons/file_type_peeky.svg" }, | |
| 957 | "_f_perl": { "iconPath": "../../icons/file_type_perl.svg" }, | |
| 958 | "_f_perl6": { "iconPath": "../../icons/file_type_perl6.svg" }, | |
| 959 | "_f_pgsql": { "iconPath": "../../icons/file_type_pgsql.svg" }, | |
| 960 | "_f_photoshop": { "iconPath": "../../icons/file_type_photoshop.svg" }, | |
| 961 | "_f_php": { "iconPath": "../../icons/file_type_php.svg" }, | |
| 962 | "_f_phpcsfixer": { "iconPath": "../../icons/file_type_phpcsfixer.svg" }, | |
| 963 | "_f_phpstan": { "iconPath": "../../icons/file_type_phpstan.svg" }, | |
| 964 | "_f_phpunit": { "iconPath": "../../icons/file_type_phpunit.svg" }, | |
| 965 | "_f_phraseapp": { "iconPath": "../../icons/file_type_phraseapp.svg" }, | |
| 966 | "_f_pine": { "iconPath": "../../icons/file_type_pine.svg" }, | |
| 967 | "_f_pip": { "iconPath": "../../icons/file_type_pip.svg" }, | |
| 968 | "_f_pipeline": { "iconPath": "../../icons/file_type_pipeline.svg" }, | |
| 969 | "_f_plantuml": { "iconPath": "../../icons/file_type_plantuml.svg" }, | |
| 970 | "_f_platformio": { "iconPath": "../../icons/file_type_platformio.svg" }, | |
| 971 | "_f_playwright": { "iconPath": "../../icons/file_type_playwright.svg" }, | |
| 972 | "_f_plsql": { "iconPath": "../../icons/file_type_plsql.svg" }, | |
| 973 | "_f_plsql_package": { "iconPath": "../../icons/file_type_plsql_package.svg" }, | |
| 974 | "_f_plsql_package_body": { "iconPath": "../../icons/file_type_plsql_package_body.svg" }, | |
| 975 | "_f_plsql_package_header": { "iconPath": "../../icons/file_type_plsql_package_header.svg" }, | |
| 976 | "_f_plsql_package_spec": { "iconPath": "../../icons/file_type_plsql_package_spec.svg" }, | |
| 977 | "_f_pm2": { "iconPath": "../../icons/file_type_pm2.svg" }, | |
| 978 | "_f_pnpm": { "iconPath": "../../icons/file_type_pnpm.svg" }, | |
| 979 | "_f_light_pnpm": { "iconPath": "../../icons/file_type_light_pnpm.svg" }, | |
| 980 | "_f_poedit": { "iconPath": "../../icons/file_type_poedit.svg" }, | |
| 981 | "_f_poetry": { "iconPath": "../../icons/file_type_poetry.svg" }, | |
| 982 | "_f_polyglot": { "iconPath": "../../icons/file_type_polyglot.svg" }, | |
| 983 | "_f_polymer": { "iconPath": "../../icons/file_type_polymer.svg" }, | |
| 984 | "_f_pony": { "iconPath": "../../icons/file_type_pony.svg" }, | |
| 985 | "_f_postcss": { "iconPath": "../../icons/file_type_postcss.svg" }, | |
| 986 | "_f_postcssconfig": { "iconPath": "../../icons/file_type_postcssconfig.svg" }, | |
| 987 | "_f_postman": { "iconPath": "../../icons/file_type_postman.svg" }, | |
| 988 | "_f_postscript": { "iconPath": "../../icons/file_type_postscript.svg" }, | |
| 989 | "_f_powerpoint": { "iconPath": "../../icons/file_type_powerpoint.svg" }, | |
| 990 | "_f_powershell": { "iconPath": "../../icons/file_type_powershell.svg" }, | |
| 991 | "_f_powershell_format": { "iconPath": "../../icons/file_type_powershell_format.svg" }, | |
| 992 | "_f_powershell_psd": { "iconPath": "../../icons/file_type_powershell_psd.svg" }, | |
| 993 | "_f_powershell_psm": { "iconPath": "../../icons/file_type_powershell_psm.svg" }, | |
| 994 | "_f_powershell_types": { "iconPath": "../../icons/file_type_powershell_types.svg" }, | |
| 995 | "_f_preact": { "iconPath": "../../icons/file_type_preact.svg" }, | |
| 996 | "_f_precommit": { "iconPath": "../../icons/file_type_precommit.svg" }, | |
| 997 | "_f_prettier": { "iconPath": "../../icons/file_type_prettier.svg" }, | |
| 998 | "_f_light_prettier": { "iconPath": "../../icons/file_type_light_prettier.svg" }, | |
| 999 | "_f_prisma": { "iconPath": "../../icons/file_type_prisma.svg" }, | |
| 1000 | "_f_light_prisma": { "iconPath": "../../icons/file_type_light_prisma.svg" }, | |
| 1001 | "_f_prismaconfig": { "iconPath": "../../icons/file_type_prismaconfig.svg" }, | |
| 1002 | "_f_processinglang": { "iconPath": "../../icons/file_type_processinglang.svg" }, | |
| 1003 | "_f_procfile": { "iconPath": "../../icons/file_type_procfile.svg" }, | |
| 1004 | "_f_progress": { "iconPath": "../../icons/file_type_progress.svg" }, | |
| 1005 | "_f_prolog": { "iconPath": "../../icons/file_type_prolog.svg" }, | |
| 1006 | "_f_prometheus": { "iconPath": "../../icons/file_type_prometheus.svg" }, | |
| 1007 | "_f_protobuf": { "iconPath": "../../icons/file_type_protobuf.svg" }, | |
| 1008 | "_f_protractor": { "iconPath": "../../icons/file_type_protractor.svg" }, | |
| 1009 | "_f_publiccode": { "iconPath": "../../icons/file_type_publiccode.svg" }, | |
| 1010 | "_f_publisher": { "iconPath": "../../icons/file_type_publisher.svg" }, | |
| 1011 | "_f_pug": { "iconPath": "../../icons/file_type_pug.svg" }, | |
| 1012 | "_f_pulumi": { "iconPath": "../../icons/file_type_pulumi.svg" }, | |
| 1013 | "_f_puppet": { "iconPath": "../../icons/file_type_puppet.svg" }, | |
| 1014 | "_f_purescript": { "iconPath": "../../icons/file_type_purescript.svg" }, | |
| 1015 | "_f_light_purescript": { "iconPath": "../../icons/file_type_light_purescript.svg" }, | |
| 1016 | "_f_purgecss": { "iconPath": "../../icons/file_type_purgecss.svg" }, | |
| 1017 | "_f_pyenv": { "iconPath": "../../icons/file_type_pyenv.svg" }, | |
| 1018 | "_f_pypi": { "iconPath": "../../icons/file_type_pypi.svg" }, | |
| 1019 | "_f_pyret": { "iconPath": "../../icons/file_type_pyret.svg" }, | |
| 1020 | "_f_pyscript": { "iconPath": "../../icons/file_type_pyscript.svg" }, | |
| 1021 | "_f_pytest": { "iconPath": "../../icons/file_type_pytest.svg" }, | |
| 1022 | "_f_python": { "iconPath": "../../icons/file_type_python.svg" }, | |
| 1023 | "_f_pythowo": { "iconPath": "../../icons/file_type_pythowo.svg" }, | |
| 1024 | "_f_pytyped": { "iconPath": "../../icons/file_type_pytyped.svg" }, | |
| 1025 | "_f_pyup": { "iconPath": "../../icons/file_type_pyup.svg" }, | |
| 1026 | "_f_q": { "iconPath": "../../icons/file_type_q.svg" }, | |
| 1027 | "_f_qbs": { "iconPath": "../../icons/file_type_qbs.svg" }, | |
| 1028 | "_f_qlikview": { "iconPath": "../../icons/file_type_qlikview.svg" }, | |
| 1029 | "_f_qml": { "iconPath": "../../icons/file_type_qml.svg" }, | |
| 1030 | "_f_qmldir": { "iconPath": "../../icons/file_type_qmldir.svg" }, | |
| 1031 | "_f_qsharp": { "iconPath": "../../icons/file_type_qsharp.svg" }, | |
| 1032 | "_f_quasar": { "iconPath": "../../icons/file_type_quasar.svg" }, | |
| 1033 | "_f_light_quasar": { "iconPath": "../../icons/file_type_light_quasar.svg" }, | |
| 1034 | "_f_r": { "iconPath": "../../icons/file_type_r.svg" }, | |
| 1035 | "_f_ra_syntax_tree": { "iconPath": "../../icons/file_type_ra_syntax_tree.svg" }, | |
| 1036 | "_f_racket": { "iconPath": "../../icons/file_type_racket.svg" }, | |
| 1037 | "_f_rails": { "iconPath": "../../icons/file_type_rails.svg" }, | |
| 1038 | "_f_rake": { "iconPath": "../../icons/file_type_rake.svg" }, | |
| 1039 | "_f_raku": { "iconPath": "../../icons/file_type_raku.svg" }, | |
| 1040 | "_f_light_raku": { "iconPath": "../../icons/file_type_light_raku.svg" }, | |
| 1041 | "_f_raml": { "iconPath": "../../icons/file_type_raml.svg" }, | |
| 1042 | "_f_razor": { "iconPath": "../../icons/file_type_razor.svg" }, | |
| 1043 | "_f_razzle": { "iconPath": "../../icons/file_type_razzle.svg" }, | |
| 1044 | "_f_light_razzle": { "iconPath": "../../icons/file_type_light_razzle.svg" }, | |
| 1045 | "_f_reactjs": { "iconPath": "../../icons/file_type_reactjs.svg" }, | |
| 1046 | "_f_reactrouter": { "iconPath": "../../icons/file_type_reactrouter.svg" }, | |
| 1047 | "_f_light_reactrouter": { "iconPath": "../../icons/file_type_light_reactrouter.svg" }, | |
| 1048 | "_f_reacttemplate": { "iconPath": "../../icons/file_type_reacttemplate.svg" }, | |
| 1049 | "_f_reactts": { "iconPath": "../../icons/file_type_reactts.svg" }, | |
| 1050 | "_f_reason": { "iconPath": "../../icons/file_type_reason.svg" }, | |
| 1051 | "_f_red": { "iconPath": "../../icons/file_type_red.svg" }, | |
| 1052 | "_f_registry": { "iconPath": "../../icons/file_type_registry.svg" }, | |
| 1053 | "_f_rego": { "iconPath": "../../icons/file_type_rego.svg" }, | |
| 1054 | "_f_rehype": { "iconPath": "../../icons/file_type_rehype.svg" }, | |
| 1055 | "_f_light_rehype": { "iconPath": "../../icons/file_type_light_rehype.svg" }, | |
| 1056 | "_f_remark": { "iconPath": "../../icons/file_type_remark.svg" }, | |
| 1057 | "_f_light_remark": { "iconPath": "../../icons/file_type_light_remark.svg" }, | |
| 1058 | "_f_renovate": { "iconPath": "../../icons/file_type_renovate.svg" }, | |
| 1059 | "_f_replit": { "iconPath": "../../icons/file_type_replit.svg" }, | |
| 1060 | "_f_light_replit": { "iconPath": "../../icons/file_type_light_replit.svg" }, | |
| 1061 | "_f_rescript": { "iconPath": "../../icons/file_type_rescript.svg" }, | |
| 1062 | "_f_rest": { "iconPath": "../../icons/file_type_rest.svg" }, | |
| 1063 | "_f_retext": { "iconPath": "../../icons/file_type_retext.svg" }, | |
| 1064 | "_f_light_retext": { "iconPath": "../../icons/file_type_light_retext.svg" }, | |
| 1065 | "_f_rexx": { "iconPath": "../../icons/file_type_rexx.svg" }, | |
| 1066 | "_f_riot": { "iconPath": "../../icons/file_type_riot.svg" }, | |
| 1067 | "_f_ripple": { "iconPath": "../../icons/file_type_ripple.svg" }, | |
| 1068 | "_f_rmd": { "iconPath": "../../icons/file_type_rmd.svg" }, | |
| 1069 | "_f_rnc": { "iconPath": "../../icons/file_type_rnc.svg" }, | |
| 1070 | "_f_robotframework": { "iconPath": "../../icons/file_type_robotframework.svg" }, | |
| 1071 | "_f_robots": { "iconPath": "../../icons/file_type_robots.svg" }, | |
| 1072 | "_f_rolldown": { "iconPath": "../../icons/file_type_rolldown.svg" }, | |
| 1073 | "_f_light_rolldown": { "iconPath": "../../icons/file_type_light_rolldown.svg" }, | |
| 1074 | "_f_rollup": { "iconPath": "../../icons/file_type_rollup.svg" }, | |
| 1075 | "_f_rome": { "iconPath": "../../icons/file_type_rome.svg" }, | |
| 1076 | "_f_light_rome": { "iconPath": "../../icons/file_type_light_rome.svg" }, | |
| 1077 | "_f_ron": { "iconPath": "../../icons/file_type_ron.svg" }, | |
| 1078 | "_f_rproj": { "iconPath": "../../icons/file_type_rproj.svg" }, | |
| 1079 | "_f_rspec": { "iconPath": "../../icons/file_type_rspec.svg" }, | |
| 1080 | "_f_rss": { "iconPath": "../../icons/file_type_rss.svg" }, | |
| 1081 | "_f_rubocop": { "iconPath": "../../icons/file_type_rubocop.svg" }, | |
| 1082 | "_f_light_rubocop": { "iconPath": "../../icons/file_type_light_rubocop.svg" }, | |
| 1083 | "_f_ruby": { "iconPath": "../../icons/file_type_ruby.svg" }, | |
| 1084 | "_f_rust": { "iconPath": "../../icons/file_type_rust.svg" }, | |
| 1085 | "_f_light_rust": { "iconPath": "../../icons/file_type_light_rust.svg" }, | |
| 1086 | "_f_rust_toolchain": { "iconPath": "../../icons/file_type_rust_toolchain.svg" }, | |
| 1087 | "_f_light_rust_toolchain": { "iconPath": "../../icons/file_type_light_rust_toolchain.svg" }, | |
| 1088 | "_f_s-lang": { "iconPath": "../../icons/file_type_s-lang.svg" }, | |
| 1089 | "_f_safetensors": { "iconPath": "../../icons/file_type_safetensors.svg" }, | |
| 1090 | "_f_light_safetensors": { "iconPath": "../../icons/file_type_light_safetensors.svg" }, | |
| 1091 | "_f_sails": { "iconPath": "../../icons/file_type_sails.svg" }, | |
| 1092 | "_f_saltstack": { "iconPath": "../../icons/file_type_saltstack.svg" }, | |
| 1093 | "_f_san": { "iconPath": "../../icons/file_type_san.svg" }, | |
| 1094 | "_f_sapphire_framework_cli": { "iconPath": "../../icons/file_type_sapphire_framework_cli.svg" }, | |
| 1095 | "_f_sas": { "iconPath": "../../icons/file_type_sas.svg" }, | |
| 1096 | "_f_sass": { "iconPath": "../../icons/file_type_sass.svg" }, | |
| 1097 | "_f_sbt": { "iconPath": "../../icons/file_type_sbt.svg" }, | |
| 1098 | "_f_scala": { "iconPath": "../../icons/file_type_scala.svg" }, | |
| 1099 | "_f_scilab": { "iconPath": "../../icons/file_type_scilab.svg" }, | |
| 1100 | "_f_script": { "iconPath": "../../icons/file_type_script.svg" }, | |
| 1101 | "_f_scss": { "iconPath": "../../icons/file_type_scss.svg" }, | |
| 1102 | "_f_sdlang": { "iconPath": "../../icons/file_type_sdlang.svg" }, | |
| 1103 | "_f_search_result": { "iconPath": "../../icons/file_type_search_result.svg" }, | |
| 1104 | "_f_seedkit": { "iconPath": "../../icons/file_type_seedkit.svg" }, | |
| 1105 | "_f_sentry": { "iconPath": "../../icons/file_type_sentry.svg" }, | |
| 1106 | "_f_sequelize": { "iconPath": "../../icons/file_type_sequelize.svg" }, | |
| 1107 | "_f_serverless": { "iconPath": "../../icons/file_type_serverless.svg" }, | |
| 1108 | "_f_shadcn": { "iconPath": "../../icons/file_type_shadcn.svg" }, | |
| 1109 | "_f_light_shadcn": { "iconPath": "../../icons/file_type_light_shadcn.svg" }, | |
| 1110 | "_f_shaderlab": { "iconPath": "../../icons/file_type_shaderlab.svg" }, | |
| 1111 | "_f_light_shaderlab": { "iconPath": "../../icons/file_type_light_shaderlab.svg" }, | |
| 1112 | "_f_shell": { "iconPath": "../../icons/file_type_shell.svg" }, | |
| 1113 | "_f_shellcheck": { "iconPath": "../../icons/file_type_shellcheck.svg" }, | |
| 1114 | "_f_shuttle": { "iconPath": "../../icons/file_type_shuttle.svg" }, | |
| 1115 | "_f_silverstripe": { "iconPath": "../../icons/file_type_silverstripe.svg" }, | |
| 1116 | "_f_sino": { "iconPath": "../../icons/file_type_sino.svg" }, | |
| 1117 | "_f_sitemap": { "iconPath": "../../icons/file_type_sitemap.svg" }, | |
| 1118 | "_f_siyuan": { "iconPath": "../../icons/file_type_siyuan.svg" }, | |
| 1119 | "_f_sketch": { "iconPath": "../../icons/file_type_sketch.svg" }, | |
| 1120 | "_f_skipper": { "iconPath": "../../icons/file_type_skipper.svg" }, | |
| 1121 | "_f_slang": { "iconPath": "../../icons/file_type_slang.svg" }, | |
| 1122 | "_f_slashup": { "iconPath": "../../icons/file_type_slashup.svg" }, | |
| 1123 | "_f_slice": { "iconPath": "../../icons/file_type_slice.svg" }, | |
| 1124 | "_f_slim": { "iconPath": "../../icons/file_type_slim.svg" }, | |
| 1125 | "_f_slint": { "iconPath": "../../icons/file_type_slint.svg" }, | |
| 1126 | "_f_sln": { "iconPath": "../../icons/file_type_sln.svg" }, | |
| 1127 | "_f_smarty": { "iconPath": "../../icons/file_type_smarty.svg" }, | |
| 1128 | "_f_smithery": { "iconPath": "../../icons/file_type_smithery.svg" }, | |
| 1129 | "_f_snakemake": { "iconPath": "../../icons/file_type_snakemake.svg" }, | |
| 1130 | "_f_snapcraft": { "iconPath": "../../icons/file_type_snapcraft.svg" }, | |
| 1131 | "_f_snaplet": { "iconPath": "../../icons/file_type_snaplet.svg" }, | |
| 1132 | "_f_snort": { "iconPath": "../../icons/file_type_snort.svg" }, | |
| 1133 | "_f_snyk": { "iconPath": "../../icons/file_type_snyk.svg" }, | |
| 1134 | "_f_solidarity": { "iconPath": "../../icons/file_type_solidarity.svg" }, | |
| 1135 | "_f_solidity": { "iconPath": "../../icons/file_type_solidity.svg" }, | |
| 1136 | "_f_light_solidity": { "iconPath": "../../icons/file_type_light_solidity.svg" }, | |
| 1137 | "_f_source": { "iconPath": "../../icons/file_type_source.svg" }, | |
| 1138 | "_f_spacengine": { "iconPath": "../../icons/file_type_spacengine.svg" }, | |
| 1139 | "_f_sparql": { "iconPath": "../../icons/file_type_sparql.svg" }, | |
| 1140 | "_f_spin": { "iconPath": "../../icons/file_type_spin.svg" }, | |
| 1141 | "_f_light_spin": { "iconPath": "../../icons/file_type_light_spin.svg" }, | |
| 1142 | "_f_sqf": { "iconPath": "../../icons/file_type_sqf.svg" }, | |
| 1143 | "_f_sql": { "iconPath": "../../icons/file_type_sql.svg" }, | |
| 1144 | "_f_sqlite": { "iconPath": "../../icons/file_type_sqlite.svg" }, | |
| 1145 | "_f_squirrel": { "iconPath": "../../icons/file_type_squirrel.svg" }, | |
| 1146 | "_f_sss": { "iconPath": "../../icons/file_type_sss.svg" }, | |
| 1147 | "_f_sst": { "iconPath": "../../icons/file_type_sst.svg" }, | |
| 1148 | "_f_stackblitz": { "iconPath": "../../icons/file_type_stackblitz.svg" }, | |
| 1149 | "_f_stan": { "iconPath": "../../icons/file_type_stan.svg" }, | |
| 1150 | "_f_stata": { "iconPath": "../../icons/file_type_stata.svg" }, | |
| 1151 | "_f_stencil": { "iconPath": "../../icons/file_type_stencil.svg" }, | |
| 1152 | "_f_storyboard": { "iconPath": "../../icons/file_type_storyboard.svg" }, | |
| 1153 | "_f_storybook": { "iconPath": "../../icons/file_type_storybook.svg" }, | |
| 1154 | "_f_stryker": { "iconPath": "../../icons/file_type_stryker.svg" }, | |
| 1155 | "_f_stylable": { "iconPath": "../../icons/file_type_stylable.svg" }, | |
| 1156 | "_f_style": { "iconPath": "../../icons/file_type_style.svg" }, | |
| 1157 | "_f_styled": { "iconPath": "../../icons/file_type_styled.svg" }, | |
| 1158 | "_f_stylelint": { "iconPath": "../../icons/file_type_stylelint.svg" }, | |
| 1159 | "_f_light_stylelint": { "iconPath": "../../icons/file_type_light_stylelint.svg" }, | |
| 1160 | "_f_stylish_haskell": { "iconPath": "../../icons/file_type_stylish_haskell.svg" }, | |
| 1161 | "_f_stylus": { "iconPath": "../../icons/file_type_stylus.svg" }, | |
| 1162 | "_f_light_stylus": { "iconPath": "../../icons/file_type_light_stylus.svg" }, | |
| 1163 | "_f_sublime": { "iconPath": "../../icons/file_type_sublime.svg" }, | |
| 1164 | "_f_subversion": { "iconPath": "../../icons/file_type_subversion.svg" }, | |
| 1165 | "_f_svelte": { "iconPath": "../../icons/file_type_svelte.svg" }, | |
| 1166 | "_f_svelteconfig": { "iconPath": "../../icons/file_type_svelteconfig.svg" }, | |
| 1167 | "_f_svg": { "iconPath": "../../icons/file_type_svg.svg" }, | |
| 1168 | "_f_svgo": { "iconPath": "../../icons/file_type_svgo.svg" }, | |
| 1169 | "_f_swagger": { "iconPath": "../../icons/file_type_swagger.svg" }, | |
| 1170 | "_f_swc": { "iconPath": "../../icons/file_type_swc.svg" }, | |
| 1171 | "_f_swift": { "iconPath": "../../icons/file_type_swift.svg" }, | |
| 1172 | "_f_swig": { "iconPath": "../../icons/file_type_swig.svg" }, | |
| 1173 | "_f_symfony": { "iconPath": "../../icons/file_type_symfony.svg" }, | |
| 1174 | "_f_light_symfony": { "iconPath": "../../icons/file_type_light_symfony.svg" }, | |
| 1175 | "_f_syncpack": { "iconPath": "../../icons/file_type_syncpack.svg" }, | |
| 1176 | "_f_systemd": { "iconPath": "../../icons/file_type_systemd.svg" }, | |
| 1177 | "_f_light_systemd": { "iconPath": "../../icons/file_type_light_systemd.svg" }, | |
| 1178 | "_f_systemverilog": { "iconPath": "../../icons/file_type_systemverilog.svg" }, | |
| 1179 | "_f_light_systemverilog": { "iconPath": "../../icons/file_type_light_systemverilog.svg" }, | |
| 1180 | "_f_t4tt": { "iconPath": "../../icons/file_type_t4tt.svg" }, | |
| 1181 | "_f_tailwind": { "iconPath": "../../icons/file_type_tailwind.svg" }, | |
| 1182 | "_f_tamagui": { "iconPath": "../../icons/file_type_tamagui.svg" }, | |
| 1183 | "_f_taplo": { "iconPath": "../../icons/file_type_taplo.svg" }, | |
| 1184 | "_f_taskfile": { "iconPath": "../../icons/file_type_taskfile.svg" }, | |
| 1185 | "_f_tauri": { "iconPath": "../../icons/file_type_tauri.svg" }, | |
| 1186 | "_f_tcl": { "iconPath": "../../icons/file_type_tcl.svg" }, | |
| 1187 | "_f_teal": { "iconPath": "../../icons/file_type_teal.svg" }, | |
| 1188 | "_f_templ": { "iconPath": "../../icons/file_type_templ.svg" }, | |
| 1189 | "_f_tera": { "iconPath": "../../icons/file_type_tera.svg" }, | |
| 1190 | "_f_terraform": { "iconPath": "../../icons/file_type_terraform.svg" }, | |
| 1191 | "_f_test": { "iconPath": "../../icons/file_type_test.svg" }, | |
| 1192 | "_f_testcafe": { "iconPath": "../../icons/file_type_testcafe.svg" }, | |
| 1193 | "_f_light_testcafe": { "iconPath": "../../icons/file_type_light_testcafe.svg" }, | |
| 1194 | "_f_testjs": { "iconPath": "../../icons/file_type_testjs.svg" }, | |
| 1195 | "_f_light_testjs": { "iconPath": "../../icons/file_type_light_testjs.svg" }, | |
| 1196 | "_f_testplane": { "iconPath": "../../icons/file_type_testplane.svg" }, | |
| 1197 | "_f_testts": { "iconPath": "../../icons/file_type_testts.svg" }, | |
| 1198 | "_f_tex": { "iconPath": "../../icons/file_type_tex.svg" }, | |
| 1199 | "_f_light_tex": { "iconPath": "../../icons/file_type_light_tex.svg" }, | |
| 1200 | "_f_text": { "iconPath": "../../icons/file_type_text.svg" }, | |
| 1201 | "_f_textile": { "iconPath": "../../icons/file_type_textile.svg" }, | |
| 1202 | "_f_tfs": { "iconPath": "../../icons/file_type_tfs.svg" }, | |
| 1203 | "_f_tiltfile": { "iconPath": "../../icons/file_type_tiltfile.svg" }, | |
| 1204 | "_f_tm": { "iconPath": "../../icons/file_type_tm.svg" }, | |
| 1205 | "_f_light_tm": { "iconPath": "../../icons/file_type_light_tm.svg" }, | |
| 1206 | "_f_tmux": { "iconPath": "../../icons/file_type_tmux.svg" }, | |
| 1207 | "_f_light_tmux": { "iconPath": "../../icons/file_type_light_tmux.svg" }, | |
| 1208 | "_f_todo": { "iconPath": "../../icons/file_type_todo.svg" }, | |
| 1209 | "_f_light_todo": { "iconPath": "../../icons/file_type_light_todo.svg" }, | |
| 1210 | "_f_toit": { "iconPath": "../../icons/file_type_toit.svg" }, | |
| 1211 | "_f_light_toit": { "iconPath": "../../icons/file_type_light_toit.svg" }, | |
| 1212 | "_f_toml": { "iconPath": "../../icons/file_type_toml.svg" }, | |
| 1213 | "_f_light_toml": { "iconPath": "../../icons/file_type_light_toml.svg" }, | |
| 1214 | "_f_toon": { "iconPath": "../../icons/file_type_toon.svg" }, | |
| 1215 | "_f_light_toon": { "iconPath": "../../icons/file_type_light_toon.svg" }, | |
| 1216 | "_f_tox": { "iconPath": "../../icons/file_type_tox.svg" }, | |
| 1217 | "_f_travis": { "iconPath": "../../icons/file_type_travis.svg" }, | |
| 1218 | "_f_tree": { "iconPath": "../../icons/file_type_tree.svg" }, | |
| 1219 | "_f_light_tree": { "iconPath": "../../icons/file_type_light_tree.svg" }, | |
| 1220 | "_f_tres": { "iconPath": "../../icons/file_type_tres.svg" }, | |
| 1221 | "_f_truffle": { "iconPath": "../../icons/file_type_truffle.svg" }, | |
| 1222 | "_f_trunk": { "iconPath": "../../icons/file_type_trunk.svg" }, | |
| 1223 | "_f_tsbuildinfo": { "iconPath": "../../icons/file_type_tsbuildinfo.svg" }, | |
| 1224 | "_f_tscn": { "iconPath": "../../icons/file_type_tscn.svg" }, | |
| 1225 | "_f_tsconfig": { "iconPath": "../../icons/file_type_tsconfig.svg" }, | |
| 1226 | "_f_tsdoc": { "iconPath": "../../icons/file_type_tsdoc.svg" }, | |
| 1227 | "_f_tsdown": { "iconPath": "../../icons/file_type_tsdown.svg" }, | |
| 1228 | "_f_tslint": { "iconPath": "../../icons/file_type_tslint.svg" }, | |
| 1229 | "_f_tt": { "iconPath": "../../icons/file_type_tt.svg" }, | |
| 1230 | "_f_ttcn": { "iconPath": "../../icons/file_type_ttcn.svg" }, | |
| 1231 | "_f_tuc": { "iconPath": "../../icons/file_type_tuc.svg" }, | |
| 1232 | "_f_turbo": { "iconPath": "../../icons/file_type_turbo.svg" }, | |
| 1233 | "_f_light_turbo": { "iconPath": "../../icons/file_type_light_turbo.svg" }, | |
| 1234 | "_f_twig": { "iconPath": "../../icons/file_type_twig.svg" }, | |
| 1235 | "_f_typedoc": { "iconPath": "../../icons/file_type_typedoc.svg" }, | |
| 1236 | "_f_typescript": { "iconPath": "../../icons/file_type_typescript.svg" }, | |
| 1237 | "_f_typescriptdef": { "iconPath": "../../icons/file_type_typescriptdef.svg" }, | |
| 1238 | "_f_typo3": { "iconPath": "../../icons/file_type_typo3.svg" }, | |
| 1239 | "_f_uiua": { "iconPath": "../../icons/file_type_uiua.svg" }, | |
| 1240 | "_f_unibeautify": { "iconPath": "../../icons/file_type_unibeautify.svg" }, | |
| 1241 | "_f_light_unibeautify": { "iconPath": "../../icons/file_type_light_unibeautify.svg" }, | |
| 1242 | "_f_unison": { "iconPath": "../../icons/file_type_unison.svg" }, | |
| 1243 | "_f_unlicense": { "iconPath": "../../icons/file_type_unlicense.svg" }, | |
| 1244 | "_f_unocss": { "iconPath": "../../icons/file_type_unocss.svg" }, | |
| 1245 | "_f_uv": { "iconPath": "../../icons/file_type_uv.svg" }, | |
| 1246 | "_f_vagrant": { "iconPath": "../../icons/file_type_vagrant.svg" }, | |
| 1247 | "_f_vala": { "iconPath": "../../icons/file_type_vala.svg" }, | |
| 1248 | "_f_vanilla_extract": { "iconPath": "../../icons/file_type_vanilla_extract.svg" }, | |
| 1249 | "_f_vapi": { "iconPath": "../../icons/file_type_vapi.svg" }, | |
| 1250 | "_f_vapor": { "iconPath": "../../icons/file_type_vapor.svg" }, | |
| 1251 | "_f_vash": { "iconPath": "../../icons/file_type_vash.svg" }, | |
| 1252 | "_f_light_vash": { "iconPath": "../../icons/file_type_light_vash.svg" }, | |
| 1253 | "_f_vb": { "iconPath": "../../icons/file_type_vb.svg" }, | |
| 1254 | "_f_vba": { "iconPath": "../../icons/file_type_vba.svg" }, | |
| 1255 | "_f_vbhtml": { "iconPath": "../../icons/file_type_vbhtml.svg" }, | |
| 1256 | "_f_vbproj": { "iconPath": "../../icons/file_type_vbproj.svg" }, | |
| 1257 | "_f_vcxproj": { "iconPath": "../../icons/file_type_vcxproj.svg" }, | |
| 1258 | "_f_velocity": { "iconPath": "../../icons/file_type_velocity.svg" }, | |
| 1259 | "_f_vento": { "iconPath": "../../icons/file_type_vento.svg" }, | |
| 1260 | "_f_vercel": { "iconPath": "../../icons/file_type_vercel.svg" }, | |
| 1261 | "_f_light_vercel": { "iconPath": "../../icons/file_type_light_vercel.svg" }, | |
| 1262 | "_f_verilog": { "iconPath": "../../icons/file_type_verilog.svg" }, | |
| 1263 | "_f_vhdl": { "iconPath": "../../icons/file_type_vhdl.svg" }, | |
| 1264 | "_f_video": { "iconPath": "../../icons/file_type_video.svg" }, | |
| 1265 | "_f_view": { "iconPath": "../../icons/file_type_view.svg" }, | |
| 1266 | "_f_vim": { "iconPath": "../../icons/file_type_vim.svg" }, | |
| 1267 | "_f_vite": { "iconPath": "../../icons/file_type_vite.svg" }, | |
| 1268 | "_f_light_vite": { "iconPath": "../../icons/file_type_light_vite.svg" }, | |
| 1269 | "_f_vitest": { "iconPath": "../../icons/file_type_vitest.svg" }, | |
| 1270 | "_f_light_vitest": { "iconPath": "../../icons/file_type_light_vitest.svg" }, | |
| 1271 | "_f_vlang": { "iconPath": "../../icons/file_type_vlang.svg" }, | |
| 1272 | "_f_volt": { "iconPath": "../../icons/file_type_volt.svg" }, | |
| 1273 | "_f_vroom": { "iconPath": "../../icons/file_type_vroom.svg" }, | |
| 1274 | "_f_vscode": { "iconPath": "../../icons/file_type_vscode.svg" }, | |
| 1275 | "_f_vscode_test": { "iconPath": "../../icons/file_type_vscode_test.svg" }, | |
| 1276 | "_f_vsix": { "iconPath": "../../icons/file_type_vsix.svg" }, | |
| 1277 | "_f_light_vsix": { "iconPath": "../../icons/file_type_light_vsix.svg" }, | |
| 1278 | "_f_vsixmanifest": { "iconPath": "../../icons/file_type_vsixmanifest.svg" }, | |
| 1279 | "_f_light_vsixmanifest": { "iconPath": "../../icons/file_type_light_vsixmanifest.svg" }, | |
| 1280 | "_f_vue": { "iconPath": "../../icons/file_type_vue.svg" }, | |
| 1281 | "_f_vueconfig": { "iconPath": "../../icons/file_type_vueconfig.svg" }, | |
| 1282 | "_f_vvvvvv": { "iconPath": "../../icons/file_type_vvvvvv.svg" }, | |
| 1283 | "_f_vyper": { "iconPath": "../../icons/file_type_vyper.svg" }, | |
| 1284 | "_f_wallaby": { "iconPath": "../../icons/file_type_wallaby.svg" }, | |
| 1285 | "_f_wally": { "iconPath": "../../icons/file_type_wally.svg" }, | |
| 1286 | "_f_wasm": { "iconPath": "../../icons/file_type_wasm.svg" }, | |
| 1287 | "_f_watchmanconfig": { "iconPath": "../../icons/file_type_watchmanconfig.svg" }, | |
| 1288 | "_f_wdio": { "iconPath": "../../icons/file_type_wdio.svg" }, | |
| 1289 | "_f_weblate": { "iconPath": "../../icons/file_type_weblate.svg" }, | |
| 1290 | "_f_webpack": { "iconPath": "../../icons/file_type_webpack.svg" }, | |
| 1291 | "_f_wenyan": { "iconPath": "../../icons/file_type_wenyan.svg" }, | |
| 1292 | "_f_wercker": { "iconPath": "../../icons/file_type_wercker.svg" }, | |
| 1293 | "_f_wgsl": { "iconPath": "../../icons/file_type_wgsl.svg" }, | |
| 1294 | "_f_wikitext": { "iconPath": "../../icons/file_type_wikitext.svg" }, | |
| 1295 | "_f_windi": { "iconPath": "../../icons/file_type_windi.svg" }, | |
| 1296 | "_f_wit": { "iconPath": "../../icons/file_type_wit.svg" }, | |
| 1297 | "_f_wolfram": { "iconPath": "../../icons/file_type_wolfram.svg" }, | |
| 1298 | "_f_word": { "iconPath": "../../icons/file_type_word.svg" }, | |
| 1299 | "_f_wpml": { "iconPath": "../../icons/file_type_wpml.svg" }, | |
| 1300 | "_f_wurst": { "iconPath": "../../icons/file_type_wurst.svg" }, | |
| 1301 | "_f_wxml": { "iconPath": "../../icons/file_type_wxml.svg" }, | |
| 1302 | "_f_wxss": { "iconPath": "../../icons/file_type_wxss.svg" }, | |
| 1303 | "_f_wxt": { "iconPath": "../../icons/file_type_wxt.svg" }, | |
| 1304 | "_f_xaml": { "iconPath": "../../icons/file_type_xaml.svg" }, | |
| 1305 | "_f_xcode": { "iconPath": "../../icons/file_type_xcode.svg" }, | |
| 1306 | "_f_xfl": { "iconPath": "../../icons/file_type_xfl.svg" }, | |
| 1307 | "_f_light_xfl": { "iconPath": "../../icons/file_type_light_xfl.svg" }, | |
| 1308 | "_f_xib": { "iconPath": "../../icons/file_type_xib.svg" }, | |
| 1309 | "_f_xliff": { "iconPath": "../../icons/file_type_xliff.svg" }, | |
| 1310 | "_f_xmake": { "iconPath": "../../icons/file_type_xmake.svg" }, | |
| 1311 | "_f_xml": { "iconPath": "../../icons/file_type_xml.svg" }, | |
| 1312 | "_f_xo": { "iconPath": "../../icons/file_type_xo.svg" }, | |
| 1313 | "_f_xorg": { "iconPath": "../../icons/file_type_xorg.svg" }, | |
| 1314 | "_f_light_xorg": { "iconPath": "../../icons/file_type_light_xorg.svg" }, | |
| 1315 | "_f_xquery": { "iconPath": "../../icons/file_type_xquery.svg" }, | |
| 1316 | "_f_xsl": { "iconPath": "../../icons/file_type_xsl.svg" }, | |
| 1317 | "_f_yacc": { "iconPath": "../../icons/file_type_yacc.svg" }, | |
| 1318 | "_f_yaml": { "iconPath": "../../icons/file_type_yaml.svg" }, | |
| 1319 | "_f_light_yaml": { "iconPath": "../../icons/file_type_light_yaml.svg" }, | |
| 1320 | "_f_yamllint": { "iconPath": "../../icons/file_type_yamllint.svg" }, | |
| 1321 | "_f_yandex": { "iconPath": "../../icons/file_type_yandex.svg" }, | |
| 1322 | "_f_yang": { "iconPath": "../../icons/file_type_yang.svg" }, | |
| 1323 | "_f_yarn": { "iconPath": "../../icons/file_type_yarn.svg" }, | |
| 1324 | "_f_yeoman": { "iconPath": "../../icons/file_type_yeoman.svg" }, | |
| 1325 | "_f_zig": { "iconPath": "../../icons/file_type_zig.svg" }, | |
| 1326 | "_f_zip": { "iconPath": "../../icons/file_type_zip.svg" } | |
| 1327 | }, | |
| 1328 | "file": "_file", | |
| 1329 | "folder": "_folder", | |
| 1330 | "folderExpanded": "_folder_open", | |
| 1331 | "rootFolder": "_root_folder", | |
| 1332 | "rootFolderExpanded": "_root_folder_open", | |
| 1333 | "folderNames": { | |
| 1334 | "android": "_fd_android", | |
| 1335 | ".angular": "_fd_angular", | |
| 1336 | "apache": "_fd_apache", | |
| 1337 | "apache2": "_fd_apache", | |
| 1338 | "httpd": "_fd_apache", | |
| 1339 | "api": "_fd_api", | |
| 1340 | ".api": "_fd_api", | |
| 1341 | "apis": "_fd_api", | |
| 1342 | ".apis": "_fd_api", | |
| 1343 | "app": "_fd_app", | |
| 1344 | "apps": "_fd_app", | |
| 1345 | ".app": "_fd_app", | |
| 1346 | "application": "_fd_app", | |
| 1347 | "applications": "_fd_app", | |
| 1348 | "arangodb": "_fd_arangodb", | |
| 1349 | "arango": "_fd_arangodb", | |
| 1350 | "assets": "_fd_asset", | |
| 1351 | ".assets": "_fd_asset", | |
| 1352 | ".astro": "_fd_astro", | |
| 1353 | "audio": "_fd_audio", | |
| 1354 | ".audio": "_fd_audio", | |
| 1355 | "audios": "_fd_audio", | |
| 1356 | ".audios": "_fd_audio", | |
| 1357 | "sound": "_fd_audio", | |
| 1358 | ".sound": "_fd_audio", | |
| 1359 | "sounds": "_fd_audio", | |
| 1360 | ".sounds": "_fd_audio", | |
| 1361 | "aurelia_project": "_fd_aurelia", | |
| 1362 | "aws": "_fd_aws", | |
| 1363 | ".aws": "_fd_aws", | |
| 1364 | "azure": "_fd_azure", | |
| 1365 | ".azure": "_fd_azure", | |
| 1366 | "azure-pipelines": "_fd_azurepipelines", | |
| 1367 | ".azure-pipelines": "_fd_azurepipelines", | |
| 1368 | "bin": "_fd_binary", | |
| 1369 | ".bin": "_fd_binary", | |
| 1370 | "blocs": "_fd_bloc", | |
| 1371 | "bloc": "_fd_bloc", | |
| 1372 | "blueprint": "_fd_blueprint", | |
| 1373 | ".blueprint": "_fd_blueprint", | |
| 1374 | "blueprints": "_fd_blueprint", | |
| 1375 | ".blueprints": "_fd_blueprint", | |
| 1376 | "bot": "_fd_bot", | |
| 1377 | ".bot": "_fd_bot", | |
| 1378 | "bower_components": "_fd_bower", | |
| 1379 | ".buildkite": "_fd_buildkite", | |
| 1380 | "cake": "_fd_cake", | |
| 1381 | ".cake": "_fd_cake", | |
| 1382 | "cargo": "_fd_cargo", | |
| 1383 | ".cargo": "_fd_cargo", | |
| 1384 | "crates": "_fd_cargo", | |
| 1385 | "certificates": "_fd_certificate", | |
| 1386 | ".certificates": "_fd_certificate", | |
| 1387 | "certs": "_fd_certificate", | |
| 1388 | "certs.": "_fd_certificate", | |
| 1389 | ".changeset": "_fd_changesets", | |
| 1390 | "chef": "_fd_chef", | |
| 1391 | ".chef": "_fd_chef", | |
| 1392 | ".circleci": "_fd_circleci", | |
| 1393 | ".claude": "_fd_claude", | |
| 1394 | "cli": "_fd_cli", | |
| 1395 | "cmd": "_fd_cli", | |
| 1396 | "command": "_fd_cli", | |
| 1397 | "commands": "_fd_cli", | |
| 1398 | "commandline": "_fd_cli", | |
| 1399 | "console": "_fd_cli", | |
| 1400 | "client": "_fd_client", | |
| 1401 | "clients": "_fd_client", | |
| 1402 | ".cloudflare": "_fd_cloudflare", | |
| 1403 | "cloudflare": "_fd_cloudflare", | |
| 1404 | ".cmake": "_fd_cmake", | |
| 1405 | "cmake": "_fd_cmake", | |
| 1406 | "common": "_fd_common", | |
| 1407 | "component": "_fd_component", | |
| 1408 | "components": "_fd_component", | |
| 1409 | ".components": "_fd_component", | |
| 1410 | "gui": "_fd_component", | |
| 1411 | "src-ui": "_fd_component", | |
| 1412 | "ui": "_fd_component", | |
| 1413 | "widgets": "_fd_component", | |
| 1414 | "composer": "_fd_composer", | |
| 1415 | ".composer": "_fd_composer", | |
| 1416 | "conf": "_fd_config", | |
| 1417 | ".conf": "_fd_config", | |
| 1418 | "config": "_fd_config", | |
| 1419 | ".config": "_fd_config", | |
| 1420 | "configs": "_fd_config", | |
| 1421 | ".configs": "_fd_config", | |
| 1422 | "configuration": "_fd_config", | |
| 1423 | ".configuration": "_fd_config", | |
| 1424 | "configurations": "_fd_config", | |
| 1425 | ".configurations": "_fd_config", | |
| 1426 | "setting": "_fd_config", | |
| 1427 | ".setting": "_fd_config", | |
| 1428 | "settings": "_fd_config", | |
| 1429 | ".settings": "_fd_config", | |
| 1430 | "ini": "_fd_config", | |
| 1431 | ".ini": "_fd_config", | |
| 1432 | "initializers": "_fd_config", | |
| 1433 | ".initializers": "_fd_config", | |
| 1434 | "__controller__": "_fd_controller", | |
| 1435 | "__controllers__": "_fd_controller", | |
| 1436 | "controller": "_fd_controller", | |
| 1437 | "controllers": "_fd_controller", | |
| 1438 | ".controllers": "_fd_controller", | |
| 1439 | "__handler__": "_fd_controller", | |
| 1440 | "__handlers__": "_fd_controller", | |
| 1441 | "handler": "_fd_controller", | |
| 1442 | ".handler": "_fd_controller", | |
| 1443 | "handlers": "_fd_controller", | |
| 1444 | ".handlers": "_fd_controller", | |
| 1445 | "coverage": "_fd_coverage", | |
| 1446 | "css": "_fd_css", | |
| 1447 | "_css": "_fd_css", | |
| 1448 | "cubits": "_fd_cubit", | |
| 1449 | "cubit": "_fd_cubit", | |
| 1450 | ".cursor": "_fd_cursor", | |
| 1451 | "cypress": "_fd_cypress", | |
| 1452 | ".dapr": "_fd_dapr", | |
| 1453 | "dapr": "_fd_dapr", | |
| 1454 | "dart": "_fd_dart", | |
| 1455 | ".dart_tool": "_fd_dart", | |
| 1456 | "datadog": "_fd_datadog", | |
| 1457 | ".datadog": "_fd_datadog", | |
| 1458 | "db": "_fd_db", | |
| 1459 | "database": "_fd_db", | |
| 1460 | "sql": "_fd_db", | |
| 1461 | "data": "_fd_db", | |
| 1462 | "repo": "_fd_db", | |
| 1463 | "repository": "_fd_db", | |
| 1464 | "repositories": "_fd_db", | |
| 1465 | "store": "_fd_db", | |
| 1466 | "stores": "_fd_db", | |
| 1467 | "debian": "_fd_debian", | |
| 1468 | "deb": "_fd_debian", | |
| 1469 | ".dependabot": "_fd_dependabot", | |
| 1470 | ".devcontainer": "_fd_devcontainer", | |
| 1471 | "dist": "_fd_dist", | |
| 1472 | ".dist": "_fd_dist", | |
| 1473 | "dists": "_fd_dist", | |
| 1474 | "out": "_fd_dist", | |
| 1475 | "outs": "_fd_dist", | |
| 1476 | "export": "_fd_dist", | |
| 1477 | "exports": "_fd_dist", | |
| 1478 | "build": "_fd_dist", | |
| 1479 | ".build": "_fd_dist", | |
| 1480 | "builds": "_fd_dist", | |
| 1481 | "release": "_fd_dist", | |
| 1482 | "releases": "_fd_dist", | |
| 1483 | "target": "_fd_dist", | |
| 1484 | "targets": "_fd_dist", | |
| 1485 | "docker": "_fd_docker", | |
| 1486 | ".docker": "_fd_docker", | |
| 1487 | "docs": "_fd_docs", | |
| 1488 | "doc": "_fd_docs", | |
| 1489 | "document": "_fd_docs", | |
| 1490 | "documents": "_fd_docs", | |
| 1491 | "e2e": "_fd_e2e", | |
| 1492 | ".elasticbeanstalk": "_fd_elasticbeanstalk", | |
| 1493 | ".ebextensions": "_fd_elasticbeanstalk", | |
| 1494 | "electron": "_fd_electron", | |
| 1495 | ".expo": "_fd_expo", | |
| 1496 | ".expo-shared": "_fd_expo", | |
| 1497 | "favicon": "_fd_favicon", | |
| 1498 | "favicons": "_fd_favicon", | |
| 1499 | "flow": "_fd_flow", | |
| 1500 | "flow-typed": "_fd_flow", | |
| 1501 | "flutter": "_fd_flutter", | |
| 1502 | "Flutter": "_fd_flutter", | |
| 1503 | "fonts": "_fd_fonts", | |
| 1504 | "font": "_fd_fonts", | |
| 1505 | "fnt": "_fd_fonts", | |
| 1506 | "webfonts": "_fd_fonts", | |
| 1507 | ".front-commerce": "_fd_frontcommerce", | |
| 1508 | "gcp": "_fd_gcp", | |
| 1509 | ".gcp": "_fd_gcp", | |
| 1510 | ".gemini": "_fd_gemini", | |
| 1511 | ".git": "_fd_git", | |
| 1512 | "submodules": "_fd_git", | |
| 1513 | ".submodules": "_fd_git", | |
| 1514 | ".github": "_fd_github", | |
| 1515 | ".gitlab": "_fd_gitlab", | |
| 1516 | "godot": "_fd_godot", | |
| 1517 | ".godot": "_fd_godot", | |
| 1518 | "gradle": "_fd_gradle", | |
| 1519 | ".gradle": "_fd_gradle", | |
| 1520 | "graphql": "_fd_graphql", | |
| 1521 | "grunt": "_fd_grunt", | |
| 1522 | "gulp": "_fd_gulp", | |
| 1523 | "gulpfile.js": "_fd_gulp", | |
| 1524 | "gulpfile.coffee": "_fd_gulp", | |
| 1525 | "gulpfile.ts": "_fd_gulp", | |
| 1526 | "gulpfile.babel.js": "_fd_gulp", | |
| 1527 | "gulpfile.babel.coffee": "_fd_gulp", | |
| 1528 | "gulpfile.babel.ts": "_fd_gulp", | |
| 1529 | ".haxelib": "_fd_haxelib", | |
| 1530 | "haxe_libraries": "_fd_haxelib", | |
| 1531 | "helper": "_fd_helper", | |
| 1532 | ".helper": "_fd_helper", | |
| 1533 | "helpers": "_fd_helper", | |
| 1534 | ".helpers": "_fd_helper", | |
| 1535 | ".histoire": "_fd_histoire", | |
| 1536 | "hook": "_fd_hook", | |
| 1537 | ".hook": "_fd_hook", | |
| 1538 | "hooks": "_fd_hook", | |
| 1539 | ".hooks": "_fd_hook", | |
| 1540 | ".husky": "_fd_husky", | |
| 1541 | ".idea": "_fd_idea", | |
| 1542 | ".fig": "_fd_images", | |
| 1543 | ".figs": "_fd_images", | |
| 1544 | "images": "_fd_images", | |
| 1545 | "image": "_fd_images", | |
| 1546 | ".img": "_fd_images", | |
| 1547 | "img": "_fd_images", | |
| 1548 | ".imgs": "_fd_images", | |
| 1549 | "imgs": "_fd_images", | |
| 1550 | "icons": "_fd_images", | |
| 1551 | "icon": "_fd_images", | |
| 1552 | "ico": "_fd_images", | |
| 1553 | "screenshot": "_fd_images", | |
| 1554 | "screenshots": "_fd_images", | |
| 1555 | "svg": "_fd_images", | |
| 1556 | "texture": "_fd_images", | |
| 1557 | "textures": "_fd_images", | |
| 1558 | "include": "_fd_include", | |
| 1559 | "includes": "_fd_include", | |
| 1560 | "incl": "_fd_include", | |
| 1561 | "inc": "_fd_include", | |
| 1562 | ".include": "_fd_include", | |
| 1563 | ".includes": "_fd_include", | |
| 1564 | ".incl": "_fd_include", | |
| 1565 | ".inc": "_fd_include", | |
| 1566 | "_include": "_fd_include", | |
| 1567 | "_includes": "_fd_include", | |
| 1568 | "_incl": "_fd_include", | |
| 1569 | "_inc": "_fd_include", | |
| 1570 | "interface": "_fd_interfaces", | |
| 1571 | "interfaces": "_fd_interfaces", | |
| 1572 | "ios": "_fd_ios", | |
| 1573 | "js": "_fd_js", | |
| 1574 | "json": "_fd_json", | |
| 1575 | ".junie": "_fd_junie", | |
| 1576 | "kotlin": "_fd_kotlin", | |
| 1577 | "Kotlin": "_fd_kotlin", | |
| 1578 | ".kotlin": "_fd_kotlin", | |
| 1579 | "kubernetes": "_fd_kubernetes", | |
| 1580 | "k8s": "_fd_kubernetes", | |
| 1581 | "kube": "_fd_kubernetes", | |
| 1582 | "kuber": "_fd_kubernetes", | |
| 1583 | ".kubernetes": "_fd_kubernetes", | |
| 1584 | ".k8s": "_fd_kubernetes", | |
| 1585 | ".kube": "_fd_kubernetes", | |
| 1586 | ".kuber": "_fd_kubernetes", | |
| 1587 | "less": "_fd_less", | |
| 1588 | "_less": "_fd_less", | |
| 1589 | "lib": "_fd_library", | |
| 1590 | "libs": "_fd_library", | |
| 1591 | ".lib": "_fd_library", | |
| 1592 | ".libs": "_fd_library", | |
| 1593 | "__lib__": "_fd_library", | |
| 1594 | "__libs__": "_fd_library", | |
| 1595 | "library": "_fd_library", | |
| 1596 | "libraries": "_fd_library", | |
| 1597 | "linux": "_fd_linux", | |
| 1598 | "lang": "_fd_locale", | |
| 1599 | "language": "_fd_locale", | |
| 1600 | "languages": "_fd_locale", | |
| 1601 | "locale": "_fd_locale", | |
| 1602 | "locales": "_fd_locale", | |
| 1603 | "_locale": "_fd_locale", | |
| 1604 | "_locales": "_fd_locale", | |
| 1605 | "internationalization": "_fd_locale", | |
| 1606 | "globalization": "_fd_locale", | |
| 1607 | "localization": "_fd_locale", | |
| 1608 | "i18n": "_fd_locale", | |
| 1609 | "g11n": "_fd_locale", | |
| 1610 | "l10n": "_fd_locale", | |
| 1611 | "log": "_fd_log", | |
| 1612 | "logs": "_fd_log", | |
| 1613 | "macos": "_fd_macos", | |
| 1614 | "darwin": "_fd_macos", | |
| 1615 | "mariadb": "_fd_mariadb", | |
| 1616 | "maria": "_fd_mariadb", | |
| 1617 | ".mvn": "_fd_maven", | |
| 1618 | "mediawiki": "_fd_mediawiki", | |
| 1619 | "memcached": "_fd_memcached", | |
| 1620 | ".memcached": "_fd_memcached", | |
| 1621 | ".meteor": "_fd_meteor", | |
| 1622 | "middleware": "_fd_middleware", | |
| 1623 | "middlewares": "_fd_middleware", | |
| 1624 | ".minecraft": "_fd_minecraft", | |
| 1625 | "minikube": "_fd_minikube", | |
| 1626 | "minik8s": "_fd_minikube", | |
| 1627 | "minikuber": "_fd_minikube", | |
| 1628 | "mjml": "_fd_mjml", | |
| 1629 | ".mjml": "_fd_mjml", | |
| 1630 | "mocks": "_fd_mock", | |
| 1631 | ".mocks": "_fd_mock", | |
| 1632 | "__mocks__": "_fd_mock", | |
| 1633 | "model": "_fd_model", | |
| 1634 | ".model": "_fd_model", | |
| 1635 | "models": "_fd_model", | |
| 1636 | ".models": "_fd_model", | |
| 1637 | "entities": "_fd_model", | |
| 1638 | ".entities": "_fd_model", | |
| 1639 | "modules": "_fd_module", | |
| 1640 | "mojo": "_fd_mojo", | |
| 1641 | "mongodb": "_fd_mongodb", | |
| 1642 | "mongo": "_fd_mongodb", | |
| 1643 | ".mypy_cache": "_fd_mypy", | |
| 1644 | "mysqldb": "_fd_mysql", | |
| 1645 | "mysql": "_fd_mysql", | |
| 1646 | "netlify": "_fd_netlify", | |
| 1647 | ".next": "_fd_next", | |
| 1648 | "nginx": "_fd_nginx", | |
| 1649 | "conf.d": "_fd_nginx", | |
| 1650 | ".niv": "_fd_nix", | |
| 1651 | ".nix": "_fd_nix", | |
| 1652 | "nix": "_fd_nix", | |
| 1653 | "niv": "_fd_nix", | |
| 1654 | "node_modules": "_fd_node", | |
| 1655 | "notebooks": "_fd_notebooks", | |
| 1656 | "notebook": "_fd_notebooks", | |
| 1657 | "notification": "_fd_notification", | |
| 1658 | "notifications": "_fd_notification", | |
| 1659 | "event": "_fd_notification", | |
| 1660 | "events": "_fd_notification", | |
| 1661 | ".nuget": "_fd_nuget", | |
| 1662 | "nuxt": "_fd_nuxt", | |
| 1663 | ".nuxt": "_fd_nuxt", | |
| 1664 | "package": "_fd_package", | |
| 1665 | "packages": "_fd_package", | |
| 1666 | ".package": "_fd_package", | |
| 1667 | ".packages": "_fd_package", | |
| 1668 | "pkg": "_fd_package", | |
| 1669 | ".paket": "_fd_paket", | |
| 1670 | "php": "_fd_php", | |
| 1671 | ".pio": "_fd_platformio", | |
| 1672 | ".pioenvs": "_fd_platformio", | |
| 1673 | "plugin": "_fd_plugin", | |
| 1674 | ".plugin": "_fd_plugin", | |
| 1675 | "plugins": "_fd_plugin", | |
| 1676 | ".plugins": "_fd_plugin", | |
| 1677 | "extension": "_fd_plugin", | |
| 1678 | ".extension": "_fd_plugin", | |
| 1679 | "extensions": "_fd_plugin", | |
| 1680 | ".extensions": "_fd_plugin", | |
| 1681 | "prisma": "_fd_prisma", | |
| 1682 | "private": "_fd_private", | |
| 1683 | ".private": "_fd_private", | |
| 1684 | "public": "_fd_public", | |
| 1685 | ".public": "_fd_public", | |
| 1686 | ".pytest_cache": "_fd_pytest", | |
| 1687 | ".venv": "_fd_python", | |
| 1688 | ".virtualenv": "_fd_python", | |
| 1689 | "__pycache__": "_fd_python", | |
| 1690 | "ravendb": "_fd_ravendb", | |
| 1691 | "redis": "_fd_redis", | |
| 1692 | "redux": "_fd_redux", | |
| 1693 | "route": "_fd_route", | |
| 1694 | "routes": "_fd_route", | |
| 1695 | "_route": "_fd_route", | |
| 1696 | "_routes": "_fd_route", | |
| 1697 | "router": "_fd_route", | |
| 1698 | "routers": "_fd_route", | |
| 1699 | "sass": "_fd_sass", | |
| 1700 | "scss": "_fd_sass", | |
| 1701 | "_sass": "_fd_sass", | |
| 1702 | "_scss": "_fd_sass", | |
| 1703 | "script": "_fd_script", | |
| 1704 | "scripts": "_fd_script", | |
| 1705 | "seedkit": "_fd_seedkit", | |
| 1706 | ".seedkit": "_fd_seedkit", | |
| 1707 | "server": "_fd_server", | |
| 1708 | "service": "_fd_services", | |
| 1709 | "services": "_fd_services", | |
| 1710 | "share": "_fd_shared", | |
| 1711 | "shared": "_fd_shared", | |
| 1712 | ".share": "_fd_shared", | |
| 1713 | ".shared": "_fd_shared", | |
| 1714 | "__shared__": "_fd_shared", | |
| 1715 | "__share__": "_fd_shared", | |
| 1716 | ".snaplet": "_fd_snaplet", | |
| 1717 | ".spin": "_fd_spin", | |
| 1718 | "src": "_fd_src", | |
| 1719 | "source": "_fd_src", | |
| 1720 | "sources": "_fd_src", | |
| 1721 | "__src__": "_fd_src", | |
| 1722 | "sso": "_fd_sso", | |
| 1723 | "story": "_fd_story", | |
| 1724 | "stories": "_fd_story", | |
| 1725 | "__stories__": "_fd_story", | |
| 1726 | ".storybook": "_fd_story", | |
| 1727 | "style": "_fd_style", | |
| 1728 | "styles": "_fd_style", | |
| 1729 | "supabase": "_fd_supabase", | |
| 1730 | "svelte": "_fd_svelte", | |
| 1731 | ".svelte-kit": "_fd_svelte", | |
| 1732 | "src-tauri": "_fd_tauri", | |
| 1733 | "temp": "_fd_temp", | |
| 1734 | ".temp": "_fd_temp", | |
| 1735 | "tmp": "_fd_temp", | |
| 1736 | ".tmp": "_fd_temp", | |
| 1737 | "template": "_fd_template", | |
| 1738 | ".template": "_fd_template", | |
| 1739 | "templates": "_fd_template", | |
| 1740 | ".templates": "_fd_template", | |
| 1741 | "tests": "_fd_test", | |
| 1742 | ".tests": "_fd_test", | |
| 1743 | "test": "_fd_test", | |
| 1744 | ".test": "_fd_test", | |
| 1745 | "__tests__": "_fd_test", | |
| 1746 | "__test__": "_fd_test", | |
| 1747 | "spec": "_fd_test", | |
| 1748 | ".spec": "_fd_test", | |
| 1749 | "specs": "_fd_test", | |
| 1750 | ".specs": "_fd_test", | |
| 1751 | "integration": "_fd_test", | |
| 1752 | "theme": "_fd_theme", | |
| 1753 | "themes": "_fd_theme", | |
| 1754 | "tool": "_fd_tools", | |
| 1755 | "tools": "_fd_tools", | |
| 1756 | ".tools": "_fd_tools", | |
| 1757 | "util": "_fd_tools", | |
| 1758 | "utils": "_fd_tools", | |
| 1759 | "utilities": "_fd_tools", | |
| 1760 | "tooling": "_fd_tools", | |
| 1761 | ".travis": "_fd_travis", | |
| 1762 | ".trunk": "_fd_trunk", | |
| 1763 | ".turbo": "_fd_turbo", | |
| 1764 | "typescript": "_fd_typescript", | |
| 1765 | "ts": "_fd_typescript", | |
| 1766 | "typings": "_fd_typings", | |
| 1767 | "types": "_fd_typings", | |
| 1768 | "@types": "_fd_typings", | |
| 1769 | "vagrant": "_fd_vagrant", | |
| 1770 | ".vagrant": "_fd_vagrant", | |
| 1771 | ".vercel": "_fd_vercel", | |
| 1772 | "video": "_fd_video", | |
| 1773 | ".video": "_fd_video", | |
| 1774 | "videos": "_fd_video", | |
| 1775 | ".videos": "_fd_video", | |
| 1776 | "html": "_fd_view", | |
| 1777 | "view": "_fd_view", | |
| 1778 | "views": "_fd_view", | |
| 1779 | "layout": "_fd_view", | |
| 1780 | "layouts": "_fd_view", | |
| 1781 | "page": "_fd_view", | |
| 1782 | "pages": "_fd_view", | |
| 1783 | "_view": "_fd_view", | |
| 1784 | "_views": "_fd_view", | |
| 1785 | "_layout": "_fd_view", | |
| 1786 | "_layouts": "_fd_view", | |
| 1787 | "_page": "_fd_view", | |
| 1788 | "_pages": "_fd_view", | |
| 1789 | ".vitepress": "_fd_vitepress", | |
| 1790 | ".vs": "_fd_vs", | |
| 1791 | ".vscode": "_fd_vscode", | |
| 1792 | "vscode": "_fd_vscode", | |
| 1793 | ".vscode-test": "_fd_vscode_test", | |
| 1794 | "wasm": "_fd_wasm", | |
| 1795 | "WASM": "_fd_wasm", | |
| 1796 | "webassembly": "_fd_wasm", | |
| 1797 | "WebAssembly": "_fd_wasm", | |
| 1798 | "webpack": "_fd_webpack", | |
| 1799 | ".webpack": "_fd_webpack", | |
| 1800 | "windows": "_fd_windows", | |
| 1801 | "win32": "_fd_windows", | |
| 1802 | ".windsurf": "_fd_windsurf", | |
| 1803 | "wit": "_fd_wit", | |
| 1804 | ".web": "_fd_www", | |
| 1805 | "www": "_fd_www", | |
| 1806 | "wwwroot": "_fd_www", | |
| 1807 | "web": "_fd_www", | |
| 1808 | ".yarn": "_fd_yarn", | |
| 1809 | ".zed": "_fd_zed" | |
| 1810 | }, | |
| 1811 | "folderNamesExpanded": { | |
| 1812 | "android": "_fd_android_open", | |
| 1813 | ".angular": "_fd_angular_open", | |
| 1814 | "apache": "_fd_apache_open", | |
| 1815 | "apache2": "_fd_apache_open", | |
| 1816 | "httpd": "_fd_apache_open", | |
| 1817 | "api": "_fd_api_open", | |
| 1818 | ".api": "_fd_api_open", | |
| 1819 | "apis": "_fd_api_open", | |
| 1820 | ".apis": "_fd_api_open", | |
| 1821 | "app": "_fd_app_open", | |
| 1822 | "apps": "_fd_app_open", | |
| 1823 | ".app": "_fd_app_open", | |
| 1824 | "application": "_fd_app_open", | |
| 1825 | "applications": "_fd_app_open", | |
| 1826 | "arangodb": "_fd_arangodb_open", | |
| 1827 | "arango": "_fd_arangodb_open", | |
| 1828 | "assets": "_fd_asset_open", | |
| 1829 | ".assets": "_fd_asset_open", | |
| 1830 | ".astro": "_fd_astro_open", | |
| 1831 | "audio": "_fd_audio_open", | |
| 1832 | ".audio": "_fd_audio_open", | |
| 1833 | "audios": "_fd_audio_open", | |
| 1834 | ".audios": "_fd_audio_open", | |
| 1835 | "sound": "_fd_audio_open", | |
| 1836 | ".sound": "_fd_audio_open", | |
| 1837 | "sounds": "_fd_audio_open", | |
| 1838 | ".sounds": "_fd_audio_open", | |
| 1839 | "aurelia_project": "_fd_aurelia_open", | |
| 1840 | "aws": "_fd_aws_open", | |
| 1841 | ".aws": "_fd_aws_open", | |
| 1842 | "azure": "_fd_azure_open", | |
| 1843 | ".azure": "_fd_azure_open", | |
| 1844 | "azure-pipelines": "_fd_azurepipelines_open", | |
| 1845 | ".azure-pipelines": "_fd_azurepipelines_open", | |
| 1846 | "bin": "_fd_binary_open", | |
| 1847 | ".bin": "_fd_binary_open", | |
| 1848 | "blocs": "_fd_bloc_open", | |
| 1849 | "bloc": "_fd_bloc_open", | |
| 1850 | "blueprint": "_fd_blueprint_open", | |
| 1851 | ".blueprint": "_fd_blueprint_open", | |
| 1852 | "blueprints": "_fd_blueprint_open", | |
| 1853 | ".blueprints": "_fd_blueprint_open", | |
| 1854 | "bot": "_fd_bot_open", | |
| 1855 | ".bot": "_fd_bot_open", | |
| 1856 | "bower_components": "_fd_bower_open", | |
| 1857 | ".buildkite": "_fd_buildkite_open", | |
| 1858 | "cake": "_fd_cake_open", | |
| 1859 | ".cake": "_fd_cake_open", | |
| 1860 | "cargo": "_fd_cargo_open", | |
| 1861 | ".cargo": "_fd_cargo_open", | |
| 1862 | "crates": "_fd_cargo_open", | |
| 1863 | "certificates": "_fd_certificate_open", | |
| 1864 | ".certificates": "_fd_certificate_open", | |
| 1865 | "certs": "_fd_certificate_open", | |
| 1866 | "certs.": "_fd_certificate_open", | |
| 1867 | ".changeset": "_fd_changesets_open", | |
| 1868 | "chef": "_fd_chef_open", | |
| 1869 | ".chef": "_fd_chef_open", | |
| 1870 | ".circleci": "_fd_circleci_open", | |
| 1871 | ".claude": "_fd_claude_open", | |
| 1872 | "cli": "_fd_cli_open", | |
| 1873 | "cmd": "_fd_cli_open", | |
| 1874 | "command": "_fd_cli_open", | |
| 1875 | "commands": "_fd_cli_open", | |
| 1876 | "commandline": "_fd_cli_open", | |
| 1877 | "console": "_fd_cli_open", | |
| 1878 | "client": "_fd_client_open", | |
| 1879 | "clients": "_fd_client_open", | |
| 1880 | ".cloudflare": "_fd_cloudflare_open", | |
| 1881 | "cloudflare": "_fd_cloudflare_open", | |
| 1882 | ".cmake": "_fd_cmake_open", | |
| 1883 | "cmake": "_fd_cmake_open", | |
| 1884 | "common": "_fd_common_open", | |
| 1885 | "component": "_fd_component_open", | |
| 1886 | "components": "_fd_component_open", | |
| 1887 | ".components": "_fd_component_open", | |
| 1888 | "gui": "_fd_component_open", | |
| 1889 | "src-ui": "_fd_component_open", | |
| 1890 | "ui": "_fd_component_open", | |
| 1891 | "widgets": "_fd_component_open", | |
| 1892 | "composer": "_fd_composer_open", | |
| 1893 | ".composer": "_fd_composer_open", | |
| 1894 | "conf": "_fd_config_open", | |
| 1895 | ".conf": "_fd_config_open", | |
| 1896 | "config": "_fd_config_open", | |
| 1897 | ".config": "_fd_config_open", | |
| 1898 | "configs": "_fd_config_open", | |
| 1899 | ".configs": "_fd_config_open", | |
| 1900 | "configuration": "_fd_config_open", | |
| 1901 | ".configuration": "_fd_config_open", | |
| 1902 | "configurations": "_fd_config_open", | |
| 1903 | ".configurations": "_fd_config_open", | |
| 1904 | "setting": "_fd_config_open", | |
| 1905 | ".setting": "_fd_config_open", | |
| 1906 | "settings": "_fd_config_open", | |
| 1907 | ".settings": "_fd_config_open", | |
| 1908 | "ini": "_fd_config_open", | |
| 1909 | ".ini": "_fd_config_open", | |
| 1910 | "initializers": "_fd_config_open", | |
| 1911 | ".initializers": "_fd_config_open", | |
| 1912 | "__controller__": "_fd_controller_open", | |
| 1913 | "__controllers__": "_fd_controller_open", | |
| 1914 | "controller": "_fd_controller_open", | |
| 1915 | "controllers": "_fd_controller_open", | |
| 1916 | ".controllers": "_fd_controller_open", | |
| 1917 | "__handler__": "_fd_controller_open", | |
| 1918 | "__handlers__": "_fd_controller_open", | |
| 1919 | "handler": "_fd_controller_open", | |
| 1920 | ".handler": "_fd_controller_open", | |
| 1921 | "handlers": "_fd_controller_open", | |
| 1922 | ".handlers": "_fd_controller_open", | |
| 1923 | "coverage": "_fd_coverage_open", | |
| 1924 | "css": "_fd_css_open", | |
| 1925 | "_css": "_fd_css_open", | |
| 1926 | "cubits": "_fd_cubit_open", | |
| 1927 | "cubit": "_fd_cubit_open", | |
| 1928 | ".cursor": "_fd_cursor_open", | |
| 1929 | "cypress": "_fd_cypress_open", | |
| 1930 | ".dapr": "_fd_dapr_open", | |
| 1931 | "dapr": "_fd_dapr_open", | |
| 1932 | "dart": "_fd_dart_open", | |
| 1933 | ".dart_tool": "_fd_dart_open", | |
| 1934 | "datadog": "_fd_datadog_open", | |
| 1935 | ".datadog": "_fd_datadog_open", | |
| 1936 | "db": "_fd_db_open", | |
| 1937 | "database": "_fd_db_open", | |
| 1938 | "sql": "_fd_db_open", | |
| 1939 | "data": "_fd_db_open", | |
| 1940 | "repo": "_fd_db_open", | |
| 1941 | "repository": "_fd_db_open", | |
| 1942 | "repositories": "_fd_db_open", | |
| 1943 | "store": "_fd_db_open", | |
| 1944 | "stores": "_fd_db_open", | |
| 1945 | "debian": "_fd_debian_open", | |
| 1946 | "deb": "_fd_debian_open", | |
| 1947 | ".dependabot": "_fd_dependabot_open", | |
| 1948 | ".devcontainer": "_fd_devcontainer_open", | |
| 1949 | "dist": "_fd_dist_open", | |
| 1950 | ".dist": "_fd_dist_open", | |
| 1951 | "dists": "_fd_dist_open", | |
| 1952 | "out": "_fd_dist_open", | |
| 1953 | "outs": "_fd_dist_open", | |
| 1954 | "export": "_fd_dist_open", | |
| 1955 | "exports": "_fd_dist_open", | |
| 1956 | "build": "_fd_dist_open", | |
| 1957 | ".build": "_fd_dist_open", | |
| 1958 | "builds": "_fd_dist_open", | |
| 1959 | "release": "_fd_dist_open", | |
| 1960 | "releases": "_fd_dist_open", | |
| 1961 | "target": "_fd_dist_open", | |
| 1962 | "targets": "_fd_dist_open", | |
| 1963 | "docker": "_fd_docker_open", | |
| 1964 | ".docker": "_fd_docker_open", | |
| 1965 | "docs": "_fd_docs_open", | |
| 1966 | "doc": "_fd_docs_open", | |
| 1967 | "document": "_fd_docs_open", | |
| 1968 | "documents": "_fd_docs_open", | |
| 1969 | "e2e": "_fd_e2e_open", | |
| 1970 | ".elasticbeanstalk": "_fd_elasticbeanstalk_open", | |
| 1971 | ".ebextensions": "_fd_elasticbeanstalk_open", | |
| 1972 | "electron": "_fd_electron_open", | |
| 1973 | ".expo": "_fd_expo_open", | |
| 1974 | ".expo-shared": "_fd_expo_open", | |
| 1975 | "favicon": "_fd_favicon_open", | |
| 1976 | "favicons": "_fd_favicon_open", | |
| 1977 | "flow": "_fd_flow_open", | |
| 1978 | "flow-typed": "_fd_flow_open", | |
| 1979 | "flutter": "_fd_flutter_open", | |
| 1980 | "Flutter": "_fd_flutter_open", | |
| 1981 | "fonts": "_fd_fonts_open", | |
| 1982 | "font": "_fd_fonts_open", | |
| 1983 | "fnt": "_fd_fonts_open", | |
| 1984 | "webfonts": "_fd_fonts_open", | |
| 1985 | ".front-commerce": "_fd_frontcommerce_open", | |
| 1986 | "gcp": "_fd_gcp_open", | |
| 1987 | ".gcp": "_fd_gcp_open", | |
| 1988 | ".gemini": "_fd_gemini_open", | |
| 1989 | ".git": "_fd_git_open", | |
| 1990 | "submodules": "_fd_git_open", | |
| 1991 | ".submodules": "_fd_git_open", | |
| 1992 | ".github": "_fd_github_open", | |
| 1993 | ".gitlab": "_fd_gitlab_open", | |
| 1994 | "godot": "_fd_godot_open", | |
| 1995 | ".godot": "_fd_godot_open", | |
| 1996 | "gradle": "_fd_gradle_open", | |
| 1997 | ".gradle": "_fd_gradle_open", | |
| 1998 | "graphql": "_fd_graphql_open", | |
| 1999 | "grunt": "_fd_grunt_open", | |
| 2000 | "gulp": "_fd_gulp_open", | |
| 2001 | "gulpfile.js": "_fd_gulp_open", | |
| 2002 | "gulpfile.coffee": "_fd_gulp_open", | |
| 2003 | "gulpfile.ts": "_fd_gulp_open", | |
| 2004 | "gulpfile.babel.js": "_fd_gulp_open", | |
| 2005 | "gulpfile.babel.coffee": "_fd_gulp_open", | |
| 2006 | "gulpfile.babel.ts": "_fd_gulp_open", | |
| 2007 | ".haxelib": "_fd_haxelib_open", | |
| 2008 | "haxe_libraries": "_fd_haxelib_open", | |
| 2009 | "helper": "_fd_helper_open", | |
| 2010 | ".helper": "_fd_helper_open", | |
| 2011 | "helpers": "_fd_helper_open", | |
| 2012 | ".helpers": "_fd_helper_open", | |
| 2013 | ".histoire": "_fd_histoire_open", | |
| 2014 | "hook": "_fd_hook_open", | |
| 2015 | ".hook": "_fd_hook_open", | |
| 2016 | "hooks": "_fd_hook_open", | |
| 2017 | ".hooks": "_fd_hook_open", | |
| 2018 | ".husky": "_fd_husky_open", | |
| 2019 | ".idea": "_fd_idea_open", | |
| 2020 | ".fig": "_fd_images_open", | |
| 2021 | ".figs": "_fd_images_open", | |
| 2022 | "images": "_fd_images_open", | |
| 2023 | "image": "_fd_images_open", | |
| 2024 | ".img": "_fd_images_open", | |
| 2025 | "img": "_fd_images_open", | |
| 2026 | ".imgs": "_fd_images_open", | |
| 2027 | "imgs": "_fd_images_open", | |
| 2028 | "icons": "_fd_images_open", | |
| 2029 | "icon": "_fd_images_open", | |
| 2030 | "ico": "_fd_images_open", | |
| 2031 | "screenshot": "_fd_images_open", | |
| 2032 | "screenshots": "_fd_images_open", | |
| 2033 | "svg": "_fd_images_open", | |
| 2034 | "texture": "_fd_images_open", | |
| 2035 | "textures": "_fd_images_open", | |
| 2036 | "include": "_fd_include_open", | |
| 2037 | "includes": "_fd_include_open", | |
| 2038 | "incl": "_fd_include_open", | |
| 2039 | "inc": "_fd_include_open", | |
| 2040 | ".include": "_fd_include_open", | |
| 2041 | ".includes": "_fd_include_open", | |
| 2042 | ".incl": "_fd_include_open", | |
| 2043 | ".inc": "_fd_include_open", | |
| 2044 | "_include": "_fd_include_open", | |
| 2045 | "_includes": "_fd_include_open", | |
| 2046 | "_incl": "_fd_include_open", | |
| 2047 | "_inc": "_fd_include_open", | |
| 2048 | "interface": "_fd_interfaces_open", | |
| 2049 | "interfaces": "_fd_interfaces_open", | |
| 2050 | "ios": "_fd_ios_open", | |
| 2051 | "js": "_fd_js_open", | |
| 2052 | "json": "_fd_json_open", | |
| 2053 | ".junie": "_fd_junie_open", | |
| 2054 | "kotlin": "_fd_kotlin_open", | |
| 2055 | "Kotlin": "_fd_kotlin_open", | |
| 2056 | ".kotlin": "_fd_kotlin_open", | |
| 2057 | "kubernetes": "_fd_kubernetes_open", | |
| 2058 | "k8s": "_fd_kubernetes_open", | |
| 2059 | "kube": "_fd_kubernetes_open", | |
| 2060 | "kuber": "_fd_kubernetes_open", | |
| 2061 | ".kubernetes": "_fd_kubernetes_open", | |
| 2062 | ".k8s": "_fd_kubernetes_open", | |
| 2063 | ".kube": "_fd_kubernetes_open", | |
| 2064 | ".kuber": "_fd_kubernetes_open", | |
| 2065 | "less": "_fd_less_open", | |
| 2066 | "_less": "_fd_less_open", | |
| 2067 | "lib": "_fd_library_open", | |
| 2068 | "libs": "_fd_library_open", | |
| 2069 | ".lib": "_fd_library_open", | |
| 2070 | ".libs": "_fd_library_open", | |
| 2071 | "__lib__": "_fd_library_open", | |
| 2072 | "__libs__": "_fd_library_open", | |
| 2073 | "library": "_fd_library_open", | |
| 2074 | "libraries": "_fd_library_open", | |
| 2075 | "linux": "_fd_linux_open", | |
| 2076 | "lang": "_fd_locale_open", | |
| 2077 | "language": "_fd_locale_open", | |
| 2078 | "languages": "_fd_locale_open", | |
| 2079 | "locale": "_fd_locale_open", | |
| 2080 | "locales": "_fd_locale_open", | |
| 2081 | "_locale": "_fd_locale_open", | |
| 2082 | "_locales": "_fd_locale_open", | |
| 2083 | "internationalization": "_fd_locale_open", | |
| 2084 | "globalization": "_fd_locale_open", | |
| 2085 | "localization": "_fd_locale_open", | |
| 2086 | "i18n": "_fd_locale_open", | |
| 2087 | "g11n": "_fd_locale_open", | |
| 2088 | "l10n": "_fd_locale_open", | |
| 2089 | "log": "_fd_log_open", | |
| 2090 | "logs": "_fd_log_open", | |
| 2091 | "macos": "_fd_macos_open", | |
| 2092 | "darwin": "_fd_macos_open", | |
| 2093 | "mariadb": "_fd_mariadb_open", | |
| 2094 | "maria": "_fd_mariadb_open", | |
| 2095 | ".mvn": "_fd_maven_open", | |
| 2096 | "mediawiki": "_fd_mediawiki_open", | |
| 2097 | "memcached": "_fd_memcached_open", | |
| 2098 | ".memcached": "_fd_memcached_open", | |
| 2099 | ".meteor": "_fd_meteor_open", | |
| 2100 | "middleware": "_fd_middleware_open", | |
| 2101 | "middlewares": "_fd_middleware_open", | |
| 2102 | ".minecraft": "_fd_minecraft_open", | |
| 2103 | "minikube": "_fd_minikube_open", | |
| 2104 | "minik8s": "_fd_minikube_open", | |
| 2105 | "minikuber": "_fd_minikube_open", | |
| 2106 | "mjml": "_fd_mjml_open", | |
| 2107 | ".mjml": "_fd_mjml_open", | |
| 2108 | "mocks": "_fd_mock_open", | |
| 2109 | ".mocks": "_fd_mock_open", | |
| 2110 | "__mocks__": "_fd_mock_open", | |
| 2111 | "model": "_fd_model_open", | |
| 2112 | ".model": "_fd_model_open", | |
| 2113 | "models": "_fd_model_open", | |
| 2114 | ".models": "_fd_model_open", | |
| 2115 | "entities": "_fd_model_open", | |
| 2116 | ".entities": "_fd_model_open", | |
| 2117 | "modules": "_fd_module_open", | |
| 2118 | "mojo": "_fd_mojo_open", | |
| 2119 | "mongodb": "_fd_mongodb_open", | |
| 2120 | "mongo": "_fd_mongodb_open", | |
| 2121 | ".mypy_cache": "_fd_mypy_open", | |
| 2122 | "mysqldb": "_fd_mysql_open", | |
| 2123 | "mysql": "_fd_mysql_open", | |
| 2124 | "netlify": "_fd_netlify_open", | |
| 2125 | ".next": "_fd_next_open", | |
| 2126 | "nginx": "_fd_nginx_open", | |
| 2127 | "conf.d": "_fd_nginx_open", | |
| 2128 | ".niv": "_fd_nix_open", | |
| 2129 | ".nix": "_fd_nix_open", | |
| 2130 | "nix": "_fd_nix_open", | |
| 2131 | "niv": "_fd_nix_open", | |
| 2132 | "node_modules": "_fd_node_open", | |
| 2133 | "notebooks": "_fd_notebooks_open", | |
| 2134 | "notebook": "_fd_notebooks_open", | |
| 2135 | "notification": "_fd_notification_open", | |
| 2136 | "notifications": "_fd_notification_open", | |
| 2137 | "event": "_fd_notification_open", | |
| 2138 | "events": "_fd_notification_open", | |
| 2139 | ".nuget": "_fd_nuget_open", | |
| 2140 | "nuxt": "_fd_nuxt_open", | |
| 2141 | ".nuxt": "_fd_nuxt_open", | |
| 2142 | "package": "_fd_package_open", | |
| 2143 | "packages": "_fd_package_open", | |
| 2144 | ".package": "_fd_package_open", | |
| 2145 | ".packages": "_fd_package_open", | |
| 2146 | "pkg": "_fd_package_open", | |
| 2147 | ".paket": "_fd_paket_open", | |
| 2148 | "php": "_fd_php_open", | |
| 2149 | ".pio": "_fd_platformio_open", | |
| 2150 | ".pioenvs": "_fd_platformio_open", | |
| 2151 | "plugin": "_fd_plugin_open", | |
| 2152 | ".plugin": "_fd_plugin_open", | |
| 2153 | "plugins": "_fd_plugin_open", | |
| 2154 | ".plugins": "_fd_plugin_open", | |
| 2155 | "extension": "_fd_plugin_open", | |
| 2156 | ".extension": "_fd_plugin_open", | |
| 2157 | "extensions": "_fd_plugin_open", | |
| 2158 | ".extensions": "_fd_plugin_open", | |
| 2159 | "prisma": "_fd_prisma_open", | |
| 2160 | "private": "_fd_private_open", | |
| 2161 | ".private": "_fd_private_open", | |
| 2162 | "public": "_fd_public_open", | |
| 2163 | ".public": "_fd_public_open", | |
| 2164 | ".pytest_cache": "_fd_pytest_open", | |
| 2165 | ".venv": "_fd_python_open", | |
| 2166 | ".virtualenv": "_fd_python_open", | |
| 2167 | "__pycache__": "_fd_python_open", | |
| 2168 | "ravendb": "_fd_ravendb_open", | |
| 2169 | "redis": "_fd_redis_open", | |
| 2170 | "redux": "_fd_redux_open", | |
| 2171 | "route": "_fd_route_open", | |
| 2172 | "routes": "_fd_route_open", | |
| 2173 | "_route": "_fd_route_open", | |
| 2174 | "_routes": "_fd_route_open", | |
| 2175 | "router": "_fd_route_open", | |
| 2176 | "routers": "_fd_route_open", | |
| 2177 | "sass": "_fd_sass_open", | |
| 2178 | "scss": "_fd_sass_open", | |
| 2179 | "_sass": "_fd_sass_open", | |
| 2180 | "_scss": "_fd_sass_open", | |
| 2181 | "script": "_fd_script_open", | |
| 2182 | "scripts": "_fd_script_open", | |
| 2183 | "seedkit": "_fd_seedkit_open", | |
| 2184 | ".seedkit": "_fd_seedkit_open", | |
| 2185 | "server": "_fd_server_open", | |
| 2186 | "service": "_fd_services_open", | |
| 2187 | "services": "_fd_services_open", | |
| 2188 | "share": "_fd_shared_open", | |
| 2189 | "shared": "_fd_shared_open", | |
| 2190 | ".share": "_fd_shared_open", | |
| 2191 | ".shared": "_fd_shared_open", | |
| 2192 | "__shared__": "_fd_shared_open", | |
| 2193 | "__share__": "_fd_shared_open", | |
| 2194 | ".snaplet": "_fd_snaplet_open", | |
| 2195 | ".spin": "_fd_spin_open", | |
| 2196 | "src": "_fd_src_open", | |
| 2197 | "source": "_fd_src_open", | |
| 2198 | "sources": "_fd_src_open", | |
| 2199 | "__src__": "_fd_src_open", | |
| 2200 | "sso": "_fd_sso_open", | |
| 2201 | "story": "_fd_story_open", | |
| 2202 | "stories": "_fd_story_open", | |
| 2203 | "__stories__": "_fd_story_open", | |
| 2204 | ".storybook": "_fd_story_open", | |
| 2205 | "style": "_fd_style_open", | |
| 2206 | "styles": "_fd_style_open", | |
| 2207 | "supabase": "_fd_supabase_open", | |
| 2208 | "svelte": "_fd_svelte_open", | |
| 2209 | ".svelte-kit": "_fd_svelte_open", | |
| 2210 | "src-tauri": "_fd_tauri_open", | |
| 2211 | "temp": "_fd_temp_open", | |
| 2212 | ".temp": "_fd_temp_open", | |
| 2213 | "tmp": "_fd_temp_open", | |
| 2214 | ".tmp": "_fd_temp_open", | |
| 2215 | "template": "_fd_template_open", | |
| 2216 | ".template": "_fd_template_open", | |
| 2217 | "templates": "_fd_template_open", | |
| 2218 | ".templates": "_fd_template_open", | |
| 2219 | "tests": "_fd_test_open", | |
| 2220 | ".tests": "_fd_test_open", | |
| 2221 | "test": "_fd_test_open", | |
| 2222 | ".test": "_fd_test_open", | |
| 2223 | "__tests__": "_fd_test_open", | |
| 2224 | "__test__": "_fd_test_open", | |
| 2225 | "spec": "_fd_test_open", | |
| 2226 | ".spec": "_fd_test_open", | |
| 2227 | "specs": "_fd_test_open", | |
| 2228 | ".specs": "_fd_test_open", | |
| 2229 | "integration": "_fd_test_open", | |
| 2230 | "theme": "_fd_theme_open", | |
| 2231 | "themes": "_fd_theme_open", | |
| 2232 | "tool": "_fd_tools_open", | |
| 2233 | "tools": "_fd_tools_open", | |
| 2234 | ".tools": "_fd_tools_open", | |
| 2235 | "util": "_fd_tools_open", | |
| 2236 | "utils": "_fd_tools_open", | |
| 2237 | "utilities": "_fd_tools_open", | |
| 2238 | "tooling": "_fd_tools_open", | |
| 2239 | ".travis": "_fd_travis_open", | |
| 2240 | ".trunk": "_fd_trunk_open", | |
| 2241 | ".turbo": "_fd_turbo_open", | |
| 2242 | "typescript": "_fd_typescript_open", | |
| 2243 | "ts": "_fd_typescript_open", | |
| 2244 | "typings": "_fd_typings_open", | |
| 2245 | "types": "_fd_typings_open", | |
| 2246 | "@types": "_fd_typings_open", | |
| 2247 | "vagrant": "_fd_vagrant_open", | |
| 2248 | ".vagrant": "_fd_vagrant_open", | |
| 2249 | ".vercel": "_fd_vercel_open", | |
| 2250 | "video": "_fd_video_open", | |
| 2251 | ".video": "_fd_video_open", | |
| 2252 | "videos": "_fd_video_open", | |
| 2253 | ".videos": "_fd_video_open", | |
| 2254 | "html": "_fd_view_open", | |
| 2255 | "view": "_fd_view_open", | |
| 2256 | "views": "_fd_view_open", | |
| 2257 | "layout": "_fd_view_open", | |
| 2258 | "layouts": "_fd_view_open", | |
| 2259 | "page": "_fd_view_open", | |
| 2260 | "pages": "_fd_view_open", | |
| 2261 | "_view": "_fd_view_open", | |
| 2262 | "_views": "_fd_view_open", | |
| 2263 | "_layout": "_fd_view_open", | |
| 2264 | "_layouts": "_fd_view_open", | |
| 2265 | "_page": "_fd_view_open", | |
| 2266 | "_pages": "_fd_view_open", | |
| 2267 | ".vitepress": "_fd_vitepress_open", | |
| 2268 | ".vs": "_fd_vs_open", | |
| 2269 | ".vscode": "_fd_vscode_open", | |
| 2270 | "vscode": "_fd_vscode_open", | |
| 2271 | ".vscode-test": "_fd_vscode_test_open", | |
| 2272 | "wasm": "_fd_wasm_open", | |
| 2273 | "WASM": "_fd_wasm_open", | |
| 2274 | "webassembly": "_fd_wasm_open", | |
| 2275 | "WebAssembly": "_fd_wasm_open", | |
| 2276 | "webpack": "_fd_webpack_open", | |
| 2277 | ".webpack": "_fd_webpack_open", | |
| 2278 | "windows": "_fd_windows_open", | |
| 2279 | "win32": "_fd_windows_open", | |
| 2280 | ".windsurf": "_fd_windsurf_open", | |
| 2281 | "wit": "_fd_wit_open", | |
| 2282 | ".web": "_fd_www_open", | |
| 2283 | "www": "_fd_www_open", | |
| 2284 | "wwwroot": "_fd_www_open", | |
| 2285 | "web": "_fd_www_open", | |
| 2286 | ".yarn": "_fd_yarn_open", | |
| 2287 | ".zed": "_fd_zed_open" | |
| 2288 | }, | |
| 2289 | "fileExtensions": { | |
| 2290 | "accdb": "_f_access", | |
| 2291 | "accdt": "_f_access", | |
| 2292 | "mdb": "_f_access", | |
| 2293 | "accda": "_f_access", | |
| 2294 | "accdc": "_f_access", | |
| 2295 | "accde": "_f_access", | |
| 2296 | "accdp": "_f_access", | |
| 2297 | "accdr": "_f_access", | |
| 2298 | "accdu": "_f_access", | |
| 2299 | "ade": "_f_access", | |
| 2300 | "adp": "_f_access", | |
| 2301 | "laccdb": "_f_access", | |
| 2302 | "ldb": "_f_access", | |
| 2303 | "mam": "_f_access", | |
| 2304 | "maq": "_f_access", | |
| 2305 | "mdw": "_f_access", | |
| 2306 | "af": "_f_affinity", | |
| 2307 | "aftemplate": "_f_affinity", | |
| 2308 | "afdesign": "_f_affinitydesigner", | |
| 2309 | "affinitydesigner": "_f_affinitydesigner", | |
| 2310 | "afphoto": "_f_affinityphoto", | |
| 2311 | "affinityphoto": "_f_affinityphoto", | |
| 2312 | "afpub": "_f_affinitypublisher", | |
| 2313 | "affinitypublisher": "_f_affinitypublisher", | |
| 2314 | "ai": "_f_ai", | |
| 2315 | "dal": "_f_al_dal", | |
| 2316 | "gs": "_f_appscript", | |
| 2317 | "ino": "_f_arduino", | |
| 2318 | "pde": "_f_arduino", | |
| 2319 | "ase": "_f_aseprite", | |
| 2320 | "aseprite": "_f_aseprite", | |
| 2321 | "aspx": "_f_aspx", | |
| 2322 | "ascx": "_f_aspx", | |
| 2323 | "asm": "_f_assembly", | |
| 2324 | "s": "_f_assembly", | |
| 2325 | "astro": "_f_astro", | |
| 2326 | "atom": "_f_atom", | |
| 2327 | "aac": "_f_audio", | |
| 2328 | "act": "_f_audio", | |
| 2329 | "aiff": "_f_audio", | |
| 2330 | "amr": "_f_audio", | |
| 2331 | "ape": "_f_audio", | |
| 2332 | "au": "_f_audio", | |
| 2333 | "dct": "_f_audio", | |
| 2334 | "dss": "_f_audio", | |
| 2335 | "dvf": "_f_audio", | |
| 2336 | "flac": "_f_audio", | |
| 2337 | "gsm": "_f_audio", | |
| 2338 | "iklax": "_f_audio", | |
| 2339 | "ivs": "_f_audio", | |
| 2340 | "m4a": "_f_audio", | |
| 2341 | "m4b": "_f_audio", | |
| 2342 | "m4p": "_f_audio", | |
| 2343 | "mmf": "_f_audio", | |
| 2344 | "mogg": "_f_audio", | |
| 2345 | "mp3": "_f_audio", | |
| 2346 | "mpc": "_f_audio", | |
| 2347 | "msv": "_f_audio", | |
| 2348 | "oga": "_f_audio", | |
| 2349 | "ogg": "_f_audio", | |
| 2350 | "opus": "_f_audio", | |
| 2351 | "ra": "_f_audio", | |
| 2352 | "raw": "_f_audio", | |
| 2353 | "tta": "_f_audio", | |
| 2354 | "vox": "_f_audio", | |
| 2355 | "wav": "_f_audio", | |
| 2356 | "wma": "_f_audio", | |
| 2357 | "avif": "_f_avif", | |
| 2358 | "awk": "_f_awk", | |
| 2359 | "back": "_f_bak", | |
| 2360 | "backup": "_f_bak", | |
| 2361 | "bak": "_f_bak", | |
| 2362 | "a": "_f_binary", | |
| 2363 | "app": "_f_binary", | |
| 2364 | "bin": "_f_binary", | |
| 2365 | "cmo": "_f_binary", | |
| 2366 | "cmx": "_f_binary", | |
| 2367 | "cma": "_f_binary", | |
| 2368 | "cmxa": "_f_binary", | |
| 2369 | "cmi": "_f_binary", | |
| 2370 | "dll": "_f_binary", | |
| 2371 | "exe": "_f_binary", | |
| 2372 | "hl": "_f_binary", | |
| 2373 | "ilk": "_f_binary", | |
| 2374 | "lib": "_f_binary", | |
| 2375 | "n": "_f_binary", | |
| 2376 | "ndll": "_f_binary", | |
| 2377 | "o": "_f_binary", | |
| 2378 | "obj": "_f_binary", | |
| 2379 | "pyc": "_f_binary", | |
| 2380 | "pyd": "_f_binary", | |
| 2381 | "pyo": "_f_binary", | |
| 2382 | "pdb": "_f_binary", | |
| 2383 | "scpt": "_f_binary", | |
| 2384 | "scptd": "_f_binary", | |
| 2385 | "so": "_f_binary", | |
| 2386 | "blend": "_f_blender", | |
| 2387 | "bb": "_f_blitzbasic", | |
| 2388 | "buf.yaml": "_f_buf", | |
| 2389 | "buf.yml": "_f_buf", | |
| 2390 | "buf.gen.yml": "_f_buf", | |
| 2391 | "buf.gen.yaml": "_f_buf", | |
| 2392 | "buf.work.yaml": "_f_buf", | |
| 2393 | "buf.work.yml": "_f_buf", | |
| 2394 | "gemfile": "_f_bundler", | |
| 2395 | "gemfile.lock": "_f_bundler", | |
| 2396 | "cake": "_f_cake", | |
| 2397 | "csr": "_f_cert", | |
| 2398 | "crt": "_f_cert", | |
| 2399 | "cer": "_f_cert", | |
| 2400 | "der": "_f_cert", | |
| 2401 | "pfx": "_f_cert", | |
| 2402 | "p12": "_f_cert", | |
| 2403 | "p7b": "_f_cert", | |
| 2404 | "p7r": "_f_cert", | |
| 2405 | "src": "_f_cert", | |
| 2406 | "crl": "_f_cert", | |
| 2407 | "sst": "_f_cert", | |
| 2408 | "stl": "_f_cert", | |
| 2409 | "lucee": "_f_cf", | |
| 2410 | "h": "_f_cheader", | |
| 2411 | "class": "_f_class", | |
| 2412 | "cjm": "_f_clojure", | |
| 2413 | "cljc": "_f_clojure", | |
| 2414 | "cljs": "_f_clojurescript", | |
| 2415 | "kit": "_f_codekit", | |
| 2416 | "plist": "_f_config", | |
| 2417 | "lcov": "_f_coverage", | |
| 2418 | "hpp": "_f_cppheader", | |
| 2419 | "hh": "_f_cppheader", | |
| 2420 | "hxx": "_f_cppheader", | |
| 2421 | "h++": "_f_cppheader", | |
| 2422 | "csx": "_f_csharp", | |
| 2423 | "csproj": "_f_csproj", | |
| 2424 | "css.map": "_f_cssmap", | |
| 2425 | "cy.js": "_f_cypress_spec", | |
| 2426 | "cy.mjs": "_f_cypress_spec", | |
| 2427 | "cy.cjs": "_f_cypress_spec", | |
| 2428 | "cy.coffee": "_f_cypress_spec", | |
| 2429 | "cy.ts": "_f_cypress_spec", | |
| 2430 | "cy.tsx": "_f_cypress_spec", | |
| 2431 | "cy.jsx": "_f_cypress_spec", | |
| 2432 | "g.dart": "_f_dartlang_generated", | |
| 2433 | "freezed.dart": "_f_dartlang_generated", | |
| 2434 | "db": "_f_db", | |
| 2435 | "deb": "_f_debian", | |
| 2436 | "resolver.json": "_f_design_tokens", | |
| 2437 | "resolver.json5": "_f_design_tokens", | |
| 2438 | "resolver.jsonc": "_f_design_tokens", | |
| 2439 | "tokens.json": "_f_design_tokens", | |
| 2440 | "tokens.json5": "_f_design_tokens", | |
| 2441 | "tokens.jsonc": "_f_design_tokens", | |
| 2442 | "djt": "_f_django", | |
| 2443 | "eco": "_f_docpad", | |
| 2444 | "drawio": "_f_drawio", | |
| 2445 | "dio": "_f_drawio", | |
| 2446 | "drawio.png": "_f_drawio", | |
| 2447 | "drawio.svg": "_f_drawio", | |
| 2448 | "dio.png": "_f_drawio", | |
| 2449 | "dio.svg": "_f_drawio", | |
| 2450 | "dtd": "_f_dtd", | |
| 2451 | "eex": "_f_eex", | |
| 2452 | "heex": "_f_eex", | |
| 2453 | "phoenix-heex": "_f_eex", | |
| 2454 | "html-heex": "_f_eex", | |
| 2455 | "ejs": "_f_ejs", | |
| 2456 | "el": "_f_emacs", | |
| 2457 | "elc": "_f_emacs", | |
| 2458 | "ensime": "_f_ensime", | |
| 2459 | "eps": "_f_eps", | |
| 2460 | "epub": "_f_epub", | |
| 2461 | "excalidraw": "_f_excalidraw", | |
| 2462 | "excalidraw.json": "_f_excalidraw", | |
| 2463 | "excalidraw.svg": "_f_excalidraw", | |
| 2464 | "excalidraw.png": "_f_excalidraw", | |
| 2465 | "xls": "_f_excel", | |
| 2466 | "xlsx": "_f_excel", | |
| 2467 | "xlsm": "_f_excel", | |
| 2468 | "ods": "_f_libreoffice_calc", | |
| 2469 | "fods": "_f_libreoffice_calc", | |
| 2470 | "xlsb": "_f_excel", | |
| 2471 | "fbx": "_f_fbx", | |
| 2472 | "fba": "_f_fitbit", | |
| 2473 | "fla": "_f_fla", | |
| 2474 | "swf": "_f_flash", | |
| 2475 | "swc": "_f_flash", | |
| 2476 | "js.flow": "_f_flow", | |
| 2477 | "woff": "_f_font", | |
| 2478 | "woff2": "_f_font", | |
| 2479 | "ttf": "_f_font", | |
| 2480 | "otf": "_f_libreoffice_math", | |
| 2481 | "eot": "_f_font", | |
| 2482 | "pfa": "_f_font", | |
| 2483 | "pfb": "_f_font", | |
| 2484 | "sfd": "_f_font", | |
| 2485 | "fsproj": "_f_fsproj", | |
| 2486 | "gmx": "_f_gamemaker", | |
| 2487 | "yy": "_f_gamemaker2", | |
| 2488 | "yyp": "_f_gamemaker2", | |
| 2489 | "gd.uid": "_f_gduid", | |
| 2490 | "geojson": "_f_geojson", | |
| 2491 | "xcf": "_f_gimp", | |
| 2492 | "gleam": "_f_gleam", | |
| 2493 | "glb": "_f_gltf", | |
| 2494 | "gltf": "_f_gltf", | |
| 2495 | "am": "_f_gnu", | |
| 2496 | "ld": "_f_gnu", | |
| 2497 | "m4": "_f_gnu", | |
| 2498 | "makefile": "_f_gnu", | |
| 2499 | "godot": "_f_godot", | |
| 2500 | "gpg": "_f_gpg", | |
| 2501 | "pgp": "_f_gpg", | |
| 2502 | "gradle": "_f_gradle", | |
| 2503 | "gr": "_f_grain", | |
| 2504 | "hcl2": "_f_hashicorp", | |
| 2505 | "sentinel": "_f_hashicorp", | |
| 2506 | "hxproj": "_f_haxedevelop", | |
| 2507 | "ejs.t": "_f_hygen", | |
| 2508 | "idr": "_f_idris", | |
| 2509 | "lidr": "_f_idris", | |
| 2510 | "ibc": "_f_idrisbin", | |
| 2511 | "ipkg": "_f_idrispkg", | |
| 2512 | "jpeg": "_f_image", | |
| 2513 | "jpg": "_f_image", | |
| 2514 | "gif": "_f_image", | |
| 2515 | "png": "_f_image", | |
| 2516 | "bmp": "_f_image", | |
| 2517 | "tiff": "_f_image", | |
| 2518 | "heic": "_f_image", | |
| 2519 | "ico": "_f_image", | |
| 2520 | "icns": "_f_image", | |
| 2521 | "webp": "_f_image", | |
| 2522 | "imba": "_f_imba", | |
| 2523 | "imba2": "_f_imba", | |
| 2524 | "inc": "_f_inc", | |
| 2525 | "include": "_f_inc", | |
| 2526 | "infopathxml": "_f_infopath", | |
| 2527 | "xsn": "_f_infopath", | |
| 2528 | "xsf": "_f_infopath", | |
| 2529 | "xtp2": "_f_infopath", | |
| 2530 | "jar": "_f_jar", | |
| 2531 | "jbuilder": "_f_jbuilder", | |
| 2532 | "iml": "_f_jetbrains", | |
| 2533 | "js.map": "_f_jsmap", | |
| 2534 | "cjs.map": "_f_jsmap", | |
| 2535 | "mjs.map": "_f_jsmap", | |
| 2536 | "jsonl": "_f_json", | |
| 2537 | "ndjson": "_f_json", | |
| 2538 | "json5": "_f_json5", | |
| 2539 | "schema.json": "_f_json_schema", | |
| 2540 | "jsonld": "_f_jsonld", | |
| 2541 | "json-ld": "_f_jsonld", | |
| 2542 | "jsp": "_f_jsp", | |
| 2543 | "jss": "_f_jss", | |
| 2544 | "juice": "_f_juice", | |
| 2545 | "ipynb": "_f_jupyter", | |
| 2546 | "key": "_f_key", | |
| 2547 | "pem": "_f_key", | |
| 2548 | "ktm": "_f_kotlin", | |
| 2549 | "language-configuration.json": "_f_language_configuration", | |
| 2550 | "master": "_f_layout", | |
| 2551 | "layout.html": "_f_layout", | |
| 2552 | "layout.htm": "_f_layout", | |
| 2553 | "odb": "_f_libreoffice_base", | |
| 2554 | "otb": "_f_libreoffice_base", | |
| 2555 | "ots": "_f_libreoffice_calc", | |
| 2556 | "odg": "_f_libreoffice_draw", | |
| 2557 | "otg": "_f_libreoffice_draw", | |
| 2558 | "odp": "_f_libreoffice_impress", | |
| 2559 | "otp": "_f_libreoffice_impress", | |
| 2560 | "odf": "_f_libreoffice_math", | |
| 2561 | "odt": "_f_libreoffice_writer", | |
| 2562 | "ott": "_f_libreoffice_writer", | |
| 2563 | "enc": "_f_license", | |
| 2564 | "license": "_f_license", | |
| 2565 | "lic": "_f_license", | |
| 2566 | "hxp": "_f_lime", | |
| 2567 | "liquid": "_f_liquid", | |
| 2568 | "ls": "_f_livescript", | |
| 2569 | "lnk": "_f_lnk", | |
| 2570 | "log": "_f_log", | |
| 2571 | "tlg": "_f_log", | |
| 2572 | "luau": "_f_luau", | |
| 2573 | "crec": "_f_lync", | |
| 2574 | "ocrec": "_f_lync", | |
| 2575 | "map": "_f_map", | |
| 2576 | "mdown": "_f_markdown", | |
| 2577 | "markdown": "_f_markdown", | |
| 2578 | "marko.js": "_f_markojs", | |
| 2579 | "fig": "_f_matlab", | |
| 2580 | "mex": "_f_matlab", | |
| 2581 | "mexn": "_f_matlab", | |
| 2582 | "mexrs6": "_f_matlab", | |
| 2583 | "mn": "_f_matlab", | |
| 2584 | "mum": "_f_matlab", | |
| 2585 | "mx": "_f_matlab", | |
| 2586 | "mx3": "_f_matlab", | |
| 2587 | "rwd": "_f_matlab", | |
| 2588 | "slx": "_f_matlab", | |
| 2589 | "slddc": "_f_matlab", | |
| 2590 | "smv": "_f_matlab", | |
| 2591 | "xvc": "_f_matlab", | |
| 2592 | "mca": "_f_minecraft", | |
| 2593 | "mcmeta": "_f_minecraft", | |
| 2594 | "mojo": "_f_mojo", | |
| 2595 | "🔥": "_f_mojo", | |
| 2596 | "mql.yaml": "_f_mondoo", | |
| 2597 | "mql.yml": "_f_mondoo", | |
| 2598 | "mustache": "_f_mustache", | |
| 2599 | "mst": "_f_mustache", | |
| 2600 | "ndst.yaml": "_f_ndst", | |
| 2601 | "ndst.yml": "_f_ndst", | |
| 2602 | "ndst.json": "_f_ndst", | |
| 2603 | "njsproj": "_f_njsproj", | |
| 2604 | "noc": "_f_noc", | |
| 2605 | "nupkg": "_f_nuget", | |
| 2606 | "snupkg": "_f_nuget", | |
| 2607 | "nuspec": "_f_nuget", | |
| 2608 | "psmdcp": "_f_nuget", | |
| 2609 | "npy": "_f_numpy", | |
| 2610 | "npz": "_f_numpy", | |
| 2611 | "nunj": "_f_nunjucks", | |
| 2612 | "njs": "_f_nunjucks", | |
| 2613 | "mli": "_f_ocaml_intf", | |
| 2614 | "one": "_f_onenote", | |
| 2615 | "onepkg": "_f_onenote", | |
| 2616 | "onetoc": "_f_onenote", | |
| 2617 | "onetoc2": "_f_onenote", | |
| 2618 | "sig": "_f_onenote", | |
| 2619 | "opam": "_f_opam", | |
| 2620 | "cl": "_f_opencl", | |
| 2621 | "opencl": "_f_opencl", | |
| 2622 | "tofu": "_f_opentofu", | |
| 2623 | "tofu.json": "_f_opentofu", | |
| 2624 | "tofutest.hcl": "_f_opentofu", | |
| 2625 | "tofutest.json": "_f_opentofu", | |
| 2626 | "org": "_f_org", | |
| 2627 | "pst": "_f_outlook", | |
| 2628 | "bcmx": "_f_outlook", | |
| 2629 | "otm": "_f_outlook", | |
| 2630 | "msg": "_f_outlook", | |
| 2631 | "oft": "_f_outlook", | |
| 2632 | "ovpn": "_f_ovpn", | |
| 2633 | "pkg": "_f_package", | |
| 2634 | "paket.references": "_f_paket", | |
| 2635 | "paket.template": "_f_paket", | |
| 2636 | "patch": "_f_patch", | |
| 2637 | "pcd": "_f_pcl", | |
| 2638 | "pdf": "_f_pdf", | |
| 2639 | "psd": "_f_photoshop", | |
| 2640 | "php1": "_f_php", | |
| 2641 | "php2": "_f_php", | |
| 2642 | "php3": "_f_php", | |
| 2643 | "php4": "_f_php", | |
| 2644 | "php5": "_f_php", | |
| 2645 | "php6": "_f_php", | |
| 2646 | "phps": "_f_php", | |
| 2647 | "phpsa": "_f_php", | |
| 2648 | "phpt": "_f_php", | |
| 2649 | "phtml": "_f_php", | |
| 2650 | "phar": "_f_php", | |
| 2651 | "pipeline": "_f_pipeline", | |
| 2652 | "pu": "_f_plantuml", | |
| 2653 | "plantuml": "_f_plantuml", | |
| 2654 | "iuml": "_f_plantuml", | |
| 2655 | "puml": "_f_plantuml", | |
| 2656 | "pck": "_f_plsql_package", | |
| 2657 | "pkb": "_f_plsql_package_body", | |
| 2658 | "pkh": "_f_plsql_package_header", | |
| 2659 | "pks": "_f_plsql_package_spec", | |
| 2660 | "po": "_f_poedit", | |
| 2661 | "mo": "_f_poedit", | |
| 2662 | "postman_collection.json": "_f_postman", | |
| 2663 | "postman_environment.json": "_f_postman", | |
| 2664 | "postman_globals.json": "_f_postman", | |
| 2665 | "pot": "_f_powerpoint", | |
| 2666 | "potx": "_f_powerpoint", | |
| 2667 | "potm": "_f_powerpoint", | |
| 2668 | "pps": "_f_powerpoint", | |
| 2669 | "ppsx": "_f_powerpoint", | |
| 2670 | "ppsm": "_f_powerpoint", | |
| 2671 | "ppt": "_f_powerpoint", | |
| 2672 | "pptx": "_f_powerpoint", | |
| 2673 | "pptm": "_f_powerpoint", | |
| 2674 | "pa": "_f_powerpoint", | |
| 2675 | "ppa": "_f_powerpoint", | |
| 2676 | "ppam": "_f_powerpoint", | |
| 2677 | "sldm": "_f_powerpoint", | |
| 2678 | "sldx": "_f_powerpoint", | |
| 2679 | "format.ps1xml": "_f_powershell_format", | |
| 2680 | "psd1": "_f_powershell_psd", | |
| 2681 | "psm1": "_f_powershell_psm", | |
| 2682 | "types.ps1xml": "_f_powershell_types", | |
| 2683 | "pro": "_f_prolog", | |
| 2684 | "pub": "_f_publisher", | |
| 2685 | "puz": "_f_publisher", | |
| 2686 | "pyowo": "_f_pythowo", | |
| 2687 | "q": "_f_q", | |
| 2688 | "qbs": "_f_qbs", | |
| 2689 | "qvd": "_f_qlikview", | |
| 2690 | "qvw": "_f_qlikview", | |
| 2691 | "rast": "_f_ra_syntax_tree", | |
| 2692 | "rake": "_f_rake", | |
| 2693 | "rt": "_f_reacttemplate", | |
| 2694 | "reg": "_f_registry", | |
| 2695 | "rego": "_f_rego", | |
| 2696 | "ripple": "_f_ripple", | |
| 2697 | "rnc": "_f_rnc", | |
| 2698 | "ron": "_f_ron", | |
| 2699 | "rproj": "_f_rproj", | |
| 2700 | "rss": "_f_rss", | |
| 2701 | "rbs": "_f_ruby", | |
| 2702 | "safetensors": "_f_safetensors", | |
| 2703 | "sls": "_f_saltstack", | |
| 2704 | "sass": "_f_sass", | |
| 2705 | "scssm": "_f_scss", | |
| 2706 | "unity": "_f_shaderlab", | |
| 2707 | "fish": "_f_shell", | |
| 2708 | "sy": "_f_siyuan", | |
| 2709 | "sketch": "_f_sketch", | |
| 2710 | "eskip": "_f_skipper", | |
| 2711 | "sln": "_f_sln", | |
| 2712 | "slnx": "_f_sln", | |
| 2713 | "slnf": "_f_sln", | |
| 2714 | "spe": "_f_spacengine", | |
| 2715 | "sqlite": "_f_sqlite", | |
| 2716 | "sqlite3": "_f_sqlite", | |
| 2717 | "db3": "_f_sqlite", | |
| 2718 | "sss": "_f_sss", | |
| 2719 | "dta": "_f_stata", | |
| 2720 | "storyboard": "_f_storyboard", | |
| 2721 | "story.js": "_f_storybook", | |
| 2722 | "story.jsx": "_f_storybook", | |
| 2723 | "story.ts": "_f_storybook", | |
| 2724 | "story.tsx": "_f_storybook", | |
| 2725 | "story.mdx": "_f_storybook", | |
| 2726 | "story.svelte": "_f_storybook", | |
| 2727 | "stories.js": "_f_storybook", | |
| 2728 | "stories.jsx": "_f_storybook", | |
| 2729 | "stories.ts": "_f_storybook", | |
| 2730 | "stories.tsx": "_f_storybook", | |
| 2731 | "stories.mdx": "_f_storybook", | |
| 2732 | "stories.svelte": "_f_storybook", | |
| 2733 | "sublime-project": "_f_sublime", | |
| 2734 | "svg": "_f_svg", | |
| 2735 | "tcl": "_f_tcl", | |
| 2736 | "exp": "_f_tcl", | |
| 2737 | "templ": "_f_templ", | |
| 2738 | "tfstate": "_f_terraform", | |
| 2739 | "tfvars": "_f_terraform", | |
| 2740 | "tf": "_f_terraform", | |
| 2741 | "tf.json": "_f_terraform", | |
| 2742 | "tst": "_f_test", | |
| 2743 | "test.js": "_f_testjs", | |
| 2744 | "test.jsx": "_f_testjs", | |
| 2745 | "test.mjs": "_f_testjs", | |
| 2746 | "spec.js": "_f_testjs", | |
| 2747 | "spec.jsx": "_f_testjs", | |
| 2748 | "spec.mjs": "_f_testjs", | |
| 2749 | "testplane.ts": "_f_testplane", | |
| 2750 | "testplane.js": "_f_testplane", | |
| 2751 | "testplane.cts": "_f_testplane", | |
| 2752 | "testplane.cjs": "_f_testplane", | |
| 2753 | "testplane.tsx": "_f_testplane", | |
| 2754 | "testplane.jsx": "_f_testplane", | |
| 2755 | "test.ts": "_f_testts", | |
| 2756 | "test.tsx": "_f_testts", | |
| 2757 | "test.mts": "_f_testts", | |
| 2758 | "spec.ts": "_f_testts", | |
| 2759 | "spec.tsx": "_f_testts", | |
| 2760 | "spec.mts": "_f_testts", | |
| 2761 | "e2e-test.ts": "_f_testts", | |
| 2762 | "e2e-test.tsx": "_f_testts", | |
| 2763 | "e2e-test.mts": "_f_testts", | |
| 2764 | "e2e-spec.ts": "_f_testts", | |
| 2765 | "e2e-spec.tsx": "_f_testts", | |
| 2766 | "e2e-spec.mts": "_f_testts", | |
| 2767 | "test-d.ts": "_f_testts", | |
| 2768 | "test-d.tsx": "_f_testts", | |
| 2769 | "test-d.mts": "_f_testts", | |
| 2770 | "texi": "_f_tex", | |
| 2771 | "tikz": "_f_tex", | |
| 2772 | "csv": "_f_text", | |
| 2773 | "tsv": "_f_text", | |
| 2774 | "tm": "_f_tm", | |
| 2775 | "tm.hcl": "_f_tm", | |
| 2776 | "todo": "_f_todo", | |
| 2777 | "tree": "_f_tree", | |
| 2778 | "tres": "_f_tres", | |
| 2779 | "tsbuildinfo": "_f_tsbuildinfo", | |
| 2780 | "tscn": "_f_tscn", | |
| 2781 | "tt2": "_f_tt", | |
| 2782 | "d.ts": "_f_typescriptdef", | |
| 2783 | "d.cts": "_f_typescriptdef", | |
| 2784 | "d.mts": "_f_typescriptdef", | |
| 2785 | "ua": "_f_uiua", | |
| 2786 | "u": "_f_unison", | |
| 2787 | "vala": "_f_vala", | |
| 2788 | "css.ts": "_f_vanilla_extract", | |
| 2789 | "vapi": "_f_vapi", | |
| 2790 | "vash": "_f_vash", | |
| 2791 | "vbhtml": "_f_vbhtml", | |
| 2792 | "vbproj": "_f_vbproj", | |
| 2793 | "vcxproj": "_f_vcxproj", | |
| 2794 | "vto": "_f_vento", | |
| 2795 | "vento": "_f_vento", | |
| 2796 | "3g2": "_f_video", | |
| 2797 | "3gp": "_f_video", | |
| 2798 | "asf": "_f_video", | |
| 2799 | "amv": "_f_video", | |
| 2800 | "avi": "_f_video", | |
| 2801 | "divx": "_f_video", | |
| 2802 | "qt": "_f_video", | |
| 2803 | "f4a": "_f_video", | |
| 2804 | "f4b": "_f_video", | |
| 2805 | "f4p": "_f_video", | |
| 2806 | "f4v": "_f_video", | |
| 2807 | "flv": "_f_video", | |
| 2808 | "m2v": "_f_video", | |
| 2809 | "m4v": "_f_video", | |
| 2810 | "mkv": "_f_video", | |
| 2811 | "mk3d": "_f_video", | |
| 2812 | "mov": "_f_video", | |
| 2813 | "mp2": "_f_video", | |
| 2814 | "mp4": "_f_video", | |
| 2815 | "mpe": "_f_video", | |
| 2816 | "mpeg": "_f_video", | |
| 2817 | "mpeg2": "_f_video", | |
| 2818 | "mpg": "_f_video", | |
| 2819 | "mpv": "_f_video", | |
| 2820 | "nsv": "_f_video", | |
| 2821 | "ogv": "_f_video", | |
| 2822 | "rm": "_f_video", | |
| 2823 | "rmvb": "_f_video", | |
| 2824 | "svi": "_f_video", | |
| 2825 | "vob": "_f_video", | |
| 2826 | "webm": "_f_video", | |
| 2827 | "wmv": "_f_video", | |
| 2828 | "code-snippets": "_f_vscode", | |
| 2829 | "code-workspace": "_f_vscode", | |
| 2830 | "vsix": "_f_vsix", | |
| 2831 | "vsixmanifest": "_f_vsixmanifest", | |
| 2832 | "vvvvvv": "_f_vvvvvv", | |
| 2833 | "vyi": "_f_vyper", | |
| 2834 | "wasm": "_f_wasm", | |
| 2835 | "wikitext": "_f_wikitext", | |
| 2836 | "wit": "_f_wit", | |
| 2837 | "doc": "_f_word", | |
| 2838 | "docx": "_f_word", | |
| 2839 | "docm": "_f_word", | |
| 2840 | "dot": "_f_word", | |
| 2841 | "dotx": "_f_word", | |
| 2842 | "dotm": "_f_word", | |
| 2843 | "wll": "_f_word", | |
| 2844 | "wxml": "_f_wxml", | |
| 2845 | "wxss": "_f_wxss", | |
| 2846 | "xaml": "_f_xaml", | |
| 2847 | "axaml": "_f_xaml", | |
| 2848 | "xcodeproj": "_f_xcode", | |
| 2849 | "xfl": "_f_xfl", | |
| 2850 | "xib": "_f_xib", | |
| 2851 | "xliff": "_f_xliff", | |
| 2852 | "xlf": "_f_xliff", | |
| 2853 | "pex": "_f_xml", | |
| 2854 | "tmlanguage": "_f_xml", | |
| 2855 | "zip": "_f_zip", | |
| 2856 | "rar": "_f_zip", | |
| 2857 | "7z": "_f_zip", | |
| 2858 | "tar": "_f_zip", | |
| 2859 | "tgz": "_f_zip", | |
| 2860 | "bz": "_f_zip", | |
| 2861 | "gz": "_f_zip", | |
| 2862 | "bzip2": "_f_zip", | |
| 2863 | "xz": "_f_zip", | |
| 2864 | "bz2": "_f_zip", | |
| 2865 | "whl": "_f_zip", | |
| 2866 | "zipx": "_f_zip", | |
| 2867 | "br": "_f_zip" | |
| 2868 | }, | |
| 2869 | "fileNames": { | |
| 2870 | "agents.md": "_f_agents", | |
| 2871 | "alchemy.run.ts": "_f_alchemy", | |
| 2872 | ".all-contributorsrc": "_f_allcontributors", | |
| 2873 | "allurerc.js": "_f_allure", | |
| 2874 | "allurerc.mjs": "_f_allure", | |
| 2875 | "allurerc.cjs": "_f_allure", | |
| 2876 | "allurerc.json": "_f_allure", | |
| 2877 | "allurerc.yaml": "_f_allure", | |
| 2878 | "allurerc.yml": "_f_allure", | |
| 2879 | ".angular-cli.json": "_f_angular", | |
| 2880 | "angular-cli.json": "_f_angular", | |
| 2881 | "angular.json": "_f_angular", | |
| 2882 | ".angular.json": "_f_angular", | |
| 2883 | "antlers.html": "_f_antlers_html", | |
| 2884 | "api-extractor.json": "_f_api_extractor", | |
| 2885 | "api-extractor-base.json": "_f_api_extractor", | |
| 2886 | ".appsemblerc.yaml": "_f_appsemble", | |
| 2887 | "app-definition.yaml": "_f_appsemble", | |
| 2888 | "appveyor.yml": "_f_appveyor", | |
| 2889 | ".appveyor.yml": "_f_appveyor", | |
| 2890 | "asconfig.json": "_f_assembly_script", | |
| 2891 | "astro.config.js": "_f_astroconfig", | |
| 2892 | "astro.config.cjs": "_f_astroconfig", | |
| 2893 | "astro.config.mjs": "_f_astroconfig", | |
| 2894 | "astro.config.ts": "_f_astroconfig", | |
| 2895 | "astro.config.cts": "_f_astroconfig", | |
| 2896 | "astro.config.mts": "_f_astroconfig", | |
| 2897 | ".attw.json": "_f_attw", | |
| 2898 | "aurelia.json": "_f_aurelia", | |
| 2899 | "azure-pipelines.yml": "_f_azurepipelines", | |
| 2900 | ".vsts-ci.yml": "_f_azurepipelines", | |
| 2901 | ".babelrc": "_f_babel", | |
| 2902 | ".babelignore": "_f_babel", | |
| 2903 | ".babelrc.js": "_f_babel", | |
| 2904 | ".babelrc.cjs": "_f_babel", | |
| 2905 | ".babelrc.mjs": "_f_babel", | |
| 2906 | ".babelrc.json": "_f_babel", | |
| 2907 | "babel.config.js": "_f_babel", | |
| 2908 | "babel.config.cjs": "_f_babel", | |
| 2909 | "babel.config.mjs": "_f_babel", | |
| 2910 | "babel.config.json": "_f_babel", | |
| 2911 | ".bzrignore": "_f_bazaar", | |
| 2912 | "BUILD.bazel": "_f_bazel", | |
| 2913 | ".bazelrc": "_f_bazel", | |
| 2914 | "bazel.rc": "_f_bazel", | |
| 2915 | "bazel.bazelrc": "_f_bazel", | |
| 2916 | ".bazelignore": "_f_bazel_ignore", | |
| 2917 | ".bazelversion": "_f_bazel_version", | |
| 2918 | "biome.json": "_f_biome", | |
| 2919 | "biome.jsonc": "_f_biome", | |
| 2920 | "bitbucket-pipelines.yml": "_f_bitbucketpipeline", | |
| 2921 | ".bithoundrc": "_f_bithound", | |
| 2922 | ".bowerrc": "_f_bower", | |
| 2923 | "bower.json": "_f_bower", | |
| 2924 | ".browserslistrc": "_f_browserslist", | |
| 2925 | "browserslist": "_f_browserslist", | |
| 2926 | ".buckconfig": "_f_buckbuild", | |
| 2927 | ".bunfig.toml": "_f_bun", | |
| 2928 | "bunfig.toml": "_f_bunfig", | |
| 2929 | "bun.lockb": "_f_bun", | |
| 2930 | "bun.lock": "_f_bun", | |
| 2931 | ".bundlemonrc": "_f_bundlemon", | |
| 2932 | "bundlemon.config.js": "_f_bundlemon", | |
| 2933 | "bundlemon.config.mjs": "_f_bundlemon", | |
| 2934 | "bundlemon.config.cjs": "_f_bundlemon", | |
| 2935 | ".bundlemonrc.js": "_f_bundlemon", | |
| 2936 | ".bundlemonrc.mjs": "_f_bundlemon", | |
| 2937 | ".bundlemonrc.cjs": "_f_bundlemon", | |
| 2938 | ".bundlemonrc.json": "_f_bundlemon", | |
| 2939 | ".bundlemonrc.yaml": "_f_bundlemon", | |
| 2940 | ".bundlemonrc.yml": "_f_bundlemon", | |
| 2941 | "gemfile": "_f_bundler", | |
| 2942 | "gemfile.lock": "_f_bundler", | |
| 2943 | "capacitor.config.json": "_f_capacitor", | |
| 2944 | "capacitor.config.ts": "_f_capacitor", | |
| 2945 | "Cargo.toml": "_f_cargo", | |
| 2946 | "Cargo.lock": "_f_cargo", | |
| 2947 | ".changie.yaml": "_f_changie", | |
| 2948 | ".changie.yml": "_f_changie", | |
| 2949 | "chefignore": "_f_chef", | |
| 2950 | "berksfile": "_f_chef", | |
| 2951 | "berksfile.lock": "_f_chef", | |
| 2952 | "policyfile.rb": "_f_chef", | |
| 2953 | "policyfile.lock.json": "_f_chef", | |
| 2954 | "circle.yml": "_f_circleci", | |
| 2955 | "claude.md": "_f_claude", | |
| 2956 | "wrangler.toml": "_f_cloudflare", | |
| 2957 | ".cfignore": "_f_cloudfoundry", | |
| 2958 | ".codacy.yml": "_f_codacy", | |
| 2959 | ".codacy.yaml": "_f_codacy", | |
| 2960 | ".codeclimate.yml": "_f_codeclimate", | |
| 2961 | "codecov.yml": "_f_codecov", | |
| 2962 | ".codecov.yml": "_f_codecov", | |
| 2963 | "config.codekit": "_f_codekit", | |
| 2964 | "config.codekit2": "_f_codekit", | |
| 2965 | "config.codekit3": "_f_codekit", | |
| 2966 | ".config.codekit": "_f_codekit", | |
| 2967 | ".config.codekit2": "_f_codekit", | |
| 2968 | ".config.codekit3": "_f_codekit", | |
| 2969 | "codemagic.yaml": "_f_codemagic", | |
| 2970 | "codeowners": "_f_codeowners", | |
| 2971 | ".coderabbit.yaml": "_f_coderabbit", | |
| 2972 | "coffeelint.json": "_f_coffeelint", | |
| 2973 | ".coffeelintignore": "_f_coffeelint", | |
| 2974 | ".czrc": "_f_commitizen", | |
| 2975 | ".cz.json": "_f_commitizen", | |
| 2976 | ".commitlintrc": "_f_commitlint", | |
| 2977 | "commitlint.config.js": "_f_commitlint", | |
| 2978 | "commitlint.config.cjs": "_f_commitlint", | |
| 2979 | "commitlint.config.mjs": "_f_commitlint", | |
| 2980 | "commitlint.config.ts": "_f_commitlint", | |
| 2981 | "commitlint.config.cts": "_f_commitlint", | |
| 2982 | ".commitlintrc.json": "_f_commitlint", | |
| 2983 | ".commitlintrc.yaml": "_f_commitlint", | |
| 2984 | ".commitlintrc.yml": "_f_commitlint", | |
| 2985 | ".commitlintrc.js": "_f_commitlint", | |
| 2986 | ".commitlintrc.cjs": "_f_commitlint", | |
| 2987 | ".commitlintrc.mjs": "_f_commitlint", | |
| 2988 | ".commitlintrc.ts": "_f_commitlint", | |
| 2989 | ".commitlintrc.cts": "_f_commitlint", | |
| 2990 | "composer.json": "_f_composer", | |
| 2991 | "composer.lock": "_f_composer", | |
| 2992 | "conanfile.txt": "_f_conan", | |
| 2993 | "conanfile.py": "_f_conan", | |
| 2994 | ".condarc": "_f_conda", | |
| 2995 | ".tool-versions": "_f_config", | |
| 2996 | "copilot-instructions.md": "_f_copilot", | |
| 2997 | "github-copilot.xml": "_f_copilot", | |
| 2998 | ".coverage": "_f_coverage", | |
| 2999 | "lcov.info": "_f_coverage", | |
| 3000 | ".coveralls.yml": "_f_coveralls", | |
| 3001 | "craco.config.ts": "_f_craco", | |
| 3002 | "craco.config.js": "_f_craco", | |
| 3003 | "craco.config.cjs": "_f_craco", | |
| 3004 | ".cracorc.ts": "_f_craco", | |
| 3005 | ".cracorc.js": "_f_craco", | |
| 3006 | ".cracorc": "_f_craco", | |
| 3007 | "crowdin.yml": "_f_crowdin", | |
| 3008 | "cspell.config.json": "_f_cspell", | |
| 3009 | "cspell.config.js": "_f_cspell", | |
| 3010 | "cspell.config.mjs": "_f_cspell", | |
| 3011 | "cspell.config.cjs": "_f_cspell", | |
| 3012 | "cspell.config.yaml": "_f_cspell", | |
| 3013 | "cspell.config.yml": "_f_cspell", | |
| 3014 | "cspell.json": "_f_cspell", | |
| 3015 | "cspell.js": "_f_cspell", | |
| 3016 | "cspell.mjs": "_f_cspell", | |
| 3017 | "cspell.cjs": "_f_cspell", | |
| 3018 | "cspell.yaml": "_f_cspell", | |
| 3019 | "cspell.yml": "_f_cspell", | |
| 3020 | ".cspell.json": "_f_cspell", | |
| 3021 | ".cspell.js": "_f_cspell", | |
| 3022 | ".cspell.mjs": "_f_cspell", | |
| 3023 | ".cspell.cjs": "_f_cspell", | |
| 3024 | ".cspell.yaml": "_f_cspell", | |
| 3025 | ".cspell.yml": "_f_cspell", | |
| 3026 | ".csscomb.json": "_f_csscomb", | |
| 3027 | ".csslintrc": "_f_csslint", | |
| 3028 | ".cursorignore": "_f_cursorrules", | |
| 3029 | ".cursorindexingignore": "_f_cursorrules", | |
| 3030 | ".cursorrules": "_f_cursorrules", | |
| 3031 | ".cvsignore": "_f_cvs", | |
| 3032 | "cypress.json": "_f_cypress", | |
| 3033 | "cypress.env.json": "_f_cypress", | |
| 3034 | "cypress.config.js": "_f_cypress", | |
| 3035 | "cypress.config.ts": "_f_cypress", | |
| 3036 | "cypress.config.cjs": "_f_cypress", | |
| 3037 | "cypress.config.mjs": "_f_cypress", | |
| 3038 | ".boringignore": "_f_darcs", | |
| 3039 | ".pubignore": "_f_dartlang_ignore", | |
| 3040 | "service.datadog.yaml": "_f_datadog", | |
| 3041 | "datadog-ci.json": "_f_datadog", | |
| 3042 | "static-analysis.datadog.yml": "_f_datadog", | |
| 3043 | "static-analysis.datadog.yaml": "_f_datadog", | |
| 3044 | "aptfile": "_f_debian", | |
| 3045 | "deno.json": "_f_deno", | |
| 3046 | "deno.jsonc": "_f_deno", | |
| 3047 | "deno.lock": "_f_deno", | |
| 3048 | ".denoifyrc": "_f_denoify", | |
| 3049 | "denoify.config.json": "_f_denoify", | |
| 3050 | "denoify.config.js": "_f_denoify", | |
| 3051 | "dependabot.yml": "_f_dependabot", | |
| 3052 | "dependabot.yaml": "_f_dependabot", | |
| 3053 | "dependencies.yml": "_f_dependencies", | |
| 3054 | "devcontainer.json": "_f_devcontainer", | |
| 3055 | ".devcontainer.json": "_f_devcontainer", | |
| 3056 | "devvit.json": "_f_devvit", | |
| 3057 | "devvit.dev.json": "_f_devvit", | |
| 3058 | "devvit.prod.json": "_f_devvit", | |
| 3059 | "devvit.test.json": "_f_devvit", | |
| 3060 | "devvit.config.js": "_f_devvit", | |
| 3061 | "devvit.config.cjs": "_f_devvit", | |
| 3062 | "devvit.config.mjs": "_f_devvit", | |
| 3063 | "devvit.config.ts": "_f_devvit", | |
| 3064 | "devvit.config.mts": "_f_devvit", | |
| 3065 | "devvit.config.dev.js": "_f_devvit", | |
| 3066 | "devvit.config.dev.cjs": "_f_devvit", | |
| 3067 | "devvit.config.dev.mjs": "_f_devvit", | |
| 3068 | "devvit.config.dev.ts": "_f_devvit", | |
| 3069 | "devvit.config.dev.mts": "_f_devvit", | |
| 3070 | "devvit.config.prod.js": "_f_devvit", | |
| 3071 | "devvit.config.prod.cjs": "_f_devvit", | |
| 3072 | "devvit.config.prod.mjs": "_f_devvit", | |
| 3073 | "devvit.config.prod.ts": "_f_devvit", | |
| 3074 | "devvit.config.prod.mts": "_f_devvit", | |
| 3075 | "devvit.config.test.js": "_f_devvit", | |
| 3076 | "devvit.config.test.cjs": "_f_devvit", | |
| 3077 | "devvit.config.test.mjs": "_f_devvit", | |
| 3078 | "devvit.config.test.ts": "_f_devvit", | |
| 3079 | "devvit.config.test.mts": "_f_devvit", | |
| 3080 | ".dockerignore": "_f_docker", | |
| 3081 | "compose.yaml": "_f_docker", | |
| 3082 | "compose.yml": "_f_docker", | |
| 3083 | "docker-compose.yaml": "_f_docker", | |
| 3084 | "docker-compose.yml": "_f_docker", | |
| 3085 | "docker-compose.debug.yaml": "_f_docker", | |
| 3086 | "docker-compose.debug.yml": "_f_docker", | |
| 3087 | "docker-compose.ci-build.yaml": "_f_docker", | |
| 3088 | "docker-compose.ci-build.yml": "_f_docker", | |
| 3089 | "docker-compose.override.yaml": "_f_docker", | |
| 3090 | "docker-compose.override.yml": "_f_docker", | |
| 3091 | "docker-compose.vs.debug.yaml": "_f_docker", | |
| 3092 | "docker-compose.vs.debug.yml": "_f_docker", | |
| 3093 | "docker-compose.vs.release.yaml": "_f_docker", | |
| 3094 | "docker-compose.vs.release.yml": "_f_docker", | |
| 3095 | "docker-cloud.yaml": "_f_docker", | |
| 3096 | "docker-cloud.yml": "_f_docker", | |
| 3097 | "docker-compose.test.yml": "_f_dockertest", | |
| 3098 | "docusaurus.config.ts": "_f_docusaurus", | |
| 3099 | "docusaurus.config.mts": "_f_docusaurus", | |
| 3100 | "docusaurus.config.cts": "_f_docusaurus", | |
| 3101 | "docusaurus.config.js": "_f_docusaurus", | |
| 3102 | "docusaurus.config.mjs": "_f_docusaurus", | |
| 3103 | "docusaurus.config.cjs": "_f_docusaurus", | |
| 3104 | ".doczrc": "_f_docz", | |
| 3105 | "docz.js": "_f_docz", | |
| 3106 | "docz.json": "_f_docz", | |
| 3107 | ".docz.js": "_f_docz", | |
| 3108 | ".docz.json": "_f_docz", | |
| 3109 | "doczrc.js": "_f_docz", | |
| 3110 | "doczrc.json": "_f_docz", | |
| 3111 | "docz.config.js": "_f_docz", | |
| 3112 | "docz.config.json": "_f_docz", | |
| 3113 | ".dojorc": "_f_dojo", | |
| 3114 | "doppler.yaml": "_f_doppler", | |
| 3115 | "doppler-template.yaml": "_f_doppler", | |
| 3116 | ".drone.yml": "_f_drone", | |
| 3117 | ".drone.yml.sig": "_f_drone", | |
| 3118 | "dune": "_f_dune", | |
| 3119 | "dune-project": "_f_dune", | |
| 3120 | "dune-workspace": "_f_dune", | |
| 3121 | ".dvc": "_f_dvc", | |
| 3122 | ".earthlyignore": "_f_earthly", | |
| 3123 | "Earthfile": "_f_earthly", | |
| 3124 | "store.config.json": "_f_eas-metadata", | |
| 3125 | ".editorconfig": "_f_editorconfig", | |
| 3126 | "forge.config.js": "_f_electron", | |
| 3127 | "forge.config.ts": "_f_electron", | |
| 3128 | ".eleventy.js": "_f_eleventy", | |
| 3129 | "eleventy.config.js": "_f_eleventy", | |
| 3130 | "eleventy.config.cjs": "_f_eleventy", | |
| 3131 | "eleventy.config.mjs": "_f_eleventy", | |
| 3132 | "elm-package.json": "_f_elm", | |
| 3133 | ".ember-cli": "_f_ember", | |
| 3134 | "emakefile": "_f_erlang", | |
| 3135 | ".emakerfile": "_f_erlang", | |
| 3136 | "esbuild.js": "_f_esbuild", | |
| 3137 | "esbuild.mjs": "_f_esbuild", | |
| 3138 | "esbuild.cjs": "_f_esbuild", | |
| 3139 | "esbuild.ts": "_f_esbuild", | |
| 3140 | "esbuild.config.js": "_f_esbuild", | |
| 3141 | "esbuild.config.mjs": "_f_esbuild", | |
| 3142 | "esbuild.config.cjs": "_f_esbuild", | |
| 3143 | "esbuild.config.ts": "_f_esbuild", | |
| 3144 | ".eslintrc": "_f_eslint", | |
| 3145 | ".eslintignore": "_f_eslint", | |
| 3146 | ".eslintcache": "_f_eslint", | |
| 3147 | "eslint.config.js": "_f_eslint", | |
| 3148 | "eslint.config.mjs": "_f_eslint", | |
| 3149 | "eslint.config.cjs": "_f_eslint", | |
| 3150 | "eslint.config.ts": "_f_eslint", | |
| 3151 | "eslint.config.mts": "_f_eslint", | |
| 3152 | "eslint.config.cts": "_f_eslint", | |
| 3153 | ".eslintrc.js": "_f_eslint", | |
| 3154 | ".eslintrc.mjs": "_f_eslint", | |
| 3155 | ".eslintrc.cjs": "_f_eslint", | |
| 3156 | ".eslintrc.ts": "_f_eslint", | |
| 3157 | ".eslintrc.mts": "_f_eslint", | |
| 3158 | ".eslintrc.cts": "_f_eslint", | |
| 3159 | ".eslintrc.json": "_f_eslint", | |
| 3160 | ".eslintrc.yaml": "_f_eslint", | |
| 3161 | ".eslintrc.yml": "_f_eslint", | |
| 3162 | "app.json": "_f_expo", | |
| 3163 | "app.config.js": "_f_expo", | |
| 3164 | "app.config.json": "_f_expo", | |
| 3165 | "app.config.json5": "_f_expo", | |
| 3166 | ".fantasticonrc": "_f_fantasticon", | |
| 3167 | "fantasticonrc": "_f_fantasticon", | |
| 3168 | ".fantasticonrc.json": "_f_fantasticon", | |
| 3169 | "fantasticonrc.json": "_f_fantasticon", | |
| 3170 | ".fantasticonrc.js": "_f_fantasticon", | |
| 3171 | "fantasticonrc.js": "_f_fantasticon", | |
| 3172 | "fastly.toml": "_f_fastly", | |
| 3173 | ".faunarc": "_f_fauna", | |
| 3174 | "favicon.ico": "_f_favicon", | |
| 3175 | ".firebaserc": "_f_firebase", | |
| 3176 | "firebase.json": "_f_firebasehosting", | |
| 3177 | "storage.rules": "_f_firebasestorage", | |
| 3178 | "firestore.rules": "_f_firestore", | |
| 3179 | "firestore.indexes.json": "_f_firestore", | |
| 3180 | "flareact.config.js": "_f_flareact", | |
| 3181 | ".flooignore": "_f_floobits", | |
| 3182 | ".flowconfig": "_f_flow", | |
| 3183 | ".flutter-plugins": "_f_flutter", | |
| 3184 | ".metadata": "_f_flutter", | |
| 3185 | ".flutter-plugins-dependencies": "_f_flutter", | |
| 3186 | "pubspec.lock": "_f_flutter_package", | |
| 3187 | "pubspec.yaml": "_f_flutter_package", | |
| 3188 | ".packages": "_f_flutter_package", | |
| 3189 | "fly.toml": "_f_flyio", | |
| 3190 | "formkit.config.js": "_f_formkit", | |
| 3191 | "formkit.config.mjs": "_f_formkit", | |
| 3192 | "formkit.config.cjs": "_f_formkit", | |
| 3193 | "formkit.config.ts": "_f_formkit", | |
| 3194 | ".fossaignore": "_f_fossa", | |
| 3195 | "ignore-glob": "_f_fossil", | |
| 3196 | ".front-commerce.js": "_f_frontcommerce", | |
| 3197 | "front-commerce.config.ts": "_f_frontcommerce", | |
| 3198 | "funding.yml": "_f_funding", | |
| 3199 | "fuse.js": "_f_fusebox", | |
| 3200 | "gatsby-browser.js": "_f_gatsby", | |
| 3201 | "gatsby-browser.ts": "_f_gatsby", | |
| 3202 | "gatsby-browser.tsx": "_f_gatsby", | |
| 3203 | "gatsby-ssr.js": "_f_gatsby", | |
| 3204 | "gatsby-ssr.ts": "_f_gatsby", | |
| 3205 | "gatsby-ssr.tsx": "_f_gatsby", | |
| 3206 | "gatsby-config.js": "_f_gatsby", | |
| 3207 | "gatsby-config.ts": "_f_gatsby", | |
| 3208 | "gatsby-config.mjs": "_f_gatsby", | |
| 3209 | "gatsby-node.js": "_f_gatsby", | |
| 3210 | "gatsby-node.ts": "_f_gatsby", | |
| 3211 | "gatsby-node.mjs": "_f_gatsby", | |
| 3212 | ".gcloudignore": "_f_gcloud", | |
| 3213 | "gemini.md": "_f_gemini", | |
| 3214 | "GEMINI.md": "_f_gemini", | |
| 3215 | ".git": "_f_git", | |
| 3216 | ".gitattributes": "_f_git", | |
| 3217 | ".gitconfig": "_f_git", | |
| 3218 | ".gitignore": "_f_git", | |
| 3219 | ".gitmodules": "_f_git", | |
| 3220 | ".gitkeep": "_f_git", | |
| 3221 | ".git-blame-ignore-revs": "_f_git", | |
| 3222 | ".mailmap": "_f_git", | |
| 3223 | ".issuetracker": "_f_git", | |
| 3224 | ".gitlab-ci.yml": "_f_gitlab", | |
| 3225 | ".gitpod.yaml": "_f_gitpod", | |
| 3226 | ".gitpod.yml": "_f_gitpod", | |
| 3227 | "gitpod.yaml": "_f_gitpod", | |
| 3228 | "gitpod.yml": "_f_gitpod", | |
| 3229 | "gleam.toml": "_f_gleamconfig", | |
| 3230 | "glide.yml": "_f_glide", | |
| 3231 | ".glitterrc": "_f_glitter", | |
| 3232 | "go.sum": "_f_go_package", | |
| 3233 | "go.mod": "_f_go_package", | |
| 3234 | "go.work": "_f_go_work", | |
| 3235 | "go.work.sum": "_f_go_work", | |
| 3236 | ".gqlconfig": "_f_graphql", | |
| 3237 | ".graphqlconfig": "_f_graphql_config", | |
| 3238 | ".graphqlconfig.yml": "_f_graphql_config", | |
| 3239 | ".graphqlconfig.yaml": "_f_graphql_config", | |
| 3240 | "greenkeeper.json": "_f_greenkeeper", | |
| 3241 | "gridsome.config.js": "_f_gridsome", | |
| 3242 | "gridsome.config.ts": "_f_gridsome", | |
| 3243 | "gridsome.server.js": "_f_gridsome", | |
| 3244 | "gridsome.server.ts": "_f_gridsome", | |
| 3245 | "gridsome.client.js": "_f_gridsome", | |
| 3246 | "gridsome.client.ts": "_f_gridsome", | |
| 3247 | "gruntfile.js": "_f_grunt", | |
| 3248 | "gruntfile.cjs": "_f_grunt", | |
| 3249 | "gruntfile.coffee": "_f_grunt", | |
| 3250 | "gruntfile.ts": "_f_grunt", | |
| 3251 | "gruntfile.babel.js": "_f_grunt", | |
| 3252 | "gruntfile.babel.cjs": "_f_grunt", | |
| 3253 | "gruntfile.babel.coffee": "_f_grunt", | |
| 3254 | "gruntfile.babel.ts": "_f_grunt", | |
| 3255 | "gulpfile.js": "_f_gulp", | |
| 3256 | "gulpfile.coffee": "_f_gulp", | |
| 3257 | "gulpfile.ts": "_f_gulp", | |
| 3258 | "gulpfile.mjs": "_f_gulp", | |
| 3259 | "gulpfile.esm.js": "_f_gulp", | |
| 3260 | "gulpfile.esm.coffee": "_f_gulp", | |
| 3261 | "gulpfile.esm.ts": "_f_gulp", | |
| 3262 | "gulpfile.esm.mjs": "_f_gulp", | |
| 3263 | "gulpfile.babel.js": "_f_gulp", | |
| 3264 | "gulpfile.babel.coffee": "_f_gulp", | |
| 3265 | "gulpfile.babel.ts": "_f_gulp", | |
| 3266 | "gulpfile.babel.mjs": "_f_gulp", | |
| 3267 | "hardhat.config.cjs": "_f_hardhat", | |
| 3268 | "hardhat.config.js": "_f_hardhat", | |
| 3269 | "hardhat.config.ts": "_f_hardhat", | |
| 3270 | "haxelib.json": "_f_haxe", | |
| 3271 | "checkstyle.json": "_f_haxecheckstyle", | |
| 3272 | ".p4ignore": "_f_helix", | |
| 3273 | "chart.lock": "_f_helm", | |
| 3274 | "chart.yaml": "_f_helm", | |
| 3275 | "histoire.config.ts": "_f_histoire", | |
| 3276 | "histoire.config.js": "_f_histoire", | |
| 3277 | ".histoire.ts": "_f_histoire", | |
| 3278 | ".histoire.js": "_f_histoire", | |
| 3279 | "horusec-config.json": "_f_horusec", | |
| 3280 | ".htmlhintrc": "_f_htmlhint", | |
| 3281 | ".htmlvalidateignore": "_f_htmlvalidate", | |
| 3282 | ".htmlvalidate.js": "_f_htmlvalidate", | |
| 3283 | ".htmlvalidate.cjs": "_f_htmlvalidate", | |
| 3284 | ".htmlvalidate.json": "_f_htmlvalidate", | |
| 3285 | "hugo.json": "_f_hugo", | |
| 3286 | "hugo.toml": "_f_hugo", | |
| 3287 | "hugo.yaml": "_f_hugo", | |
| 3288 | ".hugo_build.lock": "_f_hugo", | |
| 3289 | "humans.txt": "_f_humanstxt", | |
| 3290 | ".huskyrc": "_f_husky", | |
| 3291 | "husky.config.js": "_f_husky", | |
| 3292 | ".huskyrc.js": "_f_husky", | |
| 3293 | ".huskyrc.json": "_f_husky", | |
| 3294 | ".huskyrc.yaml": "_f_husky", | |
| 3295 | ".huskyrc.yml": "_f_husky", | |
| 3296 | "ionic.project": "_f_ionic", | |
| 3297 | "ionic.config.json": "_f_ionic", | |
| 3298 | "jakefile": "_f_jake", | |
| 3299 | "jakefile.js": "_f_jake", | |
| 3300 | "jasmine.json": "_f_jasmine", | |
| 3301 | ".nojekyll": "_f_jekyll", | |
| 3302 | "jest.json": "_f_jest", | |
| 3303 | "jest.config.babel.js": "_f_jest", | |
| 3304 | ".jestrc": "_f_jest", | |
| 3305 | ".jestrc.js": "_f_jest", | |
| 3306 | ".jestrc.json": "_f_jest", | |
| 3307 | "jest.config.js": "_f_jest", | |
| 3308 | "jest.config.cjs": "_f_jest", | |
| 3309 | "jest.config.mjs": "_f_jest", | |
| 3310 | "jest.config.json": "_f_jest", | |
| 3311 | "jest.config.ts": "_f_jest", | |
| 3312 | "jest.config.base.js": "_f_jest", | |
| 3313 | "jest.config.base.cjs": "_f_jest", | |
| 3314 | "jest.config.base.mjs": "_f_jest", | |
| 3315 | "jest.config.base.json": "_f_jest", | |
| 3316 | "jest.config.base.ts": "_f_jest", | |
| 3317 | "jest.config.common.js": "_f_jest", | |
| 3318 | "jest.config.common.cjs": "_f_jest", | |
| 3319 | "jest.config.common.mjs": "_f_jest", | |
| 3320 | "jest.config.common.json": "_f_jest", | |
| 3321 | "jest.config.common.ts": "_f_jest", | |
| 3322 | "jest.config.e2e.js": "_f_jest", | |
| 3323 | "jest.config.e2e.cjs": "_f_jest", | |
| 3324 | "jest.config.e2e.mjs": "_f_jest", | |
| 3325 | "jest.config.e2e.json": "_f_jest", | |
| 3326 | "jest.config.e2e.ts": "_f_jest", | |
| 3327 | "jest.config.int.js": "_f_jest", | |
| 3328 | "jest.config.int.cjs": "_f_jest", | |
| 3329 | "jest.config.int.mjs": "_f_jest", | |
| 3330 | "jest.config.int.json": "_f_jest", | |
| 3331 | "jest.config.int.ts": "_f_jest", | |
| 3332 | "jest.config.integration.js": "_f_jest", | |
| 3333 | "jest.config.integration.cjs": "_f_jest", | |
| 3334 | "jest.config.integration.mjs": "_f_jest", | |
| 3335 | "jest.config.integration.json": "_f_jest", | |
| 3336 | "jest.config.integration.ts": "_f_jest", | |
| 3337 | "jest.config.unit.js": "_f_jest", | |
| 3338 | "jest.config.unit.cjs": "_f_jest", | |
| 3339 | "jest.config.unit.mjs": "_f_jest", | |
| 3340 | "jest.config.unit.json": "_f_jest", | |
| 3341 | "jest.config.unit.ts": "_f_jest", | |
| 3342 | ".jpmignore": "_f_jpm", | |
| 3343 | ".jsbeautifyrc": "_f_jsbeautify", | |
| 3344 | "jsbeautifyrc": "_f_jsbeautify", | |
| 3345 | ".jsbeautify": "_f_jsbeautify", | |
| 3346 | "jsbeautify": "_f_jsbeautify", | |
| 3347 | "jsconfig.json": "_f_jsconfig", | |
| 3348 | ".jscpd.json": "_f_jscpd", | |
| 3349 | "jscpd-report.xml": "_f_jscpd", | |
| 3350 | "jscpd-report.json": "_f_jscpd", | |
| 3351 | "jscpd-report.html": "_f_jscpd", | |
| 3352 | ".jshintrc": "_f_jshint", | |
| 3353 | ".jshintignore": "_f_jshint", | |
| 3354 | "jsr.json": "_f_jsr", | |
| 3355 | "karma.conf.js": "_f_karma", | |
| 3356 | "karma.conf.coffee": "_f_karma", | |
| 3357 | "karma.conf.ts": "_f_karma", | |
| 3358 | ".kitchen.yml": "_f_kitchenci", | |
| 3359 | "kitchen.yml": "_f_kitchenci", | |
| 3360 | ".kiteignore": "_f_kite", | |
| 3361 | "knip.json": "_f_knip", | |
| 3362 | "knip.jsonc": "_f_knip", | |
| 3363 | ".knip.json": "_f_knip", | |
| 3364 | ".knip.jsonc": "_f_knip", | |
| 3365 | "knip.ts": "_f_knip", | |
| 3366 | "knip.js": "_f_knip", | |
| 3367 | "knip.config.ts": "_f_knip", | |
| 3368 | "knip.config.js": "_f_knip", | |
| 3369 | "language-configuration.json": "_f_language_configuration", | |
| 3370 | "layout.html": "_f_layout", | |
| 3371 | "layout.htm": "_f_layout", | |
| 3372 | "lakefile.lean": "_f_leanconfig", | |
| 3373 | "lakefile.toml": "_f_leanconfig", | |
| 3374 | "lake-manifest.json": "_f_leanconfig", | |
| 3375 | "lean-toolchain": "_f_leanconfig", | |
| 3376 | "lefthook.json": "_f_lefthook", | |
| 3377 | "lefthook.toml": "_f_lefthook", | |
| 3378 | "lefthook.yaml": "_f_lefthook", | |
| 3379 | "lefthook.yml": "_f_lefthook", | |
| 3380 | "lefthook-local.json": "_f_lefthook", | |
| 3381 | "lefthook-local.toml": "_f_lefthook", | |
| 3382 | "lefthook-local.yaml": "_f_lefthook", | |
| 3383 | "lefthook-local.yml": "_f_lefthook", | |
| 3384 | ".lefthook.json": "_f_lefthook", | |
| 3385 | ".lefthook.toml": "_f_lefthook", | |
| 3386 | ".lefthook.yaml": "_f_lefthook", | |
| 3387 | ".lefthook.yml": "_f_lefthook", | |
| 3388 | ".lefthook-local.json": "_f_lefthook", | |
| 3389 | ".lefthook-local.toml": "_f_lefthook", | |
| 3390 | ".lefthook-local.yaml": "_f_lefthook", | |
| 3391 | ".lefthook-local.yml": "_f_lefthook", | |
| 3392 | "lerna.json": "_f_lerna", | |
| 3393 | "liara.json": "_f_liara", | |
| 3394 | ".liaraignore": "_f_liara", | |
| 3395 | "licence": "_f_license", | |
| 3396 | "LICENCE": "_f_license", | |
| 3397 | "license": "_f_license", | |
| 3398 | "LICENSE": "_f_license", | |
| 3399 | "copying": "_f_license", | |
| 3400 | "copying.lesser": "_f_license", | |
| 3401 | "LICENSE-MIT": "_f_license", | |
| 3402 | "license-mit": "_f_license", | |
| 3403 | "LICENSE-APACHE": "_f_license", | |
| 3404 | "license-apache": "_f_license", | |
| 3405 | "LICENSE-GPL": "_f_license", | |
| 3406 | "license-gpl": "_f_license", | |
| 3407 | "LICENSE-AGPL": "_f_license", | |
| 3408 | "license-agpl": "_f_license", | |
| 3409 | "LICENCE.md": "_f_license", | |
| 3410 | "LICENCE.txt": "_f_license", | |
| 3411 | "licence.md": "_f_license", | |
| 3412 | "licence.txt": "_f_license", | |
| 3413 | "LICENSE.md": "_f_license", | |
| 3414 | "LICENSE.txt": "_f_license", | |
| 3415 | "license.md": "_f_license", | |
| 3416 | "license.txt": "_f_license", | |
| 3417 | "copying.md": "_f_license", | |
| 3418 | "copying.txt": "_f_license", | |
| 3419 | "copying.lesser.md": "_f_license", | |
| 3420 | "copying.lesser.txt": "_f_license", | |
| 3421 | "LICENSE-MIT.md": "_f_license", | |
| 3422 | "LICENSE-MIT.txt": "_f_license", | |
| 3423 | "license-mit.md": "_f_license", | |
| 3424 | "license-mit.txt": "_f_license", | |
| 3425 | "LICENSE-APACHE.md": "_f_license", | |
| 3426 | "LICENSE-APACHE.txt": "_f_license", | |
| 3427 | "license-apache.md": "_f_license", | |
| 3428 | "license-apache.txt": "_f_license", | |
| 3429 | "LICENSE-GPL.md": "_f_license", | |
| 3430 | "LICENSE-GPL.txt": "_f_license", | |
| 3431 | "license-gpl.md": "_f_license", | |
| 3432 | "license-gpl.txt": "_f_license", | |
| 3433 | "LICENSE-AGPL.md": "_f_license", | |
| 3434 | "LICENSE-AGPL.txt": "_f_license", | |
| 3435 | "license-agpl.md": "_f_license", | |
| 3436 | "license-agpl.txt": "_f_license", | |
| 3437 | ".licrc": "_f_licensebat", | |
| 3438 | "budget.json": "_f_lighthouse", | |
| 3439 | "budgets.json": "_f_lighthouse", | |
| 3440 | ".lighthouserc.cjs": "_f_lighthouse", | |
| 3441 | ".lighthouserc.js": "_f_lighthouse", | |
| 3442 | ".lighthouserc.json": "_f_lighthouse", | |
| 3443 | ".lighthouserc.yaml": "_f_lighthouse", | |
| 3444 | ".lighthouserc.yml": "_f_lighthouse", | |
| 3445 | "include.xml": "_f_lime", | |
| 3446 | ".lintstagedrc": "_f_lintstagedrc", | |
| 3447 | ".lintstagedrc.json": "_f_lintstagedrc", | |
| 3448 | ".lintstagedrc.yaml": "_f_lintstagedrc", | |
| 3449 | ".lintstagedrc.yml": "_f_lintstagedrc", | |
| 3450 | ".lintstagedrc.mjs": "_f_lintstagedrc", | |
| 3451 | ".lintstagedrc.mts": "_f_lintstagedrc", | |
| 3452 | ".lintstagedrc.js": "_f_lintstagedrc", | |
| 3453 | ".lintstagedrc.ts": "_f_lintstagedrc", | |
| 3454 | ".lintstagedrc.cjs": "_f_lintstagedrc", | |
| 3455 | ".lintstagedrc.cts": "_f_lintstagedrc", | |
| 3456 | "lint-staged.config.mjs": "_f_lintstagedrc", | |
| 3457 | "lint-staged.config.mts": "_f_lintstagedrc", | |
| 3458 | "lint-staged.config.js": "_f_lintstagedrc", | |
| 3459 | "lint-staged.config.ts": "_f_lintstagedrc", | |
| 3460 | "lint-staged.config.cjs": "_f_lintstagedrc", | |
| 3461 | "lint-staged.config.cts": "_f_lintstagedrc", | |
| 3462 | "mailing.config.json": "_f_mailing", | |
| 3463 | "manifest": "_f_manifest", | |
| 3464 | "manifest.bak": "_f_manifest_bak", | |
| 3465 | "manifest.skip": "_f_manifest_skip", | |
| 3466 | ".markdownlintrc": "_f_markdownlint", | |
| 3467 | ".markdownlint.json": "_f_markdownlint", | |
| 3468 | ".markdownlint.jsonc": "_f_markdownlint", | |
| 3469 | ".markdownlint.yml": "_f_markdownlint", | |
| 3470 | ".markdownlint.yaml": "_f_markdownlint", | |
| 3471 | ".markdownlint.cjs": "_f_markdownlint", | |
| 3472 | ".markdownlint-cli2.json": "_f_markdownlint", | |
| 3473 | ".markdownlint-cli2.jsonc": "_f_markdownlint", | |
| 3474 | ".markdownlint-cli2.yml": "_f_markdownlint", | |
| 3475 | ".markdownlint-cli2.yaml": "_f_markdownlint", | |
| 3476 | ".markdownlint-cli2.cjs": "_f_markdownlint", | |
| 3477 | ".markdownlintignore": "_f_markdownlint_ignore", | |
| 3478 | ".markuplintrc": "_f_markuplint", | |
| 3479 | ".markuplintrc.json": "_f_markuplint", | |
| 3480 | ".markuplintrc.yaml": "_f_markuplint", | |
| 3481 | ".markuplintrc.yml": "_f_markuplint", | |
| 3482 | ".markuplintrc.js": "_f_markuplint", | |
| 3483 | ".markuplintrc.cjs": "_f_markuplint", | |
| 3484 | ".markuplintrc.mjs": "_f_markuplint", | |
| 3485 | ".markuplintrc.ts": "_f_markuplint", | |
| 3486 | "markuplint.config.json": "_f_markuplint", | |
| 3487 | "markuplint.config.yaml": "_f_markuplint", | |
| 3488 | "markuplint.config.yml": "_f_markuplint", | |
| 3489 | "markuplint.config.js": "_f_markuplint", | |
| 3490 | "markuplint.config.cjs": "_f_markuplint", | |
| 3491 | "markuplint.config.mjs": "_f_markuplint", | |
| 3492 | "markuplint.config.ts": "_f_markuplint", | |
| 3493 | "master.js": "_f_master-co", | |
| 3494 | "master.ts": "_f_master-co", | |
| 3495 | "master.mjs": "_f_master-co", | |
| 3496 | "master.json": "_f_master-co", | |
| 3497 | "master.css.js": "_f_master-co", | |
| 3498 | "master.css.ts": "_f_master-co", | |
| 3499 | "master.css.mjs": "_f_master-co", | |
| 3500 | "master.css.json": "_f_master-co", | |
| 3501 | "maven.config": "_f_maven", | |
| 3502 | "pom.xml": "_f_maven", | |
| 3503 | "extensions.xml": "_f_maven", | |
| 3504 | "settings.xml": "_f_maven", | |
| 3505 | "mcp.json": "_f_mcp", | |
| 3506 | "mdx-components.cjs": "_f_mdx-components", | |
| 3507 | "mdx-components.cts": "_f_mdx-components", | |
| 3508 | "mdx-components.js": "_f_mdx-components", | |
| 3509 | "mdx-components.jsx": "_f_mdx-components", | |
| 3510 | "mdx-components.mjs": "_f_mdx-components", | |
| 3511 | "mdx-components.mts": "_f_mdx-components", | |
| 3512 | "mdx-components.ts": "_f_mdx-components", | |
| 3513 | "mdx-components.tsx": "_f_mdx-components", | |
| 3514 | ".mdxlintrc": "_f_mdxlint", | |
| 3515 | ".mdxlintignore": "_f_mdxlint", | |
| 3516 | ".mdxlintrc.cjs": "_f_mdxlint", | |
| 3517 | ".mdxlintrc.js": "_f_mdxlint", | |
| 3518 | ".mdxlintrc.json": "_f_mdxlint", | |
| 3519 | ".mdxlintrc.mjs": "_f_mdxlint", | |
| 3520 | ".mdxlintrc.yml": "_f_mdxlint", | |
| 3521 | ".mdxlintrc.yaml": "_f_mdxlint", | |
| 3522 | ".hgignore": "_f_mercurial", | |
| 3523 | "mocha.opts": "_f_mocha", | |
| 3524 | ".mocharc.cjs": "_f_mocha", | |
| 3525 | ".mocharc.js": "_f_mocha", | |
| 3526 | ".mocharc.json": "_f_mocha", | |
| 3527 | ".mocharc.jsonc": "_f_mocha", | |
| 3528 | ".mocharc.mjs": "_f_mocha", | |
| 3529 | ".mocharc.yaml": "_f_mocha", | |
| 3530 | ".mocharc.yml": "_f_mocha", | |
| 3531 | "modernizr": "_f_modernizr", | |
| 3532 | "modernizr.js": "_f_modernizr", | |
| 3533 | "modernizrrc.js": "_f_modernizr", | |
| 3534 | ".modernizr.js": "_f_modernizr", | |
| 3535 | ".modernizrrc.js": "_f_modernizr", | |
| 3536 | "moleculer.config.js": "_f_moleculer", | |
| 3537 | "moleculer.config.json": "_f_moleculer", | |
| 3538 | "moleculer.config.ts": "_f_moleculer", | |
| 3539 | ".mtn-ignore": "_f_monotone", | |
| 3540 | "motif.json": "_f_motif", | |
| 3541 | "mypy.ini": "_f_mypy", | |
| 3542 | ".mypy.ini": "_f_mypy", | |
| 3543 | ".nano-staged.js": "_f_nanostaged", | |
| 3544 | "nano-staged.js": "_f_nanostaged", | |
| 3545 | ".nano-staged.cjs": "_f_nanostaged", | |
| 3546 | "nano-staged.cjs": "_f_nanostaged", | |
| 3547 | ".nano-staged.mjs": "_f_nanostaged", | |
| 3548 | "nano-staged.mjs": "_f_nanostaged", | |
| 3549 | ".nano-staged.json": "_f_nanostaged", | |
| 3550 | "nano-staged.json": "_f_nanostaged", | |
| 3551 | ".nanostagedrc": "_f_nanostaged", | |
| 3552 | ".nest-cli.json": "_f_nestjs", | |
| 3553 | "nest-cli.json": "_f_nestjs", | |
| 3554 | "nestconfig.json": "_f_nestjs", | |
| 3555 | ".nestconfig.json": "_f_nestjs", | |
| 3556 | "netlify.toml": "_f_netlify", | |
| 3557 | "next.config.js": "_f_next", | |
| 3558 | "next.config.cjs": "_f_next", | |
| 3559 | "next.config.mjs": "_f_next", | |
| 3560 | "next.config.ts": "_f_next", | |
| 3561 | ".nf": "_f_nextflow", | |
| 3562 | "ng-tailwind.js": "_f_ng_tailwind", | |
| 3563 | "nginx.conf": "_f_nginx", | |
| 3564 | "build.ninja": "_f_ninja", | |
| 3565 | "flake.lock": "_f_nix", | |
| 3566 | ".node-version": "_f_node", | |
| 3567 | ".nvmrc": "_f_node", | |
| 3568 | "node.config.json": "_f_node", | |
| 3569 | "nodemon.json": "_f_nodemon", | |
| 3570 | ".npmignore": "_f_npm", | |
| 3571 | ".npmrc": "_f_npm", | |
| 3572 | ".package-lock.json": "_f_npm", | |
| 3573 | "package.json": "_f_npm", | |
| 3574 | "package-lock.json": "_f_npm", | |
| 3575 | "npm-shrinkwrap.json": "_f_npm", | |
| 3576 | ".npmpackagejsonlintrc": "_f_npmpackagejsonlint", | |
| 3577 | ".npmpackagejsonlintrc.cjs": "_f_npmpackagejsonlint", | |
| 3578 | ".npmpackagejsonlintrc.js": "_f_npmpackagejsonlint", | |
| 3579 | ".npmpackagejsonlintrc.json": "_f_npmpackagejsonlint", | |
| 3580 | ".npmpackagejsonlintrc.yaml": "_f_npmpackagejsonlint", | |
| 3581 | ".npmpackagejsonlintrc.yml": "_f_npmpackagejsonlint", | |
| 3582 | ".npmpackagejsonlintrc.mjs": "_f_npmpackagejsonlint", | |
| 3583 | ".npmpackagejsonlintignore": "_f_npmpackagejsonlint", | |
| 3584 | "npmpackagejsonlint.config.cjs": "_f_npmpackagejsonlint", | |
| 3585 | "npmpackagejsonlint.config.js": "_f_npmpackagejsonlint", | |
| 3586 | "npmpackagejsonlint.config.mjs": "_f_npmpackagejsonlint", | |
| 3587 | ".nsrirc": "_f_nsri", | |
| 3588 | ".nsriignore": "_f_nsri", | |
| 3589 | "nsri.config.js": "_f_nsri", | |
| 3590 | ".nsrirc.js": "_f_nsri", | |
| 3591 | ".nsrirc.json": "_f_nsri", | |
| 3592 | ".nsrirc.yaml": "_f_nsri", | |
| 3593 | ".nsrirc.yml": "_f_nsri", | |
| 3594 | ".integrity.json": "_f_nsri-integrity", | |
| 3595 | ".nuxtignore": "_f_nuxt", | |
| 3596 | ".nuxtrc": "_f_nuxt", | |
| 3597 | "nuxt.config.js": "_f_nuxt", | |
| 3598 | "nuxt.config.ts": "_f_nuxt", | |
| 3599 | "nuxt.config.mjs": "_f_nuxt", | |
| 3600 | "nuxt.js": "_f_nuxt", | |
| 3601 | "nuxt.ts": "_f_nuxt", | |
| 3602 | "nuxt.mjs": "_f_nuxt", | |
| 3603 | "nx.json": "_f_nx", | |
| 3604 | ".nycrc": "_f_nyc", | |
| 3605 | ".nycrc.json": "_f_nyc", | |
| 3606 | ".objidconfig": "_f_objidconfig", | |
| 3607 | ".merlin": "_f_ocaml", | |
| 3608 | ".oxlintignore": "_f_oxc", | |
| 3609 | ".oxlintrc.json": "_f_oxc", | |
| 3610 | ".oxlintrc.jsonc": "_f_oxc", | |
| 3611 | ".oxfmtrc.json": "_f_oxc", | |
| 3612 | ".oxfmtrc.jsonc": "_f_oxc", | |
| 3613 | ".packshiprc": "_f_packship", | |
| 3614 | ".packshiprc.json": "_f_packship", | |
| 3615 | ".packshiprc.js": "_f_packship", | |
| 3616 | ".packshiprc.ts": "_f_packship", | |
| 3617 | "packship.config.js": "_f_packship", | |
| 3618 | "packship.config.ts": "_f_packship", | |
| 3619 | "packship.config.mjs": "_f_packship", | |
| 3620 | "packship.config.mts": "_f_packship", | |
| 3621 | "packship.config.json": "_f_packship", | |
| 3622 | "paket.dependencies": "_f_paket", | |
| 3623 | "paket.lock": "_f_paket", | |
| 3624 | "paket.references": "_f_paket", | |
| 3625 | "paket.template": "_f_paket", | |
| 3626 | "paket.local": "_f_paket", | |
| 3627 | "panda.config.js": "_f_pandacss", | |
| 3628 | "panda.config.mjs": "_f_pandacss", | |
| 3629 | "panda.config.ts": "_f_pandacss", | |
| 3630 | ".pdm-python": "_f_pdm", | |
| 3631 | "pdm.lock": "_f_pdm", | |
| 3632 | "pdm.toml": "_f_pdm", | |
| 3633 | "peeky.config.ts": "_f_peeky", | |
| 3634 | "peeky.config.js": "_f_peeky", | |
| 3635 | "peeky.config.mjs": "_f_peeky", | |
| 3636 | ".php-cs-fixer.php": "_f_phpcsfixer", | |
| 3637 | ".php-cs-fixer.dist.php": "_f_phpcsfixer", | |
| 3638 | ".php_cs": "_f_phpcsfixer", | |
| 3639 | ".php_cs.dist": "_f_phpcsfixer", | |
| 3640 | "phpstan.neon": "_f_phpstan", | |
| 3641 | "phpstan.dist.neon": "_f_phpstan", | |
| 3642 | "phpunit": "_f_phpunit", | |
| 3643 | "phpunit.xml": "_f_phpunit", | |
| 3644 | "phpunit.xml.dist": "_f_phpunit", | |
| 3645 | ".phraseapp.yml": "_f_phraseapp", | |
| 3646 | "pipfile": "_f_pip", | |
| 3647 | "pipfile.lock": "_f_pip", | |
| 3648 | "platformio.ini": "_f_platformio", | |
| 3649 | "playwright.config.js": "_f_playwright", | |
| 3650 | "playwright.config.ts": "_f_playwright", | |
| 3651 | "playwright.config.mts": "_f_playwright", | |
| 3652 | "playwright.config.mjs": "_f_playwright", | |
| 3653 | "playwright.config.cts": "_f_playwright", | |
| 3654 | "playwright.config.cjs": "_f_playwright", | |
| 3655 | "ecosystem.config.js": "_f_pm2", | |
| 3656 | "ecosystem.config.cjs": "_f_pm2", | |
| 3657 | "ecosystem.config.json": "_f_pm2", | |
| 3658 | "ecosystem.config.yaml": "_f_pm2", | |
| 3659 | "ecosystem.config.yml": "_f_pm2", | |
| 3660 | ".pnpmfile.cjs": "_f_pnpm", | |
| 3661 | "pnpmfile.js": "_f_pnpm", | |
| 3662 | "pnpm-lock.yaml": "_f_pnpm", | |
| 3663 | "pnpm-workspace.yaml": "_f_pnpm", | |
| 3664 | "poetry.lock": "_f_poetry", | |
| 3665 | ".postcssrc": "_f_postcssconfig", | |
| 3666 | ".postcssrc.json": "_f_postcssconfig", | |
| 3667 | ".postcssrc.yaml": "_f_postcssconfig", | |
| 3668 | ".postcssrc.yml": "_f_postcssconfig", | |
| 3669 | ".postcssrc.ts": "_f_postcssconfig", | |
| 3670 | ".postcssrc.cts": "_f_postcssconfig", | |
| 3671 | ".postcssrc.mts": "_f_postcssconfig", | |
| 3672 | ".postcssrc.js": "_f_postcssconfig", | |
| 3673 | ".postcssrc.cjs": "_f_postcssconfig", | |
| 3674 | ".postcssrc.mjs": "_f_postcssconfig", | |
| 3675 | "postcss.config.ts": "_f_postcssconfig", | |
| 3676 | "postcss.config.cts": "_f_postcssconfig", | |
| 3677 | "postcss.config.mts": "_f_postcssconfig", | |
| 3678 | "postcss.config.js": "_f_postcssconfig", | |
| 3679 | "postcss.config.cjs": "_f_postcssconfig", | |
| 3680 | "postcss.config.mjs": "_f_postcssconfig", | |
| 3681 | "preact.config.js": "_f_preact", | |
| 3682 | ".pre-commit-config.yaml": "_f_precommit", | |
| 3683 | ".prettierrc": "_f_prettier", | |
| 3684 | ".prettierignore": "_f_prettier", | |
| 3685 | "prettier.config.js": "_f_prettier", | |
| 3686 | "prettier.config.cjs": "_f_prettier", | |
| 3687 | "prettier.config.mjs": "_f_prettier", | |
| 3688 | "prettier.config.ts": "_f_prettier", | |
| 3689 | "prettier.config.coffee": "_f_prettier", | |
| 3690 | ".prettierrc.js": "_f_prettier", | |
| 3691 | ".prettierrc.cjs": "_f_prettier", | |
| 3692 | ".prettierrc.mjs": "_f_prettier", | |
| 3693 | ".prettierrc.json": "_f_prettier", | |
| 3694 | ".prettierrc.json5": "_f_prettier", | |
| 3695 | ".prettierrc.yml": "_f_prettier", | |
| 3696 | ".prettierrc.yaml": "_f_prettier", | |
| 3697 | ".prettierrc.toml": "_f_prettier", | |
| 3698 | "prisma.config.js": "_f_prismaconfig", | |
| 3699 | "prisma.config.ts": "_f_prismaconfig", | |
| 3700 | "procfile": "_f_procfile", | |
| 3701 | "protractor.conf.js": "_f_protractor", | |
| 3702 | "protractor.conf.coffee": "_f_protractor", | |
| 3703 | "protractor.conf.ts": "_f_protractor", | |
| 3704 | "publiccode.yml": "_f_publiccode", | |
| 3705 | ".jade-lintrc": "_f_pug", | |
| 3706 | ".pug-lintrc": "_f_pug", | |
| 3707 | ".jade-lint.json": "_f_pug", | |
| 3708 | ".pug-lintrc.js": "_f_pug", | |
| 3709 | ".pug-lintrc.json": "_f_pug", | |
| 3710 | "Pulumi.yaml": "_f_pulumi", | |
| 3711 | "Pulumi.yml": "_f_pulumi", | |
| 3712 | "Pulumi.dev.yaml": "_f_pulumi", | |
| 3713 | "Pulumi.dev.yml": "_f_pulumi", | |
| 3714 | "Pulumi.prod.yaml": "_f_pulumi", | |
| 3715 | "Pulumi.prod.yml": "_f_pulumi", | |
| 3716 | "purgecss.config.js": "_f_purgecss", | |
| 3717 | ".python-version": "_f_pyenv", | |
| 3718 | "pyproject.toml": "_f_pypi", | |
| 3719 | "pytest.ini": "_f_pytest", | |
| 3720 | ".pytest.ini": "_f_pytest", | |
| 3721 | "pytest.toml": "_f_pytest", | |
| 3722 | ".pytest.toml": "_f_pytest", | |
| 3723 | "py.typed": "_f_pytyped", | |
| 3724 | ".pyup": "_f_pyup", | |
| 3725 | ".pyup.yml": "_f_pyup", | |
| 3726 | "qmldir": "_f_qmldir", | |
| 3727 | "quasar.conf.js": "_f_quasar", | |
| 3728 | "quasar.config.js": "_f_quasar", | |
| 3729 | "quasar.config.mjs": "_f_quasar", | |
| 3730 | "quasar.config.cjs": "_f_quasar", | |
| 3731 | "quasar.config.ts": "_f_quasar", | |
| 3732 | "Rakefile": "_f_rake", | |
| 3733 | "razzle.config.js": "_f_razzle", | |
| 3734 | "react-router.config.js": "_f_reactrouter", | |
| 3735 | "react-router.config.ts": "_f_reactrouter", | |
| 3736 | ".rehyperc": "_f_rehype", | |
| 3737 | ".rehypeignore": "_f_rehype", | |
| 3738 | ".rehyperc.cjs": "_f_rehype", | |
| 3739 | ".rehyperc.js": "_f_rehype", | |
| 3740 | ".rehyperc.json": "_f_rehype", | |
| 3741 | ".rehyperc.mjs": "_f_rehype", | |
| 3742 | ".rehyperc.yml": "_f_rehype", | |
| 3743 | ".rehyperc.yaml": "_f_rehype", | |
| 3744 | ".remarkrc": "_f_remark", | |
| 3745 | ".remarkignore": "_f_remark", | |
| 3746 | ".remarkrc.cjs": "_f_remark", | |
| 3747 | ".remarkrc.js": "_f_remark", | |
| 3748 | ".remarkrc.json": "_f_remark", | |
| 3749 | ".remarkrc.mjs": "_f_remark", | |
| 3750 | ".remarkrc.yml": "_f_remark", | |
| 3751 | ".remarkrc.yaml": "_f_remark", | |
| 3752 | ".renovaterc": "_f_renovate", | |
| 3753 | ".renovaterc.json": "_f_renovate", | |
| 3754 | "renovate.json": "_f_renovate", | |
| 3755 | "renovate.json5": "_f_renovate", | |
| 3756 | ".replit": "_f_replit", | |
| 3757 | "replit.nix": "_f_replit", | |
| 3758 | ".retextrc": "_f_retext", | |
| 3759 | ".retextignore": "_f_retext", | |
| 3760 | ".retextrc.cjs": "_f_retext", | |
| 3761 | ".retextrc.js": "_f_retext", | |
| 3762 | ".retextrc.json": "_f_retext", | |
| 3763 | ".retextrc.mjs": "_f_retext", | |
| 3764 | ".retextrc.yml": "_f_retext", | |
| 3765 | ".retextrc.yaml": "_f_retext", | |
| 3766 | "robots.txt": "_f_robots", | |
| 3767 | "rolldown.config.js": "_f_rolldown", | |
| 3768 | "rolldown.config.cjs": "_f_rolldown", | |
| 3769 | "rolldown.config.mjs": "_f_rolldown", | |
| 3770 | "rolldown.config.coffee": "_f_rolldown", | |
| 3771 | "rolldown.config.ts": "_f_rolldown", | |
| 3772 | "rolldown.config.mts": "_f_rolldown", | |
| 3773 | "rolldown.config.cts": "_f_rolldown", | |
| 3774 | "rolldown.config.common.js": "_f_rolldown", | |
| 3775 | "rolldown.config.common.cjs": "_f_rolldown", | |
| 3776 | "rolldown.config.common.mjs": "_f_rolldown", | |
| 3777 | "rolldown.config.common.coffee": "_f_rolldown", | |
| 3778 | "rolldown.config.common.ts": "_f_rolldown", | |
| 3779 | "rolldown.config.common.mts": "_f_rolldown", | |
| 3780 | "rolldown.config.common.cts": "_f_rolldown", | |
| 3781 | "rolldown.config.dev.js": "_f_rolldown", | |
| 3782 | "rolldown.config.dev.cjs": "_f_rolldown", | |
| 3783 | "rolldown.config.dev.mjs": "_f_rolldown", | |
| 3784 | "rolldown.config.dev.coffee": "_f_rolldown", | |
| 3785 | "rolldown.config.dev.ts": "_f_rolldown", | |
| 3786 | "rolldown.config.dev.mts": "_f_rolldown", | |
| 3787 | "rolldown.config.dev.cts": "_f_rolldown", | |
| 3788 | "rolldown.config.prod.js": "_f_rolldown", | |
| 3789 | "rolldown.config.prod.cjs": "_f_rolldown", | |
| 3790 | "rolldown.config.prod.mjs": "_f_rolldown", | |
| 3791 | "rolldown.config.prod.coffee": "_f_rolldown", | |
| 3792 | "rolldown.config.prod.ts": "_f_rolldown", | |
| 3793 | "rolldown.config.prod.mts": "_f_rolldown", | |
| 3794 | "rolldown.config.prod.cts": "_f_rolldown", | |
| 3795 | "rollup.config.js": "_f_rollup", | |
| 3796 | "rollup.config.cjs": "_f_rollup", | |
| 3797 | "rollup.config.mjs": "_f_rollup", | |
| 3798 | "rollup.config.coffee": "_f_rollup", | |
| 3799 | "rollup.config.ts": "_f_rollup", | |
| 3800 | "rollup.config.common.js": "_f_rollup", | |
| 3801 | "rollup.config.common.cjs": "_f_rollup", | |
| 3802 | "rollup.config.common.mjs": "_f_rollup", | |
| 3803 | "rollup.config.common.coffee": "_f_rollup", | |
| 3804 | "rollup.config.common.ts": "_f_rollup", | |
| 3805 | "rollup.config.dev.js": "_f_rollup", | |
| 3806 | "rollup.config.dev.cjs": "_f_rollup", | |
| 3807 | "rollup.config.dev.mjs": "_f_rollup", | |
| 3808 | "rollup.config.dev.coffee": "_f_rollup", | |
| 3809 | "rollup.config.dev.ts": "_f_rollup", | |
| 3810 | "rollup.config.prod.js": "_f_rollup", | |
| 3811 | "rollup.config.prod.cjs": "_f_rollup", | |
| 3812 | "rollup.config.prod.mjs": "_f_rollup", | |
| 3813 | "rollup.config.prod.coffee": "_f_rollup", | |
| 3814 | "rollup.config.prod.ts": "_f_rollup", | |
| 3815 | "rome.json": "_f_rome", | |
| 3816 | ".rspec": "_f_rspec", | |
| 3817 | ".rubocop.yml": "_f_rubocop", | |
| 3818 | ".rubocop_todo.yml": "_f_rubocop", | |
| 3819 | ".ruby-version": "_f_ruby", | |
| 3820 | "rust-toolchain": "_f_rust_toolchain", | |
| 3821 | "rust-toolchain.toml": "_f_rust_toolchain", | |
| 3822 | ".slshrc": "_f_s-lang", | |
| 3823 | ".isisrc": "_f_s-lang", | |
| 3824 | ".jedrc": "_f_s-lang", | |
| 3825 | ".sailsrc": "_f_sails", | |
| 3826 | ".sapphirerc.json": "_f_sapphire_framework_cli", | |
| 3827 | ".sapphirerc.yml": "_f_sapphire_framework_cli", | |
| 3828 | ".sapphirerc": "_f_sapphire_framework_cli", | |
| 3829 | "seedkit.config.js": "_f_seedkit", | |
| 3830 | "seedkit.config.ts": "_f_seedkit", | |
| 3831 | "seedkit.config.mjs": "_f_seedkit", | |
| 3832 | ".sentryclirc": "_f_sentry", | |
| 3833 | ".sequelizerc": "_f_sequelize", | |
| 3834 | ".sequelizerc.js": "_f_sequelize", | |
| 3835 | ".sequelizerc.json": "_f_sequelize", | |
| 3836 | "serverless.yml": "_f_serverless", | |
| 3837 | "serverless.json": "_f_serverless", | |
| 3838 | "serverless.js": "_f_serverless", | |
| 3839 | "serverless.ts": "_f_serverless", | |
| 3840 | "components.json": "_f_shadcn", | |
| 3841 | ".shellcheckrc": "_f_shellcheck", | |
| 3842 | "Shuttle.toml": "_f_shuttle", | |
| 3843 | "sitemap.xml": "_f_sitemap", | |
| 3844 | "sitemap.rss": "_f_sitemap", | |
| 3845 | "sitemap.txt": "_f_sitemap", | |
| 3846 | "sitemap.atom": "_f_sitemap", | |
| 3847 | "slash-up.config.js": "_f_slashup", | |
| 3848 | "smithery.yaml": "_f_smithery", | |
| 3849 | "snapcraft.yaml": "_f_snapcraft", | |
| 3850 | "snaplet.config.js": "_f_snaplet", | |
| 3851 | "snaplet.config.ts": "_f_snaplet", | |
| 3852 | ".snyk": "_f_snyk", | |
| 3853 | ".solidarity": "_f_solidarity", | |
| 3854 | ".solidarity.json": "_f_solidarity", | |
| 3855 | "spin.toml": "_f_spin", | |
| 3856 | "sst-env.d.ts": "_f_sst", | |
| 3857 | "sst-env.ts": "_f_sst", | |
| 3858 | "sst-env.js": "_f_sst", | |
| 3859 | "sst.config.d.ts": "_f_sst", | |
| 3860 | "sst.config.ts": "_f_sst", | |
| 3861 | "sst.config.js": "_f_sst", | |
| 3862 | ".stackblitzrc": "_f_stackblitz", | |
| 3863 | "stryker.conf.mjs": "_f_stryker", | |
| 3864 | "stryker.conf.cjs": "_f_stryker", | |
| 3865 | "stryker.conf.js": "_f_stryker", | |
| 3866 | "stryker.conf.conf": "_f_stryker", | |
| 3867 | "stryker.conf.json": "_f_stryker", | |
| 3868 | ".stryker.conf.mjs": "_f_stryker", | |
| 3869 | ".stryker.conf.cjs": "_f_stryker", | |
| 3870 | ".stryker.conf.js": "_f_stryker", | |
| 3871 | ".stryker.conf.conf": "_f_stryker", | |
| 3872 | ".stryker.conf.json": "_f_stryker", | |
| 3873 | "stryker.config.mjs": "_f_stryker", | |
| 3874 | "stryker.config.cjs": "_f_stryker", | |
| 3875 | "stryker.config.js": "_f_stryker", | |
| 3876 | "stryker.config.conf": "_f_stryker", | |
| 3877 | "stryker.config.json": "_f_stryker", | |
| 3878 | ".stryker.config.mjs": "_f_stryker", | |
| 3879 | ".stryker.config.cjs": "_f_stryker", | |
| 3880 | ".stryker.config.js": "_f_stryker", | |
| 3881 | ".stryker.config.conf": "_f_stryker", | |
| 3882 | ".stryker.config.json": "_f_stryker", | |
| 3883 | "stryker-config.mjs": "_f_stryker", | |
| 3884 | "stryker-config.cjs": "_f_stryker", | |
| 3885 | "stryker-config.js": "_f_stryker", | |
| 3886 | "stryker-config.conf": "_f_stryker", | |
| 3887 | "stryker-config.json": "_f_stryker", | |
| 3888 | "stryker4s.mjs": "_f_stryker", | |
| 3889 | "stryker4s.cjs": "_f_stryker", | |
| 3890 | "stryker4s.js": "_f_stryker", | |
| 3891 | "stryker4s.conf": "_f_stryker", | |
| 3892 | "stryker4s.json": "_f_stryker", | |
| 3893 | ".stylelintrc": "_f_stylelint", | |
| 3894 | ".stylelintignore": "_f_stylelint", | |
| 3895 | ".stylelintcache": "_f_stylelint", | |
| 3896 | "stylelint.config.js": "_f_stylelint", | |
| 3897 | "stylelint.config.json": "_f_stylelint", | |
| 3898 | "stylelint.config.yaml": "_f_stylelint", | |
| 3899 | "stylelint.config.yml": "_f_stylelint", | |
| 3900 | "stylelint.config.ts": "_f_stylelint", | |
| 3901 | "stylelint.config.cjs": "_f_stylelint", | |
| 3902 | "stylelint.config.mjs": "_f_stylelint", | |
| 3903 | ".stylelintrc.js": "_f_stylelint", | |
| 3904 | ".stylelintrc.json": "_f_stylelint", | |
| 3905 | ".stylelintrc.yaml": "_f_stylelint", | |
| 3906 | ".stylelintrc.yml": "_f_stylelint", | |
| 3907 | ".stylelintrc.ts": "_f_stylelint", | |
| 3908 | ".stylelintrc.cjs": "_f_stylelint", | |
| 3909 | ".stylelintrc.mjs": "_f_stylelint", | |
| 3910 | ".stylish-haskell.yaml": "_f_stylish_haskell", | |
| 3911 | ".svnignore": "_f_subversion", | |
| 3912 | "svelte.config.js": "_f_svelteconfig", | |
| 3913 | "svelte.config.ts": "_f_svelteconfig", | |
| 3914 | "svgo.config.cjs": "_f_svgo", | |
| 3915 | "svgo.config.js": "_f_svgo", | |
| 3916 | "svgo.config.mjs": "_f_svgo", | |
| 3917 | ".swcrc": "_f_swc", | |
| 3918 | "package.pins": "_f_swift", | |
| 3919 | "symfony.lock": "_f_symfony", | |
| 3920 | ".syncpackrc": "_f_syncpack", | |
| 3921 | ".syncpackrc.json": "_f_syncpack", | |
| 3922 | ".syncpackrc.yaml": "_f_syncpack", | |
| 3923 | ".syncpackrc.yml": "_f_syncpack", | |
| 3924 | ".syncpackrc.js": "_f_syncpack", | |
| 3925 | ".syncpackrc.cjs": "_f_syncpack", | |
| 3926 | "syncpack.config.js": "_f_syncpack", | |
| 3927 | "syncpack.config.cjs": "_f_syncpack", | |
| 3928 | "tailwind.js": "_f_tailwind", | |
| 3929 | "tailwind.cjs": "_f_tailwind", | |
| 3930 | "tailwind.coffee": "_f_tailwind", | |
| 3931 | "tailwind.ts": "_f_tailwind", | |
| 3932 | "tailwind.json": "_f_tailwind", | |
| 3933 | "tailwind.mjs": "_f_tailwind", | |
| 3934 | "tailwind.config.js": "_f_tailwind", | |
| 3935 | "tailwind.config.cjs": "_f_tailwind", | |
| 3936 | "tailwind.config.coffee": "_f_tailwind", | |
| 3937 | "tailwind.config.ts": "_f_tailwind", | |
| 3938 | "tailwind.config.json": "_f_tailwind", | |
| 3939 | "tailwind.config.mjs": "_f_tailwind", | |
| 3940 | ".tailwind.js": "_f_tailwind", | |
| 3941 | ".tailwind.cjs": "_f_tailwind", | |
| 3942 | ".tailwind.coffee": "_f_tailwind", | |
| 3943 | ".tailwind.ts": "_f_tailwind", | |
| 3944 | ".tailwind.json": "_f_tailwind", | |
| 3945 | ".tailwind.mjs": "_f_tailwind", | |
| 3946 | ".tailwindrc.js": "_f_tailwind", | |
| 3947 | ".tailwindrc.cjs": "_f_tailwind", | |
| 3948 | ".tailwindrc.coffee": "_f_tailwind", | |
| 3949 | ".tailwindrc.ts": "_f_tailwind", | |
| 3950 | ".tailwindrc.json": "_f_tailwind", | |
| 3951 | ".tailwindrc.mjs": "_f_tailwind", | |
| 3952 | "tamagui.js": "_f_tamagui", | |
| 3953 | "tamagui.mjs": "_f_tamagui", | |
| 3954 | "tamagui.cjs": "_f_tamagui", | |
| 3955 | "tamagui.ts": "_f_tamagui", | |
| 3956 | "tamagui.mts": "_f_tamagui", | |
| 3957 | "tamagui.cts": "_f_tamagui", | |
| 3958 | "tamagui.config.js": "_f_tamagui", | |
| 3959 | "tamagui.config.mjs": "_f_tamagui", | |
| 3960 | "tamagui.config.cjs": "_f_tamagui", | |
| 3961 | "tamagui.config.ts": "_f_tamagui", | |
| 3962 | "tamagui.config.mts": "_f_tamagui", | |
| 3963 | "tamagui.config.cts": "_f_tamagui", | |
| 3964 | ".taplo.toml": "_f_taplo", | |
| 3965 | "taplo.toml": "_f_taplo", | |
| 3966 | "taskfile.yml": "_f_taskfile", | |
| 3967 | "taskfile.yaml": "_f_taskfile", | |
| 3968 | "taskfile.dist.yml": "_f_taskfile", | |
| 3969 | "taskfile.dist.yaml": "_f_taskfile", | |
| 3970 | "tauri.conf.json": "_f_tauri", | |
| 3971 | ".taurignore": "_f_tauri", | |
| 3972 | ".testcaferc.json": "_f_testcafe", | |
| 3973 | "testplane.conf.ts": "_f_testplane", | |
| 3974 | "testplane.conf.js": "_f_testplane", | |
| 3975 | "testplane.conf.cts": "_f_testplane", | |
| 3976 | "testplane.conf.cjs": "_f_testplane", | |
| 3977 | "testplane.config.ts": "_f_testplane", | |
| 3978 | "testplane.config.js": "_f_testplane", | |
| 3979 | "testplane.config.cts": "_f_testplane", | |
| 3980 | "testplane.config.cjs": "_f_testplane", | |
| 3981 | ".tfignore": "_f_tfs", | |
| 3982 | ".tiltignore": "_f_tiltfile", | |
| 3983 | ".tmux.conf": "_f_tmux", | |
| 3984 | "tox.ini": "_f_tox", | |
| 3985 | ".travis.yml": "_f_travis", | |
| 3986 | "truffle-config.js": "_f_truffle", | |
| 3987 | "truffle-config.ts": "_f_truffle", | |
| 3988 | "trunk.yaml": "_f_trunk", | |
| 3989 | "tsconfig.json": "_f_tsconfig", | |
| 3990 | "tsconfig.app.json": "_f_tsconfig", | |
| 3991 | "tsconfig.base.json": "_f_tsconfig", | |
| 3992 | "tsconfig.build.json": "_f_tsconfig", | |
| 3993 | "tsconfig.cjs.json": "_f_tsconfig", | |
| 3994 | "tsconfig.common.json": "_f_tsconfig", | |
| 3995 | "tsconfig.debug.json": "_f_tsconfig", | |
| 3996 | "tsconfig.dev.json": "_f_tsconfig", | |
| 3997 | "tsconfig.development.json": "_f_tsconfig", | |
| 3998 | "tsconfig.e2e.json": "_f_tsconfig", | |
| 3999 | "tsconfig.eslint.json": "_f_tsconfig", | |
| 4000 | "tsconfig.esm.json": "_f_tsconfig", | |
| 4001 | "tsconfig.mjs.json": "_f_tsconfig", | |
| 4002 | "tsconfig.node.json": "_f_tsconfig", | |
| 4003 | "tsconfig.prod.json": "_f_tsconfig", | |
| 4004 | "tsconfig.production.json": "_f_tsconfig", | |
| 4005 | "tsconfig.release.json": "_f_tsconfig", | |
| 4006 | "tsconfig.server.json": "_f_tsconfig", | |
| 4007 | "tsconfig.spec.json": "_f_tsconfig", | |
| 4008 | "tsconfig.staging.json": "_f_tsconfig", | |
| 4009 | "tsconfig.test.json": "_f_tsconfig", | |
| 4010 | "tsconfig.types.json": "_f_tsconfig", | |
| 4011 | "tsconfig.lib.json": "_f_tsconfig", | |
| 4012 | "tsconfig.lib.prod.json": "_f_tsconfig", | |
| 4013 | "tsdoc.json": "_f_tsdoc", | |
| 4014 | "tsdown.config.ts": "_f_tsdown", | |
| 4015 | "tsdown.config.mts": "_f_tsdown", | |
| 4016 | "tsdown.config.cts": "_f_tsdown", | |
| 4017 | "tsdown.config.js": "_f_tsdown", | |
| 4018 | "tsdown.config.mjs": "_f_tsdown", | |
| 4019 | "tsdown.config.cjs": "_f_tsdown", | |
| 4020 | "tsdown.config.json": "_f_tsdown", | |
| 4021 | "tslint.json": "_f_tslint", | |
| 4022 | "tslint.yaml": "_f_tslint", | |
| 4023 | "tslint.yml": "_f_tslint", | |
| 4024 | "turbo.json": "_f_turbo", | |
| 4025 | "turbo.jsonc": "_f_turbo", | |
| 4026 | "microfrontends.json": "_f_turbo", | |
| 4027 | "microfrontends.jsonc": "_f_turbo", | |
| 4028 | "typedoc.cjs": "_f_typedoc", | |
| 4029 | "typedoc.js": "_f_typedoc", | |
| 4030 | "typedoc.json": "_f_typedoc", | |
| 4031 | "typedoc.jsonc": "_f_typedoc", | |
| 4032 | "typedoc.mjs": "_f_typedoc", | |
| 4033 | "typedoc.config.cjs": "_f_typedoc", | |
| 4034 | "typedoc.config.js": "_f_typedoc", | |
| 4035 | "typedoc.config.json": "_f_typedoc", | |
| 4036 | "typedoc.config.jsonc": "_f_typedoc", | |
| 4037 | "typedoc.config.mjs": "_f_typedoc", | |
| 4038 | ".unibeautifyrc": "_f_unibeautify", | |
| 4039 | "unibeautify.config.js": "_f_unibeautify", | |
| 4040 | ".unibeautifyrc.js": "_f_unibeautify", | |
| 4041 | ".unibeautifyrc.json": "_f_unibeautify", | |
| 4042 | ".unibeautifyrc.yaml": "_f_unibeautify", | |
| 4043 | ".unibeautifyrc.yml": "_f_unibeautify", | |
| 4044 | "unlicense": "_f_unlicense", | |
| 4045 | "unlicence": "_f_unlicense", | |
| 4046 | "UNLICENSE": "_f_unlicense", | |
| 4047 | "UNLICENCE": "_f_unlicense", | |
| 4048 | "unlicense.md": "_f_unlicense", | |
| 4049 | "unlicense.txt": "_f_unlicense", | |
| 4050 | "unlicence.md": "_f_unlicense", | |
| 4051 | "unlicence.txt": "_f_unlicense", | |
| 4052 | "UNLICENSE.md": "_f_unlicense", | |
| 4053 | "UNLICENSE.txt": "_f_unlicense", | |
| 4054 | "UNLICENCE.md": "_f_unlicense", | |
| 4055 | "UNLICENCE.txt": "_f_unlicense", | |
| 4056 | "uno.config.js": "_f_unocss", | |
| 4057 | "uno.config.mjs": "_f_unocss", | |
| 4058 | "uno.config.ts": "_f_unocss", | |
| 4059 | "uno.config.mts": "_f_unocss", | |
| 4060 | "unocss.config.js": "_f_unocss", | |
| 4061 | "unocss.config.mjs": "_f_unocss", | |
| 4062 | "unocss.config.ts": "_f_unocss", | |
| 4063 | "unocss.config.mts": "_f_unocss", | |
| 4064 | "uv.lock": "_f_uv", | |
| 4065 | "vagrantfile": "_f_vagrant", | |
| 4066 | "vapor.yml": "_f_vapor", | |
| 4067 | "now.json": "_f_vercel", | |
| 4068 | ".nowignore": "_f_vercel", | |
| 4069 | "vercel.json": "_f_vercel", | |
| 4070 | ".vercelignore": "_f_vercel", | |
| 4071 | ".vimrc": "_f_vim", | |
| 4072 | ".gvimrc": "_f_vim", | |
| 4073 | "vite.config.js": "_f_vite", | |
| 4074 | "vite.config.mjs": "_f_vite", | |
| 4075 | "vite.config.ts": "_f_vite", | |
| 4076 | "vite.config.cjs": "_f_vite", | |
| 4077 | "vite.config.mts": "_f_vite", | |
| 4078 | "vite.config.cts": "_f_vite", | |
| 4079 | "vitest.config.js": "_f_vitest", | |
| 4080 | "vitest.config.mjs": "_f_vitest", | |
| 4081 | "vitest.config.ts": "_f_vitest", | |
| 4082 | "vitest.config.cjs": "_f_vitest", | |
| 4083 | "vitest.config.mts": "_f_vitest", | |
| 4084 | "vitest.config.cts": "_f_vitest", | |
| 4085 | "vitest.workspace.json": "_f_vitest", | |
| 4086 | "vitest.workspace.js": "_f_vitest", | |
| 4087 | "vitest.workspace.mjs": "_f_vitest", | |
| 4088 | "vitest.workspace.ts": "_f_vitest", | |
| 4089 | "vitest.workspace.cjs": "_f_vitest", | |
| 4090 | "vitest.workspace.mts": "_f_vitest", | |
| 4091 | "vitest.workspace.cts": "_f_vitest", | |
| 4092 | "vitest.projects.json": "_f_vitest", | |
| 4093 | "vitest.projects.js": "_f_vitest", | |
| 4094 | "vitest.projects.mjs": "_f_vitest", | |
| 4095 | "vitest.projects.ts": "_f_vitest", | |
| 4096 | "vitest.projects.cjs": "_f_vitest", | |
| 4097 | "vitest.projects.mts": "_f_vitest", | |
| 4098 | "vitest.projects.cts": "_f_vitest", | |
| 4099 | ".vscodeignore": "_f_vscode", | |
| 4100 | "launch.json": "_f_vscode", | |
| 4101 | "tasks.json": "_f_vscode", | |
| 4102 | "vscodeignore.json": "_f_vscode", | |
| 4103 | ".vscode-test.js": "_f_vscode_test", | |
| 4104 | ".vscode-test.json": "_f_vscode_test", | |
| 4105 | ".vscode-test.mjs": "_f_vscode_test", | |
| 4106 | ".vuerc": "_f_vueconfig", | |
| 4107 | "vue.config.js": "_f_vueconfig", | |
| 4108 | "vue.config.cjs": "_f_vueconfig", | |
| 4109 | "vue.config.mjs": "_f_vueconfig", | |
| 4110 | "wallaby.json": "_f_wallaby", | |
| 4111 | "wallaby.js": "_f_wallaby", | |
| 4112 | "wallaby.ts": "_f_wallaby", | |
| 4113 | "wallaby.coffee": "_f_wallaby", | |
| 4114 | "wallaby.conf.json": "_f_wallaby", | |
| 4115 | "wallaby.conf.js": "_f_wallaby", | |
| 4116 | "wallaby.conf.ts": "_f_wallaby", | |
| 4117 | "wallaby.conf.coffee": "_f_wallaby", | |
| 4118 | ".wallaby.json": "_f_wallaby", | |
| 4119 | ".wallaby.js": "_f_wallaby", | |
| 4120 | ".wallaby.ts": "_f_wallaby", | |
| 4121 | ".wallaby.coffee": "_f_wallaby", | |
| 4122 | ".wallaby.conf.json": "_f_wallaby", | |
| 4123 | ".wallaby.conf.js": "_f_wallaby", | |
| 4124 | ".wallaby.conf.ts": "_f_wallaby", | |
| 4125 | ".wallaby.conf.coffee": "_f_wallaby", | |
| 4126 | "wally.toml": "_f_wally", | |
| 4127 | "wally.lock": "_f_wally", | |
| 4128 | ".watchmanconfig": "_f_watchmanconfig", | |
| 4129 | "wdio.json": "_f_wdio", | |
| 4130 | "wdio.js": "_f_wdio", | |
| 4131 | "wdio.mjs": "_f_wdio", | |
| 4132 | "wdio.cjs": "_f_wdio", | |
| 4133 | "wdio.ts": "_f_wdio", | |
| 4134 | "wdio.mts": "_f_wdio", | |
| 4135 | "wdio.cts": "_f_wdio", | |
| 4136 | "wdio.conf.json": "_f_wdio", | |
| 4137 | "wdio.conf.js": "_f_wdio", | |
| 4138 | "wdio.conf.mjs": "_f_wdio", | |
| 4139 | "wdio.conf.cjs": "_f_wdio", | |
| 4140 | "wdio.conf.ts": "_f_wdio", | |
| 4141 | "wdio.conf.mts": "_f_wdio", | |
| 4142 | "wdio.conf.cts": "_f_wdio", | |
| 4143 | ".weblate": "_f_weblate", | |
| 4144 | ".weblate.ini": "_f_weblate", | |
| 4145 | "weblate": "_f_weblate", | |
| 4146 | "weblate.ini": "_f_weblate", | |
| 4147 | "webpack.base.conf.js": "_f_webpack", | |
| 4148 | "webpack.base.conf.cjs": "_f_webpack", | |
| 4149 | "webpack.base.conf.mjs": "_f_webpack", | |
| 4150 | "webpack.base.conf.coffee": "_f_webpack", | |
| 4151 | "webpack.base.conf.ts": "_f_webpack", | |
| 4152 | "webpack.common.js": "_f_webpack", | |
| 4153 | "webpack.common.cjs": "_f_webpack", | |
| 4154 | "webpack.common.mjs": "_f_webpack", | |
| 4155 | "webpack.common.coffee": "_f_webpack", | |
| 4156 | "webpack.common.ts": "_f_webpack", | |
| 4157 | "webpack.config.js": "_f_webpack", | |
| 4158 | "webpack.config.cjs": "_f_webpack", | |
| 4159 | "webpack.config.mjs": "_f_webpack", | |
| 4160 | "webpack.config.coffee": "_f_webpack", | |
| 4161 | "webpack.config.ts": "_f_webpack", | |
| 4162 | "webpack.config.base.js": "_f_webpack", | |
| 4163 | "webpack.config.base.cjs": "_f_webpack", | |
| 4164 | "webpack.config.base.mjs": "_f_webpack", | |
| 4165 | "webpack.config.base.coffee": "_f_webpack", | |
| 4166 | "webpack.config.base.ts": "_f_webpack", | |
| 4167 | "webpack.config.build.js": "_f_webpack", | |
| 4168 | "webpack.config.build.cjs": "_f_webpack", | |
| 4169 | "webpack.config.build.mjs": "_f_webpack", | |
| 4170 | "webpack.config.build.coffee": "_f_webpack", | |
| 4171 | "webpack.config.build.ts": "_f_webpack", | |
| 4172 | "webpack.config.common.js": "_f_webpack", | |
| 4173 | "webpack.config.common.cjs": "_f_webpack", | |
| 4174 | "webpack.config.common.mjs": "_f_webpack", | |
| 4175 | "webpack.config.common.coffee": "_f_webpack", | |
| 4176 | "webpack.config.common.ts": "_f_webpack", | |
| 4177 | "webpack.config.dev.js": "_f_webpack", | |
| 4178 | "webpack.config.dev.cjs": "_f_webpack", | |
| 4179 | "webpack.config.dev.mjs": "_f_webpack", | |
| 4180 | "webpack.config.dev.coffee": "_f_webpack", | |
| 4181 | "webpack.config.dev.ts": "_f_webpack", | |
| 4182 | "webpack.config.development.js": "_f_webpack", | |
| 4183 | "webpack.config.development.cjs": "_f_webpack", | |
| 4184 | "webpack.config.development.mjs": "_f_webpack", | |
| 4185 | "webpack.config.development.coffee": "_f_webpack", | |
| 4186 | "webpack.config.development.ts": "_f_webpack", | |
| 4187 | "webpack.config.staging.js": "_f_webpack", | |
| 4188 | "webpack.config.staging.cjs": "_f_webpack", | |
| 4189 | "webpack.config.staging.mjs": "_f_webpack", | |
| 4190 | "webpack.config.staging.coffee": "_f_webpack", | |
| 4191 | "webpack.config.staging.ts": "_f_webpack", | |
| 4192 | "webpack.config.test.js": "_f_webpack", | |
| 4193 | "webpack.config.test.cjs": "_f_webpack", | |
| 4194 | "webpack.config.test.mjs": "_f_webpack", | |
| 4195 | "webpack.config.test.coffee": "_f_webpack", | |
| 4196 | "webpack.config.test.ts": "_f_webpack", | |
| 4197 | "webpack.config.prod.js": "_f_webpack", | |
| 4198 | "webpack.config.prod.cjs": "_f_webpack", | |
| 4199 | "webpack.config.prod.mjs": "_f_webpack", | |
| 4200 | "webpack.config.prod.coffee": "_f_webpack", | |
| 4201 | "webpack.config.prod.ts": "_f_webpack", | |
| 4202 | "webpack.config.production.js": "_f_webpack", | |
| 4203 | "webpack.config.production.cjs": "_f_webpack", | |
| 4204 | "webpack.config.production.mjs": "_f_webpack", | |
| 4205 | "webpack.config.production.coffee": "_f_webpack", | |
| 4206 | "webpack.config.production.ts": "_f_webpack", | |
| 4207 | "webpack.config.babel.js": "_f_webpack", | |
| 4208 | "webpack.config.babel.cjs": "_f_webpack", | |
| 4209 | "webpack.config.babel.mjs": "_f_webpack", | |
| 4210 | "webpack.config.babel.coffee": "_f_webpack", | |
| 4211 | "webpack.config.babel.ts": "_f_webpack", | |
| 4212 | "webpack.config.base.babel.js": "_f_webpack", | |
| 4213 | "webpack.config.base.babel.cjs": "_f_webpack", | |
| 4214 | "webpack.config.base.babel.mjs": "_f_webpack", | |
| 4215 | "webpack.config.base.babel.coffee": "_f_webpack", | |
| 4216 | "webpack.config.base.babel.ts": "_f_webpack", | |
| 4217 | "webpack.config.common.babel.js": "_f_webpack", | |
| 4218 | "webpack.config.common.babel.cjs": "_f_webpack", | |
| 4219 | "webpack.config.common.babel.mjs": "_f_webpack", | |
| 4220 | "webpack.config.common.babel.coffee": "_f_webpack", | |
| 4221 | "webpack.config.common.babel.ts": "_f_webpack", | |
| 4222 | "webpack.config.dev.babel.js": "_f_webpack", | |
| 4223 | "webpack.config.dev.babel.cjs": "_f_webpack", | |
| 4224 | "webpack.config.dev.babel.mjs": "_f_webpack", | |
| 4225 | "webpack.config.dev.babel.coffee": "_f_webpack", | |
| 4226 | "webpack.config.dev.babel.ts": "_f_webpack", | |
| 4227 | "webpack.config.development.babel.js": "_f_webpack", | |
| 4228 | "webpack.config.development.babel.cjs": "_f_webpack", | |
| 4229 | "webpack.config.development.babel.mjs": "_f_webpack", | |
| 4230 | "webpack.config.development.babel.coffee": "_f_webpack", | |
| 4231 | "webpack.config.development.babel.ts": "_f_webpack", | |
| 4232 | "webpack.config.staging.babel.js": "_f_webpack", | |
| 4233 | "webpack.config.staging.babel.cjs": "_f_webpack", | |
| 4234 | "webpack.config.staging.babel.mjs": "_f_webpack", | |
| 4235 | "webpack.config.staging.babel.coffee": "_f_webpack", | |
| 4236 | "webpack.config.staging.babel.ts": "_f_webpack", | |
| 4237 | "webpack.config.test.babel.js": "_f_webpack", | |
| 4238 | "webpack.config.test.babel.cjs": "_f_webpack", | |
| 4239 | "webpack.config.test.babel.mjs": "_f_webpack", | |
| 4240 | "webpack.config.test.babel.coffee": "_f_webpack", | |
| 4241 | "webpack.config.test.babel.ts": "_f_webpack", | |
| 4242 | "webpack.config.prod.babel.js": "_f_webpack", | |
| 4243 | "webpack.config.prod.babel.cjs": "_f_webpack", | |
| 4244 | "webpack.config.prod.babel.mjs": "_f_webpack", | |
| 4245 | "webpack.config.prod.babel.coffee": "_f_webpack", | |
| 4246 | "webpack.config.prod.babel.ts": "_f_webpack", | |
| 4247 | "webpack.config.production.babel.js": "_f_webpack", | |
| 4248 | "webpack.config.production.babel.cjs": "_f_webpack", | |
| 4249 | "webpack.config.production.babel.mjs": "_f_webpack", | |
| 4250 | "webpack.config.production.babel.coffee": "_f_webpack", | |
| 4251 | "webpack.config.production.babel.ts": "_f_webpack", | |
| 4252 | "webpack.dev.js": "_f_webpack", | |
| 4253 | "webpack.dev.cjs": "_f_webpack", | |
| 4254 | "webpack.dev.mjs": "_f_webpack", | |
| 4255 | "webpack.dev.coffee": "_f_webpack", | |
| 4256 | "webpack.dev.ts": "_f_webpack", | |
| 4257 | "webpack.dev.conf.js": "_f_webpack", | |
| 4258 | "webpack.dev.conf.cjs": "_f_webpack", | |
| 4259 | "webpack.dev.conf.mjs": "_f_webpack", | |
| 4260 | "webpack.dev.conf.coffee": "_f_webpack", | |
| 4261 | "webpack.dev.conf.ts": "_f_webpack", | |
| 4262 | "webpack.local.js": "_f_webpack", | |
| 4263 | "webpack.local.cjs": "_f_webpack", | |
| 4264 | "webpack.local.mjs": "_f_webpack", | |
| 4265 | "webpack.local.coffee": "_f_webpack", | |
| 4266 | "webpack.local.ts": "_f_webpack", | |
| 4267 | "webpack.prod.js": "_f_webpack", | |
| 4268 | "webpack.prod.cjs": "_f_webpack", | |
| 4269 | "webpack.prod.mjs": "_f_webpack", | |
| 4270 | "webpack.prod.coffee": "_f_webpack", | |
| 4271 | "webpack.prod.ts": "_f_webpack", | |
| 4272 | "webpack.prod.conf.js": "_f_webpack", | |
| 4273 | "webpack.prod.conf.cjs": "_f_webpack", | |
| 4274 | "webpack.prod.conf.mjs": "_f_webpack", | |
| 4275 | "webpack.prod.conf.coffee": "_f_webpack", | |
| 4276 | "webpack.prod.conf.ts": "_f_webpack", | |
| 4277 | "webpack.main.config.js": "_f_webpack", | |
| 4278 | "webpack.main.config.cjs": "_f_webpack", | |
| 4279 | "webpack.main.config.mjs": "_f_webpack", | |
| 4280 | "webpack.main.config.coffee": "_f_webpack", | |
| 4281 | "webpack.main.config.ts": "_f_webpack", | |
| 4282 | "webpack.mix.js": "_f_webpack", | |
| 4283 | "webpack.mix.cjs": "_f_webpack", | |
| 4284 | "webpack.mix.mjs": "_f_webpack", | |
| 4285 | "webpack.mix.coffee": "_f_webpack", | |
| 4286 | "webpack.mix.ts": "_f_webpack", | |
| 4287 | "webpack.plugins.js": "_f_webpack", | |
| 4288 | "webpack.plugins.cjs": "_f_webpack", | |
| 4289 | "webpack.plugins.mjs": "_f_webpack", | |
| 4290 | "webpack.plugins.coffee": "_f_webpack", | |
| 4291 | "webpack.plugins.ts": "_f_webpack", | |
| 4292 | "webpack.renderer.config.js": "_f_webpack", | |
| 4293 | "webpack.renderer.config.cjs": "_f_webpack", | |
| 4294 | "webpack.renderer.config.mjs": "_f_webpack", | |
| 4295 | "webpack.renderer.config.coffee": "_f_webpack", | |
| 4296 | "webpack.renderer.config.ts": "_f_webpack", | |
| 4297 | "webpack.rules.js": "_f_webpack", | |
| 4298 | "webpack.rules.cjs": "_f_webpack", | |
| 4299 | "webpack.rules.mjs": "_f_webpack", | |
| 4300 | "webpack.rules.coffee": "_f_webpack", | |
| 4301 | "webpack.rules.ts": "_f_webpack", | |
| 4302 | "webpack.test.conf.js": "_f_webpack", | |
| 4303 | "webpack.test.conf.cjs": "_f_webpack", | |
| 4304 | "webpack.test.conf.mjs": "_f_webpack", | |
| 4305 | "webpack.test.conf.coffee": "_f_webpack", | |
| 4306 | "webpack.test.conf.ts": "_f_webpack", | |
| 4307 | "wercker.yml": "_f_wercker", | |
| 4308 | "windi.config.ts": "_f_windi", | |
| 4309 | "windi.config.js": "_f_windi", | |
| 4310 | "wpml-config.xml": "_f_wpml", | |
| 4311 | "wxt.config.js": "_f_wxt", | |
| 4312 | "wxt.config.cjs": "_f_wxt", | |
| 4313 | "wxt.config.mjs": "_f_wxt", | |
| 4314 | "wxt.config.ts": "_f_wxt", | |
| 4315 | "wxt.config.cts": "_f_wxt", | |
| 4316 | "wxt.config.mts": "_f_wxt", | |
| 4317 | ".xo-config": "_f_xo", | |
| 4318 | "xo.config.js": "_f_xo", | |
| 4319 | "xo.config.cjs": "_f_xo", | |
| 4320 | "xo.config.mjs": "_f_xo", | |
| 4321 | "xo.config.ts": "_f_xo", | |
| 4322 | "xo.config.cts": "_f_xo", | |
| 4323 | "xo.config.mts": "_f_xo", | |
| 4324 | ".xo-config.js": "_f_xo", | |
| 4325 | ".xo-config.json": "_f_xo", | |
| 4326 | ".xo-config.cjs": "_f_xo", | |
| 4327 | ".xcompose": "_f_xorg", | |
| 4328 | ".yamllint": "_f_yamllint", | |
| 4329 | ".yaspellerrc": "_f_yandex", | |
| 4330 | ".yaspeller.json": "_f_yandex", | |
| 4331 | "yarn.lock": "_f_yarn", | |
| 4332 | ".yarnrc": "_f_yarn", | |
| 4333 | ".yarnrc.yml": "_f_yarn", | |
| 4334 | ".yarnclean": "_f_yarn", | |
| 4335 | ".yarn-integrity": "_f_yarn", | |
| 4336 | ".yarn-metadata.json": "_f_yarn", | |
| 4337 | ".yarn-state.yml": "_f_yarn", | |
| 4338 | ".yarnignore": "_f_yarn", | |
| 4339 | ".yo-rc.json": "_f_yeoman" | |
| 4340 | }, | |
| 4341 | "languageIds": { | |
| 4342 | "actionscript": "_f_actionscript", | |
| 4343 | "ada": "_f_ada", | |
| 4344 | "advpl": "_f_advpl", | |
| 4345 | "affectscript": "_f_affectscript", | |
| 4346 | "agda": "_f_agda", | |
| 4347 | "lagda-md": "_f_agda", | |
| 4348 | "lagda-rst": "_f_agda", | |
| 4349 | "lagda-tex": "_f_agda", | |
| 4350 | "al": "_f_al", | |
| 4351 | "alloy": "_f_alloy", | |
| 4352 | "ansible": "_f_ansible", | |
| 4353 | "antlr": "_f_antlr", | |
| 4354 | "anyscript": "_f_anyscript", | |
| 4355 | "apacheconf": "_f_apache", | |
| 4356 | "apex": "_f_apex", | |
| 4357 | "apiblueprint": "_f_apib", | |
| 4358 | "apl": "_f_apl", | |
| 4359 | "applescript": "_f_applescript", | |
| 4360 | "asciidoc": "_f_asciidoc", | |
| 4361 | "asp": "_f_asp", | |
| 4362 | "asp (html)": "_f_asp", | |
| 4363 | "arm": "_f_assembly", | |
| 4364 | "asm": "_f_assembly", | |
| 4365 | "asm-intel-x86-generic": "_f_assembly", | |
| 4366 | "platformio-debug.asm": "_f_platformio", | |
| 4367 | "asm-collection": "_f_assembly", | |
| 4368 | "astro": "_f_astro", | |
| 4369 | "ats": "_f_ats", | |
| 4370 | "ahk": "_f_autohotkey", | |
| 4371 | "ahk2": "_f_autohotkey", | |
| 4372 | "autoit": "_f_autoit", | |
| 4373 | "avro": "_f_avro", | |
| 4374 | "azcli": "_f_azure", | |
| 4375 | "azure-pipelines": "_f_azurepipelines", | |
| 4376 | "ballerina": "_f_ballerina", | |
| 4377 | "bat": "_f_bat", | |
| 4378 | "bats": "_f_bats", | |
| 4379 | "bazel": "_f_bazel", | |
| 4380 | "bazelrc": "_f_bazel", | |
| 4381 | "starlark": "_f_bazel", | |
| 4382 | "befunge": "_f_befunge", | |
| 4383 | "befunge98": "_f_befunge", | |
| 4384 | "bicep": "_f_bicep", | |
| 4385 | "biml": "_f_biml", | |
| 4386 | "code-text-binary": "_f_binary", | |
| 4387 | "biome_syntax_tree": "_f_biome", | |
| 4388 | "blade": "_f_blade", | |
| 4389 | "laravel-blade": "_f_blade", | |
| 4390 | "blitzbasic": "_f_blitzbasic", | |
| 4391 | "bolt": "_f_bolt", | |
| 4392 | "bosque": "_f_bosque", | |
| 4393 | "browserslist": "_f_browserslist", | |
| 4394 | "bruno": "_f_bruno", | |
| 4395 | "buf": "_f_buf", | |
| 4396 | "buf-gen": "_f_buf", | |
| 4397 | "bun.lockb": "_f_bun", | |
| 4398 | "c": "_f_c", | |
| 4399 | "c3": "_f_c3", | |
| 4400 | "c-al": "_f_c_al", | |
| 4401 | "cabal": "_f_cabal", | |
| 4402 | "caddyfile": "_f_caddy", | |
| 4403 | "capnp": "_f_capnp", | |
| 4404 | "capnproto": "_f_capnp", | |
| 4405 | "casc": "_f_casc", | |
| 4406 | "cddl": "_f_cddl", | |
| 4407 | "ceylon": "_f_ceylon", | |
| 4408 | "cfml": "_f_cf", | |
| 4409 | "lang-cfml": "_f_cf", | |
| 4410 | "cfc": "_f_cfc", | |
| 4411 | "cfmhtml": "_f_cfm", | |
| 4412 | "cookbook": "_f_chef_cookbook", | |
| 4413 | "circom": "_f_circom", | |
| 4414 | "clojure": "_f_clojure", | |
| 4415 | "clojurescript": "_f_clojurescript", | |
| 4416 | "manifest-yaml": "_f_cloudfoundry", | |
| 4417 | "cmake": "_f_cmake", | |
| 4418 | "cmake-cache": "_f_cmake", | |
| 4419 | "cobol": "_f_cobol", | |
| 4420 | "ql": "_f_codeql", | |
| 4421 | "coffeescript": "_f_coffeescript", | |
| 4422 | "java-properties": "_f_config", | |
| 4423 | "properties": "_f_config", | |
| 4424 | "spring-boot-properties": "_f_config", | |
| 4425 | "confluence": "_f_confluence", | |
| 4426 | "instructions": "_f_copilot", | |
| 4427 | "prompt": "_f_copilot", | |
| 4428 | "cpp": "_f_cpp", | |
| 4429 | "crystal": "_f_crystal", | |
| 4430 | "csharp": "_f_csharp", | |
| 4431 | "css": "_f_css", | |
| 4432 | "cucumber": "_f_cucumber", | |
| 4433 | "feature": "_f_cucumber", | |
| 4434 | "cuda": "_f_cuda", | |
| 4435 | "cuda-cpp": "_f_cuda", | |
| 4436 | "cython": "_f_cython", | |
| 4437 | "dal": "_f_dal", | |
| 4438 | "dart": "_f_dartlang", | |
| 4439 | "debian-changelog": "_f_debian", | |
| 4440 | "debian-control": "_f_debian", | |
| 4441 | "debian-copyright": "_f_debian", | |
| 4442 | "debian-upstream-metadata": "_f_debian", | |
| 4443 | "pascal": "_f_delphi", | |
| 4444 | "objectpascal": "_f_delphi", | |
| 4445 | "dhall": "_f_dhall", | |
| 4446 | "diff": "_f_diff", | |
| 4447 | "django-html": "_f_django", | |
| 4448 | "django-txt": "_f_django", | |
| 4449 | "d": "_f_dlang", | |
| 4450 | "dscript": "_f_dlang", | |
| 4451 | "dml": "_f_dlang", | |
| 4452 | "diet": "_f_dlang", | |
| 4453 | "dockerbake": "_f_docker", | |
| 4454 | "dockercompose": "_f_docker", | |
| 4455 | "dockerfile": "_f_docker", | |
| 4456 | "dotenv": "_f_dotenv", | |
| 4457 | "env": "_f_dotenv", | |
| 4458 | "dotjs": "_f_dotjs", | |
| 4459 | "doxygen": "_f_doxygen", | |
| 4460 | "drawio": "_f_drawio", | |
| 4461 | "drools": "_f_drools", | |
| 4462 | "dtd": "_f_dtd", | |
| 4463 | "dustjs": "_f_dustjs", | |
| 4464 | "dylan": "_f_dylan", | |
| 4465 | "dylan-lid": "_f_dylan", | |
| 4466 | "earthfile": "_f_earthly", | |
| 4467 | "edge": "_f_edge", | |
| 4468 | "editorconfig": "_f_editorconfig", | |
| 4469 | "eex": "_f_eex", | |
| 4470 | "html-eex": "_f_eex", | |
| 4471 | "es": "_f_elastic", | |
| 4472 | "elixir": "_f_elixir", | |
| 4473 | "elm": "_f_elm", | |
| 4474 | "erb": "_f_erb", | |
| 4475 | "html.erb": "_f_erb", | |
| 4476 | "erlang": "_f_erlang", | |
| 4477 | "esphome": "_f_esphome", | |
| 4478 | "excel": "_f_excel", | |
| 4479 | "falcon": "_f_falcon", | |
| 4480 | "fql": "_f_fauna", | |
| 4481 | "fortran": "_f_fortran", | |
| 4482 | "fortran-modern": "_f_fortran", | |
| 4483 | "FortranFreeForm": "_f_fortran", | |
| 4484 | "FortranFixedForm": "_f_fortran", | |
| 4485 | "fortran_fixed-form": "_f_fortran", | |
| 4486 | "ftl": "_f_freemarker", | |
| 4487 | "fsharp": "_f_fsharp", | |
| 4488 | "fthtml": "_f_fthtml", | |
| 4489 | "galen": "_f_galen", | |
| 4490 | "gml-gms": "_f_gamemaker", | |
| 4491 | "gml-gms2": "_f_gamemaker2", | |
| 4492 | "gml-gm81": "_f_gamemaker81", | |
| 4493 | "gcode": "_f_gcode", | |
| 4494 | "gdscript": "_f_gdscript", | |
| 4495 | "genstat": "_f_genstat", | |
| 4496 | "git-commit": "_f_git", | |
| 4497 | "git-rebase": "_f_git", | |
| 4498 | "ignore": "_f_git", | |
| 4499 | "gleam": "_f_gleam", | |
| 4500 | "glimmer-js": "_f_glimmer", | |
| 4501 | "glimmer-ts": "_f_glimmer", | |
| 4502 | "glsl": "_f_glsl", | |
| 4503 | "glyphs": "_f_glyphs", | |
| 4504 | "automake": "_f_gnu", | |
| 4505 | "m4": "_f_gnu", | |
| 4506 | "makefile": "_f_gnu", | |
| 4507 | "makefile2": "_f_gnu", | |
| 4508 | "gnuplot": "_f_gnuplot", | |
| 4509 | "go": "_f_go", | |
| 4510 | "go.mod": "_f_go_package", | |
| 4511 | "go.work": "_f_go_work", | |
| 4512 | "goctl": "_f_goctl", | |
| 4513 | "gradle-kotlin-dsl": "_f_gradle", | |
| 4514 | "grain": "_f_grain", | |
| 4515 | "graphql": "_f_graphql", | |
| 4516 | "dot": "_f_graphviz", | |
| 4517 | "grit": "_f_grit", | |
| 4518 | "groovy": "_f_groovy", | |
| 4519 | "haml": "_f_haml", | |
| 4520 | "handlebars": "_f_handlebars", | |
| 4521 | "harbour": "_f_harbour", | |
| 4522 | "hcl": "_f_hashicorp", | |
| 4523 | "haskell": "_f_haskell", | |
| 4524 | "literate haskell": "_f_haskell", | |
| 4525 | "haxe": "_f_haxe", | |
| 4526 | "hxml": "_f_haxe", | |
| 4527 | "Haxe AST dump": "_f_haxe", | |
| 4528 | "helm": "_f_helm", | |
| 4529 | "hjson": "_f_hjson", | |
| 4530 | "hlsl": "_f_hlsl", | |
| 4531 | "home-assistant": "_f_homeassistant", | |
| 4532 | "hosts": "_f_host", | |
| 4533 | "html": "_f_html", | |
| 4534 | "http": "_f_http", | |
| 4535 | "hunspell.aff": "_f_hunspell", | |
| 4536 | "hunspell.dic": "_f_hunspell", | |
| 4537 | "hy": "_f_hy", | |
| 4538 | "hypr": "_f_hypr", | |
| 4539 | "icl": "_f_icl", | |
| 4540 | "imba": "_f_imba", | |
| 4541 | "4GL": "_f_informix", | |
| 4542 | "ini": "_f_ini", | |
| 4543 | "ink": "_f_ink", | |
| 4544 | "innosetup": "_f_innosetup", | |
| 4545 | "io": "_f_io", | |
| 4546 | "iodine": "_f_iodine", | |
| 4547 | "janet": "_f_janet", | |
| 4548 | "java": "_f_java", | |
| 4549 | "jekyll": "_f_jekyll", | |
| 4550 | "jenkins": "_f_jenkins", | |
| 4551 | "declarative": "_f_jenkins", | |
| 4552 | "jenkinsfile": "_f_jenkins", | |
| 4553 | "jest-snapshot": "_f_jest_snapshot", | |
| 4554 | "jinja": "_f_jinja", | |
| 4555 | "jinja-java": "_f_jinja", | |
| 4556 | "jinja-html": "_f_jinja", | |
| 4557 | "jinja-xml": "_f_jinja", | |
| 4558 | "jinja-css": "_f_jinja", | |
| 4559 | "jinja-json": "_f_jinja", | |
| 4560 | "jinja-md": "_f_jinja", | |
| 4561 | "jinja-py": "_f_jinja", | |
| 4562 | "jinja-rb": "_f_jinja", | |
| 4563 | "jinja-js": "_f_jinja", | |
| 4564 | "jinja-yaml": "_f_jinja", | |
| 4565 | "jinja-toml": "_f_jinja", | |
| 4566 | "jinja-latex": "_f_jinja", | |
| 4567 | "jinja-lua": "_f_jinja", | |
| 4568 | "jinja-properties": "_f_jinja", | |
| 4569 | "jinja-shell": "_f_jinja", | |
| 4570 | "jinja-dockerfile": "_f_jinja", | |
| 4571 | "jinja-sql": "_f_jinja", | |
| 4572 | "jinja-terraform": "_f_jinja", | |
| 4573 | "jinja-nginx": "_f_jinja", | |
| 4574 | "jinja-groovy": "_f_jinja", | |
| 4575 | "jinja-systemd": "_f_jinja", | |
| 4576 | "jinja-cpp": "_f_jinja", | |
| 4577 | "javascript": "_f_js", | |
| 4578 | "json": "_f_json", | |
| 4579 | "json-tmlanguage": "_f_json", | |
| 4580 | "jsonc": "_f_json", | |
| 4581 | "jsonl": "_f_json", | |
| 4582 | "json5": "_f_json5", | |
| 4583 | "jsonnet": "_f_jsonnet", | |
| 4584 | "julia": "_f_julia", | |
| 4585 | "juliamarkdown": "_f_julia", | |
| 4586 | "just": "_f_just", | |
| 4587 | "k": "_f_k", | |
| 4588 | "kivy": "_f_kivy", | |
| 4589 | "kos": "_f_kos", | |
| 4590 | "kotlin": "_f_kotlin", | |
| 4591 | "kotlinscript": "_f_kotlin", | |
| 4592 | "kusto": "_f_kusto", | |
| 4593 | "lark": "_f_lark", | |
| 4594 | "latino": "_f_latino", | |
| 4595 | "lean4": "_f_lean", | |
| 4596 | "lean4markdown": "_f_lean", | |
| 4597 | "less": "_f_less", | |
| 4598 | "lex": "_f_lex", | |
| 4599 | "lilypond": "_f_lilypond", | |
| 4600 | "lisp": "_f_lisp", | |
| 4601 | "autolisp": "_f_lisp", | |
| 4602 | "autolispdcl": "_f_lisp", | |
| 4603 | "log": "_f_log", | |
| 4604 | "lolcode": "_f_lolcode", | |
| 4605 | "lsl": "_f_lsl", | |
| 4606 | "lua": "_f_lua", | |
| 4607 | "markdown": "_f_markdown", | |
| 4608 | "marko": "_f_marko", | |
| 4609 | "matlab": "_f_matlab", | |
| 4610 | "maxscript": "_f_maxscript", | |
| 4611 | "mel": "_f_maya", | |
| 4612 | "mdx": "_f_mdx", | |
| 4613 | "mediawiki": "_f_mediawiki", | |
| 4614 | "mermaid": "_f_mermaid", | |
| 4615 | "meson": "_f_meson", | |
| 4616 | "metal": "_f_metal", | |
| 4617 | "bc-mcfunction": "_f_minecraft", | |
| 4618 | "bc-minecraft-language": "_f_minecraft", | |
| 4619 | "bc-minecraft-molang": "_f_minecraft", | |
| 4620 | "bc-minecraft-project": "_f_minecraft", | |
| 4621 | "mcdoc": "_f_minecraft", | |
| 4622 | "mcfunction": "_f_minecraft", | |
| 4623 | "mcmeta": "_f_minecraft", | |
| 4624 | "mcscript": "_f_minecraft", | |
| 4625 | "minecraft-lang": "_f_minecraft", | |
| 4626 | "options-txt": "_f_minecraft", | |
| 4627 | "snbt": "_f_minecraft", | |
| 4628 | "mv": "_f_mivascript", | |
| 4629 | "mjml": "_f_mjml", | |
| 4630 | "powerquery": "_f_mlang", | |
| 4631 | "mojo": "_f_mojo", | |
| 4632 | "mojolicious": "_f_mojolicious", | |
| 4633 | "mongo": "_f_mongo", | |
| 4634 | "mson": "_f_mson", | |
| 4635 | "mvt": "_f_mvt", | |
| 4636 | "mvtcss": "_f_mvtcss", | |
| 4637 | "mvtjs": "_f_mvtjs", | |
| 4638 | "nearley": "_f_nearly", | |
| 4639 | "cypher": "_f_neo4j", | |
| 4640 | "nginx": "_f_nginx", | |
| 4641 | "nim": "_f_nim", | |
| 4642 | "nimble": "_f_nimble", | |
| 4643 | "nix": "_f_nix", | |
| 4644 | "nsis": "_f_nsi", | |
| 4645 | "nfl": "_f_nsi", | |
| 4646 | "nsl": "_f_nsi", | |
| 4647 | "bridlensis": "_f_nsi", | |
| 4648 | "nunjucks": "_f_nunjucks", | |
| 4649 | "nushell": "_f_nushell", | |
| 4650 | "objective-c": "_f_objectivec", | |
| 4651 | "objective-cpp": "_f_objectivecpp", | |
| 4652 | "ocaml": "_f_ocaml", | |
| 4653 | "ocamllex": "_f_ocaml", | |
| 4654 | "menhir": "_f_ocaml", | |
| 4655 | "ogone": "_f_ogone", | |
| 4656 | "openhab": "_f_openHAB", | |
| 4657 | "scad": "_f_openscad", | |
| 4658 | "org": "_f_org", | |
| 4659 | "pddl": "_f_pddl", | |
| 4660 | "happenings": "_f_pddl_happenings", | |
| 4661 | "plan": "_f_pddl_plan", | |
| 4662 | "perl": "_f_perl", | |
| 4663 | "perl6": "_f_perl6", | |
| 4664 | "pgsql": "_f_pgsql", | |
| 4665 | "php": "_f_php", | |
| 4666 | "pine": "_f_pine", | |
| 4667 | "pinescript": "_f_pine", | |
| 4668 | "pip-requirements": "_f_pip", | |
| 4669 | "platformio-debug.disassembly": "_f_platformio", | |
| 4670 | "platformio-debug.memoryview": "_f_platformio", | |
| 4671 | "plsql": "_f_plsql", | |
| 4672 | "oracle": "_f_plsql", | |
| 4673 | "oraclesql": "_f_plsql", | |
| 4674 | "oracle-sql": "_f_plsql", | |
| 4675 | "po": "_f_poedit", | |
| 4676 | "polyglot-notebook": "_f_polyglot", | |
| 4677 | "polymer": "_f_polymer", | |
| 4678 | "pony": "_f_pony", | |
| 4679 | "postcss": "_f_postcss", | |
| 4680 | "postscript": "_f_postscript", | |
| 4681 | "powershell": "_f_powershell", | |
| 4682 | "prisma": "_f_prisma", | |
| 4683 | "pde": "_f_processinglang", | |
| 4684 | "abl": "_f_progress", | |
| 4685 | "prolog": "_f_prolog", | |
| 4686 | "prometheus": "_f_prometheus", | |
| 4687 | "proto3": "_f_protobuf", | |
| 4688 | "proto": "_f_protobuf", | |
| 4689 | "prototext": "_f_protobuf", | |
| 4690 | "jade": "_f_pug", | |
| 4691 | "puppet": "_f_puppet", | |
| 4692 | "purescript": "_f_purescript", | |
| 4693 | "pyret": "_f_pyret", | |
| 4694 | "pyscript": "_f_pyscript", | |
| 4695 | "python": "_f_python", | |
| 4696 | "pythowo": "_f_pythowo", | |
| 4697 | "qlik": "_f_qlikview", | |
| 4698 | "qml": "_f_qml", | |
| 4699 | "qsharp": "_f_qsharp", | |
| 4700 | "r": "_f_r", | |
| 4701 | "ra_syntax_tree": "_f_ra_syntax_tree", | |
| 4702 | "racket": "_f_racket", | |
| 4703 | "raku": "_f_raku", | |
| 4704 | "raml": "_f_raml", | |
| 4705 | "razor": "_f_razor", | |
| 4706 | "aspnetcorerazor": "_f_razor", | |
| 4707 | "javascriptreact": "_f_reactjs", | |
| 4708 | "typescriptreact": "_f_reactts", | |
| 4709 | "reason": "_f_reason", | |
| 4710 | "red": "_f_red", | |
| 4711 | "rescript": "_f_rescript", | |
| 4712 | "restructuredtext": "_f_rest", | |
| 4713 | "rexx": "_f_rexx", | |
| 4714 | "riot": "_f_riot", | |
| 4715 | "ripple": "_f_ripple", | |
| 4716 | "rmd": "_f_rmd", | |
| 4717 | "rnc": "_f_rnc", | |
| 4718 | "robot": "_f_robotframework", | |
| 4719 | "robots-txt": "_f_robots", | |
| 4720 | "ruby": "_f_ruby", | |
| 4721 | "rust": "_f_rust", | |
| 4722 | "s-lang": "_f_s-lang", | |
| 4723 | "san": "_f_san", | |
| 4724 | "SAS": "_f_sas", | |
| 4725 | "sass": "_f_sass", | |
| 4726 | "sass.hover": "_f_sass", | |
| 4727 | "sbt": "_f_sbt", | |
| 4728 | "scala": "_f_scala", | |
| 4729 | "scilab": "_f_scilab", | |
| 4730 | "vbscript": "_f_script", | |
| 4731 | "scss": "_f_scss", | |
| 4732 | "sdl": "_f_sdlang", | |
| 4733 | "search-result": "_f_search_result", | |
| 4734 | "shaderlab": "_f_shaderlab", | |
| 4735 | "shellscript": "_f_shell", | |
| 4736 | "silverstripe": "_f_silverstripe", | |
| 4737 | "sino": "_f_sino", | |
| 4738 | "eskip": "_f_skipper", | |
| 4739 | "slang": "_f_slang", | |
| 4740 | "slice": "_f_slice", | |
| 4741 | "slim": "_f_slim", | |
| 4742 | "slint": "_f_slint", | |
| 4743 | "sln": "_f_sln", | |
| 4744 | "smarty": "_f_smarty", | |
| 4745 | "snakemake": "_f_snakemake", | |
| 4746 | "snort": "_f_snort", | |
| 4747 | "solidity": "_f_solidity", | |
| 4748 | "sparql": "_f_sparql", | |
| 4749 | "sqf": "_f_sqf", | |
| 4750 | "sql": "_f_sql", | |
| 4751 | "squirrel": "_f_squirrel", | |
| 4752 | "stan": "_f_stan", | |
| 4753 | "stata": "_f_stata", | |
| 4754 | "stencil": "_f_stencil", | |
| 4755 | "stencil-html": "_f_stencil", | |
| 4756 | "stylable": "_f_stylable", | |
| 4757 | "source.css.styled": "_f_styled", | |
| 4758 | "stylus": "_f_stylus", | |
| 4759 | "svelte": "_f_svelte", | |
| 4760 | "svg": "_f_svg", | |
| 4761 | "Swagger": "_f_swagger", | |
| 4762 | "swagger": "_f_swagger", | |
| 4763 | "swift": "_f_swift", | |
| 4764 | "swig": "_f_swig", | |
| 4765 | "systemd-conf": "_f_systemd", | |
| 4766 | "systemd-unit-file": "_f_systemd", | |
| 4767 | "systemverilog": "_f_systemverilog", | |
| 4768 | "t4": "_f_t4tt", | |
| 4769 | "tailwindcss": "_f_tailwind", | |
| 4770 | "teal": "_f_teal", | |
| 4771 | "templ": "_f_templ", | |
| 4772 | "tera": "_f_tera", | |
| 4773 | "terraform": "_f_terraform", | |
| 4774 | "tex": "_f_tex", | |
| 4775 | "latex": "_f_tex", | |
| 4776 | "bibtex": "_f_tex", | |
| 4777 | "doctex": "_f_tex", | |
| 4778 | "csv": "_f_text", | |
| 4779 | "csv (pipe)": "_f_text", | |
| 4780 | "csv (semicolon)": "_f_text", | |
| 4781 | "csv (whitespace)": "_f_text", | |
| 4782 | "dynamic csv": "_f_text", | |
| 4783 | "tsv": "_f_text", | |
| 4784 | "plaintext": "_f_text", | |
| 4785 | "vim-help": "_f_text", | |
| 4786 | "textile": "_f_textile", | |
| 4787 | "tiltfile": "_f_tiltfile", | |
| 4788 | "toit": "_f_toit", | |
| 4789 | "toml": "_f_toml", | |
| 4790 | "toon": "_f_toon", | |
| 4791 | "tt": "_f_tt", | |
| 4792 | "ttcn": "_f_ttcn", | |
| 4793 | "tuc": "_f_tuc", | |
| 4794 | "twig": "_f_twig", | |
| 4795 | "typescript": "_f_typescript", | |
| 4796 | "typoscript": "_f_typo3", | |
| 4797 | "uiua": "_f_uiua", | |
| 4798 | "unison": "_f_unison", | |
| 4799 | "vb": "_f_vb", | |
| 4800 | "vba": "_f_vba", | |
| 4801 | "velocity": "_f_velocity", | |
| 4802 | "vento": "_f_vento", | |
| 4803 | "verilog": "_f_verilog", | |
| 4804 | "vhdl": "_f_vhdl", | |
| 4805 | "viml": "_f_vim", | |
| 4806 | "vim-snippet": "_f_vim", | |
| 4807 | "vitest-snapshot": "_f_vitest", | |
| 4808 | "v": "_f_vlang", | |
| 4809 | "volt": "_f_volt", | |
| 4810 | "vroom": "_f_vroom", | |
| 4811 | "snippets": "_f_vscode", | |
| 4812 | "vue": "_f_vue", | |
| 4813 | "vyper": "_f_vyper", | |
| 4814 | "wasm": "_f_wasm", | |
| 4815 | "wat": "_f_wasm", | |
| 4816 | "wenyan": "_f_wenyan", | |
| 4817 | "wgsl": "_f_wgsl", | |
| 4818 | "wikitext": "_f_wikitext", | |
| 4819 | "wai": "_f_wit", | |
| 4820 | "wolfram": "_f_wolfram", | |
| 4821 | "wurstlang": "_f_wurst", | |
| 4822 | "wurst": "_f_wurst", | |
| 4823 | "xmake": "_f_xmake", | |
| 4824 | "xml": "_f_xml", | |
| 4825 | "xcompose": "_f_xorg", | |
| 4826 | "xquery": "_f_xquery", | |
| 4827 | "xsl": "_f_xsl", | |
| 4828 | "xslt": "_f_xsl", | |
| 4829 | "yacc": "_f_yacc", | |
| 4830 | "yaml": "_f_yaml", | |
| 4831 | "yaml-tmlanguage": "_f_yaml", | |
| 4832 | "spring-boot-properties-yaml": "_f_yaml", | |
| 4833 | "yang": "_f_yang", | |
| 4834 | "yarnlock": "_f_yarn", | |
| 4835 | "zig": "_f_zig", | |
| 4836 | "zip": "_f_zip" | |
| 4837 | }, | |
| 4838 | "light": { | |
| 4839 | "file": "_file_light", | |
| 4840 | "folder": "_folder_light", | |
| 4841 | "folderExpanded": "_folder_light_open", | |
| 4842 | "rootFolder": "_root_folder_light", | |
| 4843 | "rootFolderExpanded": "_root_folder_light_open", | |
| 4844 | "folderNames": { | |
| 4845 | "android": "_fd_android", | |
| 4846 | ".angular": "_fd_angular", | |
| 4847 | "apache": "_fd_apache", | |
| 4848 | "apache2": "_fd_apache", | |
| 4849 | "httpd": "_fd_apache", | |
| 4850 | "api": "_fd_api", | |
| 4851 | ".api": "_fd_api", | |
| 4852 | "apis": "_fd_api", | |
| 4853 | ".apis": "_fd_api", | |
| 4854 | "app": "_fd_app", | |
| 4855 | "apps": "_fd_app", | |
| 4856 | ".app": "_fd_app", | |
| 4857 | "application": "_fd_app", | |
| 4858 | "applications": "_fd_app", | |
| 4859 | "arangodb": "_fd_arangodb", | |
| 4860 | "arango": "_fd_arangodb", | |
| 4861 | "assets": "_fd_asset", | |
| 4862 | ".assets": "_fd_asset", | |
| 4863 | ".astro": "_fd_astro", | |
| 4864 | "audio": "_fd_audio", | |
| 4865 | ".audio": "_fd_audio", | |
| 4866 | "audios": "_fd_audio", | |
| 4867 | ".audios": "_fd_audio", | |
| 4868 | "sound": "_fd_audio", | |
| 4869 | ".sound": "_fd_audio", | |
| 4870 | "sounds": "_fd_audio", | |
| 4871 | ".sounds": "_fd_audio", | |
| 4872 | "aurelia_project": "_fd_aurelia", | |
| 4873 | "aws": "_fd_aws", | |
| 4874 | ".aws": "_fd_aws", | |
| 4875 | "azure": "_fd_azure", | |
| 4876 | ".azure": "_fd_azure", | |
| 4877 | "azure-pipelines": "_fd_azurepipelines", | |
| 4878 | ".azure-pipelines": "_fd_azurepipelines", | |
| 4879 | "bin": "_fd_binary", | |
| 4880 | ".bin": "_fd_binary", | |
| 4881 | "blocs": "_fd_bloc", | |
| 4882 | "bloc": "_fd_bloc", | |
| 4883 | "blueprint": "_fd_blueprint", | |
| 4884 | ".blueprint": "_fd_blueprint", | |
| 4885 | "blueprints": "_fd_blueprint", | |
| 4886 | ".blueprints": "_fd_blueprint", | |
| 4887 | "bot": "_fd_bot", | |
| 4888 | ".bot": "_fd_bot", | |
| 4889 | "bower_components": "_fd_bower", | |
| 4890 | ".buildkite": "_fd_buildkite", | |
| 4891 | "cake": "_fd_cake", | |
| 4892 | ".cake": "_fd_cake", | |
| 4893 | "cargo": "_fd_cargo", | |
| 4894 | ".cargo": "_fd_cargo", | |
| 4895 | "crates": "_fd_cargo", | |
| 4896 | "certificates": "_fd_certificate", | |
| 4897 | ".certificates": "_fd_certificate", | |
| 4898 | "certs": "_fd_certificate", | |
| 4899 | "certs.": "_fd_certificate", | |
| 4900 | ".changeset": "_fd_changesets", | |
| 4901 | "chef": "_fd_chef", | |
| 4902 | ".chef": "_fd_chef", | |
| 4903 | ".circleci": "_fd_circleci", | |
| 4904 | ".claude": "_fd_claude", | |
| 4905 | "cli": "_fd_cli", | |
| 4906 | "cmd": "_fd_cli", | |
| 4907 | "command": "_fd_cli", | |
| 4908 | "commands": "_fd_cli", | |
| 4909 | "commandline": "_fd_cli", | |
| 4910 | "console": "_fd_cli", | |
| 4911 | "client": "_fd_client", | |
| 4912 | "clients": "_fd_client", | |
| 4913 | ".cloudflare": "_fd_cloudflare", | |
| 4914 | "cloudflare": "_fd_cloudflare", | |
| 4915 | ".cmake": "_fd_cmake", | |
| 4916 | "cmake": "_fd_cmake", | |
| 4917 | "common": "_fd_common", | |
| 4918 | "component": "_fd_component", | |
| 4919 | "components": "_fd_component", | |
| 4920 | ".components": "_fd_component", | |
| 4921 | "gui": "_fd_component", | |
| 4922 | "src-ui": "_fd_component", | |
| 4923 | "ui": "_fd_component", | |
| 4924 | "widgets": "_fd_component", | |
| 4925 | "composer": "_fd_composer", | |
| 4926 | ".composer": "_fd_composer", | |
| 4927 | "conf": "_fd_config", | |
| 4928 | ".conf": "_fd_config", | |
| 4929 | "config": "_fd_config", | |
| 4930 | ".config": "_fd_config", | |
| 4931 | "configs": "_fd_config", | |
| 4932 | ".configs": "_fd_config", | |
| 4933 | "configuration": "_fd_config", | |
| 4934 | ".configuration": "_fd_config", | |
| 4935 | "configurations": "_fd_config", | |
| 4936 | ".configurations": "_fd_config", | |
| 4937 | "setting": "_fd_config", | |
| 4938 | ".setting": "_fd_config", | |
| 4939 | "settings": "_fd_config", | |
| 4940 | ".settings": "_fd_config", | |
| 4941 | "ini": "_fd_config", | |
| 4942 | ".ini": "_fd_config", | |
| 4943 | "initializers": "_fd_config", | |
| 4944 | ".initializers": "_fd_config", | |
| 4945 | "__controller__": "_fd_controller", | |
| 4946 | "__controllers__": "_fd_controller", | |
| 4947 | "controller": "_fd_controller", | |
| 4948 | "controllers": "_fd_controller", | |
| 4949 | ".controllers": "_fd_controller", | |
| 4950 | "__handler__": "_fd_controller", | |
| 4951 | "__handlers__": "_fd_controller", | |
| 4952 | "handler": "_fd_controller", | |
| 4953 | ".handler": "_fd_controller", | |
| 4954 | "handlers": "_fd_controller", | |
| 4955 | ".handlers": "_fd_controller", | |
| 4956 | "coverage": "_fd_coverage", | |
| 4957 | "css": "_fd_css", | |
| 4958 | "_css": "_fd_css", | |
| 4959 | "cubits": "_fd_cubit", | |
| 4960 | "cubit": "_fd_cubit", | |
| 4961 | ".cursor": "_fd_light_cursor", | |
| 4962 | "cypress": "_fd_light_cypress", | |
| 4963 | ".dapr": "_fd_dapr", | |
| 4964 | "dapr": "_fd_dapr", | |
| 4965 | "dart": "_fd_dart", | |
| 4966 | ".dart_tool": "_fd_dart", | |
| 4967 | "datadog": "_fd_datadog", | |
| 4968 | ".datadog": "_fd_datadog", | |
| 4969 | "db": "_fd_db", | |
| 4970 | "database": "_fd_db", | |
| 4971 | "sql": "_fd_db", | |
| 4972 | "data": "_fd_db", | |
| 4973 | "repo": "_fd_db", | |
| 4974 | "repository": "_fd_db", | |
| 4975 | "repositories": "_fd_db", | |
| 4976 | "store": "_fd_db", | |
| 4977 | "stores": "_fd_db", | |
| 4978 | "debian": "_fd_debian", | |
| 4979 | "deb": "_fd_debian", | |
| 4980 | ".dependabot": "_fd_dependabot", | |
| 4981 | ".devcontainer": "_fd_devcontainer", | |
| 4982 | "dist": "_fd_dist", | |
| 4983 | ".dist": "_fd_dist", | |
| 4984 | "dists": "_fd_dist", | |
| 4985 | "out": "_fd_dist", | |
| 4986 | "outs": "_fd_dist", | |
| 4987 | "export": "_fd_dist", | |
| 4988 | "exports": "_fd_dist", | |
| 4989 | "build": "_fd_dist", | |
| 4990 | ".build": "_fd_dist", | |
| 4991 | "builds": "_fd_dist", | |
| 4992 | "release": "_fd_dist", | |
| 4993 | "releases": "_fd_dist", | |
| 4994 | "target": "_fd_dist", | |
| 4995 | "targets": "_fd_dist", | |
| 4996 | "docker": "_fd_docker", | |
| 4997 | ".docker": "_fd_docker", | |
| 4998 | "docs": "_fd_docs", | |
| 4999 | "doc": "_fd_docs", | |
| 5000 | "document": "_fd_docs", | |
| 5001 | "documents": "_fd_docs", | |
| 5002 | "e2e": "_fd_e2e", | |
| 5003 | ".elasticbeanstalk": "_fd_elasticbeanstalk", | |
| 5004 | ".ebextensions": "_fd_elasticbeanstalk", | |
| 5005 | "electron": "_fd_light_electron", | |
| 5006 | ".expo": "_fd_light_expo", | |
| 5007 | ".expo-shared": "_fd_light_expo", | |
| 5008 | "favicon": "_fd_favicon", | |
| 5009 | "favicons": "_fd_favicon", | |
| 5010 | "flow": "_fd_flow", | |
| 5011 | "flow-typed": "_fd_flow", | |
| 5012 | "flutter": "_fd_flutter", | |
| 5013 | "Flutter": "_fd_flutter", | |
| 5014 | "fonts": "_fd_light_fonts", | |
| 5015 | "font": "_fd_light_fonts", | |
| 5016 | "fnt": "_fd_light_fonts", | |
| 5017 | "webfonts": "_fd_light_fonts", | |
| 5018 | ".front-commerce": "_fd_frontcommerce", | |
| 5019 | "gcp": "_fd_gcp", | |
| 5020 | ".gcp": "_fd_gcp", | |
| 5021 | ".gemini": "_fd_gemini", | |
| 5022 | ".git": "_fd_git", | |
| 5023 | "submodules": "_fd_git", | |
| 5024 | ".submodules": "_fd_git", | |
| 5025 | ".github": "_fd_github", | |
| 5026 | ".gitlab": "_fd_gitlab", | |
| 5027 | "godot": "_fd_godot", | |
| 5028 | ".godot": "_fd_godot", | |
| 5029 | "gradle": "_fd_light_gradle", | |
| 5030 | ".gradle": "_fd_light_gradle", | |
| 5031 | "graphql": "_fd_graphql", | |
| 5032 | "grunt": "_fd_grunt", | |
| 5033 | "gulp": "_fd_gulp", | |
| 5034 | "gulpfile.js": "_fd_gulp", | |
| 5035 | "gulpfile.coffee": "_fd_gulp", | |
| 5036 | "gulpfile.ts": "_fd_gulp", | |
| 5037 | "gulpfile.babel.js": "_fd_gulp", | |
| 5038 | "gulpfile.babel.coffee": "_fd_gulp", | |
| 5039 | "gulpfile.babel.ts": "_fd_gulp", | |
| 5040 | ".haxelib": "_fd_haxelib", | |
| 5041 | "haxe_libraries": "_fd_haxelib", | |
| 5042 | "helper": "_fd_helper", | |
| 5043 | ".helper": "_fd_helper", | |
| 5044 | "helpers": "_fd_helper", | |
| 5045 | ".helpers": "_fd_helper", | |
| 5046 | ".histoire": "_fd_histoire", | |
| 5047 | "hook": "_fd_hook", | |
| 5048 | ".hook": "_fd_hook", | |
| 5049 | "hooks": "_fd_hook", | |
| 5050 | ".hooks": "_fd_hook", | |
| 5051 | ".husky": "_fd_husky", | |
| 5052 | ".idea": "_fd_idea", | |
| 5053 | ".fig": "_fd_images", | |
| 5054 | ".figs": "_fd_images", | |
| 5055 | "images": "_fd_images", | |
| 5056 | "image": "_fd_images", | |
| 5057 | ".img": "_fd_images", | |
| 5058 | "img": "_fd_images", | |
| 5059 | ".imgs": "_fd_images", | |
| 5060 | "imgs": "_fd_images", | |
| 5061 | "icons": "_fd_images", | |
| 5062 | "icon": "_fd_images", | |
| 5063 | "ico": "_fd_images", | |
| 5064 | "screenshot": "_fd_images", | |
| 5065 | "screenshots": "_fd_images", | |
| 5066 | "svg": "_fd_images", | |
| 5067 | "texture": "_fd_images", | |
| 5068 | "textures": "_fd_images", | |
| 5069 | "include": "_fd_include", | |
| 5070 | "includes": "_fd_include", | |
| 5071 | "incl": "_fd_include", | |
| 5072 | "inc": "_fd_include", | |
| 5073 | ".include": "_fd_include", | |
| 5074 | ".includes": "_fd_include", | |
| 5075 | ".incl": "_fd_include", | |
| 5076 | ".inc": "_fd_include", | |
| 5077 | "_include": "_fd_include", | |
| 5078 | "_includes": "_fd_include", | |
| 5079 | "_incl": "_fd_include", | |
| 5080 | "_inc": "_fd_include", | |
| 5081 | "interface": "_fd_interfaces", | |
| 5082 | "interfaces": "_fd_interfaces", | |
| 5083 | "ios": "_fd_ios", | |
| 5084 | "js": "_fd_js", | |
| 5085 | "json": "_fd_json", | |
| 5086 | ".junie": "_fd_junie", | |
| 5087 | "kotlin": "_fd_kotlin", | |
| 5088 | "Kotlin": "_fd_kotlin", | |
| 5089 | ".kotlin": "_fd_kotlin", | |
| 5090 | "kubernetes": "_fd_kubernetes", | |
| 5091 | "k8s": "_fd_kubernetes", | |
| 5092 | "kube": "_fd_kubernetes", | |
| 5093 | "kuber": "_fd_kubernetes", | |
| 5094 | ".kubernetes": "_fd_kubernetes", | |
| 5095 | ".k8s": "_fd_kubernetes", | |
| 5096 | ".kube": "_fd_kubernetes", | |
| 5097 | ".kuber": "_fd_kubernetes", | |
| 5098 | "less": "_fd_less", | |
| 5099 | "_less": "_fd_less", | |
| 5100 | "lib": "_fd_library", | |
| 5101 | "libs": "_fd_library", | |
| 5102 | ".lib": "_fd_library", | |
| 5103 | ".libs": "_fd_library", | |
| 5104 | "__lib__": "_fd_library", | |
| 5105 | "__libs__": "_fd_library", | |
| 5106 | "library": "_fd_library", | |
| 5107 | "libraries": "_fd_library", | |
| 5108 | "linux": "_fd_linux", | |
| 5109 | "lang": "_fd_locale", | |
| 5110 | "language": "_fd_locale", | |
| 5111 | "languages": "_fd_locale", | |
| 5112 | "locale": "_fd_locale", | |
| 5113 | "locales": "_fd_locale", | |
| 5114 | "_locale": "_fd_locale", | |
| 5115 | "_locales": "_fd_locale", | |
| 5116 | "internationalization": "_fd_locale", | |
| 5117 | "globalization": "_fd_locale", | |
| 5118 | "localization": "_fd_locale", | |
| 5119 | "i18n": "_fd_locale", | |
| 5120 | "g11n": "_fd_locale", | |
| 5121 | "l10n": "_fd_locale", | |
| 5122 | "log": "_fd_log", | |
| 5123 | "logs": "_fd_log", | |
| 5124 | "macos": "_fd_macos", | |
| 5125 | "darwin": "_fd_macos", | |
| 5126 | "mariadb": "_fd_mariadb", | |
| 5127 | "maria": "_fd_mariadb", | |
| 5128 | ".mvn": "_fd_maven", | |
| 5129 | "mediawiki": "_fd_mediawiki", | |
| 5130 | "memcached": "_fd_memcached", | |
| 5131 | ".memcached": "_fd_memcached", | |
| 5132 | ".meteor": "_fd_light_meteor", | |
| 5133 | "middleware": "_fd_middleware", | |
| 5134 | "middlewares": "_fd_middleware", | |
| 5135 | ".minecraft": "_fd_minecraft", | |
| 5136 | "minikube": "_fd_minikube", | |
| 5137 | "minik8s": "_fd_minikube", | |
| 5138 | "minikuber": "_fd_minikube", | |
| 5139 | "mjml": "_fd_mjml", | |
| 5140 | ".mjml": "_fd_mjml", | |
| 5141 | "mocks": "_fd_mock", | |
| 5142 | ".mocks": "_fd_mock", | |
| 5143 | "__mocks__": "_fd_mock", | |
| 5144 | "model": "_fd_model", | |
| 5145 | ".model": "_fd_model", | |
| 5146 | "models": "_fd_model", | |
| 5147 | ".models": "_fd_model", | |
| 5148 | "entities": "_fd_model", | |
| 5149 | ".entities": "_fd_model", | |
| 5150 | "modules": "_fd_module", | |
| 5151 | "mojo": "_fd_mojo", | |
| 5152 | "mongodb": "_fd_mongodb", | |
| 5153 | "mongo": "_fd_mongodb", | |
| 5154 | ".mypy_cache": "_fd_light_mypy", | |
| 5155 | "mysqldb": "_fd_light_mysql", | |
| 5156 | "mysql": "_fd_light_mysql", | |
| 5157 | "netlify": "_fd_netlify", | |
| 5158 | ".next": "_fd_next", | |
| 5159 | "nginx": "_fd_nginx", | |
| 5160 | "conf.d": "_fd_nginx", | |
| 5161 | ".niv": "_fd_nix", | |
| 5162 | ".nix": "_fd_nix", | |
| 5163 | "nix": "_fd_nix", | |
| 5164 | "niv": "_fd_nix", | |
| 5165 | "node_modules": "_fd_light_node", | |
| 5166 | "notebooks": "_fd_notebooks", | |
| 5167 | "notebook": "_fd_notebooks", | |
| 5168 | "notification": "_fd_notification", | |
| 5169 | "notifications": "_fd_notification", | |
| 5170 | "event": "_fd_notification", | |
| 5171 | "events": "_fd_notification", | |
| 5172 | ".nuget": "_fd_nuget", | |
| 5173 | "nuxt": "_fd_nuxt", | |
| 5174 | ".nuxt": "_fd_nuxt", | |
| 5175 | "package": "_fd_package", | |
| 5176 | "packages": "_fd_package", | |
| 5177 | ".package": "_fd_package", | |
| 5178 | ".packages": "_fd_package", | |
| 5179 | "pkg": "_fd_package", | |
| 5180 | ".paket": "_fd_paket", | |
| 5181 | "php": "_fd_php", | |
| 5182 | ".pio": "_fd_platformio", | |
| 5183 | ".pioenvs": "_fd_platformio", | |
| 5184 | "plugin": "_fd_plugin", | |
| 5185 | ".plugin": "_fd_plugin", | |
| 5186 | "plugins": "_fd_plugin", | |
| 5187 | ".plugins": "_fd_plugin", | |
| 5188 | "extension": "_fd_plugin", | |
| 5189 | ".extension": "_fd_plugin", | |
| 5190 | "extensions": "_fd_plugin", | |
| 5191 | ".extensions": "_fd_plugin", | |
| 5192 | "prisma": "_fd_prisma", | |
| 5193 | "private": "_fd_private", | |
| 5194 | ".private": "_fd_private", | |
| 5195 | "public": "_fd_public", | |
| 5196 | ".public": "_fd_public", | |
| 5197 | ".pytest_cache": "_fd_pytest", | |
| 5198 | ".venv": "_fd_python", | |
| 5199 | ".virtualenv": "_fd_python", | |
| 5200 | "__pycache__": "_fd_python", | |
| 5201 | "ravendb": "_fd_ravendb", | |
| 5202 | "redis": "_fd_redis", | |
| 5203 | "redux": "_fd_light_redux", | |
| 5204 | "route": "_fd_route", | |
| 5205 | "routes": "_fd_route", | |
| 5206 | "_route": "_fd_route", | |
| 5207 | "_routes": "_fd_route", | |
| 5208 | "router": "_fd_route", | |
| 5209 | "routers": "_fd_route", | |
| 5210 | "sass": "_fd_light_sass", | |
| 5211 | "scss": "_fd_light_sass", | |
| 5212 | "_sass": "_fd_light_sass", | |
| 5213 | "_scss": "_fd_light_sass", | |
| 5214 | "script": "_fd_script", | |
| 5215 | "scripts": "_fd_script", | |
| 5216 | "seedkit": "_fd_seedkit", | |
| 5217 | ".seedkit": "_fd_seedkit", | |
| 5218 | "server": "_fd_server", | |
| 5219 | "service": "_fd_services", | |
| 5220 | "services": "_fd_services", | |
| 5221 | "share": "_fd_shared", | |
| 5222 | "shared": "_fd_shared", | |
| 5223 | ".share": "_fd_shared", | |
| 5224 | ".shared": "_fd_shared", | |
| 5225 | "__shared__": "_fd_shared", | |
| 5226 | "__share__": "_fd_shared", | |
| 5227 | ".snaplet": "_fd_snaplet", | |
| 5228 | ".spin": "_fd_spin", | |
| 5229 | "src": "_fd_src", | |
| 5230 | "source": "_fd_src", | |
| 5231 | "sources": "_fd_src", | |
| 5232 | "__src__": "_fd_src", | |
| 5233 | "sso": "_fd_sso", | |
| 5234 | "story": "_fd_story", | |
| 5235 | "stories": "_fd_story", | |
| 5236 | "__stories__": "_fd_story", | |
| 5237 | ".storybook": "_fd_story", | |
| 5238 | "style": "_fd_style", | |
| 5239 | "styles": "_fd_style", | |
| 5240 | "supabase": "_fd_supabase", | |
| 5241 | "svelte": "_fd_svelte", | |
| 5242 | ".svelte-kit": "_fd_svelte", | |
| 5243 | "src-tauri": "_fd_tauri", | |
| 5244 | "temp": "_fd_temp", | |
| 5245 | ".temp": "_fd_temp", | |
| 5246 | "tmp": "_fd_temp", | |
| 5247 | ".tmp": "_fd_temp", | |
| 5248 | "template": "_fd_template", | |
| 5249 | ".template": "_fd_template", | |
| 5250 | "templates": "_fd_template", | |
| 5251 | ".templates": "_fd_template", | |
| 5252 | "tests": "_fd_test", | |
| 5253 | ".tests": "_fd_test", | |
| 5254 | "test": "_fd_test", | |
| 5255 | ".test": "_fd_test", | |
| 5256 | "__tests__": "_fd_test", | |
| 5257 | "__test__": "_fd_test", | |
| 5258 | "spec": "_fd_test", | |
| 5259 | ".spec": "_fd_test", | |
| 5260 | "specs": "_fd_test", | |
| 5261 | ".specs": "_fd_test", | |
| 5262 | "integration": "_fd_test", | |
| 5263 | "theme": "_fd_theme", | |
| 5264 | "themes": "_fd_theme", | |
| 5265 | "tool": "_fd_tools", | |
| 5266 | "tools": "_fd_tools", | |
| 5267 | ".tools": "_fd_tools", | |
| 5268 | "util": "_fd_tools", | |
| 5269 | "utils": "_fd_tools", | |
| 5270 | "utilities": "_fd_tools", | |
| 5271 | "tooling": "_fd_tools", | |
| 5272 | ".travis": "_fd_travis", | |
| 5273 | ".trunk": "_fd_trunk", | |
| 5274 | ".turbo": "_fd_turbo", | |
| 5275 | "typescript": "_fd_typescript", | |
| 5276 | "ts": "_fd_typescript", | |
| 5277 | "typings": "_fd_typings", | |
| 5278 | "types": "_fd_typings", | |
| 5279 | "@types": "_fd_typings", | |
| 5280 | "vagrant": "_fd_vagrant", | |
| 5281 | ".vagrant": "_fd_vagrant", | |
| 5282 | ".vercel": "_fd_vercel", | |
| 5283 | "video": "_fd_video", | |
| 5284 | ".video": "_fd_video", | |
| 5285 | "videos": "_fd_video", | |
| 5286 | ".videos": "_fd_video", | |
| 5287 | "html": "_fd_view", | |
| 5288 | "view": "_fd_view", | |
| 5289 | "views": "_fd_view", | |
| 5290 | "layout": "_fd_view", | |
| 5291 | "layouts": "_fd_view", | |
| 5292 | "page": "_fd_view", | |
| 5293 | "pages": "_fd_view", | |
| 5294 | "_view": "_fd_view", | |
| 5295 | "_views": "_fd_view", | |
| 5296 | "_layout": "_fd_view", | |
| 5297 | "_layouts": "_fd_view", | |
| 5298 | "_page": "_fd_view", | |
| 5299 | "_pages": "_fd_view", | |
| 5300 | ".vitepress": "_fd_vitepress", | |
| 5301 | ".vs": "_fd_vs", | |
| 5302 | ".vscode": "_fd_vscode", | |
| 5303 | "vscode": "_fd_vscode", | |
| 5304 | ".vscode-test": "_fd_vscode_test", | |
| 5305 | "wasm": "_fd_wasm", | |
| 5306 | "WASM": "_fd_wasm", | |
| 5307 | "webassembly": "_fd_wasm", | |
| 5308 | "WebAssembly": "_fd_wasm", | |
| 5309 | "webpack": "_fd_webpack", | |
| 5310 | ".webpack": "_fd_webpack", | |
| 5311 | "windows": "_fd_windows", | |
| 5312 | "win32": "_fd_windows", | |
| 5313 | ".windsurf": "_fd_windsurf", | |
| 5314 | "wit": "_fd_wit", | |
| 5315 | ".web": "_fd_www", | |
| 5316 | "www": "_fd_www", | |
| 5317 | "wwwroot": "_fd_www", | |
| 5318 | "web": "_fd_www", | |
| 5319 | ".yarn": "_fd_yarn", | |
| 5320 | ".zed": "_fd_light_zed" | |
| 5321 | }, | |
| 5322 | "folderNamesExpanded": { | |
| 5323 | "android": "_fd_android_open", | |
| 5324 | ".angular": "_fd_angular_open", | |
| 5325 | "apache": "_fd_apache_open", | |
| 5326 | "apache2": "_fd_apache_open", | |
| 5327 | "httpd": "_fd_apache_open", | |
| 5328 | "api": "_fd_api_open", | |
| 5329 | ".api": "_fd_api_open", | |
| 5330 | "apis": "_fd_api_open", | |
| 5331 | ".apis": "_fd_api_open", | |
| 5332 | "app": "_fd_app_open", | |
| 5333 | "apps": "_fd_app_open", | |
| 5334 | ".app": "_fd_app_open", | |
| 5335 | "application": "_fd_app_open", | |
| 5336 | "applications": "_fd_app_open", | |
| 5337 | "arangodb": "_fd_arangodb_open", | |
| 5338 | "arango": "_fd_arangodb_open", | |
| 5339 | "assets": "_fd_asset_open", | |
| 5340 | ".assets": "_fd_asset_open", | |
| 5341 | ".astro": "_fd_astro_open", | |
| 5342 | "audio": "_fd_audio_open", | |
| 5343 | ".audio": "_fd_audio_open", | |
| 5344 | "audios": "_fd_audio_open", | |
| 5345 | ".audios": "_fd_audio_open", | |
| 5346 | "sound": "_fd_audio_open", | |
| 5347 | ".sound": "_fd_audio_open", | |
| 5348 | "sounds": "_fd_audio_open", | |
| 5349 | ".sounds": "_fd_audio_open", | |
| 5350 | "aurelia_project": "_fd_aurelia_open", | |
| 5351 | "aws": "_fd_aws_open", | |
| 5352 | ".aws": "_fd_aws_open", | |
| 5353 | "azure": "_fd_azure_open", | |
| 5354 | ".azure": "_fd_azure_open", | |
| 5355 | "azure-pipelines": "_fd_azurepipelines_open", | |
| 5356 | ".azure-pipelines": "_fd_azurepipelines_open", | |
| 5357 | "bin": "_fd_binary_open", | |
| 5358 | ".bin": "_fd_binary_open", | |
| 5359 | "blocs": "_fd_bloc_open", | |
| 5360 | "bloc": "_fd_bloc_open", | |
| 5361 | "blueprint": "_fd_blueprint_open", | |
| 5362 | ".blueprint": "_fd_blueprint_open", | |
| 5363 | "blueprints": "_fd_blueprint_open", | |
| 5364 | ".blueprints": "_fd_blueprint_open", | |
| 5365 | "bot": "_fd_bot_open", | |
| 5366 | ".bot": "_fd_bot_open", | |
| 5367 | "bower_components": "_fd_bower_open", | |
| 5368 | ".buildkite": "_fd_buildkite_open", | |
| 5369 | "cake": "_fd_cake_open", | |
| 5370 | ".cake": "_fd_cake_open", | |
| 5371 | "cargo": "_fd_cargo_open", | |
| 5372 | ".cargo": "_fd_cargo_open", | |
| 5373 | "crates": "_fd_cargo_open", | |
| 5374 | "certificates": "_fd_certificate_open", | |
| 5375 | ".certificates": "_fd_certificate_open", | |
| 5376 | "certs": "_fd_certificate_open", | |
| 5377 | "certs.": "_fd_certificate_open", | |
| 5378 | ".changeset": "_fd_changesets_open", | |
| 5379 | "chef": "_fd_chef_open", | |
| 5380 | ".chef": "_fd_chef_open", | |
| 5381 | ".circleci": "_fd_circleci_open", | |
| 5382 | ".claude": "_fd_claude_open", | |
| 5383 | "cli": "_fd_cli_open", | |
| 5384 | "cmd": "_fd_cli_open", | |
| 5385 | "command": "_fd_cli_open", | |
| 5386 | "commands": "_fd_cli_open", | |
| 5387 | "commandline": "_fd_cli_open", | |
| 5388 | "console": "_fd_cli_open", | |
| 5389 | "client": "_fd_client_open", | |
| 5390 | "clients": "_fd_client_open", | |
| 5391 | ".cloudflare": "_fd_cloudflare_open", | |
| 5392 | "cloudflare": "_fd_cloudflare_open", | |
| 5393 | ".cmake": "_fd_cmake_open", | |
| 5394 | "cmake": "_fd_cmake_open", | |
| 5395 | "common": "_fd_common_open", | |
| 5396 | "component": "_fd_component_open", | |
| 5397 | "components": "_fd_component_open", | |
| 5398 | ".components": "_fd_component_open", | |
| 5399 | "gui": "_fd_component_open", | |
| 5400 | "src-ui": "_fd_component_open", | |
| 5401 | "ui": "_fd_component_open", | |
| 5402 | "widgets": "_fd_component_open", | |
| 5403 | "composer": "_fd_composer_open", | |
| 5404 | ".composer": "_fd_composer_open", | |
| 5405 | "conf": "_fd_config_open", | |
| 5406 | ".conf": "_fd_config_open", | |
| 5407 | "config": "_fd_config_open", | |
| 5408 | ".config": "_fd_config_open", | |
| 5409 | "configs": "_fd_config_open", | |
| 5410 | ".configs": "_fd_config_open", | |
| 5411 | "configuration": "_fd_config_open", | |
| 5412 | ".configuration": "_fd_config_open", | |
| 5413 | "configurations": "_fd_config_open", | |
| 5414 | ".configurations": "_fd_config_open", | |
| 5415 | "setting": "_fd_config_open", | |
| 5416 | ".setting": "_fd_config_open", | |
| 5417 | "settings": "_fd_config_open", | |
| 5418 | ".settings": "_fd_config_open", | |
| 5419 | "ini": "_fd_config_open", | |
| 5420 | ".ini": "_fd_config_open", | |
| 5421 | "initializers": "_fd_config_open", | |
| 5422 | ".initializers": "_fd_config_open", | |
| 5423 | "__controller__": "_fd_controller_open", | |
| 5424 | "__controllers__": "_fd_controller_open", | |
| 5425 | "controller": "_fd_controller_open", | |
| 5426 | "controllers": "_fd_controller_open", | |
| 5427 | ".controllers": "_fd_controller_open", | |
| 5428 | "__handler__": "_fd_controller_open", | |
| 5429 | "__handlers__": "_fd_controller_open", | |
| 5430 | "handler": "_fd_controller_open", | |
| 5431 | ".handler": "_fd_controller_open", | |
| 5432 | "handlers": "_fd_controller_open", | |
| 5433 | ".handlers": "_fd_controller_open", | |
| 5434 | "coverage": "_fd_coverage_open", | |
| 5435 | "css": "_fd_css_open", | |
| 5436 | "_css": "_fd_css_open", | |
| 5437 | "cubits": "_fd_cubit_open", | |
| 5438 | "cubit": "_fd_cubit_open", | |
| 5439 | ".cursor": "_fd_light_cursor_open", | |
| 5440 | "cypress": "_fd_light_cypress_open", | |
| 5441 | ".dapr": "_fd_dapr_open", | |
| 5442 | "dapr": "_fd_dapr_open", | |
| 5443 | "dart": "_fd_dart_open", | |
| 5444 | ".dart_tool": "_fd_dart_open", | |
| 5445 | "datadog": "_fd_datadog_open", | |
| 5446 | ".datadog": "_fd_datadog_open", | |
| 5447 | "db": "_fd_db_open", | |
| 5448 | "database": "_fd_db_open", | |
| 5449 | "sql": "_fd_db_open", | |
| 5450 | "data": "_fd_db_open", | |
| 5451 | "repo": "_fd_db_open", | |
| 5452 | "repository": "_fd_db_open", | |
| 5453 | "repositories": "_fd_db_open", | |
| 5454 | "store": "_fd_db_open", | |
| 5455 | "stores": "_fd_db_open", | |
| 5456 | "debian": "_fd_debian_open", | |
| 5457 | "deb": "_fd_debian_open", | |
| 5458 | ".dependabot": "_fd_dependabot_open", | |
| 5459 | ".devcontainer": "_fd_devcontainer_open", | |
| 5460 | "dist": "_fd_dist_open", | |
| 5461 | ".dist": "_fd_dist_open", | |
| 5462 | "dists": "_fd_dist_open", | |
| 5463 | "out": "_fd_dist_open", | |
| 5464 | "outs": "_fd_dist_open", | |
| 5465 | "export": "_fd_dist_open", | |
| 5466 | "exports": "_fd_dist_open", | |
| 5467 | "build": "_fd_dist_open", | |
| 5468 | ".build": "_fd_dist_open", | |
| 5469 | "builds": "_fd_dist_open", | |
| 5470 | "release": "_fd_dist_open", | |
| 5471 | "releases": "_fd_dist_open", | |
| 5472 | "target": "_fd_dist_open", | |
| 5473 | "targets": "_fd_dist_open", | |
| 5474 | "docker": "_fd_docker_open", | |
| 5475 | ".docker": "_fd_docker_open", | |
| 5476 | "docs": "_fd_docs_open", | |
| 5477 | "doc": "_fd_docs_open", | |
| 5478 | "document": "_fd_docs_open", | |
| 5479 | "documents": "_fd_docs_open", | |
| 5480 | "e2e": "_fd_e2e_open", | |
| 5481 | ".elasticbeanstalk": "_fd_elasticbeanstalk_open", | |
| 5482 | ".ebextensions": "_fd_elasticbeanstalk_open", | |
| 5483 | "electron": "_fd_light_electron_open", | |
| 5484 | ".expo": "_fd_light_expo_open", | |
| 5485 | ".expo-shared": "_fd_light_expo_open", | |
| 5486 | "favicon": "_fd_favicon_open", | |
| 5487 | "favicons": "_fd_favicon_open", | |
| 5488 | "flow": "_fd_flow_open", | |
| 5489 | "flow-typed": "_fd_flow_open", | |
| 5490 | "flutter": "_fd_flutter_open", | |
| 5491 | "Flutter": "_fd_flutter_open", | |
| 5492 | "fonts": "_fd_light_fonts_open", | |
| 5493 | "font": "_fd_light_fonts_open", | |
| 5494 | "fnt": "_fd_light_fonts_open", | |
| 5495 | "webfonts": "_fd_light_fonts_open", | |
| 5496 | ".front-commerce": "_fd_frontcommerce_open", | |
| 5497 | "gcp": "_fd_gcp_open", | |
| 5498 | ".gcp": "_fd_gcp_open", | |
| 5499 | ".gemini": "_fd_gemini_open", | |
| 5500 | ".git": "_fd_git_open", | |
| 5501 | "submodules": "_fd_git_open", | |
| 5502 | ".submodules": "_fd_git_open", | |
| 5503 | ".github": "_fd_github_open", | |
| 5504 | ".gitlab": "_fd_gitlab_open", | |
| 5505 | "godot": "_fd_godot_open", | |
| 5506 | ".godot": "_fd_godot_open", | |
| 5507 | "gradle": "_fd_light_gradle_open", | |
| 5508 | ".gradle": "_fd_light_gradle_open", | |
| 5509 | "graphql": "_fd_graphql_open", | |
| 5510 | "grunt": "_fd_grunt_open", | |
| 5511 | "gulp": "_fd_gulp_open", | |
| 5512 | "gulpfile.js": "_fd_gulp_open", | |
| 5513 | "gulpfile.coffee": "_fd_gulp_open", | |
| 5514 | "gulpfile.ts": "_fd_gulp_open", | |
| 5515 | "gulpfile.babel.js": "_fd_gulp_open", | |
| 5516 | "gulpfile.babel.coffee": "_fd_gulp_open", | |
| 5517 | "gulpfile.babel.ts": "_fd_gulp_open", | |
| 5518 | ".haxelib": "_fd_haxelib_open", | |
| 5519 | "haxe_libraries": "_fd_haxelib_open", | |
| 5520 | "helper": "_fd_helper_open", | |
| 5521 | ".helper": "_fd_helper_open", | |
| 5522 | "helpers": "_fd_helper_open", | |
| 5523 | ".helpers": "_fd_helper_open", | |
| 5524 | ".histoire": "_fd_histoire_open", | |
| 5525 | "hook": "_fd_hook_open", | |
| 5526 | ".hook": "_fd_hook_open", | |
| 5527 | "hooks": "_fd_hook_open", | |
| 5528 | ".hooks": "_fd_hook_open", | |
| 5529 | ".husky": "_fd_husky_open", | |
| 5530 | ".idea": "_fd_idea_open", | |
| 5531 | ".fig": "_fd_images_open", | |
| 5532 | ".figs": "_fd_images_open", | |
| 5533 | "images": "_fd_images_open", | |
| 5534 | "image": "_fd_images_open", | |
| 5535 | ".img": "_fd_images_open", | |
| 5536 | "img": "_fd_images_open", | |
| 5537 | ".imgs": "_fd_images_open", | |
| 5538 | "imgs": "_fd_images_open", | |
| 5539 | "icons": "_fd_images_open", | |
| 5540 | "icon": "_fd_images_open", | |
| 5541 | "ico": "_fd_images_open", | |
| 5542 | "screenshot": "_fd_images_open", | |
| 5543 | "screenshots": "_fd_images_open", | |
| 5544 | "svg": "_fd_images_open", | |
| 5545 | "texture": "_fd_images_open", | |
| 5546 | "textures": "_fd_images_open", | |
| 5547 | "include": "_fd_include_open", | |
| 5548 | "includes": "_fd_include_open", | |
| 5549 | "incl": "_fd_include_open", | |
| 5550 | "inc": "_fd_include_open", | |
| 5551 | ".include": "_fd_include_open", | |
| 5552 | ".includes": "_fd_include_open", | |
| 5553 | ".incl": "_fd_include_open", | |
| 5554 | ".inc": "_fd_include_open", | |
| 5555 | "_include": "_fd_include_open", | |
| 5556 | "_includes": "_fd_include_open", | |
| 5557 | "_incl": "_fd_include_open", | |
| 5558 | "_inc": "_fd_include_open", | |
| 5559 | "interface": "_fd_interfaces_open", | |
| 5560 | "interfaces": "_fd_interfaces_open", | |
| 5561 | "ios": "_fd_ios_open", | |
| 5562 | "js": "_fd_js_open", | |
| 5563 | "json": "_fd_json_open", | |
| 5564 | ".junie": "_fd_junie_open", | |
| 5565 | "kotlin": "_fd_kotlin_open", | |
| 5566 | "Kotlin": "_fd_kotlin_open", | |
| 5567 | ".kotlin": "_fd_kotlin_open", | |
| 5568 | "kubernetes": "_fd_kubernetes_open", | |
| 5569 | "k8s": "_fd_kubernetes_open", | |
| 5570 | "kube": "_fd_kubernetes_open", | |
| 5571 | "kuber": "_fd_kubernetes_open", | |
| 5572 | ".kubernetes": "_fd_kubernetes_open", | |
| 5573 | ".k8s": "_fd_kubernetes_open", | |
| 5574 | ".kube": "_fd_kubernetes_open", | |
| 5575 | ".kuber": "_fd_kubernetes_open", | |
| 5576 | "less": "_fd_less_open", | |
| 5577 | "_less": "_fd_less_open", | |
| 5578 | "lib": "_fd_library_open", | |
| 5579 | "libs": "_fd_library_open", | |
| 5580 | ".lib": "_fd_library_open", | |
| 5581 | ".libs": "_fd_library_open", | |
| 5582 | "__lib__": "_fd_library_open", | |
| 5583 | "__libs__": "_fd_library_open", | |
| 5584 | "library": "_fd_library_open", | |
| 5585 | "libraries": "_fd_library_open", | |
| 5586 | "linux": "_fd_linux_open", | |
| 5587 | "lang": "_fd_locale_open", | |
| 5588 | "language": "_fd_locale_open", | |
| 5589 | "languages": "_fd_locale_open", | |
| 5590 | "locale": "_fd_locale_open", | |
| 5591 | "locales": "_fd_locale_open", | |
| 5592 | "_locale": "_fd_locale_open", | |
| 5593 | "_locales": "_fd_locale_open", | |
| 5594 | "internationalization": "_fd_locale_open", | |
| 5595 | "globalization": "_fd_locale_open", | |
| 5596 | "localization": "_fd_locale_open", | |
| 5597 | "i18n": "_fd_locale_open", | |
| 5598 | "g11n": "_fd_locale_open", | |
| 5599 | "l10n": "_fd_locale_open", | |
| 5600 | "log": "_fd_log_open", | |
| 5601 | "logs": "_fd_log_open", | |
| 5602 | "macos": "_fd_macos_open", | |
| 5603 | "darwin": "_fd_macos_open", | |
| 5604 | "mariadb": "_fd_mariadb_open", | |
| 5605 | "maria": "_fd_mariadb_open", | |
| 5606 | ".mvn": "_fd_maven_open", | |
| 5607 | "mediawiki": "_fd_mediawiki_open", | |
| 5608 | "memcached": "_fd_memcached_open", | |
| 5609 | ".memcached": "_fd_memcached_open", | |
| 5610 | ".meteor": "_fd_light_meteor_open", | |
| 5611 | "middleware": "_fd_middleware_open", | |
| 5612 | "middlewares": "_fd_middleware_open", | |
| 5613 | ".minecraft": "_fd_minecraft_open", | |
| 5614 | "minikube": "_fd_minikube_open", | |
| 5615 | "minik8s": "_fd_minikube_open", | |
| 5616 | "minikuber": "_fd_minikube_open", | |
| 5617 | "mjml": "_fd_mjml_open", | |
| 5618 | ".mjml": "_fd_mjml_open", | |
| 5619 | "mocks": "_fd_mock_open", | |
| 5620 | ".mocks": "_fd_mock_open", | |
| 5621 | "__mocks__": "_fd_mock_open", | |
| 5622 | "model": "_fd_model_open", | |
| 5623 | ".model": "_fd_model_open", | |
| 5624 | "models": "_fd_model_open", | |
| 5625 | ".models": "_fd_model_open", | |
| 5626 | "entities": "_fd_model_open", | |
| 5627 | ".entities": "_fd_model_open", | |
| 5628 | "modules": "_fd_module_open", | |
| 5629 | "mojo": "_fd_mojo_open", | |
| 5630 | "mongodb": "_fd_mongodb_open", | |
| 5631 | "mongo": "_fd_mongodb_open", | |
| 5632 | ".mypy_cache": "_fd_light_mypy_open", | |
| 5633 | "mysqldb": "_fd_light_mysql_open", | |
| 5634 | "mysql": "_fd_light_mysql_open", | |
| 5635 | "netlify": "_fd_netlify_open", | |
| 5636 | ".next": "_fd_next_open", | |
| 5637 | "nginx": "_fd_nginx_open", | |
| 5638 | "conf.d": "_fd_nginx_open", | |
| 5639 | ".niv": "_fd_nix_open", | |
| 5640 | ".nix": "_fd_nix_open", | |
| 5641 | "nix": "_fd_nix_open", | |
| 5642 | "niv": "_fd_nix_open", | |
| 5643 | "node_modules": "_fd_light_node_open", | |
| 5644 | "notebooks": "_fd_notebooks_open", | |
| 5645 | "notebook": "_fd_notebooks_open", | |
| 5646 | "notification": "_fd_notification_open", | |
| 5647 | "notifications": "_fd_notification_open", | |
| 5648 | "event": "_fd_notification_open", | |
| 5649 | "events": "_fd_notification_open", | |
| 5650 | ".nuget": "_fd_nuget_open", | |
| 5651 | "nuxt": "_fd_nuxt_open", | |
| 5652 | ".nuxt": "_fd_nuxt_open", | |
| 5653 | "package": "_fd_package_open", | |
| 5654 | "packages": "_fd_package_open", | |
| 5655 | ".package": "_fd_package_open", | |
| 5656 | ".packages": "_fd_package_open", | |
| 5657 | "pkg": "_fd_package_open", | |
| 5658 | ".paket": "_fd_paket_open", | |
| 5659 | "php": "_fd_php_open", | |
| 5660 | ".pio": "_fd_platformio_open", | |
| 5661 | ".pioenvs": "_fd_platformio_open", | |
| 5662 | "plugin": "_fd_plugin_open", | |
| 5663 | ".plugin": "_fd_plugin_open", | |
| 5664 | "plugins": "_fd_plugin_open", | |
| 5665 | ".plugins": "_fd_plugin_open", | |
| 5666 | "extension": "_fd_plugin_open", | |
| 5667 | ".extension": "_fd_plugin_open", | |
| 5668 | "extensions": "_fd_plugin_open", | |
| 5669 | ".extensions": "_fd_plugin_open", | |
| 5670 | "prisma": "_fd_prisma_open", | |
| 5671 | "private": "_fd_private_open", | |
| 5672 | ".private": "_fd_private_open", | |
| 5673 | "public": "_fd_public_open", | |
| 5674 | ".public": "_fd_public_open", | |
| 5675 | ".pytest_cache": "_fd_pytest_open", | |
| 5676 | ".venv": "_fd_python_open", | |
| 5677 | ".virtualenv": "_fd_python_open", | |
| 5678 | "__pycache__": "_fd_python_open", | |
| 5679 | "ravendb": "_fd_ravendb_open", | |
| 5680 | "redis": "_fd_redis_open", | |
| 5681 | "redux": "_fd_light_redux_open", | |
| 5682 | "route": "_fd_route_open", | |
| 5683 | "routes": "_fd_route_open", | |
| 5684 | "_route": "_fd_route_open", | |
| 5685 | "_routes": "_fd_route_open", | |
| 5686 | "router": "_fd_route_open", | |
| 5687 | "routers": "_fd_route_open", | |
| 5688 | "sass": "_fd_light_sass_open", | |
| 5689 | "scss": "_fd_light_sass_open", | |
| 5690 | "_sass": "_fd_light_sass_open", | |
| 5691 | "_scss": "_fd_light_sass_open", | |
| 5692 | "script": "_fd_script_open", | |
| 5693 | "scripts": "_fd_script_open", | |
| 5694 | "seedkit": "_fd_seedkit_open", | |
| 5695 | ".seedkit": "_fd_seedkit_open", | |
| 5696 | "server": "_fd_server_open", | |
| 5697 | "service": "_fd_services_open", | |
| 5698 | "services": "_fd_services_open", | |
| 5699 | "share": "_fd_shared_open", | |
| 5700 | "shared": "_fd_shared_open", | |
| 5701 | ".share": "_fd_shared_open", | |
| 5702 | ".shared": "_fd_shared_open", | |
| 5703 | "__shared__": "_fd_shared_open", | |
| 5704 | "__share__": "_fd_shared_open", | |
| 5705 | ".snaplet": "_fd_snaplet_open", | |
| 5706 | ".spin": "_fd_spin_open", | |
| 5707 | "src": "_fd_src_open", | |
| 5708 | "source": "_fd_src_open", | |
| 5709 | "sources": "_fd_src_open", | |
| 5710 | "__src__": "_fd_src_open", | |
| 5711 | "sso": "_fd_sso_open", | |
| 5712 | "story": "_fd_story_open", | |
| 5713 | "stories": "_fd_story_open", | |
| 5714 | "__stories__": "_fd_story_open", | |
| 5715 | ".storybook": "_fd_story_open", | |
| 5716 | "style": "_fd_style_open", | |
| 5717 | "styles": "_fd_style_open", | |
| 5718 | "supabase": "_fd_supabase_open", | |
| 5719 | "svelte": "_fd_svelte_open", | |
| 5720 | ".svelte-kit": "_fd_svelte_open", | |
| 5721 | "src-tauri": "_fd_tauri_open", | |
| 5722 | "temp": "_fd_temp_open", | |
| 5723 | ".temp": "_fd_temp_open", | |
| 5724 | "tmp": "_fd_temp_open", | |
| 5725 | ".tmp": "_fd_temp_open", | |
| 5726 | "template": "_fd_template_open", | |
| 5727 | ".template": "_fd_template_open", | |
| 5728 | "templates": "_fd_template_open", | |
| 5729 | ".templates": "_fd_template_open", | |
| 5730 | "tests": "_fd_test_open", | |
| 5731 | ".tests": "_fd_test_open", | |
| 5732 | "test": "_fd_test_open", | |
| 5733 | ".test": "_fd_test_open", | |
| 5734 | "__tests__": "_fd_test_open", | |
| 5735 | "__test__": "_fd_test_open", | |
| 5736 | "spec": "_fd_test_open", | |
| 5737 | ".spec": "_fd_test_open", | |
| 5738 | "specs": "_fd_test_open", | |
| 5739 | ".specs": "_fd_test_open", | |
| 5740 | "integration": "_fd_test_open", | |
| 5741 | "theme": "_fd_theme_open", | |
| 5742 | "themes": "_fd_theme_open", | |
| 5743 | "tool": "_fd_tools_open", | |
| 5744 | "tools": "_fd_tools_open", | |
| 5745 | ".tools": "_fd_tools_open", | |
| 5746 | "util": "_fd_tools_open", | |
| 5747 | "utils": "_fd_tools_open", | |
| 5748 | "utilities": "_fd_tools_open", | |
| 5749 | "tooling": "_fd_tools_open", | |
| 5750 | ".travis": "_fd_travis_open", | |
| 5751 | ".trunk": "_fd_trunk_open", | |
| 5752 | ".turbo": "_fd_turbo_open", | |
| 5753 | "typescript": "_fd_typescript_open", | |
| 5754 | "ts": "_fd_typescript_open", | |
| 5755 | "typings": "_fd_typings_open", | |
| 5756 | "types": "_fd_typings_open", | |
| 5757 | "@types": "_fd_typings_open", | |
| 5758 | "vagrant": "_fd_vagrant_open", | |
| 5759 | ".vagrant": "_fd_vagrant_open", | |
| 5760 | ".vercel": "_fd_vercel_open", | |
| 5761 | "video": "_fd_video_open", | |
| 5762 | ".video": "_fd_video_open", | |
| 5763 | "videos": "_fd_video_open", | |
| 5764 | ".videos": "_fd_video_open", | |
| 5765 | "html": "_fd_view_open", | |
| 5766 | "view": "_fd_view_open", | |
| 5767 | "views": "_fd_view_open", | |
| 5768 | "layout": "_fd_view_open", | |
| 5769 | "layouts": "_fd_view_open", | |
| 5770 | "page": "_fd_view_open", | |
| 5771 | "pages": "_fd_view_open", | |
| 5772 | "_view": "_fd_view_open", | |
| 5773 | "_views": "_fd_view_open", | |
| 5774 | "_layout": "_fd_view_open", | |
| 5775 | "_layouts": "_fd_view_open", | |
| 5776 | "_page": "_fd_view_open", | |
| 5777 | "_pages": "_fd_view_open", | |
| 5778 | ".vitepress": "_fd_vitepress_open", | |
| 5779 | ".vs": "_fd_vs_open", | |
| 5780 | ".vscode": "_fd_vscode_open", | |
| 5781 | "vscode": "_fd_vscode_open", | |
| 5782 | ".vscode-test": "_fd_vscode_test_open", | |
| 5783 | "wasm": "_fd_wasm_open", | |
| 5784 | "WASM": "_fd_wasm_open", | |
| 5785 | "webassembly": "_fd_wasm_open", | |
| 5786 | "WebAssembly": "_fd_wasm_open", | |
| 5787 | "webpack": "_fd_webpack_open", | |
| 5788 | ".webpack": "_fd_webpack_open", | |
| 5789 | "windows": "_fd_windows_open", | |
| 5790 | "win32": "_fd_windows_open", | |
| 5791 | ".windsurf": "_fd_windsurf_open", | |
| 5792 | "wit": "_fd_wit_open", | |
| 5793 | ".web": "_fd_www_open", | |
| 5794 | "www": "_fd_www_open", | |
| 5795 | "wwwroot": "_fd_www_open", | |
| 5796 | "web": "_fd_www_open", | |
| 5797 | ".yarn": "_fd_yarn_open", | |
| 5798 | ".zed": "_fd_light_zed_open" | |
| 5799 | }, | |
| 5800 | "fileExtensions": { | |
| 5801 | "accdb": "_f_access", | |
| 5802 | "accdt": "_f_access", | |
| 5803 | "mdb": "_f_access", | |
| 5804 | "accda": "_f_access", | |
| 5805 | "accdc": "_f_access", | |
| 5806 | "accde": "_f_access", | |
| 5807 | "accdp": "_f_access", | |
| 5808 | "accdr": "_f_access", | |
| 5809 | "accdu": "_f_access", | |
| 5810 | "ade": "_f_access", | |
| 5811 | "adp": "_f_access", | |
| 5812 | "laccdb": "_f_access", | |
| 5813 | "ldb": "_f_access", | |
| 5814 | "mam": "_f_access", | |
| 5815 | "maq": "_f_access", | |
| 5816 | "mdw": "_f_access", | |
| 5817 | "af": "_f_affinity", | |
| 5818 | "aftemplate": "_f_affinity", | |
| 5819 | "afdesign": "_f_affinitydesigner", | |
| 5820 | "affinitydesigner": "_f_affinitydesigner", | |
| 5821 | "afphoto": "_f_affinityphoto", | |
| 5822 | "affinityphoto": "_f_affinityphoto", | |
| 5823 | "afpub": "_f_affinitypublisher", | |
| 5824 | "affinitypublisher": "_f_affinitypublisher", | |
| 5825 | "ai": "_f_ai", | |
| 5826 | "dal": "_f_al_dal", | |
| 5827 | "gs": "_f_appscript", | |
| 5828 | "ino": "_f_arduino", | |
| 5829 | "pde": "_f_arduino", | |
| 5830 | "ase": "_f_aseprite", | |
| 5831 | "aseprite": "_f_aseprite", | |
| 5832 | "aspx": "_f_aspx", | |
| 5833 | "ascx": "_f_aspx", | |
| 5834 | "asm": "_f_assembly", | |
| 5835 | "s": "_f_assembly", | |
| 5836 | "astro": "_f_light_astro", | |
| 5837 | "atom": "_f_atom", | |
| 5838 | "aac": "_f_audio", | |
| 5839 | "act": "_f_audio", | |
| 5840 | "aiff": "_f_audio", | |
| 5841 | "amr": "_f_audio", | |
| 5842 | "ape": "_f_audio", | |
| 5843 | "au": "_f_audio", | |
| 5844 | "dct": "_f_audio", | |
| 5845 | "dss": "_f_audio", | |
| 5846 | "dvf": "_f_audio", | |
| 5847 | "flac": "_f_audio", | |
| 5848 | "gsm": "_f_audio", | |
| 5849 | "iklax": "_f_audio", | |
| 5850 | "ivs": "_f_audio", | |
| 5851 | "m4a": "_f_audio", | |
| 5852 | "m4b": "_f_audio", | |
| 5853 | "m4p": "_f_audio", | |
| 5854 | "mmf": "_f_audio", | |
| 5855 | "mogg": "_f_audio", | |
| 5856 | "mp3": "_f_audio", | |
| 5857 | "mpc": "_f_audio", | |
| 5858 | "msv": "_f_audio", | |
| 5859 | "oga": "_f_audio", | |
| 5860 | "ogg": "_f_audio", | |
| 5861 | "opus": "_f_audio", | |
| 5862 | "ra": "_f_audio", | |
| 5863 | "raw": "_f_audio", | |
| 5864 | "tta": "_f_audio", | |
| 5865 | "vox": "_f_audio", | |
| 5866 | "wav": "_f_audio", | |
| 5867 | "wma": "_f_audio", | |
| 5868 | "avif": "_f_avif", | |
| 5869 | "awk": "_f_awk", | |
| 5870 | "back": "_f_bak", | |
| 5871 | "backup": "_f_bak", | |
| 5872 | "bak": "_f_bak", | |
| 5873 | "a": "_f_binary", | |
| 5874 | "app": "_f_binary", | |
| 5875 | "bin": "_f_binary", | |
| 5876 | "cmo": "_f_binary", | |
| 5877 | "cmx": "_f_binary", | |
| 5878 | "cma": "_f_binary", | |
| 5879 | "cmxa": "_f_binary", | |
| 5880 | "cmi": "_f_binary", | |
| 5881 | "dll": "_f_binary", | |
| 5882 | "exe": "_f_binary", | |
| 5883 | "hl": "_f_binary", | |
| 5884 | "ilk": "_f_binary", | |
| 5885 | "lib": "_f_binary", | |
| 5886 | "n": "_f_binary", | |
| 5887 | "ndll": "_f_binary", | |
| 5888 | "o": "_f_binary", | |
| 5889 | "obj": "_f_binary", | |
| 5890 | "pyc": "_f_binary", | |
| 5891 | "pyd": "_f_binary", | |
| 5892 | "pyo": "_f_binary", | |
| 5893 | "pdb": "_f_binary", | |
| 5894 | "scpt": "_f_binary", | |
| 5895 | "scptd": "_f_binary", | |
| 5896 | "so": "_f_binary", | |
| 5897 | "blend": "_f_blender", | |
| 5898 | "bb": "_f_blitzbasic", | |
| 5899 | "buf.yaml": "_f_buf", | |
| 5900 | "buf.yml": "_f_buf", | |
| 5901 | "buf.gen.yml": "_f_buf", | |
| 5902 | "buf.gen.yaml": "_f_buf", | |
| 5903 | "buf.work.yaml": "_f_buf", | |
| 5904 | "buf.work.yml": "_f_buf", | |
| 5905 | "gemfile": "_f_bundler", | |
| 5906 | "gemfile.lock": "_f_bundler", | |
| 5907 | "cake": "_f_cake", | |
| 5908 | "csr": "_f_cert", | |
| 5909 | "crt": "_f_cert", | |
| 5910 | "cer": "_f_cert", | |
| 5911 | "der": "_f_cert", | |
| 5912 | "pfx": "_f_cert", | |
| 5913 | "p12": "_f_cert", | |
| 5914 | "p7b": "_f_cert", | |
| 5915 | "p7r": "_f_cert", | |
| 5916 | "src": "_f_cert", | |
| 5917 | "crl": "_f_cert", | |
| 5918 | "sst": "_f_cert", | |
| 5919 | "stl": "_f_cert", | |
| 5920 | "lucee": "_f_cf", | |
| 5921 | "h": "_f_cheader", | |
| 5922 | "class": "_f_class", | |
| 5923 | "cjm": "_f_clojure", | |
| 5924 | "cljc": "_f_clojure", | |
| 5925 | "cljs": "_f_clojurescript", | |
| 5926 | "kit": "_f_codekit", | |
| 5927 | "plist": "_f_light_config", | |
| 5928 | "lcov": "_f_coverage", | |
| 5929 | "hpp": "_f_cppheader", | |
| 5930 | "hh": "_f_cppheader", | |
| 5931 | "hxx": "_f_cppheader", | |
| 5932 | "h++": "_f_cppheader", | |
| 5933 | "csx": "_f_csharp", | |
| 5934 | "csproj": "_f_csproj", | |
| 5935 | "css.map": "_f_cssmap", | |
| 5936 | "cy.js": "_f_light_cypress_spec", | |
| 5937 | "cy.mjs": "_f_light_cypress_spec", | |
| 5938 | "cy.cjs": "_f_light_cypress_spec", | |
| 5939 | "cy.coffee": "_f_light_cypress_spec", | |
| 5940 | "cy.ts": "_f_light_cypress_spec", | |
| 5941 | "cy.tsx": "_f_light_cypress_spec", | |
| 5942 | "cy.jsx": "_f_light_cypress_spec", | |
| 5943 | "g.dart": "_f_dartlang_generated", | |
| 5944 | "freezed.dart": "_f_dartlang_generated", | |
| 5945 | "db": "_f_light_db", | |
| 5946 | "deb": "_f_debian", | |
| 5947 | "resolver.json": "_f_design_tokens", | |
| 5948 | "resolver.json5": "_f_design_tokens", | |
| 5949 | "resolver.jsonc": "_f_design_tokens", | |
| 5950 | "tokens.json": "_f_design_tokens", | |
| 5951 | "tokens.json5": "_f_design_tokens", | |
| 5952 | "tokens.jsonc": "_f_design_tokens", | |
| 5953 | "djt": "_f_django", | |
| 5954 | "eco": "_f_light_docpad", | |
| 5955 | "drawio": "_f_drawio", | |
| 5956 | "dio": "_f_drawio", | |
| 5957 | "drawio.png": "_f_drawio", | |
| 5958 | "drawio.svg": "_f_drawio", | |
| 5959 | "dio.png": "_f_drawio", | |
| 5960 | "dio.svg": "_f_drawio", | |
| 5961 | "dtd": "_f_dtd", | |
| 5962 | "eex": "_f_eex", | |
| 5963 | "heex": "_f_eex", | |
| 5964 | "phoenix-heex": "_f_eex", | |
| 5965 | "html-heex": "_f_eex", | |
| 5966 | "ejs": "_f_ejs", | |
| 5967 | "el": "_f_emacs", | |
| 5968 | "elc": "_f_emacs", | |
| 5969 | "ensime": "_f_ensime", | |
| 5970 | "eps": "_f_eps", | |
| 5971 | "epub": "_f_epub", | |
| 5972 | "excalidraw": "_f_excalidraw", | |
| 5973 | "excalidraw.json": "_f_excalidraw", | |
| 5974 | "excalidraw.svg": "_f_excalidraw", | |
| 5975 | "excalidraw.png": "_f_excalidraw", | |
| 5976 | "xls": "_f_excel", | |
| 5977 | "xlsx": "_f_excel", | |
| 5978 | "xlsm": "_f_excel", | |
| 5979 | "ods": "_f_libreoffice_calc", | |
| 5980 | "fods": "_f_libreoffice_calc", | |
| 5981 | "xlsb": "_f_excel", | |
| 5982 | "fbx": "_f_fbx", | |
| 5983 | "fba": "_f_fitbit", | |
| 5984 | "fla": "_f_light_fla", | |
| 5985 | "swf": "_f_flash", | |
| 5986 | "swc": "_f_flash", | |
| 5987 | "js.flow": "_f_flow", | |
| 5988 | "woff": "_f_light_font", | |
| 5989 | "woff2": "_f_light_font", | |
| 5990 | "ttf": "_f_light_font", | |
| 5991 | "otf": "_f_libreoffice_math", | |
| 5992 | "eot": "_f_light_font", | |
| 5993 | "pfa": "_f_light_font", | |
| 5994 | "pfb": "_f_light_font", | |
| 5995 | "sfd": "_f_light_font", | |
| 5996 | "fsproj": "_f_fsproj", | |
| 5997 | "gmx": "_f_gamemaker", | |
| 5998 | "yy": "_f_light_gamemaker2", | |
| 5999 | "yyp": "_f_light_gamemaker2", | |
| 6000 | "gd.uid": "_f_gduid", | |
| 6001 | "geojson": "_f_geojson", | |
| 6002 | "xcf": "_f_gimp", | |
| 6003 | "gleam": "_f_gleam", | |
| 6004 | "glb": "_f_gltf", | |
| 6005 | "gltf": "_f_gltf", | |
| 6006 | "am": "_f_gnu", | |
| 6007 | "ld": "_f_gnu", | |
| 6008 | "m4": "_f_gnu", | |
| 6009 | "makefile": "_f_gnu", | |
| 6010 | "godot": "_f_godot", | |
| 6011 | "gpg": "_f_gpg", | |
| 6012 | "pgp": "_f_gpg", | |
| 6013 | "gradle": "_f_light_gradle", | |
| 6014 | "gr": "_f_grain", | |
| 6015 | "hcl2": "_f_light_hashicorp", | |
| 6016 | "sentinel": "_f_light_hashicorp", | |
| 6017 | "hxproj": "_f_haxedevelop", | |
| 6018 | "ejs.t": "_f_hygen", | |
| 6019 | "idr": "_f_idris", | |
| 6020 | "lidr": "_f_idris", | |
| 6021 | "ibc": "_f_idrisbin", | |
| 6022 | "ipkg": "_f_idrispkg", | |
| 6023 | "jpeg": "_f_image", | |
| 6024 | "jpg": "_f_image", | |
| 6025 | "gif": "_f_image", | |
| 6026 | "png": "_f_image", | |
| 6027 | "bmp": "_f_image", | |
| 6028 | "tiff": "_f_image", | |
| 6029 | "heic": "_f_image", | |
| 6030 | "ico": "_f_image", | |
| 6031 | "icns": "_f_image", | |
| 6032 | "webp": "_f_image", | |
| 6033 | "imba": "_f_imba", | |
| 6034 | "imba2": "_f_imba", | |
| 6035 | "inc": "_f_inc", | |
| 6036 | "include": "_f_inc", | |
| 6037 | "infopathxml": "_f_infopath", | |
| 6038 | "xsn": "_f_infopath", | |
| 6039 | "xsf": "_f_infopath", | |
| 6040 | "xtp2": "_f_infopath", | |
| 6041 | "jar": "_f_jar", | |
| 6042 | "jbuilder": "_f_jbuilder", | |
| 6043 | "iml": "_f_jetbrains", | |
| 6044 | "js.map": "_f_light_jsmap", | |
| 6045 | "cjs.map": "_f_light_jsmap", | |
| 6046 | "mjs.map": "_f_light_jsmap", | |
| 6047 | "jsonl": "_f_light_json", | |
| 6048 | "ndjson": "_f_light_json", | |
| 6049 | "json5": "_f_light_json5", | |
| 6050 | "schema.json": "_f_light_json_schema", | |
| 6051 | "jsonld": "_f_light_jsonld", | |
| 6052 | "json-ld": "_f_light_jsonld", | |
| 6053 | "jsp": "_f_jsp", | |
| 6054 | "jss": "_f_jss", | |
| 6055 | "juice": "_f_juice", | |
| 6056 | "ipynb": "_f_jupyter", | |
| 6057 | "key": "_f_key", | |
| 6058 | "pem": "_f_key", | |
| 6059 | "ktm": "_f_kotlin", | |
| 6060 | "language-configuration.json": "_f_language_configuration", | |
| 6061 | "master": "_f_layout", | |
| 6062 | "layout.html": "_f_layout", | |
| 6063 | "layout.htm": "_f_layout", | |
| 6064 | "odb": "_f_libreoffice_base", | |
| 6065 | "otb": "_f_libreoffice_base", | |
| 6066 | "ots": "_f_libreoffice_calc", | |
| 6067 | "odg": "_f_libreoffice_draw", | |
| 6068 | "otg": "_f_libreoffice_draw", | |
| 6069 | "odp": "_f_libreoffice_impress", | |
| 6070 | "otp": "_f_libreoffice_impress", | |
| 6071 | "odf": "_f_libreoffice_math", | |
| 6072 | "odt": "_f_libreoffice_writer", | |
| 6073 | "ott": "_f_libreoffice_writer", | |
| 6074 | "enc": "_f_license", | |
| 6075 | "license": "_f_license", | |
| 6076 | "lic": "_f_license", | |
| 6077 | "hxp": "_f_lime", | |
| 6078 | "liquid": "_f_liquid", | |
| 6079 | "ls": "_f_livescript", | |
| 6080 | "lnk": "_f_lnk", | |
| 6081 | "log": "_f_log", | |
| 6082 | "tlg": "_f_log", | |
| 6083 | "luau": "_f_luau", | |
| 6084 | "crec": "_f_lync", | |
| 6085 | "ocrec": "_f_lync", | |
| 6086 | "map": "_f_map", | |
| 6087 | "mdown": "_f_markdown", | |
| 6088 | "markdown": "_f_markdown", | |
| 6089 | "marko.js": "_f_markojs", | |
| 6090 | "fig": "_f_matlab", | |
| 6091 | "mex": "_f_matlab", | |
| 6092 | "mexn": "_f_matlab", | |
| 6093 | "mexrs6": "_f_matlab", | |
| 6094 | "mn": "_f_matlab", | |
| 6095 | "mum": "_f_matlab", | |
| 6096 | "mx": "_f_matlab", | |
| 6097 | "mx3": "_f_matlab", | |
| 6098 | "rwd": "_f_matlab", | |
| 6099 | "slx": "_f_matlab", | |
| 6100 | "slddc": "_f_matlab", | |
| 6101 | "smv": "_f_matlab", | |
| 6102 | "xvc": "_f_matlab", | |
| 6103 | "mca": "_f_minecraft", | |
| 6104 | "mcmeta": "_f_minecraft", | |
| 6105 | "mojo": "_f_mojo", | |
| 6106 | "🔥": "_f_mojo", | |
| 6107 | "mql.yaml": "_f_mondoo", | |
| 6108 | "mql.yml": "_f_mondoo", | |
| 6109 | "mustache": "_f_light_mustache", | |
| 6110 | "mst": "_f_light_mustache", | |
| 6111 | "ndst.yaml": "_f_ndst", | |
| 6112 | "ndst.yml": "_f_ndst", | |
| 6113 | "ndst.json": "_f_ndst", | |
| 6114 | "njsproj": "_f_njsproj", | |
| 6115 | "noc": "_f_noc", | |
| 6116 | "nupkg": "_f_nuget", | |
| 6117 | "snupkg": "_f_nuget", | |
| 6118 | "nuspec": "_f_nuget", | |
| 6119 | "psmdcp": "_f_nuget", | |
| 6120 | "npy": "_f_numpy", | |
| 6121 | "npz": "_f_numpy", | |
| 6122 | "nunj": "_f_nunjucks", | |
| 6123 | "njs": "_f_nunjucks", | |
| 6124 | "mli": "_f_ocaml_intf", | |
| 6125 | "one": "_f_onenote", | |
| 6126 | "onepkg": "_f_onenote", | |
| 6127 | "onetoc": "_f_onenote", | |
| 6128 | "onetoc2": "_f_onenote", | |
| 6129 | "sig": "_f_onenote", | |
| 6130 | "opam": "_f_opam", | |
| 6131 | "cl": "_f_opencl", | |
| 6132 | "opencl": "_f_opencl", | |
| 6133 | "tofu": "_f_opentofu", | |
| 6134 | "tofu.json": "_f_opentofu", | |
| 6135 | "tofutest.hcl": "_f_opentofu", | |
| 6136 | "tofutest.json": "_f_opentofu", | |
| 6137 | "org": "_f_org", | |
| 6138 | "pst": "_f_outlook", | |
| 6139 | "bcmx": "_f_outlook", | |
| 6140 | "otm": "_f_outlook", | |
| 6141 | "msg": "_f_outlook", | |
| 6142 | "oft": "_f_outlook", | |
| 6143 | "ovpn": "_f_ovpn", | |
| 6144 | "pkg": "_f_package", | |
| 6145 | "paket.references": "_f_paket", | |
| 6146 | "paket.template": "_f_paket", | |
| 6147 | "patch": "_f_patch", | |
| 6148 | "pcd": "_f_light_pcl", | |
| 6149 | "pdf": "_f_pdf", | |
| 6150 | "psd": "_f_photoshop", | |
| 6151 | "php1": "_f_php", | |
| 6152 | "php2": "_f_php", | |
| 6153 | "php3": "_f_php", | |
| 6154 | "php4": "_f_php", | |
| 6155 | "php5": "_f_php", | |
| 6156 | "php6": "_f_php", | |
| 6157 | "phps": "_f_php", | |
| 6158 | "phpsa": "_f_php", | |
| 6159 | "phpt": "_f_php", | |
| 6160 | "phtml": "_f_php", | |
| 6161 | "phar": "_f_php", | |
| 6162 | "pipeline": "_f_pipeline", | |
| 6163 | "pu": "_f_plantuml", | |
| 6164 | "plantuml": "_f_plantuml", | |
| 6165 | "iuml": "_f_plantuml", | |
| 6166 | "puml": "_f_plantuml", | |
| 6167 | "pck": "_f_plsql_package", | |
| 6168 | "pkb": "_f_plsql_package_body", | |
| 6169 | "pkh": "_f_plsql_package_header", | |
| 6170 | "pks": "_f_plsql_package_spec", | |
| 6171 | "po": "_f_poedit", | |
| 6172 | "mo": "_f_poedit", | |
| 6173 | "postman_collection.json": "_f_postman", | |
| 6174 | "postman_environment.json": "_f_postman", | |
| 6175 | "postman_globals.json": "_f_postman", | |
| 6176 | "pot": "_f_powerpoint", | |
| 6177 | "potx": "_f_powerpoint", | |
| 6178 | "potm": "_f_powerpoint", | |
| 6179 | "pps": "_f_powerpoint", | |
| 6180 | "ppsx": "_f_powerpoint", | |
| 6181 | "ppsm": "_f_powerpoint", | |
| 6182 | "ppt": "_f_powerpoint", | |
| 6183 | "pptx": "_f_powerpoint", | |
| 6184 | "pptm": "_f_powerpoint", | |
| 6185 | "pa": "_f_powerpoint", | |
| 6186 | "ppa": "_f_powerpoint", | |
| 6187 | "ppam": "_f_powerpoint", | |
| 6188 | "sldm": "_f_powerpoint", | |
| 6189 | "sldx": "_f_powerpoint", | |
| 6190 | "format.ps1xml": "_f_powershell_format", | |
| 6191 | "psd1": "_f_powershell_psd", | |
| 6192 | "psm1": "_f_powershell_psm", | |
| 6193 | "types.ps1xml": "_f_powershell_types", | |
| 6194 | "pro": "_f_prolog", | |
| 6195 | "pub": "_f_publisher", | |
| 6196 | "puz": "_f_publisher", | |
| 6197 | "pyowo": "_f_pythowo", | |
| 6198 | "q": "_f_q", | |
| 6199 | "qbs": "_f_qbs", | |
| 6200 | "qvd": "_f_qlikview", | |
| 6201 | "qvw": "_f_qlikview", | |
| 6202 | "rast": "_f_ra_syntax_tree", | |
| 6203 | "rake": "_f_rake", | |
| 6204 | "rt": "_f_reacttemplate", | |
| 6205 | "reg": "_f_registry", | |
| 6206 | "rego": "_f_rego", | |
| 6207 | "ripple": "_f_ripple", | |
| 6208 | "rnc": "_f_rnc", | |
| 6209 | "ron": "_f_ron", | |
| 6210 | "rproj": "_f_rproj", | |
| 6211 | "rss": "_f_rss", | |
| 6212 | "rbs": "_f_ruby", | |
| 6213 | "safetensors": "_f_light_safetensors", | |
| 6214 | "sls": "_f_saltstack", | |
| 6215 | "sass": "_f_sass", | |
| 6216 | "scssm": "_f_scss", | |
| 6217 | "unity": "_f_light_shaderlab", | |
| 6218 | "fish": "_f_shell", | |
| 6219 | "sy": "_f_siyuan", | |
| 6220 | "sketch": "_f_sketch", | |
| 6221 | "eskip": "_f_skipper", | |
| 6222 | "sln": "_f_sln", | |
| 6223 | "slnx": "_f_sln", | |
| 6224 | "slnf": "_f_sln", | |
| 6225 | "spe": "_f_spacengine", | |
| 6226 | "sqlite": "_f_sqlite", | |
| 6227 | "sqlite3": "_f_sqlite", | |
| 6228 | "db3": "_f_sqlite", | |
| 6229 | "sss": "_f_sss", | |
| 6230 | "dta": "_f_stata", | |
| 6231 | "storyboard": "_f_storyboard", | |
| 6232 | "story.js": "_f_storybook", | |
| 6233 | "story.jsx": "_f_storybook", | |
| 6234 | "story.ts": "_f_storybook", | |
| 6235 | "story.tsx": "_f_storybook", | |
| 6236 | "story.mdx": "_f_storybook", | |
| 6237 | "story.svelte": "_f_storybook", | |
| 6238 | "stories.js": "_f_storybook", | |
| 6239 | "stories.jsx": "_f_storybook", | |
| 6240 | "stories.ts": "_f_storybook", | |
| 6241 | "stories.tsx": "_f_storybook", | |
| 6242 | "stories.mdx": "_f_storybook", | |
| 6243 | "stories.svelte": "_f_storybook", | |
| 6244 | "sublime-project": "_f_sublime", | |
| 6245 | "svg": "_f_svg", | |
| 6246 | "tcl": "_f_tcl", | |
| 6247 | "exp": "_f_tcl", | |
| 6248 | "templ": "_f_templ", | |
| 6249 | "tfstate": "_f_terraform", | |
| 6250 | "tfvars": "_f_terraform", | |
| 6251 | "tf": "_f_terraform", | |
| 6252 | "tf.json": "_f_terraform", | |
| 6253 | "tst": "_f_test", | |
| 6254 | "test.js": "_f_light_testjs", | |
| 6255 | "test.jsx": "_f_light_testjs", | |
| 6256 | "test.mjs": "_f_light_testjs", | |
| 6257 | "spec.js": "_f_light_testjs", | |
| 6258 | "spec.jsx": "_f_light_testjs", | |
| 6259 | "spec.mjs": "_f_light_testjs", | |
| 6260 | "testplane.ts": "_f_testplane", | |
| 6261 | "testplane.js": "_f_testplane", | |
| 6262 | "testplane.cts": "_f_testplane", | |
| 6263 | "testplane.cjs": "_f_testplane", | |
| 6264 | "testplane.tsx": "_f_testplane", | |
| 6265 | "testplane.jsx": "_f_testplane", | |
| 6266 | "test.ts": "_f_testts", | |
| 6267 | "test.tsx": "_f_testts", | |
| 6268 | "test.mts": "_f_testts", | |
| 6269 | "spec.ts": "_f_testts", | |
| 6270 | "spec.tsx": "_f_testts", | |
| 6271 | "spec.mts": "_f_testts", | |
| 6272 | "e2e-test.ts": "_f_testts", | |
| 6273 | "e2e-test.tsx": "_f_testts", | |
| 6274 | "e2e-test.mts": "_f_testts", | |
| 6275 | "e2e-spec.ts": "_f_testts", | |
| 6276 | "e2e-spec.tsx": "_f_testts", | |
| 6277 | "e2e-spec.mts": "_f_testts", | |
| 6278 | "test-d.ts": "_f_testts", | |
| 6279 | "test-d.tsx": "_f_testts", | |
| 6280 | "test-d.mts": "_f_testts", | |
| 6281 | "texi": "_f_light_tex", | |
| 6282 | "tikz": "_f_light_tex", | |
| 6283 | "csv": "_f_text", | |
| 6284 | "tsv": "_f_text", | |
| 6285 | "tm": "_f_light_tm", | |
| 6286 | "tm.hcl": "_f_light_tm", | |
| 6287 | "todo": "_f_light_todo", | |
| 6288 | "tree": "_f_light_tree", | |
| 6289 | "tres": "_f_tres", | |
| 6290 | "tsbuildinfo": "_f_tsbuildinfo", | |
| 6291 | "tscn": "_f_tscn", | |
| 6292 | "tt2": "_f_tt", | |
| 6293 | "d.ts": "_f_typescriptdef", | |
| 6294 | "d.cts": "_f_typescriptdef", | |
| 6295 | "d.mts": "_f_typescriptdef", | |
| 6296 | "ua": "_f_uiua", | |
| 6297 | "u": "_f_unison", | |
| 6298 | "vala": "_f_vala", | |
| 6299 | "css.ts": "_f_vanilla_extract", | |
| 6300 | "vapi": "_f_vapi", | |
| 6301 | "vash": "_f_light_vash", | |
| 6302 | "vbhtml": "_f_vbhtml", | |
| 6303 | "vbproj": "_f_vbproj", | |
| 6304 | "vcxproj": "_f_vcxproj", | |
| 6305 | "vto": "_f_vento", | |
| 6306 | "vento": "_f_vento", | |
| 6307 | "3g2": "_f_video", | |
| 6308 | "3gp": "_f_video", | |
| 6309 | "asf": "_f_video", | |
| 6310 | "amv": "_f_video", | |
| 6311 | "avi": "_f_video", | |
| 6312 | "divx": "_f_video", | |
| 6313 | "qt": "_f_video", | |
| 6314 | "f4a": "_f_video", | |
| 6315 | "f4b": "_f_video", | |
| 6316 | "f4p": "_f_video", | |
| 6317 | "f4v": "_f_video", | |
| 6318 | "flv": "_f_video", | |
| 6319 | "m2v": "_f_video", | |
| 6320 | "m4v": "_f_video", | |
| 6321 | "mkv": "_f_video", | |
| 6322 | "mk3d": "_f_video", | |
| 6323 | "mov": "_f_video", | |
| 6324 | "mp2": "_f_video", | |
| 6325 | "mp4": "_f_video", | |
| 6326 | "mpe": "_f_video", | |
| 6327 | "mpeg": "_f_video", | |
| 6328 | "mpeg2": "_f_video", | |
| 6329 | "mpg": "_f_video", | |
| 6330 | "mpv": "_f_video", | |
| 6331 | "nsv": "_f_video", | |
| 6332 | "ogv": "_f_video", | |
| 6333 | "rm": "_f_video", | |
| 6334 | "rmvb": "_f_video", | |
| 6335 | "svi": "_f_video", | |
| 6336 | "vob": "_f_video", | |
| 6337 | "webm": "_f_video", | |
| 6338 | "wmv": "_f_video", | |
| 6339 | "code-snippets": "_f_vscode", | |
| 6340 | "code-workspace": "_f_vscode", | |
| 6341 | "vsix": "_f_light_vsix", | |
| 6342 | "vsixmanifest": "_f_light_vsixmanifest", | |
| 6343 | "vvvvvv": "_f_vvvvvv", | |
| 6344 | "vyi": "_f_vyper", | |
| 6345 | "wasm": "_f_wasm", | |
| 6346 | "wikitext": "_f_wikitext", | |
| 6347 | "wit": "_f_wit", | |
| 6348 | "doc": "_f_word", | |
| 6349 | "docx": "_f_word", | |
| 6350 | "docm": "_f_word", | |
| 6351 | "dot": "_f_word", | |
| 6352 | "dotx": "_f_word", | |
| 6353 | "dotm": "_f_word", | |
| 6354 | "wll": "_f_word", | |
| 6355 | "wxml": "_f_wxml", | |
| 6356 | "wxss": "_f_wxss", | |
| 6357 | "xaml": "_f_xaml", | |
| 6358 | "axaml": "_f_xaml", | |
| 6359 | "xcodeproj": "_f_xcode", | |
| 6360 | "xfl": "_f_light_xfl", | |
| 6361 | "xib": "_f_xib", | |
| 6362 | "xliff": "_f_xliff", | |
| 6363 | "xlf": "_f_xliff", | |
| 6364 | "pex": "_f_xml", | |
| 6365 | "tmlanguage": "_f_xml", | |
| 6366 | "zip": "_f_zip", | |
| 6367 | "rar": "_f_zip", | |
| 6368 | "7z": "_f_zip", | |
| 6369 | "tar": "_f_zip", | |
| 6370 | "tgz": "_f_zip", | |
| 6371 | "bz": "_f_zip", | |
| 6372 | "gz": "_f_zip", | |
| 6373 | "bzip2": "_f_zip", | |
| 6374 | "xz": "_f_zip", | |
| 6375 | "bz2": "_f_zip", | |
| 6376 | "whl": "_f_zip", | |
| 6377 | "zipx": "_f_zip", | |
| 6378 | "br": "_f_zip" | |
| 6379 | }, | |
| 6380 | "fileNames": { | |
| 6381 | "agents.md": "_f_light_agents", | |
| 6382 | "alchemy.run.ts": "_f_alchemy", | |
| 6383 | ".all-contributorsrc": "_f_allcontributors", | |
| 6384 | "allurerc.js": "_f_allure", | |
| 6385 | "allurerc.mjs": "_f_allure", | |
| 6386 | "allurerc.cjs": "_f_allure", | |
| 6387 | "allurerc.json": "_f_allure", | |
| 6388 | "allurerc.yaml": "_f_allure", | |
| 6389 | "allurerc.yml": "_f_allure", | |
| 6390 | ".angular-cli.json": "_f_angular", | |
| 6391 | "angular-cli.json": "_f_angular", | |
| 6392 | "angular.json": "_f_angular", | |
| 6393 | ".angular.json": "_f_angular", | |
| 6394 | "antlers.html": "_f_antlers_html", | |
| 6395 | "api-extractor.json": "_f_api_extractor", | |
| 6396 | "api-extractor-base.json": "_f_api_extractor", | |
| 6397 | ".appsemblerc.yaml": "_f_appsemble", | |
| 6398 | "app-definition.yaml": "_f_appsemble", | |
| 6399 | "appveyor.yml": "_f_appveyor", | |
| 6400 | ".appveyor.yml": "_f_appveyor", | |
| 6401 | "asconfig.json": "_f_assembly_script", | |
| 6402 | "astro.config.js": "_f_light_astroconfig", | |
| 6403 | "astro.config.cjs": "_f_light_astroconfig", | |
| 6404 | "astro.config.mjs": "_f_light_astroconfig", | |
| 6405 | "astro.config.ts": "_f_light_astroconfig", | |
| 6406 | "astro.config.cts": "_f_light_astroconfig", | |
| 6407 | "astro.config.mts": "_f_light_astroconfig", | |
| 6408 | ".attw.json": "_f_attw", | |
| 6409 | "aurelia.json": "_f_aurelia", | |
| 6410 | "azure-pipelines.yml": "_f_azurepipelines", | |
| 6411 | ".vsts-ci.yml": "_f_azurepipelines", | |
| 6412 | ".babelrc": "_f_light_babel", | |
| 6413 | ".babelignore": "_f_light_babel", | |
| 6414 | ".babelrc.js": "_f_light_babel", | |
| 6415 | ".babelrc.cjs": "_f_light_babel", | |
| 6416 | ".babelrc.mjs": "_f_light_babel", | |
| 6417 | ".babelrc.json": "_f_light_babel", | |
| 6418 | "babel.config.js": "_f_light_babel", | |
| 6419 | "babel.config.cjs": "_f_light_babel", | |
| 6420 | "babel.config.mjs": "_f_light_babel", | |
| 6421 | "babel.config.json": "_f_light_babel", | |
| 6422 | ".bzrignore": "_f_bazaar", | |
| 6423 | "BUILD.bazel": "_f_bazel", | |
| 6424 | ".bazelrc": "_f_bazel", | |
| 6425 | "bazel.rc": "_f_bazel", | |
| 6426 | "bazel.bazelrc": "_f_bazel", | |
| 6427 | ".bazelignore": "_f_bazel_ignore", | |
| 6428 | ".bazelversion": "_f_bazel_version", | |
| 6429 | "biome.json": "_f_biome", | |
| 6430 | "biome.jsonc": "_f_biome", | |
| 6431 | "bitbucket-pipelines.yml": "_f_bitbucketpipeline", | |
| 6432 | ".bithoundrc": "_f_bithound", | |
| 6433 | ".bowerrc": "_f_bower", | |
| 6434 | "bower.json": "_f_bower", | |
| 6435 | ".browserslistrc": "_f_browserslist", | |
| 6436 | "browserslist": "_f_browserslist", | |
| 6437 | ".buckconfig": "_f_buckbuild", | |
| 6438 | ".bunfig.toml": "_f_bun", | |
| 6439 | "bunfig.toml": "_f_bunfig", | |
| 6440 | "bun.lockb": "_f_bun", | |
| 6441 | "bun.lock": "_f_bun", | |
| 6442 | ".bundlemonrc": "_f_bundlemon", | |
| 6443 | "bundlemon.config.js": "_f_bundlemon", | |
| 6444 | "bundlemon.config.mjs": "_f_bundlemon", | |
| 6445 | "bundlemon.config.cjs": "_f_bundlemon", | |
| 6446 | ".bundlemonrc.js": "_f_bundlemon", | |
| 6447 | ".bundlemonrc.mjs": "_f_bundlemon", | |
| 6448 | ".bundlemonrc.cjs": "_f_bundlemon", | |
| 6449 | ".bundlemonrc.json": "_f_bundlemon", | |
| 6450 | ".bundlemonrc.yaml": "_f_bundlemon", | |
| 6451 | ".bundlemonrc.yml": "_f_bundlemon", | |
| 6452 | "gemfile": "_f_bundler", | |
| 6453 | "gemfile.lock": "_f_bundler", | |
| 6454 | "capacitor.config.json": "_f_capacitor", | |
| 6455 | "capacitor.config.ts": "_f_capacitor", | |
| 6456 | "Cargo.toml": "_f_cargo", | |
| 6457 | "Cargo.lock": "_f_cargo", | |
| 6458 | ".changie.yaml": "_f_changie", | |
| 6459 | ".changie.yml": "_f_changie", | |
| 6460 | "chefignore": "_f_chef", | |
| 6461 | "berksfile": "_f_chef", | |
| 6462 | "berksfile.lock": "_f_chef", | |
| 6463 | "policyfile.rb": "_f_chef", | |
| 6464 | "policyfile.lock.json": "_f_chef", | |
| 6465 | "circle.yml": "_f_light_circleci", | |
| 6466 | "claude.md": "_f_claude", | |
| 6467 | "wrangler.toml": "_f_cloudflare", | |
| 6468 | ".cfignore": "_f_light_cloudfoundry", | |
| 6469 | ".codacy.yml": "_f_light_codacy", | |
| 6470 | ".codacy.yaml": "_f_light_codacy", | |
| 6471 | ".codeclimate.yml": "_f_light_codeclimate", | |
| 6472 | "codecov.yml": "_f_codecov", | |
| 6473 | ".codecov.yml": "_f_codecov", | |
| 6474 | "config.codekit": "_f_codekit", | |
| 6475 | "config.codekit2": "_f_codekit", | |
| 6476 | "config.codekit3": "_f_codekit", | |
| 6477 | ".config.codekit": "_f_codekit", | |
| 6478 | ".config.codekit2": "_f_codekit", | |
| 6479 | ".config.codekit3": "_f_codekit", | |
| 6480 | "codemagic.yaml": "_f_codemagic", | |
| 6481 | "codeowners": "_f_light_codeowners", | |
| 6482 | ".coderabbit.yaml": "_f_coderabbit", | |
| 6483 | "coffeelint.json": "_f_coffeelint", | |
| 6484 | ".coffeelintignore": "_f_coffeelint", | |
| 6485 | ".czrc": "_f_commitizen", | |
| 6486 | ".cz.json": "_f_commitizen", | |
| 6487 | ".commitlintrc": "_f_commitlint", | |
| 6488 | "commitlint.config.js": "_f_commitlint", | |
| 6489 | "commitlint.config.cjs": "_f_commitlint", | |
| 6490 | "commitlint.config.mjs": "_f_commitlint", | |
| 6491 | "commitlint.config.ts": "_f_commitlint", | |
| 6492 | "commitlint.config.cts": "_f_commitlint", | |
| 6493 | ".commitlintrc.json": "_f_commitlint", | |
| 6494 | ".commitlintrc.yaml": "_f_commitlint", | |
| 6495 | ".commitlintrc.yml": "_f_commitlint", | |
| 6496 | ".commitlintrc.js": "_f_commitlint", | |
| 6497 | ".commitlintrc.cjs": "_f_commitlint", | |
| 6498 | ".commitlintrc.mjs": "_f_commitlint", | |
| 6499 | ".commitlintrc.ts": "_f_commitlint", | |
| 6500 | ".commitlintrc.cts": "_f_commitlint", | |
| 6501 | "composer.json": "_f_composer", | |
| 6502 | "composer.lock": "_f_composer", | |
| 6503 | "conanfile.txt": "_f_conan", | |
| 6504 | "conanfile.py": "_f_conan", | |
| 6505 | ".condarc": "_f_conda", | |
| 6506 | ".tool-versions": "_f_light_config", | |
| 6507 | "copilot-instructions.md": "_f_light_copilot", | |
| 6508 | "github-copilot.xml": "_f_light_copilot", | |
| 6509 | ".coverage": "_f_coverage", | |
| 6510 | "lcov.info": "_f_coverage", | |
| 6511 | ".coveralls.yml": "_f_coveralls", | |
| 6512 | "craco.config.ts": "_f_craco", | |
| 6513 | "craco.config.js": "_f_craco", | |
| 6514 | "craco.config.cjs": "_f_craco", | |
| 6515 | ".cracorc.ts": "_f_craco", | |
| 6516 | ".cracorc.js": "_f_craco", | |
| 6517 | ".cracorc": "_f_craco", | |
| 6518 | "crowdin.yml": "_f_crowdin", | |
| 6519 | "cspell.config.json": "_f_cspell", | |
| 6520 | "cspell.config.js": "_f_cspell", | |
| 6521 | "cspell.config.mjs": "_f_cspell", | |
| 6522 | "cspell.config.cjs": "_f_cspell", | |
| 6523 | "cspell.config.yaml": "_f_cspell", | |
| 6524 | "cspell.config.yml": "_f_cspell", | |
| 6525 | "cspell.json": "_f_cspell", | |
| 6526 | "cspell.js": "_f_cspell", | |
| 6527 | "cspell.mjs": "_f_cspell", | |
| 6528 | "cspell.cjs": "_f_cspell", | |
| 6529 | "cspell.yaml": "_f_cspell", | |
| 6530 | "cspell.yml": "_f_cspell", | |
| 6531 | ".cspell.json": "_f_cspell", | |
| 6532 | ".cspell.js": "_f_cspell", | |
| 6533 | ".cspell.mjs": "_f_cspell", | |
| 6534 | ".cspell.cjs": "_f_cspell", | |
| 6535 | ".cspell.yaml": "_f_cspell", | |
| 6536 | ".cspell.yml": "_f_cspell", | |
| 6537 | ".csscomb.json": "_f_csscomb", | |
| 6538 | ".csslintrc": "_f_csslint", | |
| 6539 | ".cursorignore": "_f_cursorrules", | |
| 6540 | ".cursorindexingignore": "_f_cursorrules", | |
| 6541 | ".cursorrules": "_f_cursorrules", | |
| 6542 | ".cvsignore": "_f_cvs", | |
| 6543 | "cypress.json": "_f_light_cypress", | |
| 6544 | "cypress.env.json": "_f_light_cypress", | |
| 6545 | "cypress.config.js": "_f_light_cypress", | |
| 6546 | "cypress.config.ts": "_f_light_cypress", | |
| 6547 | "cypress.config.cjs": "_f_light_cypress", | |
| 6548 | "cypress.config.mjs": "_f_light_cypress", | |
| 6549 | ".boringignore": "_f_darcs", | |
| 6550 | ".pubignore": "_f_dartlang_ignore", | |
| 6551 | "service.datadog.yaml": "_f_datadog", | |
| 6552 | "datadog-ci.json": "_f_datadog", | |
| 6553 | "static-analysis.datadog.yml": "_f_datadog", | |
| 6554 | "static-analysis.datadog.yaml": "_f_datadog", | |
| 6555 | "aptfile": "_f_debian", | |
| 6556 | "deno.json": "_f_deno", | |
| 6557 | "deno.jsonc": "_f_deno", | |
| 6558 | "deno.lock": "_f_deno", | |
| 6559 | ".denoifyrc": "_f_denoify", | |
| 6560 | "denoify.config.json": "_f_denoify", | |
| 6561 | "denoify.config.js": "_f_denoify", | |
| 6562 | "dependabot.yml": "_f_dependabot", | |
| 6563 | "dependabot.yaml": "_f_dependabot", | |
| 6564 | "dependencies.yml": "_f_dependencies", | |
| 6565 | "devcontainer.json": "_f_devcontainer", | |
| 6566 | ".devcontainer.json": "_f_devcontainer", | |
| 6567 | "devvit.json": "_f_devvit", | |
| 6568 | "devvit.dev.json": "_f_devvit", | |
| 6569 | "devvit.prod.json": "_f_devvit", | |
| 6570 | "devvit.test.json": "_f_devvit", | |
| 6571 | "devvit.config.js": "_f_devvit", | |
| 6572 | "devvit.config.cjs": "_f_devvit", | |
| 6573 | "devvit.config.mjs": "_f_devvit", | |
| 6574 | "devvit.config.ts": "_f_devvit", | |
| 6575 | "devvit.config.mts": "_f_devvit", | |
| 6576 | "devvit.config.dev.js": "_f_devvit", | |
| 6577 | "devvit.config.dev.cjs": "_f_devvit", | |
| 6578 | "devvit.config.dev.mjs": "_f_devvit", | |
| 6579 | "devvit.config.dev.ts": "_f_devvit", | |
| 6580 | "devvit.config.dev.mts": "_f_devvit", | |
| 6581 | "devvit.config.prod.js": "_f_devvit", | |
| 6582 | "devvit.config.prod.cjs": "_f_devvit", | |
| 6583 | "devvit.config.prod.mjs": "_f_devvit", | |
| 6584 | "devvit.config.prod.ts": "_f_devvit", | |
| 6585 | "devvit.config.prod.mts": "_f_devvit", | |
| 6586 | "devvit.config.test.js": "_f_devvit", | |
| 6587 | "devvit.config.test.cjs": "_f_devvit", | |
| 6588 | "devvit.config.test.mjs": "_f_devvit", | |
| 6589 | "devvit.config.test.ts": "_f_devvit", | |
| 6590 | "devvit.config.test.mts": "_f_devvit", | |
| 6591 | ".dockerignore": "_f_docker", | |
| 6592 | "compose.yaml": "_f_docker", | |
| 6593 | "compose.yml": "_f_docker", | |
| 6594 | "docker-compose.yaml": "_f_docker", | |
| 6595 | "docker-compose.yml": "_f_docker", | |
| 6596 | "docker-compose.debug.yaml": "_f_docker", | |
| 6597 | "docker-compose.debug.yml": "_f_docker", | |
| 6598 | "docker-compose.ci-build.yaml": "_f_docker", | |
| 6599 | "docker-compose.ci-build.yml": "_f_docker", | |
| 6600 | "docker-compose.override.yaml": "_f_docker", | |
| 6601 | "docker-compose.override.yml": "_f_docker", | |
| 6602 | "docker-compose.vs.debug.yaml": "_f_docker", | |
| 6603 | "docker-compose.vs.debug.yml": "_f_docker", | |
| 6604 | "docker-compose.vs.release.yaml": "_f_docker", | |
| 6605 | "docker-compose.vs.release.yml": "_f_docker", | |
| 6606 | "docker-cloud.yaml": "_f_docker", | |
| 6607 | "docker-cloud.yml": "_f_docker", | |
| 6608 | "docker-compose.test.yml": "_f_dockertest", | |
| 6609 | "docusaurus.config.ts": "_f_docusaurus", | |
| 6610 | "docusaurus.config.mts": "_f_docusaurus", | |
| 6611 | "docusaurus.config.cts": "_f_docusaurus", | |
| 6612 | "docusaurus.config.js": "_f_docusaurus", | |
| 6613 | "docusaurus.config.mjs": "_f_docusaurus", | |
| 6614 | "docusaurus.config.cjs": "_f_docusaurus", | |
| 6615 | ".doczrc": "_f_docz", | |
| 6616 | "docz.js": "_f_docz", | |
| 6617 | "docz.json": "_f_docz", | |
| 6618 | ".docz.js": "_f_docz", | |
| 6619 | ".docz.json": "_f_docz", | |
| 6620 | "doczrc.js": "_f_docz", | |
| 6621 | "doczrc.json": "_f_docz", | |
| 6622 | "docz.config.js": "_f_docz", | |
| 6623 | "docz.config.json": "_f_docz", | |
| 6624 | ".dojorc": "_f_dojo", | |
| 6625 | "doppler.yaml": "_f_doppler", | |
| 6626 | "doppler-template.yaml": "_f_doppler", | |
| 6627 | ".drone.yml": "_f_light_drone", | |
| 6628 | ".drone.yml.sig": "_f_light_drone", | |
| 6629 | "dune": "_f_dune", | |
| 6630 | "dune-project": "_f_dune", | |
| 6631 | "dune-workspace": "_f_dune", | |
| 6632 | ".dvc": "_f_dvc", | |
| 6633 | ".earthlyignore": "_f_earthly", | |
| 6634 | "Earthfile": "_f_earthly", | |
| 6635 | "store.config.json": "_f_light_eas-metadata", | |
| 6636 | ".editorconfig": "_f_editorconfig", | |
| 6637 | "forge.config.js": "_f_electron", | |
| 6638 | "forge.config.ts": "_f_electron", | |
| 6639 | ".eleventy.js": "_f_light_eleventy", | |
| 6640 | "eleventy.config.js": "_f_light_eleventy", | |
| 6641 | "eleventy.config.cjs": "_f_light_eleventy", | |
| 6642 | "eleventy.config.mjs": "_f_light_eleventy", | |
| 6643 | "elm-package.json": "_f_elm", | |
| 6644 | ".ember-cli": "_f_ember", | |
| 6645 | "emakefile": "_f_erlang", | |
| 6646 | ".emakerfile": "_f_erlang", | |
| 6647 | "esbuild.js": "_f_esbuild", | |
| 6648 | "esbuild.mjs": "_f_esbuild", | |
| 6649 | "esbuild.cjs": "_f_esbuild", | |
| 6650 | "esbuild.ts": "_f_esbuild", | |
| 6651 | "esbuild.config.js": "_f_esbuild", | |
| 6652 | "esbuild.config.mjs": "_f_esbuild", | |
| 6653 | "esbuild.config.cjs": "_f_esbuild", | |
| 6654 | "esbuild.config.ts": "_f_esbuild", | |
| 6655 | ".eslintrc": "_f_eslint", | |
| 6656 | ".eslintignore": "_f_eslint", | |
| 6657 | ".eslintcache": "_f_eslint", | |
| 6658 | "eslint.config.js": "_f_eslint", | |
| 6659 | "eslint.config.mjs": "_f_eslint", | |
| 6660 | "eslint.config.cjs": "_f_eslint", | |
| 6661 | "eslint.config.ts": "_f_eslint", | |
| 6662 | "eslint.config.mts": "_f_eslint", | |
| 6663 | "eslint.config.cts": "_f_eslint", | |
| 6664 | ".eslintrc.js": "_f_eslint", | |
| 6665 | ".eslintrc.mjs": "_f_eslint", | |
| 6666 | ".eslintrc.cjs": "_f_eslint", | |
| 6667 | ".eslintrc.ts": "_f_eslint", | |
| 6668 | ".eslintrc.mts": "_f_eslint", | |
| 6669 | ".eslintrc.cts": "_f_eslint", | |
| 6670 | ".eslintrc.json": "_f_eslint", | |
| 6671 | ".eslintrc.yaml": "_f_eslint", | |
| 6672 | ".eslintrc.yml": "_f_eslint", | |
| 6673 | "app.json": "_f_light_expo", | |
| 6674 | "app.config.js": "_f_light_expo", | |
| 6675 | "app.config.json": "_f_light_expo", | |
| 6676 | "app.config.json5": "_f_light_expo", | |
| 6677 | ".fantasticonrc": "_f_fantasticon", | |
| 6678 | "fantasticonrc": "_f_fantasticon", | |
| 6679 | ".fantasticonrc.json": "_f_fantasticon", | |
| 6680 | "fantasticonrc.json": "_f_fantasticon", | |
| 6681 | ".fantasticonrc.js": "_f_fantasticon", | |
| 6682 | "fantasticonrc.js": "_f_fantasticon", | |
| 6683 | "fastly.toml": "_f_fastly", | |
| 6684 | ".faunarc": "_f_fauna", | |
| 6685 | "favicon.ico": "_f_favicon", | |
| 6686 | ".firebaserc": "_f_firebase", | |
| 6687 | "firebase.json": "_f_light_firebasehosting", | |
| 6688 | "storage.rules": "_f_firebasestorage", | |
| 6689 | "firestore.rules": "_f_firestore", | |
| 6690 | "firestore.indexes.json": "_f_firestore", | |
| 6691 | "flareact.config.js": "_f_flareact", | |
| 6692 | ".flooignore": "_f_floobits", | |
| 6693 | ".flowconfig": "_f_flow", | |
| 6694 | ".flutter-plugins": "_f_flutter", | |
| 6695 | ".metadata": "_f_flutter", | |
| 6696 | ".flutter-plugins-dependencies": "_f_flutter", | |
| 6697 | "pubspec.lock": "_f_flutter_package", | |
| 6698 | "pubspec.yaml": "_f_flutter_package", | |
| 6699 | ".packages": "_f_flutter_package", | |
| 6700 | "fly.toml": "_f_flyio", | |
| 6701 | "formkit.config.js": "_f_formkit", | |
| 6702 | "formkit.config.mjs": "_f_formkit", | |
| 6703 | "formkit.config.cjs": "_f_formkit", | |
| 6704 | "formkit.config.ts": "_f_formkit", | |
| 6705 | ".fossaignore": "_f_fossa", | |
| 6706 | "ignore-glob": "_f_fossil", | |
| 6707 | ".front-commerce.js": "_f_frontcommerce", | |
| 6708 | "front-commerce.config.ts": "_f_frontcommerce", | |
| 6709 | "funding.yml": "_f_funding", | |
| 6710 | "fuse.js": "_f_fusebox", | |
| 6711 | "gatsby-browser.js": "_f_gatsby", | |
| 6712 | "gatsby-browser.ts": "_f_gatsby", | |
| 6713 | "gatsby-browser.tsx": "_f_gatsby", | |
| 6714 | "gatsby-ssr.js": "_f_gatsby", | |
| 6715 | "gatsby-ssr.ts": "_f_gatsby", | |
| 6716 | "gatsby-ssr.tsx": "_f_gatsby", | |
| 6717 | "gatsby-config.js": "_f_gatsby", | |
| 6718 | "gatsby-config.ts": "_f_gatsby", | |
| 6719 | "gatsby-config.mjs": "_f_gatsby", | |
| 6720 | "gatsby-node.js": "_f_gatsby", | |
| 6721 | "gatsby-node.ts": "_f_gatsby", | |
| 6722 | "gatsby-node.mjs": "_f_gatsby", | |
| 6723 | ".gcloudignore": "_f_gcloud", | |
| 6724 | "gemini.md": "_f_gemini", | |
| 6725 | "GEMINI.md": "_f_gemini", | |
| 6726 | ".git": "_f_git", | |
| 6727 | ".gitattributes": "_f_git", | |
| 6728 | ".gitconfig": "_f_git", | |
| 6729 | ".gitignore": "_f_git", | |
| 6730 | ".gitmodules": "_f_git", | |
| 6731 | ".gitkeep": "_f_git", | |
| 6732 | ".git-blame-ignore-revs": "_f_git", | |
| 6733 | ".mailmap": "_f_git", | |
| 6734 | ".issuetracker": "_f_git", | |
| 6735 | ".gitlab-ci.yml": "_f_gitlab", | |
| 6736 | ".gitpod.yaml": "_f_gitpod", | |
| 6737 | ".gitpod.yml": "_f_gitpod", | |
| 6738 | "gitpod.yaml": "_f_gitpod", | |
| 6739 | "gitpod.yml": "_f_gitpod", | |
| 6740 | "gleam.toml": "_f_gleamconfig", | |
| 6741 | "glide.yml": "_f_glide", | |
| 6742 | ".glitterrc": "_f_glitter", | |
| 6743 | "go.sum": "_f_go_package", | |
| 6744 | "go.mod": "_f_go_package", | |
| 6745 | "go.work": "_f_go_work", | |
| 6746 | "go.work.sum": "_f_go_work", | |
| 6747 | ".gqlconfig": "_f_graphql", | |
| 6748 | ".graphqlconfig": "_f_graphql_config", | |
| 6749 | ".graphqlconfig.yml": "_f_graphql_config", | |
| 6750 | ".graphqlconfig.yaml": "_f_graphql_config", | |
| 6751 | "greenkeeper.json": "_f_greenkeeper", | |
| 6752 | "gridsome.config.js": "_f_gridsome", | |
| 6753 | "gridsome.config.ts": "_f_gridsome", | |
| 6754 | "gridsome.server.js": "_f_gridsome", | |
| 6755 | "gridsome.server.ts": "_f_gridsome", | |
| 6756 | "gridsome.client.js": "_f_gridsome", | |
| 6757 | "gridsome.client.ts": "_f_gridsome", | |
| 6758 | "gruntfile.js": "_f_grunt", | |
| 6759 | "gruntfile.cjs": "_f_grunt", | |
| 6760 | "gruntfile.coffee": "_f_grunt", | |
| 6761 | "gruntfile.ts": "_f_grunt", | |
| 6762 | "gruntfile.babel.js": "_f_grunt", | |
| 6763 | "gruntfile.babel.cjs": "_f_grunt", | |
| 6764 | "gruntfile.babel.coffee": "_f_grunt", | |
| 6765 | "gruntfile.babel.ts": "_f_grunt", | |
| 6766 | "gulpfile.js": "_f_gulp", | |
| 6767 | "gulpfile.coffee": "_f_gulp", | |
| 6768 | "gulpfile.ts": "_f_gulp", | |
| 6769 | "gulpfile.mjs": "_f_gulp", | |
| 6770 | "gulpfile.esm.js": "_f_gulp", | |
| 6771 | "gulpfile.esm.coffee": "_f_gulp", | |
| 6772 | "gulpfile.esm.ts": "_f_gulp", | |
| 6773 | "gulpfile.esm.mjs": "_f_gulp", | |
| 6774 | "gulpfile.babel.js": "_f_gulp", | |
| 6775 | "gulpfile.babel.coffee": "_f_gulp", | |
| 6776 | "gulpfile.babel.ts": "_f_gulp", | |
| 6777 | "gulpfile.babel.mjs": "_f_gulp", | |
| 6778 | "hardhat.config.cjs": "_f_hardhat", | |
| 6779 | "hardhat.config.js": "_f_hardhat", | |
| 6780 | "hardhat.config.ts": "_f_hardhat", | |
| 6781 | "haxelib.json": "_f_haxe", | |
| 6782 | "checkstyle.json": "_f_haxecheckstyle", | |
| 6783 | ".p4ignore": "_f_helix", | |
| 6784 | "chart.lock": "_f_helm", | |
| 6785 | "chart.yaml": "_f_helm", | |
| 6786 | "histoire.config.ts": "_f_histoire", | |
| 6787 | "histoire.config.js": "_f_histoire", | |
| 6788 | ".histoire.ts": "_f_histoire", | |
| 6789 | ".histoire.js": "_f_histoire", | |
| 6790 | "horusec-config.json": "_f_horusec", | |
| 6791 | ".htmlhintrc": "_f_htmlhint", | |
| 6792 | ".htmlvalidateignore": "_f_htmlvalidate", | |
| 6793 | ".htmlvalidate.js": "_f_htmlvalidate", | |
| 6794 | ".htmlvalidate.cjs": "_f_htmlvalidate", | |
| 6795 | ".htmlvalidate.json": "_f_htmlvalidate", | |
| 6796 | "hugo.json": "_f_hugo", | |
| 6797 | "hugo.toml": "_f_hugo", | |
| 6798 | "hugo.yaml": "_f_hugo", | |
| 6799 | ".hugo_build.lock": "_f_hugo", | |
| 6800 | "humans.txt": "_f_humanstxt", | |
| 6801 | ".huskyrc": "_f_husky", | |
| 6802 | "husky.config.js": "_f_husky", | |
| 6803 | ".huskyrc.js": "_f_husky", | |
| 6804 | ".huskyrc.json": "_f_husky", | |
| 6805 | ".huskyrc.yaml": "_f_husky", | |
| 6806 | ".huskyrc.yml": "_f_husky", | |
| 6807 | "ionic.project": "_f_ionic", | |
| 6808 | "ionic.config.json": "_f_ionic", | |
| 6809 | "jakefile": "_f_jake", | |
| 6810 | "jakefile.js": "_f_jake", | |
| 6811 | "jasmine.json": "_f_jasmine", | |
| 6812 | ".nojekyll": "_f_jekyll", | |
| 6813 | "jest.json": "_f_jest", | |
| 6814 | "jest.config.babel.js": "_f_jest", | |
| 6815 | ".jestrc": "_f_jest", | |
| 6816 | ".jestrc.js": "_f_jest", | |
| 6817 | ".jestrc.json": "_f_jest", | |
| 6818 | "jest.config.js": "_f_jest", | |
| 6819 | "jest.config.cjs": "_f_jest", | |
| 6820 | "jest.config.mjs": "_f_jest", | |
| 6821 | "jest.config.json": "_f_jest", | |
| 6822 | "jest.config.ts": "_f_jest", | |
| 6823 | "jest.config.base.js": "_f_jest", | |
| 6824 | "jest.config.base.cjs": "_f_jest", | |
| 6825 | "jest.config.base.mjs": "_f_jest", | |
| 6826 | "jest.config.base.json": "_f_jest", | |
| 6827 | "jest.config.base.ts": "_f_jest", | |
| 6828 | "jest.config.common.js": "_f_jest", | |
| 6829 | "jest.config.common.cjs": "_f_jest", | |
| 6830 | "jest.config.common.mjs": "_f_jest", | |
| 6831 | "jest.config.common.json": "_f_jest", | |
| 6832 | "jest.config.common.ts": "_f_jest", | |
| 6833 | "jest.config.e2e.js": "_f_jest", | |
| 6834 | "jest.config.e2e.cjs": "_f_jest", | |
| 6835 | "jest.config.e2e.mjs": "_f_jest", | |
| 6836 | "jest.config.e2e.json": "_f_jest", | |
| 6837 | "jest.config.e2e.ts": "_f_jest", | |
| 6838 | "jest.config.int.js": "_f_jest", | |
| 6839 | "jest.config.int.cjs": "_f_jest", | |
| 6840 | "jest.config.int.mjs": "_f_jest", | |
| 6841 | "jest.config.int.json": "_f_jest", | |
| 6842 | "jest.config.int.ts": "_f_jest", | |
| 6843 | "jest.config.integration.js": "_f_jest", | |
| 6844 | "jest.config.integration.cjs": "_f_jest", | |
| 6845 | "jest.config.integration.mjs": "_f_jest", | |
| 6846 | "jest.config.integration.json": "_f_jest", | |
| 6847 | "jest.config.integration.ts": "_f_jest", | |
| 6848 | "jest.config.unit.js": "_f_jest", | |
| 6849 | "jest.config.unit.cjs": "_f_jest", | |
| 6850 | "jest.config.unit.mjs": "_f_jest", | |
| 6851 | "jest.config.unit.json": "_f_jest", | |
| 6852 | "jest.config.unit.ts": "_f_jest", | |
| 6853 | ".jpmignore": "_f_jpm", | |
| 6854 | ".jsbeautifyrc": "_f_jsbeautify", | |
| 6855 | "jsbeautifyrc": "_f_jsbeautify", | |
| 6856 | ".jsbeautify": "_f_jsbeautify", | |
| 6857 | "jsbeautify": "_f_jsbeautify", | |
| 6858 | "jsconfig.json": "_f_light_jsconfig", | |
| 6859 | ".jscpd.json": "_f_jscpd", | |
| 6860 | "jscpd-report.xml": "_f_jscpd", | |
| 6861 | "jscpd-report.json": "_f_jscpd", | |
| 6862 | "jscpd-report.html": "_f_jscpd", | |
| 6863 | ".jshintrc": "_f_jshint", | |
| 6864 | ".jshintignore": "_f_jshint", | |
| 6865 | "jsr.json": "_f_jsr", | |
| 6866 | "karma.conf.js": "_f_karma", | |
| 6867 | "karma.conf.coffee": "_f_karma", | |
| 6868 | "karma.conf.ts": "_f_karma", | |
| 6869 | ".kitchen.yml": "_f_kitchenci", | |
| 6870 | "kitchen.yml": "_f_kitchenci", | |
| 6871 | ".kiteignore": "_f_light_kite", | |
| 6872 | "knip.json": "_f_knip", | |
| 6873 | "knip.jsonc": "_f_knip", | |
| 6874 | ".knip.json": "_f_knip", | |
| 6875 | ".knip.jsonc": "_f_knip", | |
| 6876 | "knip.ts": "_f_knip", | |
| 6877 | "knip.js": "_f_knip", | |
| 6878 | "knip.config.ts": "_f_knip", | |
| 6879 | "knip.config.js": "_f_knip", | |
| 6880 | "language-configuration.json": "_f_language_configuration", | |
| 6881 | "layout.html": "_f_layout", | |
| 6882 | "layout.htm": "_f_layout", | |
| 6883 | "lakefile.lean": "_f_light_leanconfig", | |
| 6884 | "lakefile.toml": "_f_light_leanconfig", | |
| 6885 | "lake-manifest.json": "_f_light_leanconfig", | |
| 6886 | "lean-toolchain": "_f_light_leanconfig", | |
| 6887 | "lefthook.json": "_f_lefthook", | |
| 6888 | "lefthook.toml": "_f_lefthook", | |
| 6889 | "lefthook.yaml": "_f_lefthook", | |
| 6890 | "lefthook.yml": "_f_lefthook", | |
| 6891 | "lefthook-local.json": "_f_lefthook", | |
| 6892 | "lefthook-local.toml": "_f_lefthook", | |
| 6893 | "lefthook-local.yaml": "_f_lefthook", | |
| 6894 | "lefthook-local.yml": "_f_lefthook", | |
| 6895 | ".lefthook.json": "_f_lefthook", | |
| 6896 | ".lefthook.toml": "_f_lefthook", | |
| 6897 | ".lefthook.yaml": "_f_lefthook", | |
| 6898 | ".lefthook.yml": "_f_lefthook", | |
| 6899 | ".lefthook-local.json": "_f_lefthook", | |
| 6900 | ".lefthook-local.toml": "_f_lefthook", | |
| 6901 | ".lefthook-local.yaml": "_f_lefthook", | |
| 6902 | ".lefthook-local.yml": "_f_lefthook", | |
| 6903 | "lerna.json": "_f_light_lerna", | |
| 6904 | "liara.json": "_f_liara", | |
| 6905 | ".liaraignore": "_f_liara", | |
| 6906 | "licence": "_f_license", | |
| 6907 | "LICENCE": "_f_license", | |
| 6908 | "license": "_f_license", | |
| 6909 | "LICENSE": "_f_license", | |
| 6910 | "copying": "_f_license", | |
| 6911 | "copying.lesser": "_f_license", | |
| 6912 | "LICENSE-MIT": "_f_license", | |
| 6913 | "license-mit": "_f_license", | |
| 6914 | "LICENSE-APACHE": "_f_license", | |
| 6915 | "license-apache": "_f_license", | |
| 6916 | "LICENSE-GPL": "_f_license", | |
| 6917 | "license-gpl": "_f_license", | |
| 6918 | "LICENSE-AGPL": "_f_license", | |
| 6919 | "license-agpl": "_f_license", | |
| 6920 | "LICENCE.md": "_f_license", | |
| 6921 | "LICENCE.txt": "_f_license", | |
| 6922 | "licence.md": "_f_license", | |
| 6923 | "licence.txt": "_f_license", | |
| 6924 | "LICENSE.md": "_f_license", | |
| 6925 | "LICENSE.txt": "_f_license", | |
| 6926 | "license.md": "_f_license", | |
| 6927 | "license.txt": "_f_license", | |
| 6928 | "copying.md": "_f_license", | |
| 6929 | "copying.txt": "_f_license", | |
| 6930 | "copying.lesser.md": "_f_license", | |
| 6931 | "copying.lesser.txt": "_f_license", | |
| 6932 | "LICENSE-MIT.md": "_f_license", | |
| 6933 | "LICENSE-MIT.txt": "_f_license", | |
| 6934 | "license-mit.md": "_f_license", | |
| 6935 | "license-mit.txt": "_f_license", | |
| 6936 | "LICENSE-APACHE.md": "_f_license", | |
| 6937 | "LICENSE-APACHE.txt": "_f_license", | |
| 6938 | "license-apache.md": "_f_license", | |
| 6939 | "license-apache.txt": "_f_license", | |
| 6940 | "LICENSE-GPL.md": "_f_license", | |
| 6941 | "LICENSE-GPL.txt": "_f_license", | |
| 6942 | "license-gpl.md": "_f_license", | |
| 6943 | "license-gpl.txt": "_f_license", | |
| 6944 | "LICENSE-AGPL.md": "_f_license", | |
| 6945 | "LICENSE-AGPL.txt": "_f_license", | |
| 6946 | "license-agpl.md": "_f_license", | |
| 6947 | "license-agpl.txt": "_f_license", | |
| 6948 | ".licrc": "_f_licensebat", | |
| 6949 | "budget.json": "_f_lighthouse", | |
| 6950 | "budgets.json": "_f_lighthouse", | |
| 6951 | ".lighthouserc.cjs": "_f_lighthouse", | |
| 6952 | ".lighthouserc.js": "_f_lighthouse", | |
| 6953 | ".lighthouserc.json": "_f_lighthouse", | |
| 6954 | ".lighthouserc.yaml": "_f_lighthouse", | |
| 6955 | ".lighthouserc.yml": "_f_lighthouse", | |
| 6956 | "include.xml": "_f_lime", | |
| 6957 | ".lintstagedrc": "_f_lintstagedrc", | |
| 6958 | ".lintstagedrc.json": "_f_lintstagedrc", | |
| 6959 | ".lintstagedrc.yaml": "_f_lintstagedrc", | |
| 6960 | ".lintstagedrc.yml": "_f_lintstagedrc", | |
| 6961 | ".lintstagedrc.mjs": "_f_lintstagedrc", | |
| 6962 | ".lintstagedrc.mts": "_f_lintstagedrc", | |
| 6963 | ".lintstagedrc.js": "_f_lintstagedrc", | |
| 6964 | ".lintstagedrc.ts": "_f_lintstagedrc", | |
| 6965 | ".lintstagedrc.cjs": "_f_lintstagedrc", | |
| 6966 | ".lintstagedrc.cts": "_f_lintstagedrc", | |
| 6967 | "lint-staged.config.mjs": "_f_lintstagedrc", | |
| 6968 | "lint-staged.config.mts": "_f_lintstagedrc", | |
| 6969 | "lint-staged.config.js": "_f_lintstagedrc", | |
| 6970 | "lint-staged.config.ts": "_f_lintstagedrc", | |
| 6971 | "lint-staged.config.cjs": "_f_lintstagedrc", | |
| 6972 | "lint-staged.config.cts": "_f_lintstagedrc", | |
| 6973 | "mailing.config.json": "_f_light_mailing", | |
| 6974 | "manifest": "_f_manifest", | |
| 6975 | "manifest.bak": "_f_manifest_bak", | |
| 6976 | "manifest.skip": "_f_manifest_skip", | |
| 6977 | ".markdownlintrc": "_f_markdownlint", | |
| 6978 | ".markdownlint.json": "_f_markdownlint", | |
| 6979 | ".markdownlint.jsonc": "_f_markdownlint", | |
| 6980 | ".markdownlint.yml": "_f_markdownlint", | |
| 6981 | ".markdownlint.yaml": "_f_markdownlint", | |
| 6982 | ".markdownlint.cjs": "_f_markdownlint", | |
| 6983 | ".markdownlint-cli2.json": "_f_markdownlint", | |
| 6984 | ".markdownlint-cli2.jsonc": "_f_markdownlint", | |
| 6985 | ".markdownlint-cli2.yml": "_f_markdownlint", | |
| 6986 | ".markdownlint-cli2.yaml": "_f_markdownlint", | |
| 6987 | ".markdownlint-cli2.cjs": "_f_markdownlint", | |
| 6988 | ".markdownlintignore": "_f_markdownlint_ignore", | |
| 6989 | ".markuplintrc": "_f_light_markuplint", | |
| 6990 | ".markuplintrc.json": "_f_light_markuplint", | |
| 6991 | ".markuplintrc.yaml": "_f_light_markuplint", | |
| 6992 | ".markuplintrc.yml": "_f_light_markuplint", | |
| 6993 | ".markuplintrc.js": "_f_light_markuplint", | |
| 6994 | ".markuplintrc.cjs": "_f_light_markuplint", | |
| 6995 | ".markuplintrc.mjs": "_f_light_markuplint", | |
| 6996 | ".markuplintrc.ts": "_f_light_markuplint", | |
| 6997 | "markuplint.config.json": "_f_light_markuplint", | |
| 6998 | "markuplint.config.yaml": "_f_light_markuplint", | |
| 6999 | "markuplint.config.yml": "_f_light_markuplint", | |
| 7000 | "markuplint.config.js": "_f_light_markuplint", | |
| 7001 | "markuplint.config.cjs": "_f_light_markuplint", | |
| 7002 | "markuplint.config.mjs": "_f_light_markuplint", | |
| 7003 | "markuplint.config.ts": "_f_light_markuplint", | |
| 7004 | "master.js": "_f_master-co", | |
| 7005 | "master.ts": "_f_master-co", | |
| 7006 | "master.mjs": "_f_master-co", | |
| 7007 | "master.json": "_f_master-co", | |
| 7008 | "master.css.js": "_f_master-co", | |
| 7009 | "master.css.ts": "_f_master-co", | |
| 7010 | "master.css.mjs": "_f_master-co", | |
| 7011 | "master.css.json": "_f_master-co", | |
| 7012 | "maven.config": "_f_maven", | |
| 7013 | "pom.xml": "_f_maven", | |
| 7014 | "extensions.xml": "_f_maven", | |
| 7015 | "settings.xml": "_f_maven", | |
| 7016 | "mcp.json": "_f_light_mcp", | |
| 7017 | "mdx-components.cjs": "_f_light_mdx-components", | |
| 7018 | "mdx-components.cts": "_f_light_mdx-components", | |
| 7019 | "mdx-components.js": "_f_light_mdx-components", | |
| 7020 | "mdx-components.jsx": "_f_light_mdx-components", | |
| 7021 | "mdx-components.mjs": "_f_light_mdx-components", | |
| 7022 | "mdx-components.mts": "_f_light_mdx-components", | |
| 7023 | "mdx-components.ts": "_f_light_mdx-components", | |
| 7024 | "mdx-components.tsx": "_f_light_mdx-components", | |
| 7025 | ".mdxlintrc": "_f_mdxlint", | |
| 7026 | ".mdxlintignore": "_f_mdxlint", | |
| 7027 | ".mdxlintrc.cjs": "_f_mdxlint", | |
| 7028 | ".mdxlintrc.js": "_f_mdxlint", | |
| 7029 | ".mdxlintrc.json": "_f_mdxlint", | |
| 7030 | ".mdxlintrc.mjs": "_f_mdxlint", | |
| 7031 | ".mdxlintrc.yml": "_f_mdxlint", | |
| 7032 | ".mdxlintrc.yaml": "_f_mdxlint", | |
| 7033 | ".hgignore": "_f_mercurial", | |
| 7034 | "mocha.opts": "_f_mocha", | |
| 7035 | ".mocharc.cjs": "_f_mocha", | |
| 7036 | ".mocharc.js": "_f_mocha", | |
| 7037 | ".mocharc.json": "_f_mocha", | |
| 7038 | ".mocharc.jsonc": "_f_mocha", | |
| 7039 | ".mocharc.mjs": "_f_mocha", | |
| 7040 | ".mocharc.yaml": "_f_mocha", | |
| 7041 | ".mocharc.yml": "_f_mocha", | |
| 7042 | "modernizr": "_f_modernizr", | |
| 7043 | "modernizr.js": "_f_modernizr", | |
| 7044 | "modernizrrc.js": "_f_modernizr", | |
| 7045 | ".modernizr.js": "_f_modernizr", | |
| 7046 | ".modernizrrc.js": "_f_modernizr", | |
| 7047 | "moleculer.config.js": "_f_moleculer", | |
| 7048 | "moleculer.config.json": "_f_moleculer", | |
| 7049 | "moleculer.config.ts": "_f_moleculer", | |
| 7050 | ".mtn-ignore": "_f_monotone", | |
| 7051 | "motif.json": "_f_motif", | |
| 7052 | "mypy.ini": "_f_light_mypy", | |
| 7053 | ".mypy.ini": "_f_light_mypy", | |
| 7054 | ".nano-staged.js": "_f_nanostaged", | |
| 7055 | "nano-staged.js": "_f_nanostaged", | |
| 7056 | ".nano-staged.cjs": "_f_nanostaged", | |
| 7057 | "nano-staged.cjs": "_f_nanostaged", | |
| 7058 | ".nano-staged.mjs": "_f_nanostaged", | |
| 7059 | "nano-staged.mjs": "_f_nanostaged", | |
| 7060 | ".nano-staged.json": "_f_nanostaged", | |
| 7061 | "nano-staged.json": "_f_nanostaged", | |
| 7062 | ".nanostagedrc": "_f_nanostaged", | |
| 7063 | ".nest-cli.json": "_f_nestjs", | |
| 7064 | "nest-cli.json": "_f_nestjs", | |
| 7065 | "nestconfig.json": "_f_nestjs", | |
| 7066 | ".nestconfig.json": "_f_nestjs", | |
| 7067 | "netlify.toml": "_f_light_netlify", | |
| 7068 | "next.config.js": "_f_light_next", | |
| 7069 | "next.config.cjs": "_f_light_next", | |
| 7070 | "next.config.mjs": "_f_light_next", | |
| 7071 | "next.config.ts": "_f_light_next", | |
| 7072 | ".nf": "_f_nextflow", | |
| 7073 | "ng-tailwind.js": "_f_ng_tailwind", | |
| 7074 | "nginx.conf": "_f_nginx", | |
| 7075 | "build.ninja": "_f_ninja", | |
| 7076 | "flake.lock": "_f_nix", | |
| 7077 | ".node-version": "_f_node", | |
| 7078 | ".nvmrc": "_f_node", | |
| 7079 | "node.config.json": "_f_node", | |
| 7080 | "nodemon.json": "_f_nodemon", | |
| 7081 | ".npmignore": "_f_npm", | |
| 7082 | ".npmrc": "_f_npm", | |
| 7083 | ".package-lock.json": "_f_npm", | |
| 7084 | "package.json": "_f_npm", | |
| 7085 | "package-lock.json": "_f_npm", | |
| 7086 | "npm-shrinkwrap.json": "_f_npm", | |
| 7087 | ".npmpackagejsonlintrc": "_f_npmpackagejsonlint", | |
| 7088 | ".npmpackagejsonlintrc.cjs": "_f_npmpackagejsonlint", | |
| 7089 | ".npmpackagejsonlintrc.js": "_f_npmpackagejsonlint", | |
| 7090 | ".npmpackagejsonlintrc.json": "_f_npmpackagejsonlint", | |
| 7091 | ".npmpackagejsonlintrc.yaml": "_f_npmpackagejsonlint", | |
| 7092 | ".npmpackagejsonlintrc.yml": "_f_npmpackagejsonlint", | |
| 7093 | ".npmpackagejsonlintrc.mjs": "_f_npmpackagejsonlint", | |
| 7094 | ".npmpackagejsonlintignore": "_f_npmpackagejsonlint", | |
| 7095 | "npmpackagejsonlint.config.cjs": "_f_npmpackagejsonlint", | |
| 7096 | "npmpackagejsonlint.config.js": "_f_npmpackagejsonlint", | |
| 7097 | "npmpackagejsonlint.config.mjs": "_f_npmpackagejsonlint", | |
| 7098 | ".nsrirc": "_f_nsri", | |
| 7099 | ".nsriignore": "_f_nsri", | |
| 7100 | "nsri.config.js": "_f_nsri", | |
| 7101 | ".nsrirc.js": "_f_nsri", | |
| 7102 | ".nsrirc.json": "_f_nsri", | |
| 7103 | ".nsrirc.yaml": "_f_nsri", | |
| 7104 | ".nsrirc.yml": "_f_nsri", | |
| 7105 | ".integrity.json": "_f_nsri-integrity", | |
| 7106 | ".nuxtignore": "_f_nuxt", | |
| 7107 | ".nuxtrc": "_f_nuxt", | |
| 7108 | "nuxt.config.js": "_f_nuxt", | |
| 7109 | "nuxt.config.ts": "_f_nuxt", | |
| 7110 | "nuxt.config.mjs": "_f_nuxt", | |
| 7111 | "nuxt.js": "_f_nuxt", | |
| 7112 | "nuxt.ts": "_f_nuxt", | |
| 7113 | "nuxt.mjs": "_f_nuxt", | |
| 7114 | "nx.json": "_f_light_nx", | |
| 7115 | ".nycrc": "_f_nyc", | |
| 7116 | ".nycrc.json": "_f_nyc", | |
| 7117 | ".objidconfig": "_f_light_objidconfig", | |
| 7118 | ".merlin": "_f_ocaml", | |
| 7119 | ".oxlintignore": "_f_light_oxc", | |
| 7120 | ".oxlintrc.json": "_f_light_oxc", | |
| 7121 | ".oxlintrc.jsonc": "_f_light_oxc", | |
| 7122 | ".oxfmtrc.json": "_f_light_oxc", | |
| 7123 | ".oxfmtrc.jsonc": "_f_light_oxc", | |
| 7124 | ".packshiprc": "_f_light_packship", | |
| 7125 | ".packshiprc.json": "_f_light_packship", | |
| 7126 | ".packshiprc.js": "_f_light_packship", | |
| 7127 | ".packshiprc.ts": "_f_light_packship", | |
| 7128 | "packship.config.js": "_f_light_packship", | |
| 7129 | "packship.config.ts": "_f_light_packship", | |
| 7130 | "packship.config.mjs": "_f_light_packship", | |
| 7131 | "packship.config.mts": "_f_light_packship", | |
| 7132 | "packship.config.json": "_f_light_packship", | |
| 7133 | "paket.dependencies": "_f_paket", | |
| 7134 | "paket.lock": "_f_paket", | |
| 7135 | "paket.references": "_f_paket", | |
| 7136 | "paket.template": "_f_paket", | |
| 7137 | "paket.local": "_f_paket", | |
| 7138 | "panda.config.js": "_f_pandacss", | |
| 7139 | "panda.config.mjs": "_f_pandacss", | |
| 7140 | "panda.config.ts": "_f_pandacss", | |
| 7141 | ".pdm-python": "_f_pdm", | |
| 7142 | "pdm.lock": "_f_pdm", | |
| 7143 | "pdm.toml": "_f_pdm", | |
| 7144 | "peeky.config.ts": "_f_peeky", | |
| 7145 | "peeky.config.js": "_f_peeky", | |
| 7146 | "peeky.config.mjs": "_f_peeky", | |
| 7147 | ".php-cs-fixer.php": "_f_phpcsfixer", | |
| 7148 | ".php-cs-fixer.dist.php": "_f_phpcsfixer", | |
| 7149 | ".php_cs": "_f_phpcsfixer", | |
| 7150 | ".php_cs.dist": "_f_phpcsfixer", | |
| 7151 | "phpstan.neon": "_f_phpstan", | |
| 7152 | "phpstan.dist.neon": "_f_phpstan", | |
| 7153 | "phpunit": "_f_phpunit", | |
| 7154 | "phpunit.xml": "_f_phpunit", | |
| 7155 | "phpunit.xml.dist": "_f_phpunit", | |
| 7156 | ".phraseapp.yml": "_f_phraseapp", | |
| 7157 | "pipfile": "_f_pip", | |
| 7158 | "pipfile.lock": "_f_pip", | |
| 7159 | "platformio.ini": "_f_platformio", | |
| 7160 | "playwright.config.js": "_f_playwright", | |
| 7161 | "playwright.config.ts": "_f_playwright", | |
| 7162 | "playwright.config.mts": "_f_playwright", | |
| 7163 | "playwright.config.mjs": "_f_playwright", | |
| 7164 | "playwright.config.cts": "_f_playwright", | |
| 7165 | "playwright.config.cjs": "_f_playwright", | |
| 7166 | "ecosystem.config.js": "_f_pm2", | |
| 7167 | "ecosystem.config.cjs": "_f_pm2", | |
| 7168 | "ecosystem.config.json": "_f_pm2", | |
| 7169 | "ecosystem.config.yaml": "_f_pm2", | |
| 7170 | "ecosystem.config.yml": "_f_pm2", | |
| 7171 | ".pnpmfile.cjs": "_f_light_pnpm", | |
| 7172 | "pnpmfile.js": "_f_light_pnpm", | |
| 7173 | "pnpm-lock.yaml": "_f_light_pnpm", | |
| 7174 | "pnpm-workspace.yaml": "_f_light_pnpm", | |
| 7175 | "poetry.lock": "_f_poetry", | |
| 7176 | ".postcssrc": "_f_postcssconfig", | |
| 7177 | ".postcssrc.json": "_f_postcssconfig", | |
| 7178 | ".postcssrc.yaml": "_f_postcssconfig", | |
| 7179 | ".postcssrc.yml": "_f_postcssconfig", | |
| 7180 | ".postcssrc.ts": "_f_postcssconfig", | |
| 7181 | ".postcssrc.cts": "_f_postcssconfig", | |
| 7182 | ".postcssrc.mts": "_f_postcssconfig", | |
| 7183 | ".postcssrc.js": "_f_postcssconfig", | |
| 7184 | ".postcssrc.cjs": "_f_postcssconfig", | |
| 7185 | ".postcssrc.mjs": "_f_postcssconfig", | |
| 7186 | "postcss.config.ts": "_f_postcssconfig", | |
| 7187 | "postcss.config.cts": "_f_postcssconfig", | |
| 7188 | "postcss.config.mts": "_f_postcssconfig", | |
| 7189 | "postcss.config.js": "_f_postcssconfig", | |
| 7190 | "postcss.config.cjs": "_f_postcssconfig", | |
| 7191 | "postcss.config.mjs": "_f_postcssconfig", | |
| 7192 | "preact.config.js": "_f_preact", | |
| 7193 | ".pre-commit-config.yaml": "_f_precommit", | |
| 7194 | ".prettierrc": "_f_light_prettier", | |
| 7195 | ".prettierignore": "_f_light_prettier", | |
| 7196 | "prettier.config.js": "_f_light_prettier", | |
| 7197 | "prettier.config.cjs": "_f_light_prettier", | |
| 7198 | "prettier.config.mjs": "_f_light_prettier", | |
| 7199 | "prettier.config.ts": "_f_light_prettier", | |
| 7200 | "prettier.config.coffee": "_f_light_prettier", | |
| 7201 | ".prettierrc.js": "_f_light_prettier", | |
| 7202 | ".prettierrc.cjs": "_f_light_prettier", | |
| 7203 | ".prettierrc.mjs": "_f_light_prettier", | |
| 7204 | ".prettierrc.json": "_f_light_prettier", | |
| 7205 | ".prettierrc.json5": "_f_light_prettier", | |
| 7206 | ".prettierrc.yml": "_f_light_prettier", | |
| 7207 | ".prettierrc.yaml": "_f_light_prettier", | |
| 7208 | ".prettierrc.toml": "_f_light_prettier", | |
| 7209 | "prisma.config.js": "_f_prismaconfig", | |
| 7210 | "prisma.config.ts": "_f_prismaconfig", | |
| 7211 | "procfile": "_f_procfile", | |
| 7212 | "protractor.conf.js": "_f_protractor", | |
| 7213 | "protractor.conf.coffee": "_f_protractor", | |
| 7214 | "protractor.conf.ts": "_f_protractor", | |
| 7215 | "publiccode.yml": "_f_publiccode", | |
| 7216 | ".jade-lintrc": "_f_pug", | |
| 7217 | ".pug-lintrc": "_f_pug", | |
| 7218 | ".jade-lint.json": "_f_pug", | |
| 7219 | ".pug-lintrc.js": "_f_pug", | |
| 7220 | ".pug-lintrc.json": "_f_pug", | |
| 7221 | "Pulumi.yaml": "_f_pulumi", | |
| 7222 | "Pulumi.yml": "_f_pulumi", | |
| 7223 | "Pulumi.dev.yaml": "_f_pulumi", | |
| 7224 | "Pulumi.dev.yml": "_f_pulumi", | |
| 7225 | "Pulumi.prod.yaml": "_f_pulumi", | |
| 7226 | "Pulumi.prod.yml": "_f_pulumi", | |
| 7227 | "purgecss.config.js": "_f_purgecss", | |
| 7228 | ".python-version": "_f_pyenv", | |
| 7229 | "pyproject.toml": "_f_pypi", | |
| 7230 | "pytest.ini": "_f_pytest", | |
| 7231 | ".pytest.ini": "_f_pytest", | |
| 7232 | "pytest.toml": "_f_pytest", | |
| 7233 | ".pytest.toml": "_f_pytest", | |
| 7234 | "py.typed": "_f_pytyped", | |
| 7235 | ".pyup": "_f_pyup", | |
| 7236 | ".pyup.yml": "_f_pyup", | |
| 7237 | "qmldir": "_f_qmldir", | |
| 7238 | "quasar.conf.js": "_f_light_quasar", | |
| 7239 | "quasar.config.js": "_f_light_quasar", | |
| 7240 | "quasar.config.mjs": "_f_light_quasar", | |
| 7241 | "quasar.config.cjs": "_f_light_quasar", | |
| 7242 | "quasar.config.ts": "_f_light_quasar", | |
| 7243 | "Rakefile": "_f_rake", | |
| 7244 | "razzle.config.js": "_f_light_razzle", | |
| 7245 | "react-router.config.js": "_f_light_reactrouter", | |
| 7246 | "react-router.config.ts": "_f_light_reactrouter", | |
| 7247 | ".rehyperc": "_f_light_rehype", | |
| 7248 | ".rehypeignore": "_f_light_rehype", | |
| 7249 | ".rehyperc.cjs": "_f_light_rehype", | |
| 7250 | ".rehyperc.js": "_f_light_rehype", | |
| 7251 | ".rehyperc.json": "_f_light_rehype", | |
| 7252 | ".rehyperc.mjs": "_f_light_rehype", | |
| 7253 | ".rehyperc.yml": "_f_light_rehype", | |
| 7254 | ".rehyperc.yaml": "_f_light_rehype", | |
| 7255 | ".remarkrc": "_f_light_remark", | |
| 7256 | ".remarkignore": "_f_light_remark", | |
| 7257 | ".remarkrc.cjs": "_f_light_remark", | |
| 7258 | ".remarkrc.js": "_f_light_remark", | |
| 7259 | ".remarkrc.json": "_f_light_remark", | |
| 7260 | ".remarkrc.mjs": "_f_light_remark", | |
| 7261 | ".remarkrc.yml": "_f_light_remark", | |
| 7262 | ".remarkrc.yaml": "_f_light_remark", | |
| 7263 | ".renovaterc": "_f_renovate", | |
| 7264 | ".renovaterc.json": "_f_renovate", | |
| 7265 | "renovate.json": "_f_renovate", | |
| 7266 | "renovate.json5": "_f_renovate", | |
| 7267 | ".replit": "_f_light_replit", | |
| 7268 | "replit.nix": "_f_light_replit", | |
| 7269 | ".retextrc": "_f_light_retext", | |
| 7270 | ".retextignore": "_f_light_retext", | |
| 7271 | ".retextrc.cjs": "_f_light_retext", | |
| 7272 | ".retextrc.js": "_f_light_retext", | |
| 7273 | ".retextrc.json": "_f_light_retext", | |
| 7274 | ".retextrc.mjs": "_f_light_retext", | |
| 7275 | ".retextrc.yml": "_f_light_retext", | |
| 7276 | ".retextrc.yaml": "_f_light_retext", | |
| 7277 | "robots.txt": "_f_robots", | |
| 7278 | "rolldown.config.js": "_f_light_rolldown", | |
| 7279 | "rolldown.config.cjs": "_f_light_rolldown", | |
| 7280 | "rolldown.config.mjs": "_f_light_rolldown", | |
| 7281 | "rolldown.config.coffee": "_f_light_rolldown", | |
| 7282 | "rolldown.config.ts": "_f_light_rolldown", | |
| 7283 | "rolldown.config.mts": "_f_light_rolldown", | |
| 7284 | "rolldown.config.cts": "_f_light_rolldown", | |
| 7285 | "rolldown.config.common.js": "_f_light_rolldown", | |
| 7286 | "rolldown.config.common.cjs": "_f_light_rolldown", | |
| 7287 | "rolldown.config.common.mjs": "_f_light_rolldown", | |
| 7288 | "rolldown.config.common.coffee": "_f_light_rolldown", | |
| 7289 | "rolldown.config.common.ts": "_f_light_rolldown", | |
| 7290 | "rolldown.config.common.mts": "_f_light_rolldown", | |
| 7291 | "rolldown.config.common.cts": "_f_light_rolldown", | |
| 7292 | "rolldown.config.dev.js": "_f_light_rolldown", | |
| 7293 | "rolldown.config.dev.cjs": "_f_light_rolldown", | |
| 7294 | "rolldown.config.dev.mjs": "_f_light_rolldown", | |
| 7295 | "rolldown.config.dev.coffee": "_f_light_rolldown", | |
| 7296 | "rolldown.config.dev.ts": "_f_light_rolldown", | |
| 7297 | "rolldown.config.dev.mts": "_f_light_rolldown", | |
| 7298 | "rolldown.config.dev.cts": "_f_light_rolldown", | |
| 7299 | "rolldown.config.prod.js": "_f_light_rolldown", | |
| 7300 | "rolldown.config.prod.cjs": "_f_light_rolldown", | |
| 7301 | "rolldown.config.prod.mjs": "_f_light_rolldown", | |
| 7302 | "rolldown.config.prod.coffee": "_f_light_rolldown", | |
| 7303 | "rolldown.config.prod.ts": "_f_light_rolldown", | |
| 7304 | "rolldown.config.prod.mts": "_f_light_rolldown", | |
| 7305 | "rolldown.config.prod.cts": "_f_light_rolldown", | |
| 7306 | "rollup.config.js": "_f_rollup", | |
| 7307 | "rollup.config.cjs": "_f_rollup", | |
| 7308 | "rollup.config.mjs": "_f_rollup", | |
| 7309 | "rollup.config.coffee": "_f_rollup", | |
| 7310 | "rollup.config.ts": "_f_rollup", | |
| 7311 | "rollup.config.common.js": "_f_rollup", | |
| 7312 | "rollup.config.common.cjs": "_f_rollup", | |
| 7313 | "rollup.config.common.mjs": "_f_rollup", | |
| 7314 | "rollup.config.common.coffee": "_f_rollup", | |
| 7315 | "rollup.config.common.ts": "_f_rollup", | |
| 7316 | "rollup.config.dev.js": "_f_rollup", | |
| 7317 | "rollup.config.dev.cjs": "_f_rollup", | |
| 7318 | "rollup.config.dev.mjs": "_f_rollup", | |
| 7319 | "rollup.config.dev.coffee": "_f_rollup", | |
| 7320 | "rollup.config.dev.ts": "_f_rollup", | |
| 7321 | "rollup.config.prod.js": "_f_rollup", | |
| 7322 | "rollup.config.prod.cjs": "_f_rollup", | |
| 7323 | "rollup.config.prod.mjs": "_f_rollup", | |
| 7324 | "rollup.config.prod.coffee": "_f_rollup", | |
| 7325 | "rollup.config.prod.ts": "_f_rollup", | |
| 7326 | "rome.json": "_f_light_rome", | |
| 7327 | ".rspec": "_f_rspec", | |
| 7328 | ".rubocop.yml": "_f_light_rubocop", | |
| 7329 | ".rubocop_todo.yml": "_f_light_rubocop", | |
| 7330 | ".ruby-version": "_f_ruby", | |
| 7331 | "rust-toolchain": "_f_light_rust_toolchain", | |
| 7332 | "rust-toolchain.toml": "_f_light_rust_toolchain", | |
| 7333 | ".slshrc": "_f_s-lang", | |
| 7334 | ".isisrc": "_f_s-lang", | |
| 7335 | ".jedrc": "_f_s-lang", | |
| 7336 | ".sailsrc": "_f_sails", | |
| 7337 | ".sapphirerc.json": "_f_sapphire_framework_cli", | |
| 7338 | ".sapphirerc.yml": "_f_sapphire_framework_cli", | |
| 7339 | ".sapphirerc": "_f_sapphire_framework_cli", | |
| 7340 | "seedkit.config.js": "_f_seedkit", | |
| 7341 | "seedkit.config.ts": "_f_seedkit", | |
| 7342 | "seedkit.config.mjs": "_f_seedkit", | |
| 7343 | ".sentryclirc": "_f_sentry", | |
| 7344 | ".sequelizerc": "_f_sequelize", | |
| 7345 | ".sequelizerc.js": "_f_sequelize", | |
| 7346 | ".sequelizerc.json": "_f_sequelize", | |
| 7347 | "serverless.yml": "_f_serverless", | |
| 7348 | "serverless.json": "_f_serverless", | |
| 7349 | "serverless.js": "_f_serverless", | |
| 7350 | "serverless.ts": "_f_serverless", | |
| 7351 | "components.json": "_f_light_shadcn", | |
| 7352 | ".shellcheckrc": "_f_shellcheck", | |
| 7353 | "Shuttle.toml": "_f_shuttle", | |
| 7354 | "sitemap.xml": "_f_sitemap", | |
| 7355 | "sitemap.rss": "_f_sitemap", | |
| 7356 | "sitemap.txt": "_f_sitemap", | |
| 7357 | "sitemap.atom": "_f_sitemap", | |
| 7358 | "slash-up.config.js": "_f_slashup", | |
| 7359 | "smithery.yaml": "_f_smithery", | |
| 7360 | "snapcraft.yaml": "_f_snapcraft", | |
| 7361 | "snaplet.config.js": "_f_snaplet", | |
| 7362 | "snaplet.config.ts": "_f_snaplet", | |
| 7363 | ".snyk": "_f_snyk", | |
| 7364 | ".solidarity": "_f_solidarity", | |
| 7365 | ".solidarity.json": "_f_solidarity", | |
| 7366 | "spin.toml": "_f_light_spin", | |
| 7367 | "sst-env.d.ts": "_f_sst", | |
| 7368 | "sst-env.ts": "_f_sst", | |
| 7369 | "sst-env.js": "_f_sst", | |
| 7370 | "sst.config.d.ts": "_f_sst", | |
| 7371 | "sst.config.ts": "_f_sst", | |
| 7372 | "sst.config.js": "_f_sst", | |
| 7373 | ".stackblitzrc": "_f_stackblitz", | |
| 7374 | "stryker.conf.mjs": "_f_stryker", | |
| 7375 | "stryker.conf.cjs": "_f_stryker", | |
| 7376 | "stryker.conf.js": "_f_stryker", | |
| 7377 | "stryker.conf.conf": "_f_stryker", | |
| 7378 | "stryker.conf.json": "_f_stryker", | |
| 7379 | ".stryker.conf.mjs": "_f_stryker", | |
| 7380 | ".stryker.conf.cjs": "_f_stryker", | |
| 7381 | ".stryker.conf.js": "_f_stryker", | |
| 7382 | ".stryker.conf.conf": "_f_stryker", | |
| 7383 | ".stryker.conf.json": "_f_stryker", | |
| 7384 | "stryker.config.mjs": "_f_stryker", | |
| 7385 | "stryker.config.cjs": "_f_stryker", | |
| 7386 | "stryker.config.js": "_f_stryker", | |
| 7387 | "stryker.config.conf": "_f_stryker", | |
| 7388 | "stryker.config.json": "_f_stryker", | |
| 7389 | ".stryker.config.mjs": "_f_stryker", | |
| 7390 | ".stryker.config.cjs": "_f_stryker", | |
| 7391 | ".stryker.config.js": "_f_stryker", | |
| 7392 | ".stryker.config.conf": "_f_stryker", | |
| 7393 | ".stryker.config.json": "_f_stryker", | |
| 7394 | "stryker-config.mjs": "_f_stryker", | |
| 7395 | "stryker-config.cjs": "_f_stryker", | |
| 7396 | "stryker-config.js": "_f_stryker", | |
| 7397 | "stryker-config.conf": "_f_stryker", | |
| 7398 | "stryker-config.json": "_f_stryker", | |
| 7399 | "stryker4s.mjs": "_f_stryker", | |
| 7400 | "stryker4s.cjs": "_f_stryker", | |
| 7401 | "stryker4s.js": "_f_stryker", | |
| 7402 | "stryker4s.conf": "_f_stryker", | |
| 7403 | "stryker4s.json": "_f_stryker", | |
| 7404 | ".stylelintrc": "_f_light_stylelint", | |
| 7405 | ".stylelintignore": "_f_light_stylelint", | |
| 7406 | ".stylelintcache": "_f_light_stylelint", | |
| 7407 | "stylelint.config.js": "_f_light_stylelint", | |
| 7408 | "stylelint.config.json": "_f_light_stylelint", | |
| 7409 | "stylelint.config.yaml": "_f_light_stylelint", | |
| 7410 | "stylelint.config.yml": "_f_light_stylelint", | |
| 7411 | "stylelint.config.ts": "_f_light_stylelint", | |
| 7412 | "stylelint.config.cjs": "_f_light_stylelint", | |
| 7413 | "stylelint.config.mjs": "_f_light_stylelint", | |
| 7414 | ".stylelintrc.js": "_f_light_stylelint", | |
| 7415 | ".stylelintrc.json": "_f_light_stylelint", | |
| 7416 | ".stylelintrc.yaml": "_f_light_stylelint", | |
| 7417 | ".stylelintrc.yml": "_f_light_stylelint", | |
| 7418 | ".stylelintrc.ts": "_f_light_stylelint", | |
| 7419 | ".stylelintrc.cjs": "_f_light_stylelint", | |
| 7420 | ".stylelintrc.mjs": "_f_light_stylelint", | |
| 7421 | ".stylish-haskell.yaml": "_f_stylish_haskell", | |
| 7422 | ".svnignore": "_f_subversion", | |
| 7423 | "svelte.config.js": "_f_svelteconfig", | |
| 7424 | "svelte.config.ts": "_f_svelteconfig", | |
| 7425 | "svgo.config.cjs": "_f_svgo", | |
| 7426 | "svgo.config.js": "_f_svgo", | |
| 7427 | "svgo.config.mjs": "_f_svgo", | |
| 7428 | ".swcrc": "_f_swc", | |
| 7429 | "package.pins": "_f_swift", | |
| 7430 | "symfony.lock": "_f_light_symfony", | |
| 7431 | ".syncpackrc": "_f_syncpack", | |
| 7432 | ".syncpackrc.json": "_f_syncpack", | |
| 7433 | ".syncpackrc.yaml": "_f_syncpack", | |
| 7434 | ".syncpackrc.yml": "_f_syncpack", | |
| 7435 | ".syncpackrc.js": "_f_syncpack", | |
| 7436 | ".syncpackrc.cjs": "_f_syncpack", | |
| 7437 | "syncpack.config.js": "_f_syncpack", | |
| 7438 | "syncpack.config.cjs": "_f_syncpack", | |
| 7439 | "tailwind.js": "_f_tailwind", | |
| 7440 | "tailwind.cjs": "_f_tailwind", | |
| 7441 | "tailwind.coffee": "_f_tailwind", | |
| 7442 | "tailwind.ts": "_f_tailwind", | |
| 7443 | "tailwind.json": "_f_tailwind", | |
| 7444 | "tailwind.mjs": "_f_tailwind", | |
| 7445 | "tailwind.config.js": "_f_tailwind", | |
| 7446 | "tailwind.config.cjs": "_f_tailwind", | |
| 7447 | "tailwind.config.coffee": "_f_tailwind", | |
| 7448 | "tailwind.config.ts": "_f_tailwind", | |
| 7449 | "tailwind.config.json": "_f_tailwind", | |
| 7450 | "tailwind.config.mjs": "_f_tailwind", | |
| 7451 | ".tailwind.js": "_f_tailwind", | |
| 7452 | ".tailwind.cjs": "_f_tailwind", | |
| 7453 | ".tailwind.coffee": "_f_tailwind", | |
| 7454 | ".tailwind.ts": "_f_tailwind", | |
| 7455 | ".tailwind.json": "_f_tailwind", | |
| 7456 | ".tailwind.mjs": "_f_tailwind", | |
| 7457 | ".tailwindrc.js": "_f_tailwind", | |
| 7458 | ".tailwindrc.cjs": "_f_tailwind", | |
| 7459 | ".tailwindrc.coffee": "_f_tailwind", | |
| 7460 | ".tailwindrc.ts": "_f_tailwind", | |
| 7461 | ".tailwindrc.json": "_f_tailwind", | |
| 7462 | ".tailwindrc.mjs": "_f_tailwind", | |
| 7463 | "tamagui.js": "_f_tamagui", | |
| 7464 | "tamagui.mjs": "_f_tamagui", | |
| 7465 | "tamagui.cjs": "_f_tamagui", | |
| 7466 | "tamagui.ts": "_f_tamagui", | |
| 7467 | "tamagui.mts": "_f_tamagui", | |
| 7468 | "tamagui.cts": "_f_tamagui", | |
| 7469 | "tamagui.config.js": "_f_tamagui", | |
| 7470 | "tamagui.config.mjs": "_f_tamagui", | |
| 7471 | "tamagui.config.cjs": "_f_tamagui", | |
| 7472 | "tamagui.config.ts": "_f_tamagui", | |
| 7473 | "tamagui.config.mts": "_f_tamagui", | |
| 7474 | "tamagui.config.cts": "_f_tamagui", | |
| 7475 | ".taplo.toml": "_f_taplo", | |
| 7476 | "taplo.toml": "_f_taplo", | |
| 7477 | "taskfile.yml": "_f_taskfile", | |
| 7478 | "taskfile.yaml": "_f_taskfile", | |
| 7479 | "taskfile.dist.yml": "_f_taskfile", | |
| 7480 | "taskfile.dist.yaml": "_f_taskfile", | |
| 7481 | "tauri.conf.json": "_f_tauri", | |
| 7482 | ".taurignore": "_f_tauri", | |
| 7483 | ".testcaferc.json": "_f_light_testcafe", | |
| 7484 | "testplane.conf.ts": "_f_testplane", | |
| 7485 | "testplane.conf.js": "_f_testplane", | |
| 7486 | "testplane.conf.cts": "_f_testplane", | |
| 7487 | "testplane.conf.cjs": "_f_testplane", | |
| 7488 | "testplane.config.ts": "_f_testplane", | |
| 7489 | "testplane.config.js": "_f_testplane", | |
| 7490 | "testplane.config.cts": "_f_testplane", | |
| 7491 | "testplane.config.cjs": "_f_testplane", | |
| 7492 | ".tfignore": "_f_tfs", | |
| 7493 | ".tiltignore": "_f_tiltfile", | |
| 7494 | ".tmux.conf": "_f_light_tmux", | |
| 7495 | "tox.ini": "_f_tox", | |
| 7496 | ".travis.yml": "_f_travis", | |
| 7497 | "truffle-config.js": "_f_truffle", | |
| 7498 | "truffle-config.ts": "_f_truffle", | |
| 7499 | "trunk.yaml": "_f_trunk", | |
| 7500 | "tsconfig.json": "_f_tsconfig", | |
| 7501 | "tsconfig.app.json": "_f_tsconfig", | |
| 7502 | "tsconfig.base.json": "_f_tsconfig", | |
| 7503 | "tsconfig.build.json": "_f_tsconfig", | |
| 7504 | "tsconfig.cjs.json": "_f_tsconfig", | |
| 7505 | "tsconfig.common.json": "_f_tsconfig", | |
| 7506 | "tsconfig.debug.json": "_f_tsconfig", | |
| 7507 | "tsconfig.dev.json": "_f_tsconfig", | |
| 7508 | "tsconfig.development.json": "_f_tsconfig", | |
| 7509 | "tsconfig.e2e.json": "_f_tsconfig", | |
| 7510 | "tsconfig.eslint.json": "_f_tsconfig", | |
| 7511 | "tsconfig.esm.json": "_f_tsconfig", | |
| 7512 | "tsconfig.mjs.json": "_f_tsconfig", | |
| 7513 | "tsconfig.node.json": "_f_tsconfig", | |
| 7514 | "tsconfig.prod.json": "_f_tsconfig", | |
| 7515 | "tsconfig.production.json": "_f_tsconfig", | |
| 7516 | "tsconfig.release.json": "_f_tsconfig", | |
| 7517 | "tsconfig.server.json": "_f_tsconfig", | |
| 7518 | "tsconfig.spec.json": "_f_tsconfig", | |
| 7519 | "tsconfig.staging.json": "_f_tsconfig", | |
| 7520 | "tsconfig.test.json": "_f_tsconfig", | |
| 7521 | "tsconfig.types.json": "_f_tsconfig", | |
| 7522 | "tsconfig.lib.json": "_f_tsconfig", | |
| 7523 | "tsconfig.lib.prod.json": "_f_tsconfig", | |
| 7524 | "tsdoc.json": "_f_tsdoc", | |
| 7525 | "tsdown.config.ts": "_f_tsdown", | |
| 7526 | "tsdown.config.mts": "_f_tsdown", | |
| 7527 | "tsdown.config.cts": "_f_tsdown", | |
| 7528 | "tsdown.config.js": "_f_tsdown", | |
| 7529 | "tsdown.config.mjs": "_f_tsdown", | |
| 7530 | "tsdown.config.cjs": "_f_tsdown", | |
| 7531 | "tsdown.config.json": "_f_tsdown", | |
| 7532 | "tslint.json": "_f_tslint", | |
| 7533 | "tslint.yaml": "_f_tslint", | |
| 7534 | "tslint.yml": "_f_tslint", | |
| 7535 | "turbo.json": "_f_light_turbo", | |
| 7536 | "turbo.jsonc": "_f_light_turbo", | |
| 7537 | "microfrontends.json": "_f_light_turbo", | |
| 7538 | "microfrontends.jsonc": "_f_light_turbo", | |
| 7539 | "typedoc.cjs": "_f_typedoc", | |
| 7540 | "typedoc.js": "_f_typedoc", | |
| 7541 | "typedoc.json": "_f_typedoc", | |
| 7542 | "typedoc.jsonc": "_f_typedoc", | |
| 7543 | "typedoc.mjs": "_f_typedoc", | |
| 7544 | "typedoc.config.cjs": "_f_typedoc", | |
| 7545 | "typedoc.config.js": "_f_typedoc", | |
| 7546 | "typedoc.config.json": "_f_typedoc", | |
| 7547 | "typedoc.config.jsonc": "_f_typedoc", | |
| 7548 | "typedoc.config.mjs": "_f_typedoc", | |
| 7549 | ".unibeautifyrc": "_f_light_unibeautify", | |
| 7550 | "unibeautify.config.js": "_f_light_unibeautify", | |
| 7551 | ".unibeautifyrc.js": "_f_light_unibeautify", | |
| 7552 | ".unibeautifyrc.json": "_f_light_unibeautify", | |
| 7553 | ".unibeautifyrc.yaml": "_f_light_unibeautify", | |
| 7554 | ".unibeautifyrc.yml": "_f_light_unibeautify", | |
| 7555 | "unlicense": "_f_unlicense", | |
| 7556 | "unlicence": "_f_unlicense", | |
| 7557 | "UNLICENSE": "_f_unlicense", | |
| 7558 | "UNLICENCE": "_f_unlicense", | |
| 7559 | "unlicense.md": "_f_unlicense", | |
| 7560 | "unlicense.txt": "_f_unlicense", | |
| 7561 | "unlicence.md": "_f_unlicense", | |
| 7562 | "unlicence.txt": "_f_unlicense", | |
| 7563 | "UNLICENSE.md": "_f_unlicense", | |
| 7564 | "UNLICENSE.txt": "_f_unlicense", | |
| 7565 | "UNLICENCE.md": "_f_unlicense", | |
| 7566 | "UNLICENCE.txt": "_f_unlicense", | |
| 7567 | "uno.config.js": "_f_unocss", | |
| 7568 | "uno.config.mjs": "_f_unocss", | |
| 7569 | "uno.config.ts": "_f_unocss", | |
| 7570 | "uno.config.mts": "_f_unocss", | |
| 7571 | "unocss.config.js": "_f_unocss", | |
| 7572 | "unocss.config.mjs": "_f_unocss", | |
| 7573 | "unocss.config.ts": "_f_unocss", | |
| 7574 | "unocss.config.mts": "_f_unocss", | |
| 7575 | "uv.lock": "_f_uv", | |
| 7576 | "vagrantfile": "_f_vagrant", | |
| 7577 | "vapor.yml": "_f_vapor", | |
| 7578 | "now.json": "_f_light_vercel", | |
| 7579 | ".nowignore": "_f_light_vercel", | |
| 7580 | "vercel.json": "_f_light_vercel", | |
| 7581 | ".vercelignore": "_f_light_vercel", | |
| 7582 | ".vimrc": "_f_vim", | |
| 7583 | ".gvimrc": "_f_vim", | |
| 7584 | "vite.config.js": "_f_light_vite", | |
| 7585 | "vite.config.mjs": "_f_light_vite", | |
| 7586 | "vite.config.ts": "_f_light_vite", | |
| 7587 | "vite.config.cjs": "_f_light_vite", | |
| 7588 | "vite.config.mts": "_f_light_vite", | |
| 7589 | "vite.config.cts": "_f_light_vite", | |
| 7590 | "vitest.config.js": "_f_light_vitest", | |
| 7591 | "vitest.config.mjs": "_f_light_vitest", | |
| 7592 | "vitest.config.ts": "_f_light_vitest", | |
| 7593 | "vitest.config.cjs": "_f_light_vitest", | |
| 7594 | "vitest.config.mts": "_f_light_vitest", | |
| 7595 | "vitest.config.cts": "_f_light_vitest", | |
| 7596 | "vitest.workspace.json": "_f_light_vitest", | |
| 7597 | "vitest.workspace.js": "_f_light_vitest", | |
| 7598 | "vitest.workspace.mjs": "_f_light_vitest", | |
| 7599 | "vitest.workspace.ts": "_f_light_vitest", | |
| 7600 | "vitest.workspace.cjs": "_f_light_vitest", | |
| 7601 | "vitest.workspace.mts": "_f_light_vitest", | |
| 7602 | "vitest.workspace.cts": "_f_light_vitest", | |
| 7603 | "vitest.projects.json": "_f_light_vitest", | |
| 7604 | "vitest.projects.js": "_f_light_vitest", | |
| 7605 | "vitest.projects.mjs": "_f_light_vitest", | |
| 7606 | "vitest.projects.ts": "_f_light_vitest", | |
| 7607 | "vitest.projects.cjs": "_f_light_vitest", | |
| 7608 | "vitest.projects.mts": "_f_light_vitest", | |
| 7609 | "vitest.projects.cts": "_f_light_vitest", | |
| 7610 | ".vscodeignore": "_f_vscode", | |
| 7611 | "launch.json": "_f_vscode", | |
| 7612 | "tasks.json": "_f_vscode", | |
| 7613 | "vscodeignore.json": "_f_vscode", | |
| 7614 | ".vscode-test.js": "_f_vscode_test", | |
| 7615 | ".vscode-test.json": "_f_vscode_test", | |
| 7616 | ".vscode-test.mjs": "_f_vscode_test", | |
| 7617 | ".vuerc": "_f_vueconfig", | |
| 7618 | "vue.config.js": "_f_vueconfig", | |
| 7619 | "vue.config.cjs": "_f_vueconfig", | |
| 7620 | "vue.config.mjs": "_f_vueconfig", | |
| 7621 | "wallaby.json": "_f_wallaby", | |
| 7622 | "wallaby.js": "_f_wallaby", | |
| 7623 | "wallaby.ts": "_f_wallaby", | |
| 7624 | "wallaby.coffee": "_f_wallaby", | |
| 7625 | "wallaby.conf.json": "_f_wallaby", | |
| 7626 | "wallaby.conf.js": "_f_wallaby", | |
| 7627 | "wallaby.conf.ts": "_f_wallaby", | |
| 7628 | "wallaby.conf.coffee": "_f_wallaby", | |
| 7629 | ".wallaby.json": "_f_wallaby", | |
| 7630 | ".wallaby.js": "_f_wallaby", | |
| 7631 | ".wallaby.ts": "_f_wallaby", | |
| 7632 | ".wallaby.coffee": "_f_wallaby", | |
| 7633 | ".wallaby.conf.json": "_f_wallaby", | |
| 7634 | ".wallaby.conf.js": "_f_wallaby", | |
| 7635 | ".wallaby.conf.ts": "_f_wallaby", | |
| 7636 | ".wallaby.conf.coffee": "_f_wallaby", | |
| 7637 | "wally.toml": "_f_wally", | |
| 7638 | "wally.lock": "_f_wally", | |
| 7639 | ".watchmanconfig": "_f_watchmanconfig", | |
| 7640 | "wdio.json": "_f_wdio", | |
| 7641 | "wdio.js": "_f_wdio", | |
| 7642 | "wdio.mjs": "_f_wdio", | |
| 7643 | "wdio.cjs": "_f_wdio", | |
| 7644 | "wdio.ts": "_f_wdio", | |
| 7645 | "wdio.mts": "_f_wdio", | |
| 7646 | "wdio.cts": "_f_wdio", | |
| 7647 | "wdio.conf.json": "_f_wdio", | |
| 7648 | "wdio.conf.js": "_f_wdio", | |
| 7649 | "wdio.conf.mjs": "_f_wdio", | |
| 7650 | "wdio.conf.cjs": "_f_wdio", | |
| 7651 | "wdio.conf.ts": "_f_wdio", | |
| 7652 | "wdio.conf.mts": "_f_wdio", | |
| 7653 | "wdio.conf.cts": "_f_wdio", | |
| 7654 | ".weblate": "_f_weblate", | |
| 7655 | ".weblate.ini": "_f_weblate", | |
| 7656 | "weblate": "_f_weblate", | |
| 7657 | "weblate.ini": "_f_weblate", | |
| 7658 | "webpack.base.conf.js": "_f_webpack", | |
| 7659 | "webpack.base.conf.cjs": "_f_webpack", | |
| 7660 | "webpack.base.conf.mjs": "_f_webpack", | |
| 7661 | "webpack.base.conf.coffee": "_f_webpack", | |
| 7662 | "webpack.base.conf.ts": "_f_webpack", | |
| 7663 | "webpack.common.js": "_f_webpack", | |
| 7664 | "webpack.common.cjs": "_f_webpack", | |
| 7665 | "webpack.common.mjs": "_f_webpack", | |
| 7666 | "webpack.common.coffee": "_f_webpack", | |
| 7667 | "webpack.common.ts": "_f_webpack", | |
| 7668 | "webpack.config.js": "_f_webpack", | |
| 7669 | "webpack.config.cjs": "_f_webpack", | |
| 7670 | "webpack.config.mjs": "_f_webpack", | |
| 7671 | "webpack.config.coffee": "_f_webpack", | |
| 7672 | "webpack.config.ts": "_f_webpack", | |
| 7673 | "webpack.config.base.js": "_f_webpack", | |
| 7674 | "webpack.config.base.cjs": "_f_webpack", | |
| 7675 | "webpack.config.base.mjs": "_f_webpack", | |
| 7676 | "webpack.config.base.coffee": "_f_webpack", | |
| 7677 | "webpack.config.base.ts": "_f_webpack", | |
| 7678 | "webpack.config.build.js": "_f_webpack", | |
| 7679 | "webpack.config.build.cjs": "_f_webpack", | |
| 7680 | "webpack.config.build.mjs": "_f_webpack", | |
| 7681 | "webpack.config.build.coffee": "_f_webpack", | |
| 7682 | "webpack.config.build.ts": "_f_webpack", | |
| 7683 | "webpack.config.common.js": "_f_webpack", | |
| 7684 | "webpack.config.common.cjs": "_f_webpack", | |
| 7685 | "webpack.config.common.mjs": "_f_webpack", | |
| 7686 | "webpack.config.common.coffee": "_f_webpack", | |
| 7687 | "webpack.config.common.ts": "_f_webpack", | |
| 7688 | "webpack.config.dev.js": "_f_webpack", | |
| 7689 | "webpack.config.dev.cjs": "_f_webpack", | |
| 7690 | "webpack.config.dev.mjs": "_f_webpack", | |
| 7691 | "webpack.config.dev.coffee": "_f_webpack", | |
| 7692 | "webpack.config.dev.ts": "_f_webpack", | |
| 7693 | "webpack.config.development.js": "_f_webpack", | |
| 7694 | "webpack.config.development.cjs": "_f_webpack", | |
| 7695 | "webpack.config.development.mjs": "_f_webpack", | |
| 7696 | "webpack.config.development.coffee": "_f_webpack", | |
| 7697 | "webpack.config.development.ts": "_f_webpack", | |
| 7698 | "webpack.config.staging.js": "_f_webpack", | |
| 7699 | "webpack.config.staging.cjs": "_f_webpack", | |
| 7700 | "webpack.config.staging.mjs": "_f_webpack", | |
| 7701 | "webpack.config.staging.coffee": "_f_webpack", | |
| 7702 | "webpack.config.staging.ts": "_f_webpack", | |
| 7703 | "webpack.config.test.js": "_f_webpack", | |
| 7704 | "webpack.config.test.cjs": "_f_webpack", | |
| 7705 | "webpack.config.test.mjs": "_f_webpack", | |
| 7706 | "webpack.config.test.coffee": "_f_webpack", | |
| 7707 | "webpack.config.test.ts": "_f_webpack", | |
| 7708 | "webpack.config.prod.js": "_f_webpack", | |
| 7709 | "webpack.config.prod.cjs": "_f_webpack", | |
| 7710 | "webpack.config.prod.mjs": "_f_webpack", | |
| 7711 | "webpack.config.prod.coffee": "_f_webpack", | |
| 7712 | "webpack.config.prod.ts": "_f_webpack", | |
| 7713 | "webpack.config.production.js": "_f_webpack", | |
| 7714 | "webpack.config.production.cjs": "_f_webpack", | |
| 7715 | "webpack.config.production.mjs": "_f_webpack", | |
| 7716 | "webpack.config.production.coffee": "_f_webpack", | |
| 7717 | "webpack.config.production.ts": "_f_webpack", | |
| 7718 | "webpack.config.babel.js": "_f_webpack", | |
| 7719 | "webpack.config.babel.cjs": "_f_webpack", | |
| 7720 | "webpack.config.babel.mjs": "_f_webpack", | |
| 7721 | "webpack.config.babel.coffee": "_f_webpack", | |
| 7722 | "webpack.config.babel.ts": "_f_webpack", | |
| 7723 | "webpack.config.base.babel.js": "_f_webpack", | |
| 7724 | "webpack.config.base.babel.cjs": "_f_webpack", | |
| 7725 | "webpack.config.base.babel.mjs": "_f_webpack", | |
| 7726 | "webpack.config.base.babel.coffee": "_f_webpack", | |
| 7727 | "webpack.config.base.babel.ts": "_f_webpack", | |
| 7728 | "webpack.config.common.babel.js": "_f_webpack", | |
| 7729 | "webpack.config.common.babel.cjs": "_f_webpack", | |
| 7730 | "webpack.config.common.babel.mjs": "_f_webpack", | |
| 7731 | "webpack.config.common.babel.coffee": "_f_webpack", | |
| 7732 | "webpack.config.common.babel.ts": "_f_webpack", | |
| 7733 | "webpack.config.dev.babel.js": "_f_webpack", | |
| 7734 | "webpack.config.dev.babel.cjs": "_f_webpack", | |
| 7735 | "webpack.config.dev.babel.mjs": "_f_webpack", | |
| 7736 | "webpack.config.dev.babel.coffee": "_f_webpack", | |
| 7737 | "webpack.config.dev.babel.ts": "_f_webpack", | |
| 7738 | "webpack.config.development.babel.js": "_f_webpack", | |
| 7739 | "webpack.config.development.babel.cjs": "_f_webpack", | |
| 7740 | "webpack.config.development.babel.mjs": "_f_webpack", | |
| 7741 | "webpack.config.development.babel.coffee": "_f_webpack", | |
| 7742 | "webpack.config.development.babel.ts": "_f_webpack", | |
| 7743 | "webpack.config.staging.babel.js": "_f_webpack", | |
| 7744 | "webpack.config.staging.babel.cjs": "_f_webpack", | |
| 7745 | "webpack.config.staging.babel.mjs": "_f_webpack", | |
| 7746 | "webpack.config.staging.babel.coffee": "_f_webpack", | |
| 7747 | "webpack.config.staging.babel.ts": "_f_webpack", | |
| 7748 | "webpack.config.test.babel.js": "_f_webpack", | |
| 7749 | "webpack.config.test.babel.cjs": "_f_webpack", | |
| 7750 | "webpack.config.test.babel.mjs": "_f_webpack", | |
| 7751 | "webpack.config.test.babel.coffee": "_f_webpack", | |
| 7752 | "webpack.config.test.babel.ts": "_f_webpack", | |
| 7753 | "webpack.config.prod.babel.js": "_f_webpack", | |
| 7754 | "webpack.config.prod.babel.cjs": "_f_webpack", | |
| 7755 | "webpack.config.prod.babel.mjs": "_f_webpack", | |
| 7756 | "webpack.config.prod.babel.coffee": "_f_webpack", | |
| 7757 | "webpack.config.prod.babel.ts": "_f_webpack", | |
| 7758 | "webpack.config.production.babel.js": "_f_webpack", | |
| 7759 | "webpack.config.production.babel.cjs": "_f_webpack", | |
| 7760 | "webpack.config.production.babel.mjs": "_f_webpack", | |
| 7761 | "webpack.config.production.babel.coffee": "_f_webpack", | |
| 7762 | "webpack.config.production.babel.ts": "_f_webpack", | |
| 7763 | "webpack.dev.js": "_f_webpack", | |
| 7764 | "webpack.dev.cjs": "_f_webpack", | |
| 7765 | "webpack.dev.mjs": "_f_webpack", | |
| 7766 | "webpack.dev.coffee": "_f_webpack", | |
| 7767 | "webpack.dev.ts": "_f_webpack", | |
| 7768 | "webpack.dev.conf.js": "_f_webpack", | |
| 7769 | "webpack.dev.conf.cjs": "_f_webpack", | |
| 7770 | "webpack.dev.conf.mjs": "_f_webpack", | |
| 7771 | "webpack.dev.conf.coffee": "_f_webpack", | |
| 7772 | "webpack.dev.conf.ts": "_f_webpack", | |
| 7773 | "webpack.local.js": "_f_webpack", | |
| 7774 | "webpack.local.cjs": "_f_webpack", | |
| 7775 | "webpack.local.mjs": "_f_webpack", | |
| 7776 | "webpack.local.coffee": "_f_webpack", | |
| 7777 | "webpack.local.ts": "_f_webpack", | |
| 7778 | "webpack.prod.js": "_f_webpack", | |
| 7779 | "webpack.prod.cjs": "_f_webpack", | |
| 7780 | "webpack.prod.mjs": "_f_webpack", | |
| 7781 | "webpack.prod.coffee": "_f_webpack", | |
| 7782 | "webpack.prod.ts": "_f_webpack", | |
| 7783 | "webpack.prod.conf.js": "_f_webpack", | |
| 7784 | "webpack.prod.conf.cjs": "_f_webpack", | |
| 7785 | "webpack.prod.conf.mjs": "_f_webpack", | |
| 7786 | "webpack.prod.conf.coffee": "_f_webpack", | |
| 7787 | "webpack.prod.conf.ts": "_f_webpack", | |
| 7788 | "webpack.main.config.js": "_f_webpack", | |
| 7789 | "webpack.main.config.cjs": "_f_webpack", | |
| 7790 | "webpack.main.config.mjs": "_f_webpack", | |
| 7791 | "webpack.main.config.coffee": "_f_webpack", | |
| 7792 | "webpack.main.config.ts": "_f_webpack", | |
| 7793 | "webpack.mix.js": "_f_webpack", | |
| 7794 | "webpack.mix.cjs": "_f_webpack", | |
| 7795 | "webpack.mix.mjs": "_f_webpack", | |
| 7796 | "webpack.mix.coffee": "_f_webpack", | |
| 7797 | "webpack.mix.ts": "_f_webpack", | |
| 7798 | "webpack.plugins.js": "_f_webpack", | |
| 7799 | "webpack.plugins.cjs": "_f_webpack", | |
| 7800 | "webpack.plugins.mjs": "_f_webpack", | |
| 7801 | "webpack.plugins.coffee": "_f_webpack", | |
| 7802 | "webpack.plugins.ts": "_f_webpack", | |
| 7803 | "webpack.renderer.config.js": "_f_webpack", | |
| 7804 | "webpack.renderer.config.cjs": "_f_webpack", | |
| 7805 | "webpack.renderer.config.mjs": "_f_webpack", | |
| 7806 | "webpack.renderer.config.coffee": "_f_webpack", | |
| 7807 | "webpack.renderer.config.ts": "_f_webpack", | |
| 7808 | "webpack.rules.js": "_f_webpack", | |
| 7809 | "webpack.rules.cjs": "_f_webpack", | |
| 7810 | "webpack.rules.mjs": "_f_webpack", | |
| 7811 | "webpack.rules.coffee": "_f_webpack", | |
| 7812 | "webpack.rules.ts": "_f_webpack", | |
| 7813 | "webpack.test.conf.js": "_f_webpack", | |
| 7814 | "webpack.test.conf.cjs": "_f_webpack", | |
| 7815 | "webpack.test.conf.mjs": "_f_webpack", | |
| 7816 | "webpack.test.conf.coffee": "_f_webpack", | |
| 7817 | "webpack.test.conf.ts": "_f_webpack", | |
| 7818 | "wercker.yml": "_f_wercker", | |
| 7819 | "windi.config.ts": "_f_windi", | |
| 7820 | "windi.config.js": "_f_windi", | |
| 7821 | "wpml-config.xml": "_f_wpml", | |
| 7822 | "wxt.config.js": "_f_wxt", | |
| 7823 | "wxt.config.cjs": "_f_wxt", | |
| 7824 | "wxt.config.mjs": "_f_wxt", | |
| 7825 | "wxt.config.ts": "_f_wxt", | |
| 7826 | "wxt.config.cts": "_f_wxt", | |
| 7827 | "wxt.config.mts": "_f_wxt", | |
| 7828 | ".xo-config": "_f_xo", | |
| 7829 | "xo.config.js": "_f_xo", | |
| 7830 | "xo.config.cjs": "_f_xo", | |
| 7831 | "xo.config.mjs": "_f_xo", | |
| 7832 | "xo.config.ts": "_f_xo", | |
| 7833 | "xo.config.cts": "_f_xo", | |
| 7834 | "xo.config.mts": "_f_xo", | |
| 7835 | ".xo-config.js": "_f_xo", | |
| 7836 | ".xo-config.json": "_f_xo", | |
| 7837 | ".xo-config.cjs": "_f_xo", | |
| 7838 | ".xcompose": "_f_light_xorg", | |
| 7839 | ".yamllint": "_f_yamllint", | |
| 7840 | ".yaspellerrc": "_f_yandex", | |
| 7841 | ".yaspeller.json": "_f_yandex", | |
| 7842 | "yarn.lock": "_f_yarn", | |
| 7843 | ".yarnrc": "_f_yarn", | |
| 7844 | ".yarnrc.yml": "_f_yarn", | |
| 7845 | ".yarnclean": "_f_yarn", | |
| 7846 | ".yarn-integrity": "_f_yarn", | |
| 7847 | ".yarn-metadata.json": "_f_yarn", | |
| 7848 | ".yarn-state.yml": "_f_yarn", | |
| 7849 | ".yarnignore": "_f_yarn", | |
| 7850 | ".yo-rc.json": "_f_yeoman" | |
| 7851 | }, | |
| 7852 | "languageIds": { | |
| 7853 | "actionscript": "_f_actionscript", | |
| 7854 | "ada": "_f_light_ada", | |
| 7855 | "advpl": "_f_advpl", | |
| 7856 | "affectscript": "_f_affectscript", | |
| 7857 | "agda": "_f_light_agda", | |
| 7858 | "lagda-md": "_f_light_agda", | |
| 7859 | "lagda-rst": "_f_light_agda", | |
| 7860 | "lagda-tex": "_f_light_agda", | |
| 7861 | "al": "_f_al", | |
| 7862 | "alloy": "_f_alloy", | |
| 7863 | "ansible": "_f_ansible", | |
| 7864 | "antlr": "_f_antlr", | |
| 7865 | "anyscript": "_f_anyscript", | |
| 7866 | "apacheconf": "_f_apache", | |
| 7867 | "apex": "_f_apex", | |
| 7868 | "apiblueprint": "_f_apib", | |
| 7869 | "apl": "_f_light_apl", | |
| 7870 | "applescript": "_f_applescript", | |
| 7871 | "asciidoc": "_f_asciidoc", | |
| 7872 | "asp": "_f_asp", | |
| 7873 | "asp (html)": "_f_asp", | |
| 7874 | "arm": "_f_assembly", | |
| 7875 | "asm": "_f_assembly", | |
| 7876 | "asm-intel-x86-generic": "_f_assembly", | |
| 7877 | "platformio-debug.asm": "_f_platformio", | |
| 7878 | "asm-collection": "_f_assembly", | |
| 7879 | "astro": "_f_light_astro", | |
| 7880 | "ats": "_f_ats", | |
| 7881 | "ahk": "_f_autohotkey", | |
| 7882 | "ahk2": "_f_autohotkey", | |
| 7883 | "autoit": "_f_autoit", | |
| 7884 | "avro": "_f_avro", | |
| 7885 | "azcli": "_f_azure", | |
| 7886 | "azure-pipelines": "_f_azurepipelines", | |
| 7887 | "ballerina": "_f_ballerina", | |
| 7888 | "bat": "_f_bat", | |
| 7889 | "bats": "_f_bats", | |
| 7890 | "bazel": "_f_bazel", | |
| 7891 | "bazelrc": "_f_bazel", | |
| 7892 | "starlark": "_f_bazel", | |
| 7893 | "befunge": "_f_befunge", | |
| 7894 | "befunge98": "_f_befunge", | |
| 7895 | "bicep": "_f_bicep", | |
| 7896 | "biml": "_f_biml", | |
| 7897 | "code-text-binary": "_f_binary", | |
| 7898 | "biome_syntax_tree": "_f_biome", | |
| 7899 | "blade": "_f_blade", | |
| 7900 | "laravel-blade": "_f_blade", | |
| 7901 | "blitzbasic": "_f_blitzbasic", | |
| 7902 | "bolt": "_f_bolt", | |
| 7903 | "bosque": "_f_bosque", | |
| 7904 | "browserslist": "_f_browserslist", | |
| 7905 | "bruno": "_f_bruno", | |
| 7906 | "buf": "_f_buf", | |
| 7907 | "buf-gen": "_f_buf", | |
| 7908 | "bun.lockb": "_f_bun", | |
| 7909 | "c": "_f_c", | |
| 7910 | "c3": "_f_c3", | |
| 7911 | "c-al": "_f_c_al", | |
| 7912 | "cabal": "_f_cabal", | |
| 7913 | "caddyfile": "_f_caddy", | |
| 7914 | "capnp": "_f_capnp", | |
| 7915 | "capnproto": "_f_capnp", | |
| 7916 | "casc": "_f_casc", | |
| 7917 | "cddl": "_f_cddl", | |
| 7918 | "ceylon": "_f_ceylon", | |
| 7919 | "cfml": "_f_cf", | |
| 7920 | "lang-cfml": "_f_cf", | |
| 7921 | "cfc": "_f_cfc", | |
| 7922 | "cfmhtml": "_f_cfm", | |
| 7923 | "cookbook": "_f_chef_cookbook", | |
| 7924 | "circom": "_f_circom", | |
| 7925 | "clojure": "_f_clojure", | |
| 7926 | "clojurescript": "_f_clojurescript", | |
| 7927 | "manifest-yaml": "_f_light_cloudfoundry", | |
| 7928 | "cmake": "_f_cmake", | |
| 7929 | "cmake-cache": "_f_cmake", | |
| 7930 | "cobol": "_f_cobol", | |
| 7931 | "ql": "_f_codeql", | |
| 7932 | "coffeescript": "_f_coffeescript", | |
| 7933 | "java-properties": "_f_light_config", | |
| 7934 | "properties": "_f_light_config", | |
| 7935 | "spring-boot-properties": "_f_light_config", | |
| 7936 | "confluence": "_f_confluence", | |
| 7937 | "instructions": "_f_light_copilot", | |
| 7938 | "prompt": "_f_light_copilot", | |
| 7939 | "cpp": "_f_cpp", | |
| 7940 | "crystal": "_f_light_crystal", | |
| 7941 | "csharp": "_f_csharp", | |
| 7942 | "css": "_f_css", | |
| 7943 | "cucumber": "_f_cucumber", | |
| 7944 | "feature": "_f_cucumber", | |
| 7945 | "cuda": "_f_cuda", | |
| 7946 | "cuda-cpp": "_f_cuda", | |
| 7947 | "cython": "_f_cython", | |
| 7948 | "dal": "_f_dal", | |
| 7949 | "dart": "_f_dartlang", | |
| 7950 | "debian-changelog": "_f_debian", | |
| 7951 | "debian-control": "_f_debian", | |
| 7952 | "debian-copyright": "_f_debian", | |
| 7953 | "debian-upstream-metadata": "_f_debian", | |
| 7954 | "pascal": "_f_delphi", | |
| 7955 | "objectpascal": "_f_delphi", | |
| 7956 | "dhall": "_f_light_dhall", | |
| 7957 | "diff": "_f_diff", | |
| 7958 | "django-html": "_f_django", | |
| 7959 | "django-txt": "_f_django", | |
| 7960 | "d": "_f_dlang", | |
| 7961 | "dscript": "_f_dlang", | |
| 7962 | "dml": "_f_dlang", | |
| 7963 | "diet": "_f_dlang", | |
| 7964 | "dockerbake": "_f_docker", | |
| 7965 | "dockercompose": "_f_docker", | |
| 7966 | "dockerfile": "_f_docker", | |
| 7967 | "dotenv": "_f_dotenv", | |
| 7968 | "env": "_f_dotenv", | |
| 7969 | "dotjs": "_f_dotjs", | |
| 7970 | "doxygen": "_f_doxygen", | |
| 7971 | "drawio": "_f_drawio", | |
| 7972 | "drools": "_f_drools", | |
| 7973 | "dtd": "_f_dtd", | |
| 7974 | "dustjs": "_f_dustjs", | |
| 7975 | "dylan": "_f_dylan", | |
| 7976 | "dylan-lid": "_f_dylan", | |
| 7977 | "earthfile": "_f_earthly", | |
| 7978 | "edge": "_f_edge", | |
| 7979 | "editorconfig": "_f_editorconfig", | |
| 7980 | "eex": "_f_eex", | |
| 7981 | "html-eex": "_f_eex", | |
| 7982 | "es": "_f_elastic", | |
| 7983 | "elixir": "_f_elixir", | |
| 7984 | "elm": "_f_elm", | |
| 7985 | "erb": "_f_erb", | |
| 7986 | "html.erb": "_f_erb", | |
| 7987 | "erlang": "_f_erlang", | |
| 7988 | "esphome": "_f_light_esphome", | |
| 7989 | "excel": "_f_excel", | |
| 7990 | "falcon": "_f_falcon", | |
| 7991 | "fql": "_f_fauna", | |
| 7992 | "fortran": "_f_fortran", | |
| 7993 | "fortran-modern": "_f_fortran", | |
| 7994 | "FortranFreeForm": "_f_fortran", | |
| 7995 | "FortranFixedForm": "_f_fortran", | |
| 7996 | "fortran_fixed-form": "_f_fortran", | |
| 7997 | "ftl": "_f_freemarker", | |
| 7998 | "fsharp": "_f_fsharp", | |
| 7999 | "fthtml": "_f_fthtml", | |
| 8000 | "galen": "_f_galen", | |
| 8001 | "gml-gms": "_f_gamemaker", | |
| 8002 | "gml-gms2": "_f_light_gamemaker2", | |
| 8003 | "gml-gm81": "_f_gamemaker81", | |
| 8004 | "gcode": "_f_gcode", | |
| 8005 | "gdscript": "_f_gdscript", | |
| 8006 | "genstat": "_f_genstat", | |
| 8007 | "git-commit": "_f_git", | |
| 8008 | "git-rebase": "_f_git", | |
| 8009 | "ignore": "_f_git", | |
| 8010 | "gleam": "_f_gleam", | |
| 8011 | "glimmer-js": "_f_glimmer", | |
| 8012 | "glimmer-ts": "_f_glimmer", | |
| 8013 | "glsl": "_f_glsl", | |
| 8014 | "glyphs": "_f_glyphs", | |
| 8015 | "automake": "_f_gnu", | |
| 8016 | "m4": "_f_gnu", | |
| 8017 | "makefile": "_f_gnu", | |
| 8018 | "makefile2": "_f_gnu", | |
| 8019 | "gnuplot": "_f_gnuplot", | |
| 8020 | "go": "_f_go", | |
| 8021 | "go.mod": "_f_go_package", | |
| 8022 | "go.work": "_f_go_work", | |
| 8023 | "goctl": "_f_goctl", | |
| 8024 | "gradle-kotlin-dsl": "_f_light_gradle", | |
| 8025 | "grain": "_f_grain", | |
| 8026 | "graphql": "_f_graphql", | |
| 8027 | "dot": "_f_graphviz", | |
| 8028 | "grit": "_f_grit", | |
| 8029 | "groovy": "_f_groovy", | |
| 8030 | "haml": "_f_haml", | |
| 8031 | "handlebars": "_f_handlebars", | |
| 8032 | "harbour": "_f_harbour", | |
| 8033 | "hcl": "_f_light_hashicorp", | |
| 8034 | "haskell": "_f_haskell", | |
| 8035 | "literate haskell": "_f_haskell", | |
| 8036 | "haxe": "_f_haxe", | |
| 8037 | "hxml": "_f_haxe", | |
| 8038 | "Haxe AST dump": "_f_haxe", | |
| 8039 | "helm": "_f_helm", | |
| 8040 | "hjson": "_f_light_hjson", | |
| 8041 | "hlsl": "_f_hlsl", | |
| 8042 | "home-assistant": "_f_homeassistant", | |
| 8043 | "hosts": "_f_host", | |
| 8044 | "html": "_f_html", | |
| 8045 | "http": "_f_http", | |
| 8046 | "hunspell.aff": "_f_hunspell", | |
| 8047 | "hunspell.dic": "_f_hunspell", | |
| 8048 | "hy": "_f_hy", | |
| 8049 | "hypr": "_f_hypr", | |
| 8050 | "icl": "_f_icl", | |
| 8051 | "imba": "_f_imba", | |
| 8052 | "4GL": "_f_informix", | |
| 8053 | "ini": "_f_light_ini", | |
| 8054 | "ink": "_f_ink", | |
| 8055 | "innosetup": "_f_innosetup", | |
| 8056 | "io": "_f_light_io", | |
| 8057 | "iodine": "_f_iodine", | |
| 8058 | "janet": "_f_janet", | |
| 8059 | "java": "_f_java", | |
| 8060 | "jekyll": "_f_jekyll", | |
| 8061 | "jenkins": "_f_jenkins", | |
| 8062 | "declarative": "_f_jenkins", | |
| 8063 | "jenkinsfile": "_f_jenkins", | |
| 8064 | "jest-snapshot": "_f_jest_snapshot", | |
| 8065 | "jinja": "_f_jinja", | |
| 8066 | "jinja-java": "_f_jinja", | |
| 8067 | "jinja-html": "_f_jinja", | |
| 8068 | "jinja-xml": "_f_jinja", | |
| 8069 | "jinja-css": "_f_jinja", | |
| 8070 | "jinja-json": "_f_jinja", | |
| 8071 | "jinja-md": "_f_jinja", | |
| 8072 | "jinja-py": "_f_jinja", | |
| 8073 | "jinja-rb": "_f_jinja", | |
| 8074 | "jinja-js": "_f_jinja", | |
| 8075 | "jinja-yaml": "_f_jinja", | |
| 8076 | "jinja-toml": "_f_jinja", | |
| 8077 | "jinja-latex": "_f_jinja", | |
| 8078 | "jinja-lua": "_f_jinja", | |
| 8079 | "jinja-properties": "_f_jinja", | |
| 8080 | "jinja-shell": "_f_jinja", | |
| 8081 | "jinja-dockerfile": "_f_jinja", | |
| 8082 | "jinja-sql": "_f_jinja", | |
| 8083 | "jinja-terraform": "_f_jinja", | |
| 8084 | "jinja-nginx": "_f_jinja", | |
| 8085 | "jinja-groovy": "_f_jinja", | |
| 8086 | "jinja-systemd": "_f_jinja", | |
| 8087 | "jinja-cpp": "_f_jinja", | |
| 8088 | "javascript": "_f_light_js", | |
| 8089 | "json": "_f_light_json", | |
| 8090 | "json-tmlanguage": "_f_light_json", | |
| 8091 | "jsonc": "_f_light_json", | |
| 8092 | "jsonl": "_f_light_json", | |
| 8093 | "json5": "_f_light_json5", | |
| 8094 | "jsonnet": "_f_jsonnet", | |
| 8095 | "julia": "_f_julia", | |
| 8096 | "juliamarkdown": "_f_julia", | |
| 8097 | "just": "_f_just", | |
| 8098 | "k": "_f_k", | |
| 8099 | "kivy": "_f_kivy", | |
| 8100 | "kos": "_f_kos", | |
| 8101 | "kotlin": "_f_kotlin", | |
| 8102 | "kotlinscript": "_f_kotlin", | |
| 8103 | "kusto": "_f_kusto", | |
| 8104 | "lark": "_f_light_lark", | |
| 8105 | "latino": "_f_latino", | |
| 8106 | "lean4": "_f_light_lean", | |
| 8107 | "lean4markdown": "_f_light_lean", | |
| 8108 | "less": "_f_less", | |
| 8109 | "lex": "_f_lex", | |
| 8110 | "lilypond": "_f_lilypond", | |
| 8111 | "lisp": "_f_lisp", | |
| 8112 | "autolisp": "_f_lisp", | |
| 8113 | "autolispdcl": "_f_lisp", | |
| 8114 | "log": "_f_log", | |
| 8115 | "lolcode": "_f_lolcode", | |
| 8116 | "lsl": "_f_lsl", | |
| 8117 | "lua": "_f_lua", | |
| 8118 | "markdown": "_f_markdown", | |
| 8119 | "marko": "_f_marko", | |
| 8120 | "matlab": "_f_matlab", | |
| 8121 | "maxscript": "_f_maxscript", | |
| 8122 | "mel": "_f_maya", | |
| 8123 | "mdx": "_f_light_mdx", | |
| 8124 | "mediawiki": "_f_mediawiki", | |
| 8125 | "mermaid": "_f_mermaid", | |
| 8126 | "meson": "_f_meson", | |
| 8127 | "metal": "_f_metal", | |
| 8128 | "bc-mcfunction": "_f_minecraft", | |
| 8129 | "bc-minecraft-language": "_f_minecraft", | |
| 8130 | "bc-minecraft-molang": "_f_minecraft", | |
| 8131 | "bc-minecraft-project": "_f_minecraft", | |
| 8132 | "mcdoc": "_f_minecraft", | |
| 8133 | "mcfunction": "_f_minecraft", | |
| 8134 | "mcmeta": "_f_minecraft", | |
| 8135 | "mcscript": "_f_minecraft", | |
| 8136 | "minecraft-lang": "_f_minecraft", | |
| 8137 | "options-txt": "_f_minecraft", | |
| 8138 | "snbt": "_f_minecraft", | |
| 8139 | "mv": "_f_mivascript", | |
| 8140 | "mjml": "_f_mjml", | |
| 8141 | "powerquery": "_f_light_mlang", | |
| 8142 | "mojo": "_f_mojo", | |
| 8143 | "mojolicious": "_f_mojolicious", | |
| 8144 | "mongo": "_f_mongo", | |
| 8145 | "mson": "_f_mson", | |
| 8146 | "mvt": "_f_mvt", | |
| 8147 | "mvtcss": "_f_mvtcss", | |
| 8148 | "mvtjs": "_f_mvtjs", | |
| 8149 | "nearley": "_f_nearly", | |
| 8150 | "cypher": "_f_light_neo4j", | |
| 8151 | "nginx": "_f_nginx", | |
| 8152 | "nim": "_f_nim", | |
| 8153 | "nimble": "_f_nimble", | |
| 8154 | "nix": "_f_nix", | |
| 8155 | "nsis": "_f_nsi", | |
| 8156 | "nfl": "_f_nsi", | |
| 8157 | "nsl": "_f_nsi", | |
| 8158 | "bridlensis": "_f_nsi", | |
| 8159 | "nunjucks": "_f_nunjucks", | |
| 8160 | "nushell": "_f_nushell", | |
| 8161 | "objective-c": "_f_objectivec", | |
| 8162 | "objective-cpp": "_f_objectivecpp", | |
| 8163 | "ocaml": "_f_ocaml", | |
| 8164 | "ocamllex": "_f_ocaml", | |
| 8165 | "menhir": "_f_ocaml", | |
| 8166 | "ogone": "_f_ogone", | |
| 8167 | "openhab": "_f_light_openHAB", | |
| 8168 | "scad": "_f_openscad", | |
| 8169 | "org": "_f_org", | |
| 8170 | "pddl": "_f_pddl", | |
| 8171 | "happenings": "_f_pddl_happenings", | |
| 8172 | "plan": "_f_pddl_plan", | |
| 8173 | "perl": "_f_perl", | |
| 8174 | "perl6": "_f_perl6", | |
| 8175 | "pgsql": "_f_pgsql", | |
| 8176 | "php": "_f_php", | |
| 8177 | "pine": "_f_pine", | |
| 8178 | "pinescript": "_f_pine", | |
| 8179 | "pip-requirements": "_f_pip", | |
| 8180 | "platformio-debug.disassembly": "_f_platformio", | |
| 8181 | "platformio-debug.memoryview": "_f_platformio", | |
| 8182 | "plsql": "_f_plsql", | |
| 8183 | "oracle": "_f_plsql", | |
| 8184 | "oraclesql": "_f_plsql", | |
| 8185 | "oracle-sql": "_f_plsql", | |
| 8186 | "po": "_f_poedit", | |
| 8187 | "polyglot-notebook": "_f_polyglot", | |
| 8188 | "polymer": "_f_polymer", | |
| 8189 | "pony": "_f_pony", | |
| 8190 | "postcss": "_f_postcss", | |
| 8191 | "postscript": "_f_postscript", | |
| 8192 | "powershell": "_f_powershell", | |
| 8193 | "prisma": "_f_light_prisma", | |
| 8194 | "pde": "_f_processinglang", | |
| 8195 | "abl": "_f_progress", | |
| 8196 | "prolog": "_f_prolog", | |
| 8197 | "prometheus": "_f_prometheus", | |
| 8198 | "proto3": "_f_protobuf", | |
| 8199 | "proto": "_f_protobuf", | |
| 8200 | "prototext": "_f_protobuf", | |
| 8201 | "jade": "_f_pug", | |
| 8202 | "puppet": "_f_puppet", | |
| 8203 | "purescript": "_f_light_purescript", | |
| 8204 | "pyret": "_f_pyret", | |
| 8205 | "pyscript": "_f_pyscript", | |
| 8206 | "python": "_f_python", | |
| 8207 | "pythowo": "_f_pythowo", | |
| 8208 | "qlik": "_f_qlikview", | |
| 8209 | "qml": "_f_qml", | |
| 8210 | "qsharp": "_f_qsharp", | |
| 8211 | "r": "_f_r", | |
| 8212 | "ra_syntax_tree": "_f_ra_syntax_tree", | |
| 8213 | "racket": "_f_racket", | |
| 8214 | "raku": "_f_light_raku", | |
| 8215 | "raml": "_f_raml", | |
| 8216 | "razor": "_f_razor", | |
| 8217 | "aspnetcorerazor": "_f_razor", | |
| 8218 | "javascriptreact": "_f_reactjs", | |
| 8219 | "typescriptreact": "_f_reactts", | |
| 8220 | "reason": "_f_reason", | |
| 8221 | "red": "_f_red", | |
| 8222 | "rescript": "_f_rescript", | |
| 8223 | "restructuredtext": "_f_rest", | |
| 8224 | "rexx": "_f_rexx", | |
| 8225 | "riot": "_f_riot", | |
| 8226 | "ripple": "_f_ripple", | |
| 8227 | "rmd": "_f_rmd", | |
| 8228 | "rnc": "_f_rnc", | |
| 8229 | "robot": "_f_robotframework", | |
| 8230 | "robots-txt": "_f_robots", | |
| 8231 | "ruby": "_f_ruby", | |
| 8232 | "rust": "_f_light_rust", | |
| 8233 | "s-lang": "_f_s-lang", | |
| 8234 | "san": "_f_san", | |
| 8235 | "SAS": "_f_sas", | |
| 8236 | "sass": "_f_sass", | |
| 8237 | "sass.hover": "_f_sass", | |
| 8238 | "sbt": "_f_sbt", | |
| 8239 | "scala": "_f_scala", | |
| 8240 | "scilab": "_f_scilab", | |
| 8241 | "vbscript": "_f_script", | |
| 8242 | "scss": "_f_scss", | |
| 8243 | "sdl": "_f_sdlang", | |
| 8244 | "search-result": "_f_search_result", | |
| 8245 | "shaderlab": "_f_light_shaderlab", | |
| 8246 | "shellscript": "_f_shell", | |
| 8247 | "silverstripe": "_f_silverstripe", | |
| 8248 | "sino": "_f_sino", | |
| 8249 | "eskip": "_f_skipper", | |
| 8250 | "slang": "_f_slang", | |
| 8251 | "slice": "_f_slice", | |
| 8252 | "slim": "_f_slim", | |
| 8253 | "slint": "_f_slint", | |
| 8254 | "sln": "_f_sln", | |
| 8255 | "smarty": "_f_smarty", | |
| 8256 | "snakemake": "_f_snakemake", | |
| 8257 | "snort": "_f_snort", | |
| 8258 | "solidity": "_f_light_solidity", | |
| 8259 | "sparql": "_f_sparql", | |
| 8260 | "sqf": "_f_sqf", | |
| 8261 | "sql": "_f_sql", | |
| 8262 | "squirrel": "_f_squirrel", | |
| 8263 | "stan": "_f_stan", | |
| 8264 | "stata": "_f_stata", | |
| 8265 | "stencil": "_f_stencil", | |
| 8266 | "stencil-html": "_f_stencil", | |
| 8267 | "stylable": "_f_stylable", | |
| 8268 | "source.css.styled": "_f_styled", | |
| 8269 | "stylus": "_f_light_stylus", | |
| 8270 | "svelte": "_f_svelte", | |
| 8271 | "svg": "_f_svg", | |
| 8272 | "Swagger": "_f_swagger", | |
| 8273 | "swagger": "_f_swagger", | |
| 8274 | "swift": "_f_swift", | |
| 8275 | "swig": "_f_swig", | |
| 8276 | "systemd-conf": "_f_light_systemd", | |
| 8277 | "systemd-unit-file": "_f_light_systemd", | |
| 8278 | "systemverilog": "_f_light_systemverilog", | |
| 8279 | "t4": "_f_t4tt", | |
| 8280 | "tailwindcss": "_f_tailwind", | |
| 8281 | "teal": "_f_teal", | |
| 8282 | "templ": "_f_templ", | |
| 8283 | "tera": "_f_tera", | |
| 8284 | "terraform": "_f_terraform", | |
| 8285 | "tex": "_f_light_tex", | |
| 8286 | "latex": "_f_light_tex", | |
| 8287 | "bibtex": "_f_light_tex", | |
| 8288 | "doctex": "_f_light_tex", | |
| 8289 | "csv": "_f_text", | |
| 8290 | "csv (pipe)": "_f_text", | |
| 8291 | "csv (semicolon)": "_f_text", | |
| 8292 | "csv (whitespace)": "_f_text", | |
| 8293 | "dynamic csv": "_f_text", | |
| 8294 | "tsv": "_f_text", | |
| 8295 | "plaintext": "_f_text", | |
| 8296 | "vim-help": "_f_text", | |
| 8297 | "textile": "_f_textile", | |
| 8298 | "tiltfile": "_f_tiltfile", | |
| 8299 | "toit": "_f_light_toit", | |
| 8300 | "toml": "_f_light_toml", | |
| 8301 | "toon": "_f_light_toon", | |
| 8302 | "tt": "_f_tt", | |
| 8303 | "ttcn": "_f_ttcn", | |
| 8304 | "tuc": "_f_tuc", | |
| 8305 | "twig": "_f_twig", | |
| 8306 | "typescript": "_f_typescript", | |
| 8307 | "typoscript": "_f_typo3", | |
| 8308 | "uiua": "_f_uiua", | |
| 8309 | "unison": "_f_unison", | |
| 8310 | "vb": "_f_vb", | |
| 8311 | "vba": "_f_vba", | |
| 8312 | "velocity": "_f_velocity", | |
| 8313 | "vento": "_f_vento", | |
| 8314 | "verilog": "_f_verilog", | |
| 8315 | "vhdl": "_f_vhdl", | |
| 8316 | "viml": "_f_vim", | |
| 8317 | "vim-snippet": "_f_vim", | |
| 8318 | "vitest-snapshot": "_f_light_vitest", | |
| 8319 | "v": "_f_vlang", | |
| 8320 | "volt": "_f_volt", | |
| 8321 | "vroom": "_f_vroom", | |
| 8322 | "snippets": "_f_vscode", | |
| 8323 | "vue": "_f_vue", | |
| 8324 | "vyper": "_f_vyper", | |
| 8325 | "wasm": "_f_wasm", | |
| 8326 | "wat": "_f_wasm", | |
| 8327 | "wenyan": "_f_wenyan", | |
| 8328 | "wgsl": "_f_wgsl", | |
| 8329 | "wikitext": "_f_wikitext", | |
| 8330 | "wai": "_f_wit", | |
| 8331 | "wolfram": "_f_wolfram", | |
| 8332 | "wurstlang": "_f_wurst", | |
| 8333 | "wurst": "_f_wurst", | |
| 8334 | "xmake": "_f_xmake", | |
| 8335 | "xml": "_f_xml", | |
| 8336 | "xcompose": "_f_light_xorg", | |
| 8337 | "xquery": "_f_xquery", | |
| 8338 | "xsl": "_f_xsl", | |
| 8339 | "xslt": "_f_xsl", | |
| 8340 | "yacc": "_f_yacc", | |
| 8341 | "yaml": "_f_light_yaml", | |
| 8342 | "yaml-tmlanguage": "_f_light_yaml", | |
| 8343 | "spring-boot-properties-yaml": "_f_light_yaml", | |
| 8344 | "yang": "_f_yang", | |
| 8345 | "yarnlock": "_f_yarn", | |
| 8346 | "zig": "_f_zig", | |
| 8347 | "zip": "_f_zip" | |
| 8348 | } | |
| 8349 | }, | |
| 8350 | "hidesExplorerArrows": false | |
| 8351 | } |
apps/web/src/workers/diffs.worker.ts deleted-1| ... | ... | @@ -1 +0,0 @@ |
| 1 | import "@pierre/diffs/worker/worker.js"; |
apps/web/src/worktreeCleanup.test.ts+26-12| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; |
| ... | ... | @@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 5 | 6 | |
| 6 | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 7 | 8 | return { |
| 8 | id: "thread-1", | |
| 9 | id: ThreadId.makeUnsafe("thread-1"), | |
| 9 | 10 | codexThreadId: null, |
| 10 | projectId: "project-1", | |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 11 | 12 | title: "Thread", |
| 12 | 13 | model: "gpt-5.3-codex", |
| 13 | 14 | terminalOpen: false, |
| ... | ... | @@ -24,10 +25,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 24 | 25 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 25 | 26 | session: null, |
| 26 | 27 | messages: [], |
| 27 | events: [], | |
| 28 | 28 | turnDiffSummaries: [], |
| 29 | activities: [], | |
| 29 | 30 | error: null, |
| 30 | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | latestTurn: null, | |
| 31 | 33 | branch: null, |
| 32 | 34 | worktreePath: null, |
| 33 | 35 | ...overrides, |
| ... | ... | @@ -36,37 +38,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 36 | 38 | |
| 37 | 39 | describe("getOrphanedWorktreePathForThread", () => { |
| 38 | 40 | it("returns null when the target thread does not exist", () => { |
| 39 | const result = getOrphanedWorktreePathForThread([], "missing-thread"); | |
| 41 | const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread")); | |
| 40 | 42 | expect(result).toBeNull(); |
| 41 | 43 | }); |
| 42 | 44 | |
| 43 | 45 | it("returns null when the target thread has no worktree", () => { |
| 44 | 46 | const threads = [makeThread()]; |
| 45 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 47 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 46 | 48 | expect(result).toBeNull(); |
| 47 | 49 | }); |
| 48 | 50 | |
| 49 | 51 | it("returns the path when no other thread links to that worktree", () => { |
| 50 | 52 | const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })]; |
| 51 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 53 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 52 | 54 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 53 | 55 | }); |
| 54 | 56 | |
| 55 | 57 | it("returns null when another thread links to the same worktree", () => { |
| 56 | 58 | const threads = [ |
| 57 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 58 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 59 | makeThread({ | |
| 60 | id: ThreadId.makeUnsafe("thread-1"), | |
| 61 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 62 | }), | |
| 63 | makeThread({ | |
| 64 | id: ThreadId.makeUnsafe("thread-2"), | |
| 65 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 66 | }), | |
| 59 | 67 | ]; |
| 60 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 68 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 61 | 69 | expect(result).toBeNull(); |
| 62 | 70 | }); |
| 63 | 71 | |
| 64 | 72 | it("ignores threads linked to different worktrees", () => { |
| 65 | 73 | const threads = [ |
| 66 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | |
| 67 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }), | |
| 74 | makeThread({ | |
| 75 | id: ThreadId.makeUnsafe("thread-1"), | |
| 76 | worktreePath: "/tmp/repo/worktrees/feature-a", | |
| 77 | }), | |
| 78 | makeThread({ | |
| 79 | id: ThreadId.makeUnsafe("thread-2"), | |
| 80 | worktreePath: "/tmp/repo/worktrees/feature-b", | |
| 81 | }), | |
| 68 | 82 | ]; |
| 69 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | |
| 83 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); | |
| 70 | 84 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 71 | 85 | }); |
| 72 | 86 | }); |
apps/web/src/worktreeCleanup.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null { |
| 10 | 10 | |
| 11 | 11 | export function getOrphanedWorktreePathForThread( |
| 12 | 12 | threads: readonly Thread[], |
| 13 | threadId: string, | |
| 13 | threadId: Thread["id"], | |
| 14 | 14 | ): string | null { |
| 15 | 15 | const targetThread = threads.find((thread) => thread.id === threadId); |
| 16 | 16 | if (!targetThread) { |
apps/web/src/wsNativeApi.test.ts created+310| ... | ... | @@ -0,0 +1,310 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | ORCHESTRATION_WS_CHANNELS, | |
| 4 | ORCHESTRATION_WS_METHODS, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | WS_CHANNELS, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 10 | ||
| 11 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); | |
| 12 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); | |
| 13 | const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( | |
| 14 | (channel, listener) => { | |
| 15 | const listeners = channelListeners.get(channel) ?? new Set<(data: unknown) => void>(); | |
| 16 | listeners.add(listener); | |
| 17 | channelListeners.set(channel, listeners); | |
| 18 | return () => { | |
| 19 | listeners.delete(listener); | |
| 20 | if (listeners.size === 0) { | |
| 21 | channelListeners.delete(channel); | |
| 22 | } | |
| 23 | }; | |
| 24 | }, | |
| 25 | ); | |
| 26 | ||
| 27 | vi.mock("./wsTransport", () => { | |
| 28 | return { | |
| 29 | WsTransport: class MockWsTransport { | |
| 30 | request = requestMock; | |
| 31 | subscribe = subscribeMock; | |
| 32 | }, | |
| 33 | }; | |
| 34 | }); | |
| 35 | ||
| 36 | function emitPush(channel: string, data: unknown): void { | |
| 37 | const listeners = channelListeners.get(channel); | |
| 38 | if (!listeners) return; | |
| 39 | for (const listener of listeners) { | |
| 40 | listener(data); | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | beforeEach(() => { | |
| 45 | vi.resetModules(); | |
| 46 | requestMock.mockReset(); | |
| 47 | subscribeMock.mockClear(); | |
| 48 | channelListeners.clear(); | |
| 49 | }); | |
| 50 | ||
| 51 | afterEach(() => { | |
| 52 | vi.restoreAllMocks(); | |
| 53 | }); | |
| 54 | ||
| 55 | describe("wsNativeApi", () => { | |
| 56 | it("delivers and caches valid server.welcome payloads", async () => { | |
| 57 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 58 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 59 | ||
| 60 | createWsNativeApi(); | |
| 61 | const listener = vi.fn(); | |
| 62 | onServerWelcome(listener); | |
| 63 | ||
| 64 | const payload = { cwd: "/tmp/workspace", projectName: "t3-code" }; | |
| 65 | emitPush(WS_CHANNELS.serverWelcome, payload); | |
| 66 | ||
| 67 | expect(listener).toHaveBeenCalledTimes(1); | |
| 68 | expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 69 | ||
| 70 | const lateListener = vi.fn(); | |
| 71 | onServerWelcome(lateListener); | |
| 72 | ||
| 73 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 74 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 75 | expect(warnSpy).not.toHaveBeenCalled(); | |
| 76 | }); | |
| 77 | ||
| 78 | it("preserves bootstrap ids from server.welcome payloads", async () => { | |
| 79 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 80 | ||
| 81 | createWsNativeApi(); | |
| 82 | const listener = vi.fn(); | |
| 83 | onServerWelcome(listener); | |
| 84 | ||
| 85 | emitPush(WS_CHANNELS.serverWelcome, { | |
| 86 | cwd: "/tmp/workspace", | |
| 87 | projectName: "t3-code", | |
| 88 | bootstrapProjectId: "project-1", | |
| 89 | bootstrapThreadId: "thread-1", | |
| 90 | }); | |
| 91 | ||
| 92 | expect(listener).toHaveBeenCalledTimes(1); | |
| 93 | expect(listener).toHaveBeenCalledWith( | |
| 94 | expect.objectContaining({ | |
| 95 | cwd: "/tmp/workspace", | |
| 96 | projectName: "t3-code", | |
| 97 | bootstrapProjectId: "project-1", | |
| 98 | bootstrapThreadId: "thread-1", | |
| 99 | }), | |
| 100 | ); | |
| 101 | }); | |
| 102 | ||
| 103 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { | |
| 104 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 105 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 106 | ||
| 107 | createWsNativeApi(); | |
| 108 | const listener = vi.fn(); | |
| 109 | onServerWelcome(listener); | |
| 110 | ||
| 111 | emitPush(WS_CHANNELS.serverWelcome, { cwd: 42, projectName: "t3-code" }); | |
| 112 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); | |
| 113 | ||
| 114 | expect(listener).toHaveBeenCalledTimes(1); | |
| 115 | expect(listener).toHaveBeenCalledWith( | |
| 116 | expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }), | |
| 117 | ); | |
| 118 | expect(warnSpy).toHaveBeenCalledTimes(1); | |
| 119 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { | |
| 120 | reason: "decode-failed", | |
| 121 | raw: { cwd: 42, projectName: "t3-code" }, | |
| 122 | issue: expect.stringContaining("SchemaError"), | |
| 123 | }); | |
| 124 | }); | |
| 125 | ||
| 126 | it("delivers and caches valid server.configUpdated payloads", async () => { | |
| 127 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 128 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | |
| 129 | ||
| 130 | createWsNativeApi(); | |
| 131 | const listener = vi.fn(); | |
| 132 | onServerConfigUpdated(listener); | |
| 133 | ||
| 134 | const payload = { | |
| 135 | issues: [ | |
| 136 | { | |
| 137 | kind: "keybindings.invalid-entry", | |
| 138 | index: 1, | |
| 139 | message: "Entry at index 1 is invalid.", | |
| 140 | }, | |
| 141 | ], | |
| 142 | } as const; | |
| 143 | emitPush(WS_CHANNELS.serverConfigUpdated, payload); | |
| 144 | ||
| 145 | expect(listener).toHaveBeenCalledTimes(1); | |
| 146 | expect(listener).toHaveBeenCalledWith(payload); | |
| 147 | ||
| 148 | const lateListener = vi.fn(); | |
| 149 | onServerConfigUpdated(lateListener); | |
| 150 | expect(lateListener).toHaveBeenCalledTimes(1); | |
| 151 | expect(lateListener).toHaveBeenCalledWith(payload); | |
| 152 | expect(warnSpy).not.toHaveBeenCalled(); | |
| 153 | }); | |
| 154 | ||
| 155 | it("drops malformed server.configUpdated payloads", async () => { | |
| 156 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 157 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | |
| 158 | ||
| 159 | createWsNativeApi(); | |
| 160 | const listener = vi.fn(); | |
| 161 | onServerConfigUpdated(listener); | |
| 162 | ||
| 163 | emitPush(WS_CHANNELS.serverConfigUpdated, { | |
| 164 | issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], | |
| 165 | }); | |
| 166 | emitPush(WS_CHANNELS.serverConfigUpdated, { | |
| 167 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 168 | }); | |
| 169 | ||
| 170 | expect(listener).toHaveBeenCalledTimes(1); | |
| 171 | expect(listener).toHaveBeenCalledWith({ | |
| 172 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | |
| 173 | }); | |
| 174 | expect(warnSpy).toHaveBeenCalledTimes(1); | |
| 175 | }); | |
| 176 | ||
| 177 | it("forwards valid terminal and orchestration events", async () => { | |
| 178 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 179 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 180 | ||
| 181 | const api = createWsNativeApi(); | |
| 182 | const onTerminalEvent = vi.fn(); | |
| 183 | const onDomainEvent = vi.fn(); | |
| 184 | ||
| 185 | api.terminal.onEvent(onTerminalEvent); | |
| 186 | api.orchestration.onDomainEvent(onDomainEvent); | |
| 187 | ||
| 188 | const terminalEvent = { | |
| 189 | threadId: "thread-1", | |
| 190 | terminalId: "terminal-1", | |
| 191 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 192 | type: "output", | |
| 193 | data: "hello", | |
| 194 | } as const; | |
| 195 | emitPush(WS_CHANNELS.terminalEvent, terminalEvent); | |
| 196 | ||
| 197 | const orchestrationEvent = { | |
| 198 | sequence: 1, | |
| 199 | eventId: "event-1", | |
| 200 | aggregateKind: "project", | |
| 201 | aggregateId: "project-1", | |
| 202 | occurredAt: "2026-02-24T00:00:00.000Z", | |
| 203 | commandId: null, | |
| 204 | causationEventId: null, | |
| 205 | correlationId: null, | |
| 206 | metadata: {}, | |
| 207 | type: "project.created", | |
| 208 | payload: { | |
| 209 | projectId: "project-1", | |
| 210 | title: "Project", | |
| 211 | workspaceRoot: "/tmp/workspace", | |
| 212 | defaultModel: null, | |
| 213 | scripts: [], | |
| 214 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 215 | updatedAt: "2026-02-24T00:00:00.000Z", | |
| 216 | }, | |
| 217 | } as const; | |
| 218 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); | |
| 219 | ||
| 220 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); | |
| 221 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | |
| 222 | expect(onDomainEvent).toHaveBeenCalledTimes(1); | |
| 223 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | |
| 224 | expect(warnSpy).not.toHaveBeenCalled(); | |
| 225 | }); | |
| 226 | ||
| 227 | it("drops malformed terminal and orchestration push payloads", async () => { | |
| 228 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 229 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 230 | ||
| 231 | const api = createWsNativeApi(); | |
| 232 | const onTerminalEvent = vi.fn(); | |
| 233 | const onDomainEvent = vi.fn(); | |
| 234 | ||
| 235 | api.terminal.onEvent(onTerminalEvent); | |
| 236 | api.orchestration.onDomainEvent(onDomainEvent); | |
| 237 | ||
| 238 | emitPush(WS_CHANNELS.terminalEvent, { | |
| 239 | threadId: "thread-1", | |
| 240 | terminalId: "", | |
| 241 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 242 | type: "output", | |
| 243 | data: "hello", | |
| 244 | }); | |
| 245 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, { | |
| 246 | sequence: -1, | |
| 247 | type: "project.created", | |
| 248 | }); | |
| 249 | ||
| 250 | expect(onTerminalEvent).not.toHaveBeenCalled(); | |
| 251 | expect(onDomainEvent).not.toHaveBeenCalled(); | |
| 252 | expect(warnSpy).toHaveBeenCalledTimes(2); | |
| 253 | expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket push payload", { | |
| 254 | reason: "decode-failed", | |
| 255 | raw: { | |
| 256 | threadId: "thread-1", | |
| 257 | terminalId: "", | |
| 258 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 259 | type: "output", | |
| 260 | data: "hello", | |
| 261 | }, | |
| 262 | issue: expect.stringContaining("SchemaError"), | |
| 263 | }); | |
| 264 | expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket push payload", { | |
| 265 | reason: "decode-failed", | |
| 266 | raw: { | |
| 267 | sequence: -1, | |
| 268 | type: "project.created", | |
| 269 | }, | |
| 270 | issue: expect.stringContaining("SchemaError"), | |
| 271 | }); | |
| 272 | }); | |
| 273 | ||
| 274 | it("wraps orchestration dispatch commands in the command envelope", async () => { | |
| 275 | requestMock.mockResolvedValue(undefined); | |
| 276 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 277 | ||
| 278 | const api = createWsNativeApi(); | |
| 279 | const command = { | |
| 280 | type: "project.create", | |
| 281 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 282 | projectId: ProjectId.makeUnsafe("project-1"), | |
| 283 | title: "Project", | |
| 284 | workspaceRoot: "/tmp/project", | |
| 285 | defaultModel: "gpt-5-codex", | |
| 286 | createdAt: "2026-02-24T00:00:00.000Z", | |
| 287 | } as const; | |
| 288 | await api.orchestration.dispatchCommand(command); | |
| 289 | ||
| 290 | expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 291 | command, | |
| 292 | }); | |
| 293 | }); | |
| 294 | ||
| 295 | it("forwards full-thread diff requests to the orchestration websocket method", async () => { | |
| 296 | requestMock.mockResolvedValue({ diff: "patch" }); | |
| 297 | const { createWsNativeApi } = await import("./wsNativeApi"); | |
| 298 | ||
| 299 | const api = createWsNativeApi(); | |
| 300 | await api.orchestration.getFullThreadDiff({ | |
| 301 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 302 | toTurnCount: 1, | |
| 303 | }); | |
| 304 | ||
| 305 | expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { | |
| 306 | threadId: "thread-1", | |
| 307 | toTurnCount: 1, | |
| 308 | }); | |
| 309 | }); | |
| 310 | }); |
apps/web/src/wsNativeApi.ts+85-34| ... | ... | @@ -1,11 +1,40 @@ |
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | OrchestrationEvent, | |
| 3 | ORCHESTRATION_WS_CHANNELS, | |
| 4 | ORCHESTRATION_WS_METHODS, | |
| 5 | type NativeApi, | |
| 6 | ServerConfigUpdatedPayload, | |
| 7 | TerminalEvent, | |
| 8 | WS_CHANNELS, | |
| 9 | WS_METHODS, | |
| 10 | WsWelcomePayload, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { Cause, Schema } from "effect"; | |
| 2 | 13 | |
| 3 | 14 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 4 | 15 | import { WsTransport } from "./wsTransport"; |
| 5 | 16 | |
| 6 | 17 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| 7 | 18 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); |
| 19 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); | |
| 8 | 20 | let lastWelcome: WsWelcomePayload | null = null; |
| 21 | let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null; | |
| 22 | ||
| 23 | const decodeAndWarnOnFailure = <T>( | |
| 24 | schema: Schema.Schema<T> & { readonly DecodingServices: never }, | |
| 25 | raw: unknown, | |
| 26 | ): T | null => { | |
| 27 | const decoded = Schema.decodeUnknownExit(schema)(raw); | |
| 28 | if (decoded._tag === "Failure") { | |
| 29 | console.warn("Dropped inbound WebSocket push payload", { | |
| 30 | reason: "decode-failed", | |
| 31 | raw, | |
| 32 | issue: Cause.pretty(decoded.cause), | |
| 33 | }); | |
| 34 | return null; | |
| 35 | } | |
| 36 | return decoded.value; | |
| 37 | }; | |
| 9 | 38 | |
| 10 | 39 | /** |
| 11 | 40 | * Subscribe to the server welcome message. If a welcome was already received |
| ... | ... | @@ -29,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): |
| 29 | 58 | }; |
| 30 | 59 | } |
| 31 | 60 | |
| 61 | /** | |
| 62 | * Subscribe to server config update events. Replays the latest update for | |
| 63 | * late subscribers to avoid missing config validation feedback. | |
| 64 | */ | |
| 65 | export function onServerConfigUpdated( | |
| 66 | listener: (payload: ServerConfigUpdatedPayload) => void, | |
| 67 | ): () => void { | |
| 68 | serverConfigUpdatedListeners.add(listener); | |
| 69 | ||
| 70 | if (lastServerConfigUpdated) { | |
| 71 | try { | |
| 72 | listener(lastServerConfigUpdated); | |
| 73 | } catch { | |
| 74 | // Swallow listener errors | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | return () => { | |
| 79 | serverConfigUpdatedListeners.delete(listener); | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 32 | 83 | export function createWsNativeApi(): NativeApi { |
| 33 | 84 | if (instance) return instance.api; |
| 34 | 85 | |
| ... | ... | @@ -37,7 +88,8 @@ export function createWsNativeApi(): NativeApi { |
| 37 | 88 | // Listen for server welcome and forward to registered listeners. |
| 38 | 89 | // Also cache it so late subscribers (React effects) get it immediately. |
| 39 | 90 | transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { |
| 40 | const payload = data as WsWelcomePayload; | |
| 91 | const payload = decodeAndWarnOnFailure(WsWelcomePayload, data); | |
| 92 | if (!payload) return; | |
| 41 | 93 | lastWelcome = payload; |
| 42 | 94 | for (const listener of welcomeListeners) { |
| 43 | 95 | try { |
| ... | ... | @@ -47,14 +99,20 @@ export function createWsNativeApi(): NativeApi { |
| 47 | 99 | } |
| 48 | 100 | } |
| 49 | 101 | }); |
| 102 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => { | |
| 103 | const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data); | |
| 104 | if (!payload) return; | |
| 105 | lastServerConfigUpdated = payload; | |
| 106 | for (const listener of serverConfigUpdatedListeners) { | |
| 107 | try { | |
| 108 | listener(payload); | |
| 109 | } catch { | |
| 110 | // Swallow listener errors | |
| 111 | } | |
| 112 | } | |
| 113 | }); | |
| 50 | 114 | |
| 51 | 115 | const api: NativeApi = { |
| 52 | todos: { | |
| 53 | list: async () => [], | |
| 54 | add: async () => [], | |
| 55 | toggle: async () => [], | |
| 56 | remove: async () => [], | |
| 57 | }, | |
| 58 | 116 | dialogs: { |
| 59 | 117 | pickFolder: async () => { |
| 60 | 118 | if (!window.desktopBridge) return null; |
| ... | ... | @@ -75,35 +133,13 @@ export function createWsNativeApi(): NativeApi { |
| 75 | 133 | restart: (input) => transport.request(WS_METHODS.terminalRestart, input), |
| 76 | 134 | close: (input) => transport.request(WS_METHODS.terminalClose, input), |
| 77 | 135 | onEvent: (callback) => |
| 78 | transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void), | |
| 79 | }, | |
| 80 | agent: { | |
| 81 | spawn: async () => "", | |
| 82 | kill: async () => {}, | |
| 83 | write: async () => {}, | |
| 84 | onOutput: () => () => {}, | |
| 85 | onExit: () => () => {}, | |
| 86 | }, | |
| 87 | providers: { | |
| 88 | startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), | |
| 89 | sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), | |
| 90 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | |
| 91 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | |
| 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | |
| 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | |
| 94 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), | |
| 95 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), | |
| 96 | revertToCheckpoint: (input) => | |
| 97 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), | |
| 98 | onEvent: (callback) => | |
| 99 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | |
| 136 | transport.subscribe(WS_CHANNELS.terminalEvent, (data) => { | |
| 137 | const payload = decodeAndWarnOnFailure(TerminalEvent, data); | |
| 138 | if (payload) callback(payload); | |
| 139 | }), | |
| 100 | 140 | }, |
| 101 | 141 | projects: { |
| 102 | list: () => transport.request(WS_METHODS.projectsList), | |
| 103 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | |
| 104 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | |
| 105 | 142 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 106 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | |
| 107 | 143 | }, |
| 108 | 144 | shell: { |
| 109 | 145 | openInEditor: (cwd, editor) => |
| ... | ... | @@ -149,6 +185,21 @@ export function createWsNativeApi(): NativeApi { |
| 149 | 185 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 150 | 186 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), |
| 151 | 187 | }, |
| 188 | orchestration: { | |
| 189 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), | |
| 190 | dispatchCommand: (command) => | |
| 191 | transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), | |
| 192 | getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), | |
| 193 | getFullThreadDiff: (input) => | |
| 194 | transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), | |
| 195 | replayEvents: (fromSequenceExclusive) => | |
| 196 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), | |
| 197 | onDomainEvent: (callback) => | |
| 198 | transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (data) => { | |
| 199 | const payload = decodeAndWarnOnFailure(OrchestrationEvent, data); | |
| 200 | if (payload) callback(payload); | |
| 201 | }), | |
| 202 | }, | |
| 152 | 203 | }; |
| 153 | 204 | |
| 154 | 205 | instance = { api, transport }; |
apps/web/src/wsTransport.test.ts created+175| ... | ... | @@ -0,0 +1,175 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { WsTransport } from "./wsTransport"; | |
| 4 | ||
| 5 | type WsEventType = "open" | "message" | "close" | "error"; | |
| 6 | type WsListener = (event?: { data?: unknown }) => void; | |
| 7 | ||
| 8 | const sockets: MockWebSocket[] = []; | |
| 9 | ||
| 10 | class MockWebSocket { | |
| 11 | static readonly CONNECTING = 0; | |
| 12 | static readonly OPEN = 1; | |
| 13 | static readonly CLOSING = 2; | |
| 14 | static readonly CLOSED = 3; | |
| 15 | ||
| 16 | readyState = MockWebSocket.CONNECTING; | |
| 17 | readonly sent: string[] = []; | |
| 18 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | |
| 19 | ||
| 20 | constructor(_url: string) { | |
| 21 | sockets.push(this); | |
| 22 | } | |
| 23 | ||
| 24 | addEventListener(type: WsEventType, listener: WsListener) { | |
| 25 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | |
| 26 | listeners.add(listener); | |
| 27 | this.listeners.set(type, listeners); | |
| 28 | } | |
| 29 | ||
| 30 | send(data: string) { | |
| 31 | this.sent.push(data); | |
| 32 | } | |
| 33 | ||
| 34 | close() { | |
| 35 | this.readyState = MockWebSocket.CLOSED; | |
| 36 | this.emit("close"); | |
| 37 | } | |
| 38 | ||
| 39 | open() { | |
| 40 | this.readyState = MockWebSocket.OPEN; | |
| 41 | this.emit("open"); | |
| 42 | } | |
| 43 | ||
| 44 | serverMessage(data: unknown) { | |
| 45 | this.emit("message", { data }); | |
| 46 | } | |
| 47 | ||
| 48 | private emit(type: WsEventType, event?: { data?: unknown }) { | |
| 49 | const listeners = this.listeners.get(type); | |
| 50 | if (!listeners) return; | |
| 51 | for (const listener of listeners) { | |
| 52 | listener(event); | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | const originalWebSocket = globalThis.WebSocket; | |
| 58 | ||
| 59 | function getSocket(): MockWebSocket { | |
| 60 | const socket = sockets.at(-1); | |
| 61 | if (!socket) { | |
| 62 | throw new Error("Expected a websocket instance"); | |
| 63 | } | |
| 64 | return socket; | |
| 65 | } | |
| 66 | ||
| 67 | beforeEach(() => { | |
| 68 | sockets.length = 0; | |
| 69 | ||
| 70 | Object.defineProperty(globalThis, "window", { | |
| 71 | configurable: true, | |
| 72 | value: { | |
| 73 | location: { hostname: "localhost", port: "3020" }, | |
| 74 | desktopBridge: undefined, | |
| 75 | }, | |
| 76 | }); | |
| 77 | ||
| 78 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | |
| 79 | }); | |
| 80 | ||
| 81 | afterEach(() => { | |
| 82 | globalThis.WebSocket = originalWebSocket; | |
| 83 | vi.restoreAllMocks(); | |
| 84 | }); | |
| 85 | ||
| 86 | describe("WsTransport", () => { | |
| 87 | it("routes valid push envelopes to channel listeners", () => { | |
| 88 | const transport = new WsTransport("ws://localhost:3020"); | |
| 89 | const socket = getSocket(); | |
| 90 | socket.open(); | |
| 91 | ||
| 92 | const listener = vi.fn(); | |
| 93 | transport.subscribe("providers.event", listener); | |
| 94 | ||
| 95 | socket.serverMessage( | |
| 96 | JSON.stringify({ | |
| 97 | type: "push", | |
| 98 | channel: "providers.event", | |
| 99 | data: { status: "ok" }, | |
| 100 | }), | |
| 101 | ); | |
| 102 | ||
| 103 | expect(listener).toHaveBeenCalledTimes(1); | |
| 104 | expect(listener).toHaveBeenCalledWith({ status: "ok" }); | |
| 105 | ||
| 106 | transport.dispose(); | |
| 107 | }); | |
| 108 | ||
| 109 | it("resolves pending requests for valid response envelopes", async () => { | |
| 110 | const transport = new WsTransport("ws://localhost:3020"); | |
| 111 | const socket = getSocket(); | |
| 112 | socket.open(); | |
| 113 | ||
| 114 | const requestPromise = transport.request("projects.list"); | |
| 115 | const sent = socket.sent.at(-1); | |
| 116 | if (!sent) { | |
| 117 | throw new Error("Expected request envelope to be sent"); | |
| 118 | } | |
| 119 | ||
| 120 | const requestEnvelope = JSON.parse(sent) as { id: string }; | |
| 121 | socket.serverMessage( | |
| 122 | JSON.stringify({ | |
| 123 | id: requestEnvelope.id, | |
| 124 | result: { projects: [] }, | |
| 125 | }), | |
| 126 | ); | |
| 127 | ||
| 128 | await expect(requestPromise).resolves.toEqual({ projects: [] }); | |
| 129 | ||
| 130 | transport.dispose(); | |
| 131 | }); | |
| 132 | ||
| 133 | it("drops malformed envelopes without crashing transport", () => { | |
| 134 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 135 | const transport = new WsTransport("ws://localhost:3020"); | |
| 136 | const socket = getSocket(); | |
| 137 | socket.open(); | |
| 138 | ||
| 139 | const listener = vi.fn(); | |
| 140 | transport.subscribe("providers.event", listener); | |
| 141 | ||
| 142 | socket.serverMessage("{ invalid-json"); | |
| 143 | socket.serverMessage( | |
| 144 | JSON.stringify({ | |
| 145 | type: "push", | |
| 146 | channel: 42, | |
| 147 | data: { bad: true }, | |
| 148 | }), | |
| 149 | ); | |
| 150 | socket.serverMessage( | |
| 151 | JSON.stringify({ | |
| 152 | type: "push", | |
| 153 | channel: "providers.event", | |
| 154 | data: { ok: true }, | |
| 155 | }), | |
| 156 | ); | |
| 157 | ||
| 158 | expect(listener).toHaveBeenCalledTimes(1); | |
| 159 | expect(listener).toHaveBeenCalledWith({ ok: true }); | |
| 160 | expect(warnSpy).toHaveBeenCalledTimes(2); | |
| 161 | expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket envelope", { | |
| 162 | reason: "decode-failed", | |
| 163 | issue: | |
| 164 | "SchemaError: SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)", | |
| 165 | raw: "{ invalid-json", | |
| 166 | }); | |
| 167 | expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket envelope", { | |
| 168 | reason: "decode-failed", | |
| 169 | issue: expect.stringContaining("SchemaError: Expected string, got 42"), | |
| 170 | raw: '{"type":"push","channel":42,"data":{"bad":true}}', | |
| 171 | }); | |
| 172 | ||
| 173 | transport.dispose(); | |
| 174 | }); | |
| 175 | }); |
apps/web/src/wsTransport.ts+39-26| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; | |
| 1 | import { WebSocketResponse, WsPush, WsResponse } from "@t3tools/contracts"; | |
| 2 | import { Cause, Schema } from "effect"; | |
| 2 | 3 | |
| 3 | 4 | type PushListener = (data: unknown) => void; |
| 4 | 5 | |
| ... | ... | @@ -10,6 +11,17 @@ interface PendingRequest { |
| 10 | 11 | |
| 11 | 12 | const REQUEST_TIMEOUT_MS = 60_000; |
| 12 | 13 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; |
| 14 | const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse)); | |
| 15 | const isWsPushEnvelope = Schema.is(WsPush); | |
| 16 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); | |
| 17 | ||
| 18 | interface WsRequestEnvelope { | |
| 19 | id: string; | |
| 20 | body: { | |
| 21 | _tag: string; | |
| 22 | [key: string]: unknown; | |
| 23 | }; | |
| 24 | } | |
| 13 | 25 | |
| 14 | 26 | export class WsTransport { |
| 15 | 27 | private ws: WebSocket | null = null; |
| ... | ... | @@ -41,7 +53,8 @@ export class WsTransport { |
| 41 | 53 | throw new Error("Request method is required"); |
| 42 | 54 | } |
| 43 | 55 | const id = String(this.nextId++); |
| 44 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; | |
| 56 | const body = params != null ? { ...params, _tag: method } : { _tag: method }; | |
| 57 | const message: WsRequestEnvelope = { id, body }; | |
| 45 | 58 | |
| 46 | 59 | return new Promise<T>((resolve, reject) => { |
| 47 | 60 | const timeout = setTimeout(() => { |
| ... | ... | @@ -115,25 +128,24 @@ export class WsTransport { |
| 115 | 128 | } |
| 116 | 129 | |
| 117 | 130 | private handleMessage(raw: unknown) { |
| 118 | let parsed: unknown; | |
| 119 | try { | |
| 120 | parsed = JSON.parse(String(raw)); | |
| 121 | } catch { | |
| 131 | const exit = decodeWsResponseFromJson(raw); | |
| 132 | if (exit._tag === "Failure") { | |
| 133 | console.warn("Dropped inbound WebSocket envelope", { | |
| 134 | reason: "decode-failed", | |
| 135 | raw, | |
| 136 | issue: Cause.pretty(exit.cause), | |
| 137 | }); | |
| 122 | 138 | return; |
| 123 | 139 | } |
| 124 | ||
| 125 | if (!parsed || typeof parsed !== "object") return; | |
| 126 | ||
| 127 | const message = parsed as Record<string, unknown>; | |
| 140 | const message = exit.value; | |
| 128 | 141 | |
| 129 | 142 | // Push event |
| 130 | if (message.type === "push") { | |
| 131 | const push = message as unknown as WsPush; | |
| 132 | const channelListeners = this.listeners.get(push.channel); | |
| 143 | if (isWsPushEnvelope(message)) { | |
| 144 | const channelListeners = this.listeners.get(message.channel); | |
| 133 | 145 | if (channelListeners) { |
| 134 | 146 | for (const listener of channelListeners) { |
| 135 | 147 | try { |
| 136 | listener(push.data); | |
| 148 | listener(message.data); | |
| 137 | 149 | } catch { |
| 138 | 150 | // Swallow listener errors |
| 139 | 151 | } |
| ... | ... | @@ -143,23 +155,24 @@ export class WsTransport { |
| 143 | 155 | } |
| 144 | 156 | |
| 145 | 157 | // Response to a request |
| 146 | if (typeof message.id === "string") { | |
| 147 | const response = message as unknown as WsResponse; | |
| 148 | const pending = this.pending.get(response.id); | |
| 149 | if (!pending) return; | |
| 158 | if (!isWebSocketResponseEnvelope(message)) { | |
| 159 | return; | |
| 160 | } | |
| 150 | 161 | |
| 151 | clearTimeout(pending.timeout); | |
| 152 | this.pending.delete(response.id); | |
| 162 | const pending = this.pending.get(message.id); | |
| 163 | if (!pending) return; | |
| 153 | 164 | |
| 154 | if (response.error) { | |
| 155 | pending.reject(new Error(response.error.message)); | |
| 156 | } else { | |
| 157 | pending.resolve(response.result); | |
| 158 | } | |
| 165 | clearTimeout(pending.timeout); | |
| 166 | this.pending.delete(message.id); | |
| 167 | ||
| 168 | if (message.error) { | |
| 169 | pending.reject(new Error(message.error.message)); | |
| 170 | } else { | |
| 171 | pending.resolve(message.result); | |
| 159 | 172 | } |
| 160 | 173 | } |
| 161 | 174 | |
| 162 | private send(message: WsRequest) { | |
| 175 | private send(message: WsRequestEnvelope) { | |
| 163 | 176 | if (this.ws?.readyState === WebSocket.OPEN) { |
| 164 | 177 | this.ws.send(JSON.stringify(message)); |
| 165 | 178 | return; |
apps/web/tsconfig.json+13-1| ... | ... | @@ -7,7 +7,19 @@ |
| 7 | 7 | "types": ["vite/client"], |
| 8 | 8 | "paths": { |
| 9 | 9 | "~/*": ["./src/*"] |
| 10 | } | |
| 10 | }, | |
| 11 | "plugins": [ | |
| 12 | { | |
| 13 | "name": "@effect/language-service", | |
| 14 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 15 | "diagnosticSeverity": { | |
| 16 | "importFromBarrel": "error", | |
| 17 | "anyUnknownInErrorContext": "warning", | |
| 18 | "instanceOfSchema": "warning", | |
| 19 | "deterministicKeys": "warning" | |
| 20 | } | |
| 21 | } | |
| 22 | ] | |
| 11 | 23 | }, |
| 12 | 24 | "include": ["src", "vite.config.ts"] |
| 13 | 25 | } |
apps/web/vite.config.ts+1-1| ... | ... | @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"; |
| 3 | 3 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 4 | 4 | import { defineConfig } from "vite"; |
| 5 | 5 | |
| 6 | const port = Number(process.env.PORT ?? 5173); | |
| 6 | const port = Number(process.env.PORT ?? 5733); | |
| 7 | 7 | |
| 8 | 8 | export default defineConfig({ |
| 9 | 9 | plugins: [ |
bun.lock+157-36| ... | ... | @@ -5,8 +5,8 @@ |
| 5 | 5 | "": { |
| 6 | 6 | "name": "@t3tools/monorepo", |
| 7 | 7 | "devDependencies": { |
| 8 | "oxfmt": "^0.28.0", | |
| 9 | "oxlint": "^1.43.0", | |
| 8 | "oxfmt": "^0.35.0", | |
| 9 | "oxlint": "^1.50.0", | |
| 10 | 10 | "turbo": "^2.3.3", |
| 11 | 11 | "typescript": "^5.7.3", |
| 12 | 12 | "vitest": "^4.0.0", |
| ... | ... | @@ -16,9 +16,10 @@ |
| 16 | 16 | "name": "@t3tools/desktop", |
| 17 | 17 | "version": "0.0.0", |
| 18 | 18 | "dependencies": { |
| 19 | "electron": "33.4.11", | |
| 19 | "electron": "40.6.0", | |
| 20 | 20 | }, |
| 21 | 21 | "devDependencies": { |
| 22 | "@t3tools/contracts": "workspace:*", | |
| 22 | 23 | "@types/node": "^22.10.2", |
| 23 | 24 | "electronmon": "^2.0.2", |
| 24 | 25 | "tsdown": "^0.20.3", |
| ... | ... | @@ -34,17 +35,25 @@ |
| 34 | 35 | "t3": "./dist/index.mjs", |
| 35 | 36 | }, |
| 36 | 37 | "dependencies": { |
| 38 | "@effect/platform-node": "catalog:", | |
| 39 | "@effect/sql-sqlite-bun": "catalog:", | |
| 40 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 41 | "effect": "catalog:", | |
| 37 | 42 | "node-pty": "^1.1.0", |
| 38 | 43 | "open": "^10.1.0", |
| 39 | 44 | "ws": "^8.18.0", |
| 40 | 45 | }, |
| 41 | 46 | "devDependencies": { |
| 47 | "@effect/language-service": "0.75.1", | |
| 48 | "@effect/vitest": "catalog:", | |
| 42 | 49 | "@t3tools/contracts": "workspace:*", |
| 43 | 50 | "@t3tools/web": "workspace:*", |
| 51 | "@types/bun": "^1.3.9", | |
| 44 | 52 | "@types/node": "^22.10.2", |
| 45 | 53 | "@types/ws": "^8.5.13", |
| 46 | 54 | "tsdown": "^0.20.3", |
| 47 | 55 | "typescript": "^5.7.3", |
| 56 | "vitest": "^4.0.0", | |
| 48 | 57 | }, |
| 49 | 58 | }, |
| 50 | 59 | "apps/web": { |
| ... | ... | @@ -61,6 +70,7 @@ |
| 61 | 70 | "@xterm/addon-fit": "^0.11.0", |
| 62 | 71 | "@xterm/xterm": "^6.0.0", |
| 63 | 72 | "class-variance-authority": "^0.7.1", |
| 73 | "effect": "catalog:", | |
| 64 | 74 | "highlight.js": "^11.11.1", |
| 65 | 75 | "lucide-react": "^0.564.0", |
| 66 | 76 | "react": "^19.0.0", |
| ... | ... | @@ -69,9 +79,9 @@ |
| 69 | 79 | "rehype-highlight": "^7.0.2", |
| 70 | 80 | "remark-gfm": "^4.0.1", |
| 71 | 81 | "tailwind-merge": "^3.4.0", |
| 72 | "zod": "^3.24.1", | |
| 73 | 82 | }, |
| 74 | 83 | "devDependencies": { |
| 84 | "@effect/language-service": "catalog:", | |
| 75 | 85 | "@tailwindcss/vite": "^4.0.0", |
| 76 | 86 | "@tanstack/router-plugin": "^1.161.0", |
| 77 | 87 | "@types/react": "^19.0.0", |
| ... | ... | @@ -87,14 +97,23 @@ |
| 87 | 97 | "name": "@t3tools/contracts", |
| 88 | 98 | "version": "0.0.0", |
| 89 | 99 | "dependencies": { |
| 90 | "zod": "^3.24.1", | |
| 100 | "effect": "catalog:", | |
| 91 | 101 | }, |
| 92 | 102 | "devDependencies": { |
| 103 | "@effect/language-service": "0.75.1", | |
| 104 | "@effect/vitest": "catalog:", | |
| 93 | 105 | "tsdown": "^0.20.3", |
| 94 | 106 | "typescript": "^5.7.3", |
| 95 | 107 | }, |
| 96 | 108 | }, |
| 97 | 109 | }, |
| 110 | "catalog": { | |
| 111 | "@effect/language-service": "0.75.1", | |
| 112 | "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@f591738", | |
| 113 | "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@f591738", | |
| 114 | "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@f591738", | |
| 115 | "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@f591738", | |
| 116 | }, | |
| 98 | 117 | "packages": { |
| 99 | 118 | "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], |
| 100 | 119 | |
| ... | ... | @@ -144,6 +163,16 @@ |
| 144 | 163 | |
| 145 | 164 | "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], |
| 146 | 165 | |
| 166 | "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], | |
| 167 | ||
| 168 | "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@f591738", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@f59173885df0eb490dc2e6619ae89467feac0413", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.19", "ioredis": "^5.7.0" } }], | |
| 169 | ||
| 170 | "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@f59173885df0eb490dc2e6619ae89467feac0413", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.19" } }], | |
| 171 | ||
| 172 | "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@f591738", { "peerDependencies": { "effect": "^4.0.0-beta.19" } }], | |
| 173 | ||
| 174 | "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@f591738", { "peerDependencies": { "effect": "^4.0.0-beta.19", "vitest": "^3.0.0" } }], | |
| 175 | ||
| 147 | 176 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
| 148 | 177 | |
| 149 | 178 | "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], |
| ... | ... | @@ -234,6 +263,8 @@ |
| 234 | 263 | |
| 235 | 264 | "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], |
| 236 | 265 | |
| 266 | "@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], | |
| 267 | ||
| 237 | 268 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], |
| 238 | 269 | |
| 239 | 270 | "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], |
| ... | ... | @@ -244,6 +275,18 @@ |
| 244 | 275 | |
| 245 | 276 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], |
| 246 | 277 | |
| 278 | "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], | |
| 279 | ||
| 280 | "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], | |
| 281 | ||
| 282 | "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], | |
| 283 | ||
| 284 | "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], | |
| 285 | ||
| 286 | "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], | |
| 287 | ||
| 288 | "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], | |
| 289 | ||
| 247 | 290 | "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], |
| 248 | 291 | |
| 249 | 292 | "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], |
| ... | ... | @@ -258,59 +301,81 @@ |
| 258 | 301 | |
| 259 | 302 | "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], |
| 260 | 303 | |
| 261 | "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmUfF7cNJPw57bEK7sMIqrYRgn4LH428tSgtgLTCtjuGuu1ShREyrkeB7y8HtkXRfhBs4lVY+HMLhqElJvZ6ww=="], | |
| 304 | "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="], | |
| 305 | ||
| 306 | "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="], | |
| 307 | ||
| 308 | "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.35.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA=="], | |
| 309 | ||
| 310 | "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.35.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw=="], | |
| 311 | ||
| 312 | "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.35.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ=="], | |
| 313 | ||
| 314 | "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ=="], | |
| 262 | 315 | |
| 263 | "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-S6vlV8S7jbjzJOSjfVg2CimUC0r7/aHDLdUm/3+/B/SU/s1jV7ivqWkMv1/8EB43d1BBwT9JQ60ZMTkBqeXSFA=="], | |
| 316 | "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A=="], | |
| 264 | 317 | |
| 265 | "@oxfmt/linux-arm64-gnu": ["@oxfmt/linux-arm64-gnu@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TfJkMZjePbLiskmxFXVAbGI/OZtD+y+fwS0wyW8O6DWG0ARTf0AipY9zGwGoOdpFuXOJceXvN4SHGLbYNDMY4Q=="], | |
| 318 | "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA=="], | |
| 266 | 319 | |
| 267 | "@oxfmt/linux-arm64-musl": ["@oxfmt/linux-arm64-musl@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7fyQUdW203v4WWGr1T3jwTz4L7KX9y5DeATryQ6fLT6QQp9GEuct8/k0lYhd+ys42iTV/IkJF20e3YkfSOOILg=="], | |
| 320 | "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q=="], | |
| 268 | 321 | |
| 269 | "@oxfmt/linux-x64-gnu": ["@oxfmt/linux-x64-gnu@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sRKqAvEonuz0qr1X1ncUZceOBJerKzkO2gZIZmosvy/JmqyffpIFL3OE2tqacFkeDhrC+dNYQpusO8zsfHo3pw=="], | |
| 322 | "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.35.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw=="], | |
| 270 | 323 | |
| 271 | "@oxfmt/linux-x64-musl": ["@oxfmt/linux-x64-musl@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-fW6czbXutX/tdQe8j4nSIgkUox9RXqjyxwyWXUDItpoDkoXllq17qbD7GVc0whrEhYQC6hFE1UEAcDypLJoSzw=="], | |
| 324 | "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ=="], | |
| 272 | 325 | |
| 273 | "@oxfmt/win32-arm64": ["@oxfmt/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-D/HDeQBAQRjTbD9OLV6kRDcStrIfO+JsUODDCdGmhRfNX8LPCx95GpfyybpZfn3wVF8Jq/yjPXV1xLkQ+s7RcA=="], | |
| 326 | "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w=="], | |
| 274 | 327 | |
| 275 | "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4+S2j4OxOIyo8dz5osm5dZuL0yVmxXvtmNdHB5xyGwAWVvyWNvf7tCaQD7w2fdSsAXQLOvK7KFQrHFe33nJUCA=="], | |
| 328 | "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.35.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g=="], | |
| 276 | 329 | |
| 277 | "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.46.0", "", { "os": "android", "cpu": "arm" }, "sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w=="], | |
| 330 | "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w=="], | |
| 278 | 331 | |
| 279 | "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.46.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA=="], | |
| 332 | "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA=="], | |
| 280 | 333 | |
| 281 | "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.46.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew=="], | |
| 334 | "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.35.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA=="], | |
| 282 | 335 | |
| 283 | "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.46.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA=="], | |
| 336 | "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.35.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg=="], | |
| 284 | 337 | |
| 285 | "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.46.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA=="], | |
| 338 | "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.35.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g=="], | |
| 286 | 339 | |
| 287 | "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA=="], | |
| 340 | "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ=="], | |
| 288 | 341 | |
| 289 | "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA=="], | |
| 342 | "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg=="], | |
| 290 | 343 | |
| 291 | "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ=="], | |
| 344 | "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.50.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg=="], | |
| 292 | 345 | |
| 293 | "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ=="], | |
| 346 | "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.50.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw=="], | |
| 294 | 347 | |
| 295 | "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.46.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw=="], | |
| 348 | "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.50.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw=="], | |
| 296 | 349 | |
| 297 | "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg=="], | |
| 350 | "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.50.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw=="], | |
| 298 | 351 | |
| 299 | "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w=="], | |
| 352 | "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ=="], | |
| 300 | 353 | |
| 301 | "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.46.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA=="], | |
| 354 | "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg=="], | |
| 302 | 355 | |
| 303 | "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA=="], | |
| 356 | "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA=="], | |
| 304 | 357 | |
| 305 | "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ=="], | |
| 358 | "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ=="], | |
| 306 | 359 | |
| 307 | "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.46.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ=="], | |
| 360 | "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.50.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw=="], | |
| 308 | 361 | |
| 309 | "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.46.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w=="], | |
| 362 | "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg=="], | |
| 310 | 363 | |
| 311 | "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.46.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA=="], | |
| 364 | "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA=="], | |
| 312 | 365 | |
| 313 | "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], | |
| 366 | "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.50.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ=="], | |
| 367 | ||
| 368 | "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA=="], | |
| 369 | ||
| 370 | "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ=="], | |
| 371 | ||
| 372 | "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.50.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw=="], | |
| 373 | ||
| 374 | "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.50.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ=="], | |
| 375 | ||
| 376 | "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.50.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w=="], | |
| 377 | ||
| 378 | "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="], | |
| 314 | 379 | |
| 315 | 380 | "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], |
| 316 | 381 | |
| ... | ... | @@ -494,6 +559,8 @@ |
| 494 | 559 | |
| 495 | 560 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], |
| 496 | 561 | |
| 562 | "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | |
| 563 | ||
| 497 | 564 | "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], |
| 498 | 565 | |
| 499 | 566 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], |
| ... | ... | @@ -596,6 +663,8 @@ |
| 596 | 663 | |
| 597 | 664 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 598 | 665 | |
| 666 | "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | |
| 667 | ||
| 599 | 668 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], |
| 600 | 669 | |
| 601 | 670 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], |
| ... | ... | @@ -634,6 +703,8 @@ |
| 634 | 703 | |
| 635 | 704 | "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], |
| 636 | 705 | |
| 706 | "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], | |
| 707 | ||
| 637 | 708 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], |
| 638 | 709 | |
| 639 | 710 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], |
| ... | ... | @@ -672,6 +743,8 @@ |
| 672 | 743 | |
| 673 | 744 | "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], |
| 674 | 745 | |
| 746 | "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], | |
| 747 | ||
| 675 | 748 | "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], |
| 676 | 749 | |
| 677 | 750 | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
| ... | ... | @@ -686,7 +759,9 @@ |
| 686 | 759 | |
| 687 | 760 | "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], |
| 688 | 761 | |
| 689 | "electron": ["electron@33.4.11", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg=="], | |
| 762 | "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@f591738", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], | |
| 763 | ||
| 764 | "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], | |
| 690 | 765 | |
| 691 | 766 | "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], |
| 692 | 767 | |
| ... | ... | @@ -732,12 +807,16 @@ |
| 732 | 807 | |
| 733 | 808 | "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], |
| 734 | 809 | |
| 810 | "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], | |
| 811 | ||
| 735 | 812 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], |
| 736 | 813 | |
| 737 | 814 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], |
| 738 | 815 | |
| 739 | 816 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], |
| 740 | 817 | |
| 818 | "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], | |
| 819 | ||
| 741 | 820 | "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], |
| 742 | 821 | |
| 743 | 822 | "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], |
| ... | ... | @@ -812,8 +891,12 @@ |
| 812 | 891 | |
| 813 | 892 | "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], |
| 814 | 893 | |
| 894 | "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], | |
| 895 | ||
| 815 | 896 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 816 | 897 | |
| 898 | "ioredis": ["ioredis@5.9.3", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA=="], | |
| 899 | ||
| 817 | 900 | "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], |
| 818 | 901 | |
| 819 | 902 | "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], |
| ... | ... | @@ -862,6 +945,8 @@ |
| 862 | 945 | |
| 863 | 946 | "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], |
| 864 | 947 | |
| 948 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], | |
| 949 | ||
| 865 | 950 | "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], |
| 866 | 951 | |
| 867 | 952 | "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], |
| ... | ... | @@ -888,8 +973,12 @@ |
| 888 | 973 | |
| 889 | 974 | "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], |
| 890 | 975 | |
| 976 | "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], | |
| 977 | ||
| 891 | 978 | "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], |
| 892 | 979 | |
| 980 | "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], | |
| 981 | ||
| 893 | 982 | "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], |
| 894 | 983 | |
| 895 | 984 | "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], |
| ... | ... | @@ -998,6 +1087,8 @@ |
| 998 | 1087 | |
| 999 | 1088 | "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], |
| 1000 | 1089 | |
| 1090 | "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], | |
| 1091 | ||
| 1001 | 1092 | "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], |
| 1002 | 1093 | |
| 1003 | 1094 | "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], |
| ... | ... | @@ -1008,14 +1099,22 @@ |
| 1008 | 1099 | |
| 1009 | 1100 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], |
| 1010 | 1101 | |
| 1102 | "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], | |
| 1103 | ||
| 1104 | "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], | |
| 1105 | ||
| 1011 | 1106 | "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], |
| 1012 | 1107 | |
| 1108 | "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], | |
| 1109 | ||
| 1013 | 1110 | "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], |
| 1014 | 1111 | |
| 1015 | 1112 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 1016 | 1113 | |
| 1017 | 1114 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], |
| 1018 | 1115 | |
| 1116 | "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], | |
| 1117 | ||
| 1019 | 1118 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], |
| 1020 | 1119 | |
| 1021 | 1120 | "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], |
| ... | ... | @@ -1038,9 +1137,9 @@ |
| 1038 | 1137 | |
| 1039 | 1138 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], |
| 1040 | 1139 | |
| 1041 | "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="], | |
| 1140 | "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], | |
| 1042 | 1141 | |
| 1043 | "oxlint": ["oxlint@1.46.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.46.0", "@oxlint/binding-android-arm64": "1.46.0", "@oxlint/binding-darwin-arm64": "1.46.0", "@oxlint/binding-darwin-x64": "1.46.0", "@oxlint/binding-freebsd-x64": "1.46.0", "@oxlint/binding-linux-arm-gnueabihf": "1.46.0", "@oxlint/binding-linux-arm-musleabihf": "1.46.0", "@oxlint/binding-linux-arm64-gnu": "1.46.0", "@oxlint/binding-linux-arm64-musl": "1.46.0", "@oxlint/binding-linux-ppc64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-musl": "1.46.0", "@oxlint/binding-linux-s390x-gnu": "1.46.0", "@oxlint/binding-linux-x64-gnu": "1.46.0", "@oxlint/binding-linux-x64-musl": "1.46.0", "@oxlint/binding-openharmony-arm64": "1.46.0", "@oxlint/binding-win32-arm64-msvc": "1.46.0", "@oxlint/binding-win32-ia32-msvc": "1.46.0", "@oxlint/binding-win32-x64-msvc": "1.46.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA=="], | |
| 1142 | "oxlint": ["oxlint@1.50.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.50.0", "@oxlint/binding-android-arm64": "1.50.0", "@oxlint/binding-darwin-arm64": "1.50.0", "@oxlint/binding-darwin-x64": "1.50.0", "@oxlint/binding-freebsd-x64": "1.50.0", "@oxlint/binding-linux-arm-gnueabihf": "1.50.0", "@oxlint/binding-linux-arm-musleabihf": "1.50.0", "@oxlint/binding-linux-arm64-gnu": "1.50.0", "@oxlint/binding-linux-arm64-musl": "1.50.0", "@oxlint/binding-linux-ppc64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-musl": "1.50.0", "@oxlint/binding-linux-s390x-gnu": "1.50.0", "@oxlint/binding-linux-x64-gnu": "1.50.0", "@oxlint/binding-linux-x64-musl": "1.50.0", "@oxlint/binding-openharmony-arm64": "1.50.0", "@oxlint/binding-win32-arm64-msvc": "1.50.0", "@oxlint/binding-win32-ia32-msvc": "1.50.0", "@oxlint/binding-win32-x64-msvc": "1.50.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ=="], | |
| 1044 | 1143 | |
| 1045 | 1144 | "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], |
| 1046 | 1145 | |
| ... | ... | @@ -1070,6 +1169,8 @@ |
| 1070 | 1169 | |
| 1071 | 1170 | "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], |
| 1072 | 1171 | |
| 1172 | "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], | |
| 1173 | ||
| 1073 | 1174 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], |
| 1074 | 1175 | |
| 1075 | 1176 | "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], |
| ... | ... | @@ -1086,6 +1187,10 @@ |
| 1086 | 1187 | |
| 1087 | 1188 | "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], |
| 1088 | 1189 | |
| 1190 | "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], | |
| 1191 | ||
| 1192 | "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], | |
| 1193 | ||
| 1089 | 1194 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], |
| 1090 | 1195 | |
| 1091 | 1196 | "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], |
| ... | ... | @@ -1160,6 +1265,8 @@ |
| 1160 | 1265 | |
| 1161 | 1266 | "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], |
| 1162 | 1267 | |
| 1268 | "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], | |
| 1269 | ||
| 1163 | 1270 | "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], |
| 1164 | 1271 | |
| 1165 | 1272 | "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], |
| ... | ... | @@ -1212,6 +1319,8 @@ |
| 1212 | 1319 | |
| 1213 | 1320 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], |
| 1214 | 1321 | |
| 1322 | "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], | |
| 1323 | ||
| 1215 | 1324 | "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], |
| 1216 | 1325 | |
| 1217 | 1326 | "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], |
| ... | ... | @@ -1246,6 +1355,8 @@ |
| 1246 | 1355 | |
| 1247 | 1356 | "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], |
| 1248 | 1357 | |
| 1358 | "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], | |
| 1359 | ||
| 1249 | 1360 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], |
| 1250 | 1361 | |
| 1251 | 1362 | "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], |
| ... | ... | @@ -1276,6 +1387,8 @@ |
| 1276 | 1387 | |
| 1277 | 1388 | "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], |
| 1278 | 1389 | |
| 1390 | "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], | |
| 1391 | ||
| 1279 | 1392 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], |
| 1280 | 1393 | |
| 1281 | 1394 | "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], |
| ... | ... | @@ -1304,6 +1417,8 @@ |
| 1304 | 1417 | |
| 1305 | 1418 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], |
| 1306 | 1419 | |
| 1420 | "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], | |
| 1421 | ||
| 1307 | 1422 | "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], |
| 1308 | 1423 | |
| 1309 | 1424 | "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], |
| ... | ... | @@ -1372,11 +1487,13 @@ |
| 1372 | 1487 | |
| 1373 | 1488 | "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], |
| 1374 | 1489 | |
| 1490 | "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], | |
| 1491 | ||
| 1375 | 1492 | "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], |
| 1376 | 1493 | |
| 1377 | 1494 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| 1378 | 1495 | |
| 1379 | "electron/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], | |
| 1496 | "electron/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], | |
| 1380 | 1497 | |
| 1381 | 1498 | "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], |
| 1382 | 1499 | |
| ... | ... | @@ -1422,6 +1539,10 @@ |
| 1422 | 1539 | |
| 1423 | 1540 | "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], |
| 1424 | 1541 | |
| 1542 | "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | |
| 1543 | ||
| 1544 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | |
| 1545 | ||
| 1425 | 1546 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], |
| 1426 | 1547 | } |
| 1427 | 1548 | } |
docs/adr/0001-provider-runtime-lifecycle-ownership.md created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | # ADR 0001: Provider Runtime Lifecycle Ownership | |
| 2 | ||
| 3 | ## Status | |
| 4 | Accepted | |
| 5 | ||
| 6 | ## Context | |
| 7 | Provider runtime streams can include auxiliary work (for example collab/child-agent turns) under the same provider session as a user-visible primary turn. | |
| 8 | ||
| 9 | The previous orchestration ingestion and checkpoint flows keyed lifecycle updates by `providerSessionId` alone. This allowed auxiliary `turn.completed` events to: | |
| 10 | ||
| 11 | - mark thread sessions as `ready` before the primary turn completed | |
| 12 | - clear `activeTurnId` for a still-running primary turn | |
| 13 | - emit checkpoint turn-diff summaries for auxiliary turns in the main thread timeline | |
| 14 | ||
| 15 | ## Decision | |
| 16 | Define and enforce a lifecycle ownership invariant: | |
| 17 | ||
| 18 | - Only events in the primary thread/turn lane may mutate thread session lifecycle state (`status`, `activeTurnId`, `providerThreadId`) and checkpoint turn completion. | |
| 19 | - Auxiliary events may still append messages and activities. | |
| 20 | ||
| 21 | Current enforcement is implemented with scope guards: | |
| 22 | ||
| 23 | - Runtime ingestion only applies lifecycle transitions when the event targets the active primary scope (provider thread and active turn checks). | |
| 24 | - Checkpoint capture ignores `turn.completed` events from non-primary provider thread scope, and ignores non-active turn completions while a primary turn is active. | |
| 25 | ||
| 26 | ## Consequences | |
| 27 | ||
| 28 | ### Positive | |
| 29 | - Prevents premature loss of "working" state from auxiliary turn completion. | |
| 30 | - Prevents auxiliary changed-files/checkpoint cards from appearing as if they were primary turn completion. | |
| 31 | - Keeps useful auxiliary activity/message visibility. | |
| 32 | ||
| 33 | ### Negative | |
| 34 | - Runtime events that omit thread/turn identifiers are treated conservatively in lifecycle transitions. | |
| 35 | - Full explicit lane modeling is still desirable in contracts for long-term clarity. | |
| 36 | ||
| 37 | ## Follow-up | |
| 38 | 1. Add explicit runtime lane metadata (`primary` vs `auxiliary`) to canonical provider runtime events. | |
| 39 | 2. Persist orchestration-to-provider turn binding (`TurnId` <-> `ProviderTurnId`) for stronger reconciliation and replay safety. | |
| 40 | 3. Promote lifecycle ownership checks from heuristic guards to schema-level invariants once lane metadata is available. |
package.json+17-7| ... | ... | @@ -1,10 +1,19 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/monorepo", |
| 3 | 3 | "private": true, |
| 4 | "workspaces": [ | |
| 5 | "apps/*", | |
| 6 | "packages/*" | |
| 7 | ], | |
| 4 | "workspaces": { | |
| 5 | "packages": [ | |
| 6 | "apps/*", | |
| 7 | "packages/*" | |
| 8 | ], | |
| 9 | "catalog": { | |
| 10 | "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@f591738", | |
| 11 | "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@f591738", | |
| 12 | "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@f591738", | |
| 13 | "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@f591738", | |
| 14 | "@effect/language-service": "0.75.1" | |
| 15 | } | |
| 16 | }, | |
| 8 | 17 | "scripts": { |
| 9 | 18 | "dev": "bun run scripts/dev-runner.mjs dev", |
| 10 | 19 | "dev:server": "bun run scripts/dev-runner.mjs dev:server", |
| ... | ... | @@ -27,14 +36,15 @@ |
| 27 | 36 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 28 | 37 | }, |
| 29 | 38 | "devDependencies": { |
| 30 | "oxfmt": "^0.28.0", | |
| 31 | "oxlint": "^1.43.0", | |
| 39 | "oxfmt": "^0.35.0", | |
| 40 | "oxlint": "^1.50.0", | |
| 32 | 41 | "turbo": "^2.3.3", |
| 33 | 42 | "typescript": "^5.7.3", |
| 34 | 43 | "vitest": "^4.0.0" |
| 35 | 44 | }, |
| 36 | 45 | "engines": { |
| 37 | "bun": ">=1.3.9" | |
| 46 | "bun": "^1.3.9", | |
| 47 | "node": "^24.13.1" | |
| 38 | 48 | }, |
| 39 | 49 | "packageManager": "bun@1.3.9" |
| 40 | 50 | } |
packages/contracts/package.json+4-1| ... | ... | @@ -19,13 +19,16 @@ |
| 19 | 19 | "scripts": { |
| 20 | 20 | "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean", |
| 21 | 21 | "build": "tsdown src/index.ts --format esm,cjs --dts --clean", |
| 22 | "prepare": "effect-language-service patch", | |
| 22 | 23 | "typecheck": "tsc --noEmit", |
| 23 | 24 | "test": "vitest run" |
| 24 | 25 | }, |
| 25 | 26 | "dependencies": { |
| 26 | "zod": "^3.24.1" | |
| 27 | "effect": "catalog:" | |
| 27 | 28 | }, |
| 28 | 29 | "devDependencies": { |
| 30 | "@effect/language-service": "0.75.1", | |
| 31 | "@effect/vitest": "catalog:", | |
| 29 | 32 | "tsdown": "^0.20.3", |
| 30 | 33 | "typescript": "^5.7.3" |
| 31 | 34 | } |
packages/contracts/src/agent.ts deleted-27| ... | ... | @@ -1,27 +0,0 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const agentSessionIdSchema = z.string().min(1); | |
| 4 | ||
| 5 | export const agentConfigSchema = z.object({ | |
| 6 | command: z.string().min(1), | |
| 7 | args: z.array(z.string()).default([]), | |
| 8 | cwd: z.string().optional(), | |
| 9 | env: z.record(z.string()).optional(), | |
| 10 | usePty: z.boolean().optional(), | |
| 11 | }); | |
| 12 | ||
| 13 | export const outputChunkSchema = z.object({ | |
| 14 | sessionId: z.string(), | |
| 15 | stream: z.enum(["stdout", "stderr"]), | |
| 16 | data: z.string(), | |
| 17 | }); | |
| 18 | ||
| 19 | export const agentExitSchema = z.object({ | |
| 20 | sessionId: z.string(), | |
| 21 | code: z.number().nullable(), | |
| 22 | signal: z.string().nullable(), | |
| 23 | }); | |
| 24 | ||
| 25 | export type AgentConfig = z.infer<typeof agentConfigSchema>; | |
| 26 | export type OutputChunk = z.infer<typeof outputChunkSchema>; | |
| 27 | export type AgentExit = z.infer<typeof agentExitSchema>; |
packages/contracts/src/baseSchemas.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | export const TrimmedString = Schema.Trim; | |
| 4 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); | |
| 5 | ||
| 6 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | |
| 7 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); | |
| 8 | ||
| 9 | export const IsoDateTime = Schema.String; | |
| 10 | export type IsoDateTime = typeof IsoDateTime.Type; | |
| 11 | ||
| 12 | /** | |
| 13 | * Construct a branded identifier. Enforces non-empty trimmed strings | |
| 14 | */ | |
| 15 | const makeEntityId = <Brand extends string>(brand: Brand) => | |
| 16 | TrimmedNonEmptyString.pipe(Schema.brand(brand)); | |
| 17 | ||
| 18 | export const ThreadId = makeEntityId("ThreadId"); | |
| 19 | export type ThreadId = typeof ThreadId.Type; | |
| 20 | export const ProjectId = makeEntityId("ProjectId"); | |
| 21 | export type ProjectId = typeof ProjectId.Type; | |
| 22 | export const CommandId = makeEntityId("CommandId"); | |
| 23 | export type CommandId = typeof CommandId.Type; | |
| 24 | export const EventId = makeEntityId("EventId"); | |
| 25 | export type EventId = typeof EventId.Type; | |
| 26 | export const MessageId = makeEntityId("MessageId"); | |
| 27 | export type MessageId = typeof MessageId.Type; | |
| 28 | export const TurnId = makeEntityId("TurnId"); | |
| 29 | export type TurnId = typeof TurnId.Type; | |
| 30 | ||
| 31 | export const ProviderSessionId = makeEntityId("ProviderSessionId"); | |
| 32 | export type ProviderSessionId = typeof ProviderSessionId.Type; | |
| 33 | export const ProviderThreadId = makeEntityId("ProviderThreadId"); | |
| 34 | export type ProviderThreadId = typeof ProviderThreadId.Type; | |
| 35 | export const ProviderTurnId = makeEntityId("ProviderTurnId"); | |
| 36 | export type ProviderTurnId = typeof ProviderTurnId.Type; | |
| 37 | export const ProviderItemId = makeEntityId("ProviderItemId"); | |
| 38 | export type ProviderItemId = typeof ProviderItemId.Type; | |
| 39 | export const ApprovalRequestId = makeEntityId("ApprovalRequestId"); | |
| 40 | export type ApprovalRequestId = typeof ApprovalRequestId.Type; | |
| 41 | export const CheckpointRef = makeEntityId("CheckpointRef"); | |
| 42 | export type CheckpointRef = typeof CheckpointRef.Type; |
packages/contracts/src/editor.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | ||
| 4 | export const EDITORS = [ | |
| 5 | { id: "cursor", label: "Cursor", command: "cursor" }, | |
| 6 | { id: "file-manager", label: "File Manager", command: null }, | |
| 7 | ] as const; | |
| 8 | ||
| 9 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); | |
| 10 | export type EditorId = typeof EditorId.Type; | |
| 11 | ||
| 12 | export const OpenInEditorInput = Schema.Struct({ | |
| 13 | cwd: TrimmedNonEmptyString, | |
| 14 | editor: EditorId, | |
| 15 | }); | |
| 16 | export type OpenInEditorInput = typeof OpenInEditorInput.Type; |
packages/contracts/src/git.test.ts deleted-107| ... | ... | @@ -1,107 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitPullInputSchema, | |
| 5 | gitPullResultSchema, | |
| 6 | gitRunStackedActionInputSchema, | |
| 7 | gitRunStackedActionResultSchema, | |
| 8 | gitStackedActionSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | gitStatusResultSchema, | |
| 11 | } from "./git"; | |
| 12 | ||
| 13 | describe("git contracts", () => { | |
| 14 | it("parses git status input and trims cwd", () => { | |
| 15 | const parsed = gitStatusInputSchema.parse({ cwd: " /tmp/repo " }); | |
| 16 | expect(parsed.cwd).toBe("/tmp/repo"); | |
| 17 | }); | |
| 18 | ||
| 19 | it("parses git pull input and trims cwd", () => { | |
| 20 | const parsed = gitPullInputSchema.parse({ cwd: " /tmp/repo " }); | |
| 21 | expect(parsed.cwd).toBe("/tmp/repo"); | |
| 22 | }); | |
| 23 | ||
| 24 | it("parses git status result", () => { | |
| 25 | const parsed = gitStatusResultSchema.parse({ | |
| 26 | branch: "feature/git-actions", | |
| 27 | hasWorkingTreeChanges: true, | |
| 28 | workingTree: { | |
| 29 | files: [ | |
| 30 | { path: "src/git.ts", insertions: 8, deletions: 2 }, | |
| 31 | { path: "src/git.test.ts", insertions: 4, deletions: 1 }, | |
| 32 | ], | |
| 33 | insertions: 12, | |
| 34 | deletions: 3, | |
| 35 | }, | |
| 36 | hasUpstream: false, | |
| 37 | aheadCount: 0, | |
| 38 | behindCount: 0, | |
| 39 | pr: null, | |
| 40 | }); | |
| 41 | expect(parsed.branch).toBe("feature/git-actions"); | |
| 42 | expect(parsed.hasWorkingTreeChanges).toBe(true); | |
| 43 | expect(parsed.workingTree.files[0]?.insertions).toBe(8); | |
| 44 | expect(parsed.workingTree.files).toHaveLength(2); | |
| 45 | }); | |
| 46 | ||
| 47 | it("accepts supported stacked actions", () => { | |
| 48 | expect(gitStackedActionSchema.parse("commit")).toBe("commit"); | |
| 49 | expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push"); | |
| 50 | expect(gitStackedActionSchema.parse("commit_push_pr")).toBe("commit_push_pr"); | |
| 51 | }); | |
| 52 | ||
| 53 | it("parses stacked action input", () => { | |
| 54 | const parsed = gitRunStackedActionInputSchema.parse({ | |
| 55 | cwd: "/tmp/repo", | |
| 56 | action: "commit_push", | |
| 57 | }); | |
| 58 | expect(parsed.action).toBe("commit_push"); | |
| 59 | }); | |
| 60 | ||
| 61 | it("accepts custom commit message input", () => { | |
| 62 | const parsed = gitRunStackedActionInputSchema.parse({ | |
| 63 | cwd: "/tmp/repo", | |
| 64 | action: "commit", | |
| 65 | commitMessage: " chore: update git action modal ", | |
| 66 | }); | |
| 67 | expect(parsed.commitMessage).toBe("chore: update git action modal"); | |
| 68 | }); | |
| 69 | ||
| 70 | it("parses stacked action result with metadata", () => { | |
| 71 | const parsed = gitRunStackedActionResultSchema.parse({ | |
| 72 | action: "commit_push_pr", | |
| 73 | commit: { | |
| 74 | status: "created", | |
| 75 | commitSha: "abc123", | |
| 76 | subject: "Implement stacked git actions", | |
| 77 | }, | |
| 78 | push: { | |
| 79 | status: "pushed", | |
| 80 | branch: "feature/git-actions", | |
| 81 | upstreamBranch: "origin/feature/git-actions", | |
| 82 | setUpstream: true, | |
| 83 | }, | |
| 84 | pr: { | |
| 85 | status: "created", | |
| 86 | url: "https://github.com/pingdotgg/codething-mvp/pull/123", | |
| 87 | number: 123, | |
| 88 | baseBranch: "main", | |
| 89 | headBranch: "feature/git-actions", | |
| 90 | title: "Add stacked git actions workflow", | |
| 91 | }, | |
| 92 | }); | |
| 93 | ||
| 94 | expect(parsed.commit.status).toBe("created"); | |
| 95 | expect(parsed.pr.number).toBe(123); | |
| 96 | }); | |
| 97 | ||
| 98 | it("parses git pull result", () => { | |
| 99 | const parsed = gitPullResultSchema.parse({ | |
| 100 | status: "pulled", | |
| 101 | branch: "feature/my-branch", | |
| 102 | upstreamBranch: "origin/feature/my-branch", | |
| 103 | }); | |
| 104 | expect(parsed.status).toBe("pulled"); | |
| 105 | expect(parsed.branch).toBe("feature/my-branch"); | |
| 106 | }); | |
| 107 | }); |
packages/contracts/src/git.ts+131-121| ... | ... | @@ -1,157 +1,167 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | // ── Existing branch/worktree Git API ───────────────────────────────── | |
| 4 | ||
| 5 | export const gitListBranchesInputSchema = z.object({ | |
| 6 | cwd: z.string().min(1), | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | ||
| 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | |
| 5 | ||
| 6 | // Domain Types | |
| 7 | ||
| 8 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); | |
| 9 | export type GitStackedAction = typeof GitStackedAction.Type; | |
| 10 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); | |
| 11 | export const GitPushStepStatus = Schema.Literals([ | |
| 12 | "pushed", | |
| 13 | "skipped_not_requested", | |
| 14 | "skipped_up_to_date", | |
| 15 | ]); | |
| 16 | export const GitPrStepStatus = Schema.Literals([ | |
| 17 | "created", | |
| 18 | "opened_existing", | |
| 19 | "skipped_not_requested", | |
| 20 | ]); | |
| 21 | export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | |
| 22 | export type GitStatusPrState = typeof GitStatusPrState.Type; | |
| 23 | ||
| 24 | export const GitBranch = Schema.Struct({ | |
| 25 | name: TrimmedNonEmptyStringSchema, | |
| 26 | current: Schema.Boolean, | |
| 27 | isDefault: Schema.Boolean, | |
| 28 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | |
| 7 | 29 | }); |
| 30 | export type GitBranch = typeof GitBranch.Type; | |
| 8 | 31 | |
| 9 | export const gitCreateWorktreeInputSchema = z.object({ | |
| 10 | cwd: z.string().min(1), | |
| 11 | branch: z.string().min(1), | |
| 12 | newBranch: z.string().min(1), | |
| 13 | path: z.string().min(1).optional(), | |
| 32 | export const GitWorktree = Schema.Struct({ | |
| 33 | path: TrimmedNonEmptyStringSchema, | |
| 34 | branch: TrimmedNonEmptyStringSchema, | |
| 14 | 35 | }); |
| 36 | export type GitWorktree = typeof GitWorktree.Type; | |
| 15 | 37 | |
| 16 | export const gitRemoveWorktreeInputSchema = z.object({ | |
| 17 | cwd: z.string().min(1), | |
| 18 | path: z.string().min(1), | |
| 19 | force: z.boolean().optional(), | |
| 20 | }); | |
| 38 | // RPC Inputs | |
| 21 | 39 | |
| 22 | export const gitCreateBranchInputSchema = z.object({ | |
| 23 | cwd: z.string().min(1), | |
| 24 | branch: z.string().min(1), | |
| 40 | export const GitStatusInput = Schema.Struct({ | |
| 41 | cwd: TrimmedNonEmptyStringSchema, | |
| 25 | 42 | }); |
| 43 | export type GitStatusInput = typeof GitStatusInput.Type; | |
| 26 | 44 | |
| 27 | export const gitCheckoutInputSchema = z.object({ | |
| 28 | cwd: z.string().min(1), | |
| 29 | branch: z.string().min(1), | |
| 45 | export const GitPullInput = Schema.Struct({ | |
| 46 | cwd: TrimmedNonEmptyStringSchema, | |
| 30 | 47 | }); |
| 48 | export type GitPullInput = typeof GitPullInput.Type; | |
| 31 | 49 | |
| 32 | export const gitInitInputSchema = z.object({ | |
| 33 | cwd: z.string().min(1), | |
| 50 | export const GitRunStackedActionInput = Schema.Struct({ | |
| 51 | cwd: TrimmedNonEmptyStringSchema, | |
| 52 | action: GitStackedAction, | |
| 53 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), | |
| 34 | 54 | }); |
| 55 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | |
| 35 | 56 | |
| 36 | export const gitBranchSchema = z.object({ | |
| 37 | name: z.string().min(1), | |
| 38 | current: z.boolean(), | |
| 39 | isDefault: z.boolean(), | |
| 40 | worktreePath: z.string().min(1).nullable(), | |
| 57 | export const GitListBranchesInput = Schema.Struct({ | |
| 58 | cwd: TrimmedNonEmptyStringSchema, | |
| 41 | 59 | }); |
| 60 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; | |
| 42 | 61 | |
| 43 | export const gitWorktreeSchema = z.object({ | |
| 44 | path: z.string().min(1), | |
| 45 | branch: z.string().min(1), | |
| 62 | export const GitCreateWorktreeInput = Schema.Struct({ | |
| 63 | cwd: TrimmedNonEmptyStringSchema, | |
| 64 | branch: TrimmedNonEmptyStringSchema, | |
| 65 | newBranch: TrimmedNonEmptyStringSchema, | |
| 66 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), | |
| 46 | 67 | }); |
| 68 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | |
| 47 | 69 | |
| 48 | export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>; | |
| 49 | export type GitBranch = z.infer<typeof gitBranchSchema>; | |
| 50 | export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>; | |
| 51 | export type GitWorktree = z.infer<typeof gitWorktreeSchema>; | |
| 52 | export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>; | |
| 53 | export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>; | |
| 54 | export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>; | |
| 55 | export type GitInitInput = z.infer<typeof gitInitInputSchema>; | |
| 56 | ||
| 57 | export interface GitListBranchesResult { | |
| 58 | branches: GitBranch[]; | |
| 59 | isRepo: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | export interface GitCreateWorktreeResult { | |
| 63 | worktree: GitWorktree; | |
| 64 | } | |
| 70 | export const GitRemoveWorktreeInput = Schema.Struct({ | |
| 71 | cwd: TrimmedNonEmptyStringSchema, | |
| 72 | path: TrimmedNonEmptyStringSchema, | |
| 73 | force: Schema.optional(Schema.Boolean), | |
| 74 | }); | |
| 75 | export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; | |
| 65 | 76 | |
| 66 | // ── Stacked action Git API ─────────────────────────────────────────── | |
| 77 | export const GitCreateBranchInput = Schema.Struct({ | |
| 78 | cwd: TrimmedNonEmptyStringSchema, | |
| 79 | branch: TrimmedNonEmptyStringSchema, | |
| 80 | }); | |
| 81 | export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; | |
| 67 | 82 | |
| 68 | export const gitStatusInputSchema = z.object({ | |
| 69 | cwd: z.string().trim().min(1), | |
| 83 | export const GitCheckoutInput = Schema.Struct({ | |
| 84 | cwd: TrimmedNonEmptyStringSchema, | |
| 85 | branch: TrimmedNonEmptyStringSchema, | |
| 70 | 86 | }); |
| 87 | export type GitCheckoutInput = typeof GitCheckoutInput.Type; | |
| 71 | 88 | |
| 72 | export const gitPullInputSchema = z.object({ | |
| 73 | cwd: z.string().trim().min(1), | |
| 89 | export const GitInitInput = Schema.Struct({ | |
| 90 | cwd: TrimmedNonEmptyStringSchema, | |
| 74 | 91 | }); |
| 92 | export type GitInitInput = typeof GitInitInput.Type; | |
| 75 | 93 | |
| 76 | export const gitStatusPrStateSchema = z.enum(["open", "closed", "merged"]); | |
| 94 | // RPC Results | |
| 77 | 95 | |
| 78 | export const gitStatusPrSchema = z.object({ | |
| 79 | number: z.number().int().positive(), | |
| 80 | title: z.string().min(1), | |
| 81 | url: z.string().url(), | |
| 82 | baseBranch: z.string().min(1), | |
| 83 | headBranch: z.string().min(1), | |
| 84 | state: gitStatusPrStateSchema, | |
| 96 | export const GitStatusPr = Schema.Struct({ | |
| 97 | number: PositiveInt, | |
| 98 | title: TrimmedNonEmptyStringSchema, | |
| 99 | url: Schema.String, | |
| 100 | baseBranch: TrimmedNonEmptyStringSchema, | |
| 101 | headBranch: TrimmedNonEmptyStringSchema, | |
| 102 | state: GitStatusPrState, | |
| 85 | 103 | }); |
| 86 | ||
| 87 | export const gitStatusResultSchema = z.object({ | |
| 88 | branch: z.string().min(1).nullable(), | |
| 89 | hasWorkingTreeChanges: z.boolean(), | |
| 90 | workingTree: z.object({ | |
| 91 | files: z.array( | |
| 92 | z.object({ | |
| 93 | path: z.string().min(1), | |
| 94 | insertions: z.number().int().nonnegative(), | |
| 95 | deletions: z.number().int().nonnegative(), | |
| 104 | export type GitStatusPr = typeof GitStatusPr.Type; | |
| 105 | ||
| 106 | export const GitStatusResult = Schema.Struct({ | |
| 107 | branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | |
| 108 | hasWorkingTreeChanges: Schema.Boolean, | |
| 109 | workingTree: Schema.Struct({ | |
| 110 | files: Schema.Array( | |
| 111 | Schema.Struct({ | |
| 112 | path: TrimmedNonEmptyStringSchema, | |
| 113 | insertions: NonNegativeInt, | |
| 114 | deletions: NonNegativeInt, | |
| 96 | 115 | }), |
| 97 | 116 | ), |
| 98 | insertions: z.number().int().nonnegative(), | |
| 99 | deletions: z.number().int().nonnegative(), | |
| 117 | insertions: NonNegativeInt, | |
| 118 | deletions: NonNegativeInt, | |
| 100 | 119 | }), |
| 101 | hasUpstream: z.boolean(), | |
| 102 | aheadCount: z.number().int().nonnegative(), | |
| 103 | behindCount: z.number().int().nonnegative(), | |
| 104 | pr: gitStatusPrSchema.nullable(), | |
| 120 | hasUpstream: Schema.Boolean, | |
| 121 | aheadCount: NonNegativeInt, | |
| 122 | behindCount: NonNegativeInt, | |
| 123 | pr: Schema.NullOr(GitStatusPr), | |
| 105 | 124 | }); |
| 125 | export type GitStatusResult = typeof GitStatusResult.Type; | |
| 106 | 126 | |
| 107 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); | |
| 108 | ||
| 109 | const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); | |
| 110 | const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]); | |
| 111 | const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]); | |
| 112 | ||
| 113 | export const gitRunStackedActionInputSchema = z.object({ | |
| 114 | cwd: z.string().trim().min(1), | |
| 115 | action: gitStackedActionSchema, | |
| 116 | commitMessage: z.string().trim().min(1).max(10_000).optional(), | |
| 127 | export const GitListBranchesResult = Schema.Struct({ | |
| 128 | branches: Schema.Array(GitBranch), | |
| 129 | isRepo: Schema.Boolean, | |
| 117 | 130 | }); |
| 131 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; | |
| 118 | 132 | |
| 119 | export const gitRunStackedActionResultSchema = z.object({ | |
| 120 | action: gitStackedActionSchema, | |
| 121 | commit: z.object({ | |
| 122 | status: gitCommitStepStatusSchema, | |
| 123 | commitSha: z.string().min(1).optional(), | |
| 124 | subject: z.string().min(1).optional(), | |
| 133 | export const GitCreateWorktreeResult = Schema.Struct({ | |
| 134 | worktree: GitWorktree, | |
| 135 | }); | |
| 136 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | |
| 137 | ||
| 138 | export const GitRunStackedActionResult = Schema.Struct({ | |
| 139 | action: GitStackedAction, | |
| 140 | commit: Schema.Struct({ | |
| 141 | status: GitCommitStepStatus, | |
| 142 | commitSha: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 143 | subject: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 125 | 144 | }), |
| 126 | push: z.object({ | |
| 127 | status: gitPushStepStatusSchema, | |
| 128 | branch: z.string().min(1).optional(), | |
| 129 | upstreamBranch: z.string().min(1).optional(), | |
| 130 | setUpstream: z.boolean().optional(), | |
| 145 | push: Schema.Struct({ | |
| 146 | status: GitPushStepStatus, | |
| 147 | branch: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 148 | upstreamBranch: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 149 | setUpstream: Schema.optional(Schema.Boolean), | |
| 131 | 150 | }), |
| 132 | pr: z.object({ | |
| 133 | status: gitPrStepStatusSchema, | |
| 134 | url: z.string().url().optional(), | |
| 135 | number: z.number().int().positive().optional(), | |
| 136 | baseBranch: z.string().min(1).optional(), | |
| 137 | headBranch: z.string().min(1).optional(), | |
| 138 | title: z.string().min(1).optional(), | |
| 151 | pr: Schema.Struct({ | |
| 152 | status: GitPrStepStatus, | |
| 153 | url: Schema.optional(Schema.String), | |
| 154 | number: Schema.optional(PositiveInt), | |
| 155 | baseBranch: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 156 | headBranch: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 157 | title: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 139 | 158 | }), |
| 140 | 159 | }); |
| 160 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | |
| 141 | 161 | |
| 142 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | |
| 143 | export type GitPullInput = z.input<typeof gitPullInputSchema>; | |
| 144 | export type GitStatusPrState = z.infer<typeof gitStatusPrStateSchema>; | |
| 145 | export type GitStatusPr = z.infer<typeof gitStatusPrSchema>; | |
| 146 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | |
| 147 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | |
| 148 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | |
| 149 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; | |
| 150 | ||
| 151 | export const gitPullResultSchema = z.object({ | |
| 152 | status: z.enum(["pulled", "skipped_up_to_date"]), | |
| 153 | branch: z.string().min(1), | |
| 154 | upstreamBranch: z.string().min(1).optional(), | |
| 162 | export const GitPullResult = Schema.Struct({ | |
| 163 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | |
| 164 | branch: TrimmedNonEmptyStringSchema, | |
| 165 | upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | |
| 155 | 166 | }); |
| 156 | ||
| 157 | export type GitPullResult = z.infer<typeof gitPullResultSchema>; | |
| 167 | export type GitPullResult = typeof GitPullResult.Type; |
packages/contracts/src/index.ts+5-3| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | export * from "./todo"; | |
| 1 | export * from "./baseSchemas"; | |
| 2 | 2 | export * from "./ipc"; |
| 3 | export * from "./agent"; | |
| 4 | 3 | export * from "./terminal"; |
| 5 | 4 | export * from "./provider"; |
| 5 | export * from "./providerRuntime"; | |
| 6 | 6 | export * from "./model"; |
| 7 | 7 | export * from "./ws"; |
| 8 | 8 | export * from "./keybindings"; |
| 9 | 9 | export * from "./server"; |
| 10 | export * from "./project"; | |
| 11 | 10 | export * from "./git"; |
| 11 | export * from "./orchestration"; | |
| 12 | export * from "./editor"; | |
| 13 | export * from "./project"; |
packages/contracts/src/ipc.ts+32-69| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | |
| 2 | 1 | import type { |
| 3 | 2 | GitCheckoutInput, |
| 4 | 3 | GitCreateBranchInput, |
| ... | ... | @@ -15,32 +14,7 @@ import type { |
| 15 | 14 | GitStatusInput, |
| 16 | 15 | GitStatusResult, |
| 17 | 16 | } from "./git"; |
| 18 | import type { | |
| 19 | ProviderEvent, | |
| 20 | ProviderGetCheckpointDiffInput, | |
| 21 | ProviderGetCheckpointDiffResult, | |
| 22 | ProviderInterruptTurnInput, | |
| 23 | ProviderListCheckpointsInput, | |
| 24 | ProviderListCheckpointsResult, | |
| 25 | ProviderRevertToCheckpointInput, | |
| 26 | ProviderRevertToCheckpointResult, | |
| 27 | ProviderRespondToRequestInput, | |
| 28 | ProviderSendTurnInput, | |
| 29 | ProviderSession, | |
| 30 | ProviderSessionStartInput, | |
| 31 | ProviderStopSessionInput, | |
| 32 | ProviderTurnStartResult, | |
| 33 | } from "./provider"; | |
| 34 | import type { | |
| 35 | ProjectAddInput, | |
| 36 | ProjectAddResult, | |
| 37 | ProjectListResult, | |
| 38 | ProjectSearchEntriesInput, | |
| 39 | ProjectSearchEntriesResult, | |
| 40 | ProjectRemoveInput, | |
| 41 | ProjectUpdateScriptsInput, | |
| 42 | ProjectUpdateScriptsResult, | |
| 43 | } from "./project"; | |
| 17 | import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; | |
| 44 | 18 | import type { ServerConfig } from "./server"; |
| 45 | 19 | import type { |
| 46 | 20 | TerminalClearInput, |
| ... | ... | @@ -51,23 +25,31 @@ import type { |
| 51 | 25 | TerminalSessionSnapshot, |
| 52 | 26 | TerminalWriteInput, |
| 53 | 27 | } from "./terminal"; |
| 54 | import type { NewTodoInput, Todo } from "./todo"; | |
| 55 | 28 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; |
| 29 | import type { | |
| 30 | ClientOrchestrationCommand, | |
| 31 | OrchestrationGetFullThreadDiffInput, | |
| 32 | OrchestrationGetFullThreadDiffResult, | |
| 33 | OrchestrationGetTurnDiffInput, | |
| 34 | OrchestrationGetTurnDiffResult, | |
| 35 | OrchestrationEvent, | |
| 36 | OrchestrationReadModel, | |
| 37 | } from "./orchestration"; | |
| 38 | import { EditorId } from "./editor"; | |
| 56 | 39 | |
| 57 | export const EDITORS = [ | |
| 58 | { id: "cursor", label: "Cursor", command: "cursor" }, | |
| 59 | { id: "file-manager", label: "File Manager", command: null }, | |
| 60 | ] as const; | |
| 61 | ||
| 62 | export type EditorId = (typeof EDITORS)[number]["id"]; | |
| 40 | export interface DesktopBridge { | |
| 41 | getWsUrl: () => string | null; | |
| 42 | pickFolder: () => Promise<string | null>; | |
| 43 | confirm: (message: string) => Promise<boolean>; | |
| 44 | showContextMenu: <T extends string>( | |
| 45 | items: readonly { id: T; label: string }[], | |
| 46 | position?: { x: number; y: number }, | |
| 47 | ) => Promise<T | null>; | |
| 48 | openExternal: (url: string) => Promise<boolean>; | |
| 49 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 50 | } | |
| 63 | 51 | |
| 64 | 52 | export interface NativeApi { |
| 65 | todos: { | |
| 66 | list: () => Promise<Todo[]>; | |
| 67 | add: (input: NewTodoInput) => Promise<Todo[]>; | |
| 68 | toggle: (id: string) => Promise<Todo[]>; | |
| 69 | remove: (id: string) => Promise<Todo[]>; | |
| 70 | }; | |
| 71 | 53 | dialogs: { |
| 72 | 54 | pickFolder: () => Promise<string | null>; |
| 73 | 55 | confirm: (message: string) => Promise<boolean>; |
| ... | ... | @@ -81,37 +63,8 @@ export interface NativeApi { |
| 81 | 63 | close: (input: TerminalCloseInput) => Promise<void>; |
| 82 | 64 | onEvent: (callback: (event: TerminalEvent) => void) => () => void; |
| 83 | 65 | }; |
| 84 | agent: { | |
| 85 | spawn: (config: AgentConfig) => Promise<string>; | |
| 86 | kill: (sessionId: string) => Promise<void>; | |
| 87 | write: (sessionId: string, data: string) => Promise<void>; | |
| 88 | onOutput: (callback: (chunk: OutputChunk) => void) => () => void; | |
| 89 | onExit: (callback: (exit: AgentExit) => void) => () => void; | |
| 90 | }; | |
| 91 | providers: { | |
| 92 | startSession: (input: ProviderSessionStartInput) => Promise<ProviderSession>; | |
| 93 | sendTurn: (input: ProviderSendTurnInput) => Promise<ProviderTurnStartResult>; | |
| 94 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | |
| 95 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | |
| 96 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | |
| 97 | listSessions: () => Promise<ProviderSession[]>; | |
| 98 | listCheckpoints: ( | |
| 99 | input: ProviderListCheckpointsInput, | |
| 100 | ) => Promise<ProviderListCheckpointsResult>; | |
| 101 | getCheckpointDiff: ( | |
| 102 | input: ProviderGetCheckpointDiffInput, | |
| 103 | ) => Promise<ProviderGetCheckpointDiffResult>; | |
| 104 | revertToCheckpoint: ( | |
| 105 | input: ProviderRevertToCheckpointInput, | |
| 106 | ) => Promise<ProviderRevertToCheckpointResult>; | |
| 107 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | |
| 108 | }; | |
| 109 | 66 | projects: { |
| 110 | list: () => Promise<ProjectListResult>; | |
| 111 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | |
| 112 | remove: (input: ProjectRemoveInput) => Promise<void>; | |
| 113 | 67 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 114 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | |
| 115 | 68 | }; |
| 116 | 69 | shell: { |
| 117 | 70 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| ... | ... | @@ -140,4 +93,14 @@ export interface NativeApi { |
| 140 | 93 | getConfig: () => Promise<ServerConfig>; |
| 141 | 94 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 142 | 95 | }; |
| 96 | orchestration: { | |
| 97 | getSnapshot: () => Promise<OrchestrationReadModel>; | |
| 98 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; | |
| 99 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; | |
| 100 | getFullThreadDiff: ( | |
| 101 | input: OrchestrationGetFullThreadDiffInput, | |
| 102 | ) => Promise<OrchestrationGetFullThreadDiffResult>; | |
| 103 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; | |
| 104 | onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; | |
| 105 | }; | |
| 143 | 106 | } |
packages/contracts/src/keybindings.test.ts+79-44| ... | ... | @@ -1,60 +1,89 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { Effect } from "effect"; | |
| 2 | 4 | |
| 3 | 5 | import { |
| 4 | keybindingRuleSchema, | |
| 5 | keybindingsConfigSchema, | |
| 6 | resolvedKeybindingRuleSchema, | |
| 7 | resolvedKeybindingsConfigSchema, | |
| 6 | KeybindingsConfig, | |
| 7 | KeybindingRule, | |
| 8 | ResolvedKeybindingRule, | |
| 9 | ResolvedKeybindingsConfig, | |
| 8 | 10 | } from "./keybindings"; |
| 9 | 11 | |
| 10 | describe("keybindings contracts", () => { | |
| 11 | it("parses keybinding rules", () => { | |
| 12 | const parsed = keybindingRuleSchema.parse({ | |
| 12 | const decode = <S extends Schema.Top>( | |
| 13 | schema: S, | |
| 14 | input: unknown, | |
| 15 | ): Effect.Effect<Schema.Schema.Type<S>, Schema.SchemaError, never> => | |
| 16 | Schema.decodeUnknownEffect(schema as never)(input) as Effect.Effect< | |
| 17 | Schema.Schema.Type<S>, | |
| 18 | Schema.SchemaError, | |
| 19 | never | |
| 20 | >; | |
| 21 | ||
| 22 | const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); | |
| 23 | ||
| 24 | it.effect("parses keybinding rules", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const parsed = yield* decode(KeybindingRule, { | |
| 13 | 27 | key: "mod+j", |
| 14 | 28 | command: "terminal.toggle", |
| 15 | 29 | }); |
| 16 | 30 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 17 | 31 | |
| 18 | const parsedClose = keybindingRuleSchema.parse({ | |
| 32 | const parsedClose = yield* decode(KeybindingRule, { | |
| 19 | 33 | key: "mod+w", |
| 20 | 34 | command: "terminal.close", |
| 21 | 35 | }); |
| 22 | 36 | assert.strictEqual(parsedClose.command, "terminal.close"); |
| 23 | 37 | |
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | |
| 38 | const parsedDiffToggle = yield* decode(KeybindingRule, { | |
| 39 | key: "mod+d", | |
| 40 | command: "diff.toggle", | |
| 41 | }); | |
| 42 | assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); | |
| 43 | ||
| 44 | const parsedLocal = yield* decode(KeybindingRule, { | |
| 25 | 45 | key: "mod+shift+n", |
| 26 | 46 | command: "chat.newLocal", |
| 27 | 47 | }); |
| 28 | 48 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); |
| 29 | }); | |
| 49 | }), | |
| 50 | ); | |
| 30 | 51 | |
| 31 | it("rejects invalid command values", () => { | |
| 32 | assert.throws(() => | |
| 33 | keybindingRuleSchema.parse({ | |
| 52 | it.effect("rejects invalid command values", () => | |
| 53 | Effect.gen(function* () { | |
| 54 | const result = yield* Effect.exit( | |
| 55 | decode(KeybindingRule, { | |
| 34 | 56 | key: "mod+j", |
| 35 | 57 | command: "script.Test.run", |
| 36 | 58 | }), |
| 37 | 59 | ); |
| 38 | }); | |
| 60 | assert.strictEqual(result._tag, "Failure"); | |
| 61 | }), | |
| 62 | ); | |
| 39 | 63 | |
| 40 | it("accepts dynamic script run commands", () => { | |
| 41 | const parsed = keybindingRuleSchema.parse({ | |
| 64 | it.effect("accepts dynamic script run commands", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | const parsed = yield* decode(KeybindingRule, { | |
| 42 | 67 | key: "mod+r", |
| 43 | 68 | command: "script.setup.run", |
| 44 | 69 | }); |
| 45 | 70 | assert.strictEqual(parsed.command, "script.setup.run"); |
| 46 | }); | |
| 71 | }), | |
| 72 | ); | |
| 47 | 73 | |
| 48 | it("parses keybindings array payload", () => { | |
| 49 | const parsed = keybindingsConfigSchema.parse([ | |
| 74 | it.effect("parses keybindings array payload", () => | |
| 75 | Effect.gen(function* () { | |
| 76 | const parsed = yield* decode(KeybindingsConfig, [ | |
| 50 | 77 | { key: "mod+j", command: "terminal.toggle" }, |
| 51 | 78 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 52 | 79 | ]); |
| 53 | 80 | assert.lengthOf(parsed, 2); |
| 54 | }); | |
| 81 | }), | |
| 82 | ); | |
| 55 | 83 | |
| 56 | it("parses resolved keybinding rules", () => { | |
| 57 | const parsed = resolvedKeybindingRuleSchema.parse({ | |
| 84 | it.effect("parses resolved keybinding rules", () => | |
| 85 | Effect.gen(function* () { | |
| 86 | const parsed = yield* decode(ResolvedKeybindingRule, { | |
| 58 | 87 | command: "terminal.split", |
| 59 | 88 | shortcut: { |
| 60 | 89 | key: "d", |
| ... | ... | @@ -74,10 +103,12 @@ describe("keybindings contracts", () => { |
| 74 | 103 | }, |
| 75 | 104 | }); |
| 76 | 105 | assert.strictEqual(parsed.shortcut.key, "d"); |
| 77 | }); | |
| 106 | }), | |
| 107 | ); | |
| 78 | 108 | |
| 79 | it("parses resolved keybindings arrays", () => { | |
| 80 | const parsed = resolvedKeybindingsConfigSchema.parse([ | |
| 109 | it.effect("parses resolved keybindings arrays", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | |
| 81 | 112 | { |
| 82 | 113 | command: "terminal.toggle", |
| 83 | 114 | shortcut: { |
| ... | ... | @@ -91,22 +122,26 @@ describe("keybindings contracts", () => { |
| 91 | 122 | }, |
| 92 | 123 | ]); |
| 93 | 124 | assert.lengthOf(parsed, 1); |
| 94 | }); | |
| 125 | }), | |
| 126 | ); | |
| 95 | 127 | |
| 96 | it("rejects unknown fields in resolved keybinding rules", () => { | |
| 97 | assert.throws(() => | |
| 98 | resolvedKeybindingRuleSchema.parse({ | |
| 99 | command: "terminal.toggle", | |
| 100 | shortcut: { | |
| 101 | key: "j", | |
| 102 | metaKey: false, | |
| 103 | ctrlKey: false, | |
| 104 | shiftKey: false, | |
| 105 | altKey: false, | |
| 106 | modKey: true, | |
| 107 | }, | |
| 108 | key: "mod+j", | |
| 109 | }), | |
| 110 | ); | |
| 111 | }); | |
| 112 | }); | |
| 128 | it.effect("drops unknown fields in resolved keybinding rules", () => | |
| 129 | decodeResolvedRule({ | |
| 130 | command: "terminal.toggle", | |
| 131 | shortcut: { | |
| 132 | key: "j", | |
| 133 | metaKey: false, | |
| 134 | ctrlKey: false, | |
| 135 | shiftKey: false, | |
| 136 | altKey: false, | |
| 137 | modKey: true, | |
| 138 | }, | |
| 139 | key: "mod+j", | |
| 140 | }).pipe( | |
| 141 | Effect.map((parsed) => { | |
| 142 | const view = parsed as Record<string, unknown>; | |
| 143 | assert.strictEqual("key" in view, false); | |
| 144 | assert.strictEqual(view.command, "terminal.toggle"); | |
| 145 | }), | |
| 146 | ), | |
| 147 | ); |
packages/contracts/src/keybindings.ts+82-64| ... | ... | @@ -1,89 +1,107 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedString } from "./baseSchemas"; | |
| 3 | ||
| 4 | export const MAX_KEYBINDING_COMMAND_LENGTH = 96; | |
| 5 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; | |
| 6 | export const MAX_KEYBINDING_WHEN_LENGTH = 256; | |
| 7 | export const MAX_WHEN_EXPRESSION_DEPTH = 64; | |
| 8 | export const MAX_SCRIPT_ID_LENGTH = 24; | |
| 9 | export const MAX_SCRIPT_RUN_COMMAND_LENGTH = 96; | |
| 10 | export const MAX_KEYBINDINGS_COUNT = 256; | |
| 2 | 11 | |
| 3 | 12 | export const STATIC_KEYBINDING_COMMANDS = [ |
| 4 | 13 | "terminal.toggle", |
| 5 | 14 | "terminal.split", |
| 6 | 15 | "terminal.new", |
| 7 | 16 | "terminal.close", |
| 17 | "diff.toggle", | |
| 8 | 18 | "chat.new", |
| 9 | 19 | "chat.newLocal", |
| 10 | 20 | "editor.openFavorite", |
| 11 | 21 | ] as const; |
| 12 | 22 | |
| 13 | export const keybindingCommandSchema = z.union([ | |
| 14 | z.enum(STATIC_KEYBINDING_COMMANDS), | |
| 15 | z | |
| 16 | .string() | |
| 17 | .trim() | |
| 18 | .min(1) | |
| 19 | .max(96) | |
| 20 | .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | |
| 23 | export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ | |
| 24 | Schema.Literal("script."), | |
| 25 | Schema.NonEmptyString.check( | |
| 26 | Schema.isMaxLength(MAX_SCRIPT_ID_LENGTH), | |
| 27 | Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), | |
| 28 | ), | |
| 29 | Schema.Literal(".run"), | |
| 30 | ]); | |
| 31 | ||
| 32 | export const KeybindingCommand = Schema.Union([ | |
| 33 | Schema.Literals(STATIC_KEYBINDING_COMMANDS), | |
| 34 | SCRIPT_RUN_COMMAND_PATTERN, | |
| 21 | 35 | ]); |
| 36 | export type KeybindingCommand = typeof KeybindingCommand.Type; | |
| 22 | 37 | |
| 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); | |
| 24 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | |
| 38 | export const KeybindingValue = TrimmedString.check( | |
| 39 | Schema.isMinLength(1), | |
| 40 | Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), | |
| 41 | ); | |
| 42 | export type KeybindingValue = typeof KeybindingValue.Type; | |
| 25 | 43 | |
| 26 | export const keybindingRuleSchema = z.object({ | |
| 27 | key: keybindingValueSchema, | |
| 28 | command: keybindingCommandSchema, | |
| 29 | when: keybindingWhenSchema.optional(), | |
| 44 | export const KeybindingWhen = TrimmedString.check( | |
| 45 | Schema.isMinLength(1), | |
| 46 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), | |
| 47 | ); | |
| 48 | export type KeybindingWhen = typeof KeybindingWhen.Type; | |
| 49 | export const KeybindingRule = Schema.Struct({ | |
| 50 | key: KeybindingValue, | |
| 51 | command: KeybindingCommand, | |
| 52 | when: Schema.optional(KeybindingWhen), | |
| 30 | 53 | }); |
| 54 | export type KeybindingRule = typeof KeybindingRule.Type; | |
| 31 | 55 | |
| 32 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); | |
| 56 | export const KeybindingsConfig = Schema.Array(KeybindingRule).check( | |
| 57 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | |
| 58 | ); | |
| 59 | export type KeybindingsConfig = typeof KeybindingsConfig.Type; | |
| 33 | 60 | |
| 34 | export const keybindingShortcutSchema = z | |
| 35 | .object({ | |
| 36 | key: z.string().trim().min(1).max(32), | |
| 37 | metaKey: z.boolean(), | |
| 38 | ctrlKey: z.boolean(), | |
| 39 | shiftKey: z.boolean(), | |
| 40 | altKey: z.boolean(), | |
| 41 | modKey: z.boolean(), | |
| 42 | }) | |
| 43 | .strict(); | |
| 61 | export const KeybindingShortcut = Schema.Struct({ | |
| 62 | key: KeybindingValue, | |
| 63 | metaKey: Schema.Boolean, | |
| 64 | ctrlKey: Schema.Boolean, | |
| 65 | shiftKey: Schema.Boolean, | |
| 66 | altKey: Schema.Boolean, | |
| 67 | modKey: Schema.Boolean, | |
| 68 | }); | |
| 69 | export type KeybindingShortcut = typeof KeybindingShortcut.Type; | |
| 44 | 70 | |
| 71 | export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([ | |
| 72 | Schema.Struct({ | |
| 73 | type: Schema.Literal("identifier"), | |
| 74 | name: Schema.NonEmptyString, | |
| 75 | }), | |
| 76 | Schema.Struct({ | |
| 77 | type: Schema.Literal("not"), | |
| 78 | node: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 79 | }), | |
| 80 | Schema.Struct({ | |
| 81 | type: Schema.Literal("and"), | |
| 82 | left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 83 | right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 84 | }), | |
| 85 | Schema.Struct({ | |
| 86 | type: Schema.Literal("or"), | |
| 87 | left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 88 | right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode), | |
| 89 | }), | |
| 90 | ]); | |
| 45 | 91 | export type KeybindingWhenNode = |
| 46 | 92 | | { type: "identifier"; name: string } |
| 47 | 93 | | { type: "not"; node: KeybindingWhenNode } |
| 48 | 94 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } |
| 49 | 95 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; |
| 50 | 96 | |
| 51 | export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() => | |
| 52 | z.union([ | |
| 53 | z.object({ | |
| 54 | type: z.literal("identifier"), | |
| 55 | name: z.string().min(1), | |
| 56 | }), | |
| 57 | z.object({ | |
| 58 | type: z.literal("not"), | |
| 59 | node: keybindingWhenNodeSchema, | |
| 60 | }), | |
| 61 | z.object({ | |
| 62 | type: z.literal("and"), | |
| 63 | left: keybindingWhenNodeSchema, | |
| 64 | right: keybindingWhenNodeSchema, | |
| 65 | }), | |
| 66 | z.object({ | |
| 67 | type: z.literal("or"), | |
| 68 | left: keybindingWhenNodeSchema, | |
| 69 | right: keybindingWhenNodeSchema, | |
| 70 | }), | |
| 71 | ]), | |
| 72 | ); | |
| 73 | ||
| 74 | export const resolvedKeybindingRuleSchema = z | |
| 75 | .object({ | |
| 76 | command: keybindingCommandSchema, | |
| 77 | shortcut: keybindingShortcutSchema, | |
| 78 | whenAst: keybindingWhenNodeSchema.optional(), | |
| 79 | }) | |
| 80 | .strict(); | |
| 81 | ||
| 82 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); | |
| 97 | export const ResolvedKeybindingRule = Schema.Struct({ | |
| 98 | command: KeybindingCommand, | |
| 99 | shortcut: KeybindingShortcut, | |
| 100 | whenAst: Schema.optional(KeybindingWhenNode), | |
| 101 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); | |
| 102 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; | |
| 83 | 103 | |
| 84 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | |
| 85 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | |
| 86 | export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>; | |
| 87 | export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>; | |
| 88 | export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>; | |
| 89 | export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>; | |
| 104 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( | |
| 105 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | |
| 106 | ); | |
| 107 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
packages/contracts/src/model.ts+4| ... | ... | @@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug { |
| 37 | 37 | |
| 38 | 38 | return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL; |
| 39 | 39 | } |
| 40 | ||
| 41 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | |
| 42 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | |
| 43 | export const DEFAULT_REASONING: ReasoningEffort = "high"; |
packages/contracts/src/orchestration.test.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Schema } from "effect"; | |
| 3 | ||
| 4 | import { | |
| 5 | OrchestrationGetTurnDiffInput, | |
| 6 | ProjectCreateCommand, | |
| 7 | ThreadTurnDiff, | |
| 8 | } from "./orchestration"; | |
| 9 | ||
| 10 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | |
| 11 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | |
| 12 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); | |
| 13 | ||
| 14 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | |
| 15 | Effect.gen(function* () { | |
| 16 | const parsed = yield* decodeTurnDiffInput({ | |
| 17 | threadId: "thread-1", | |
| 18 | fromTurnCount: 1, | |
| 19 | toTurnCount: 2, | |
| 20 | }); | |
| 21 | assert.strictEqual(parsed.fromTurnCount, 1); | |
| 22 | assert.strictEqual(parsed.toTurnCount, 2); | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const result = yield* Effect.exit( | |
| 29 | decodeTurnDiffInput({ | |
| 30 | threadId: "thread-1", | |
| 31 | fromTurnCount: 3, | |
| 32 | toTurnCount: 2, | |
| 33 | }), | |
| 34 | ); | |
| 35 | assert.strictEqual(result._tag, "Failure"); | |
| 36 | }), | |
| 37 | ); | |
| 38 | ||
| 39 | it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const result = yield* Effect.exit( | |
| 42 | decodeThreadTurnDiff({ | |
| 43 | threadId: "thread-1", | |
| 44 | fromTurnCount: 3, | |
| 45 | toTurnCount: 2, | |
| 46 | diff: "patch", | |
| 47 | }), | |
| 48 | ); | |
| 49 | assert.strictEqual(result._tag, "Failure"); | |
| 50 | }), | |
| 51 | ); | |
| 52 | ||
| 53 | it.effect("trims branded ids and command string fields at decode boundaries", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const parsed = yield* decodeProjectCreateCommand({ | |
| 56 | type: "project.create", | |
| 57 | commandId: " cmd-1 ", | |
| 58 | projectId: " project-1 ", | |
| 59 | title: " Project Title ", | |
| 60 | workspaceRoot: " /tmp/workspace ", | |
| 61 | defaultModel: " gpt-5.2 ", | |
| 62 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 63 | }); | |
| 64 | assert.strictEqual(parsed.commandId, "cmd-1"); | |
| 65 | assert.strictEqual(parsed.projectId, "project-1"); | |
| 66 | assert.strictEqual(parsed.title, "Project Title"); | |
| 67 | assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); | |
| 68 | assert.strictEqual(parsed.defaultModel, "gpt-5.2"); | |
| 69 | }), | |
| 70 | ); | |
| 71 | ||
| 72 | it.effect("rejects command fields that become empty after trim", () => | |
| 73 | Effect.gen(function* () { | |
| 74 | const result = yield* Effect.exit( | |
| 75 | decodeProjectCreateCommand({ | |
| 76 | type: "project.create", | |
| 77 | commandId: "cmd-1", | |
| 78 | projectId: "project-1", | |
| 79 | title: " ", | |
| 80 | workspaceRoot: "/tmp/workspace", | |
| 81 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 82 | }), | |
| 83 | ); | |
| 84 | assert.strictEqual(result._tag, "Failure"); | |
| 85 | }), | |
| 86 | ); |
packages/contracts/src/orchestration.ts created+897| ... | ... | @@ -0,0 +1,897 @@ |
| 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; | |
| 2 | import { | |
| 3 | ApprovalRequestId, | |
| 4 | CheckpointRef, | |
| 5 | CommandId, | |
| 6 | EventId, | |
| 7 | IsoDateTime, | |
| 8 | MessageId, | |
| 9 | NonNegativeInt, | |
| 10 | ProjectId, | |
| 11 | ProviderItemId, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | ThreadId, | |
| 16 | TrimmedNonEmptyString, | |
| 17 | TurnId, | |
| 18 | } from "./baseSchemas"; | |
| 19 | ||
| 20 | export const ORCHESTRATION_WS_METHODS = { | |
| 21 | getSnapshot: "orchestration.getSnapshot", | |
| 22 | dispatchCommand: "orchestration.dispatchCommand", | |
| 23 | getTurnDiff: "orchestration.getTurnDiff", | |
| 24 | getFullThreadDiff: "orchestration.getFullThreadDiff", | |
| 25 | replayEvents: "orchestration.replayEvents", | |
| 26 | } as const; | |
| 27 | ||
| 28 | export const ORCHESTRATION_WS_CHANNELS = { | |
| 29 | domainEvent: "orchestration.domainEvent", | |
| 30 | } as const; | |
| 31 | ||
| 32 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 33 | export type ProviderKind = typeof ProviderKind.Type; | |
| 34 | export const ProviderApprovalPolicy = Schema.Literals([ | |
| 35 | "untrusted", | |
| 36 | "on-failure", | |
| 37 | "on-request", | |
| 38 | "never", | |
| 39 | ]); | |
| 40 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; | |
| 41 | export const ProviderSandboxMode = Schema.Literals([ | |
| 42 | "read-only", | |
| 43 | "workspace-write", | |
| 44 | "danger-full-access", | |
| 45 | ]); | |
| 46 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | |
| 47 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); | |
| 48 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; | |
| 49 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); | |
| 50 | export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; | |
| 51 | export const ProviderApprovalDecision = Schema.Literals([ | |
| 52 | "accept", | |
| 53 | "acceptForSession", | |
| 54 | "decline", | |
| 55 | "cancel", | |
| 56 | ]); | |
| 57 | export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; | |
| 58 | ||
| 59 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | |
| 60 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 61 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | |
| 62 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | |
| 63 | ||
| 64 | // Correlation id is command id by design in this model. | |
| 65 | export const CorrelationId = CommandId; | |
| 66 | export type CorrelationId = typeof CorrelationId.Type; | |
| 67 | ||
| 68 | export const ChatImageAttachment = Schema.Struct({ | |
| 69 | type: Schema.Literal("image"), | |
| 70 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), | |
| 71 | mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), | |
| 72 | sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), | |
| 73 | dataUrl: TrimmedNonEmptyString.check( | |
| 74 | Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | |
| 75 | ), | |
| 76 | }); | |
| 77 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | |
| 78 | ||
| 79 | export const ChatAttachment = Schema.Union([ChatImageAttachment]); | |
| 80 | export type ChatAttachment = typeof ChatAttachment.Type; | |
| 81 | ||
| 82 | export const ProjectScriptIcon = Schema.Literals([ | |
| 83 | "play", | |
| 84 | "test", | |
| 85 | "lint", | |
| 86 | "configure", | |
| 87 | "build", | |
| 88 | "debug", | |
| 89 | ]); | |
| 90 | export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; | |
| 91 | ||
| 92 | export const ProjectScript = Schema.Struct({ | |
| 93 | id: TrimmedNonEmptyString, | |
| 94 | name: TrimmedNonEmptyString, | |
| 95 | command: TrimmedNonEmptyString, | |
| 96 | icon: ProjectScriptIcon, | |
| 97 | runOnWorktreeCreate: Schema.Boolean, | |
| 98 | }); | |
| 99 | export type ProjectScript = typeof ProjectScript.Type; | |
| 100 | ||
| 101 | export const OrchestrationProject = Schema.Struct({ | |
| 102 | id: ProjectId, | |
| 103 | title: TrimmedNonEmptyString, | |
| 104 | workspaceRoot: TrimmedNonEmptyString, | |
| 105 | defaultModel: Schema.NullOr(TrimmedNonEmptyString), | |
| 106 | scripts: Schema.Array(ProjectScript), | |
| 107 | createdAt: IsoDateTime, | |
| 108 | updatedAt: IsoDateTime, | |
| 109 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 110 | }); | |
| 111 | export type OrchestrationProject = typeof OrchestrationProject.Type; | |
| 112 | ||
| 113 | export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]); | |
| 114 | export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type; | |
| 115 | ||
| 116 | export const OrchestrationMessage = Schema.Struct({ | |
| 117 | id: MessageId, | |
| 118 | role: OrchestrationMessageRole, | |
| 119 | text: Schema.String, | |
| 120 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | |
| 121 | turnId: Schema.NullOr(TurnId), | |
| 122 | streaming: Schema.Boolean, | |
| 123 | createdAt: IsoDateTime, | |
| 124 | updatedAt: IsoDateTime, | |
| 125 | }); | |
| 126 | export type OrchestrationMessage = typeof OrchestrationMessage.Type; | |
| 127 | ||
| 128 | export const OrchestrationSessionStatus = Schema.Literals([ | |
| 129 | "idle", | |
| 130 | "starting", | |
| 131 | "running", | |
| 132 | "ready", | |
| 133 | "interrupted", | |
| 134 | "stopped", | |
| 135 | "error", | |
| 136 | ]); | |
| 137 | export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; | |
| 138 | ||
| 139 | export const OrchestrationSession = Schema.Struct({ | |
| 140 | threadId: ThreadId, | |
| 141 | status: OrchestrationSessionStatus, | |
| 142 | providerName: Schema.NullOr(TrimmedNonEmptyString), | |
| 143 | providerSessionId: Schema.NullOr(ProviderSessionId), | |
| 144 | providerThreadId: Schema.NullOr(ProviderThreadId), | |
| 145 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 146 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 147 | activeTurnId: Schema.NullOr(TurnId), | |
| 148 | lastError: Schema.NullOr(TrimmedNonEmptyString), | |
| 149 | updatedAt: IsoDateTime, | |
| 150 | }); | |
| 151 | export type OrchestrationSession = typeof OrchestrationSession.Type; | |
| 152 | ||
| 153 | export const OrchestrationCheckpointFile = Schema.Struct({ | |
| 154 | path: TrimmedNonEmptyString, | |
| 155 | kind: TrimmedNonEmptyString, | |
| 156 | additions: NonNegativeInt, | |
| 157 | deletions: NonNegativeInt, | |
| 158 | }); | |
| 159 | export type OrchestrationCheckpointFile = typeof OrchestrationCheckpointFile.Type; | |
| 160 | ||
| 161 | export const OrchestrationCheckpointStatus = Schema.Literals(["ready", "missing", "error"]); | |
| 162 | export type OrchestrationCheckpointStatus = typeof OrchestrationCheckpointStatus.Type; | |
| 163 | ||
| 164 | export const OrchestrationCheckpointSummary = Schema.Struct({ | |
| 165 | turnId: TurnId, | |
| 166 | checkpointTurnCount: NonNegativeInt, | |
| 167 | checkpointRef: CheckpointRef, | |
| 168 | status: OrchestrationCheckpointStatus, | |
| 169 | files: Schema.Array(OrchestrationCheckpointFile), | |
| 170 | assistantMessageId: Schema.NullOr(MessageId), | |
| 171 | completedAt: IsoDateTime, | |
| 172 | }); | |
| 173 | export type OrchestrationCheckpointSummary = typeof OrchestrationCheckpointSummary.Type; | |
| 174 | ||
| 175 | export const OrchestrationThreadActivityTone = Schema.Literals([ | |
| 176 | "info", | |
| 177 | "tool", | |
| 178 | "approval", | |
| 179 | "error", | |
| 180 | ]); | |
| 181 | export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; | |
| 182 | ||
| 183 | export const OrchestrationThreadActivity = Schema.Struct({ | |
| 184 | id: EventId, | |
| 185 | tone: OrchestrationThreadActivityTone, | |
| 186 | kind: TrimmedNonEmptyString, | |
| 187 | summary: TrimmedNonEmptyString, | |
| 188 | payload: Schema.Unknown, | |
| 189 | turnId: Schema.NullOr(TurnId), | |
| 190 | createdAt: IsoDateTime, | |
| 191 | }); | |
| 192 | export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; | |
| 193 | ||
| 194 | export const OrchestrationLatestTurnState = Schema.Literals([ | |
| 195 | "running", | |
| 196 | "interrupted", | |
| 197 | "completed", | |
| 198 | "error", | |
| 199 | ]); | |
| 200 | export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type; | |
| 201 | ||
| 202 | export const OrchestrationLatestTurn = Schema.Struct({ | |
| 203 | turnId: TurnId, | |
| 204 | state: OrchestrationLatestTurnState, | |
| 205 | requestedAt: IsoDateTime, | |
| 206 | startedAt: Schema.NullOr(IsoDateTime), | |
| 207 | completedAt: Schema.NullOr(IsoDateTime), | |
| 208 | assistantMessageId: Schema.NullOr(MessageId), | |
| 209 | }); | |
| 210 | export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; | |
| 211 | ||
| 212 | export const OrchestrationThread = Schema.Struct({ | |
| 213 | id: ThreadId, | |
| 214 | projectId: ProjectId, | |
| 215 | title: TrimmedNonEmptyString, | |
| 216 | model: TrimmedNonEmptyString, | |
| 217 | branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 218 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 219 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), | |
| 220 | createdAt: IsoDateTime, | |
| 221 | updatedAt: IsoDateTime, | |
| 222 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 223 | messages: Schema.Array(OrchestrationMessage), | |
| 224 | activities: Schema.Array(OrchestrationThreadActivity), | |
| 225 | checkpoints: Schema.Array(OrchestrationCheckpointSummary), | |
| 226 | session: Schema.NullOr(OrchestrationSession), | |
| 227 | }); | |
| 228 | export type OrchestrationThread = typeof OrchestrationThread.Type; | |
| 229 | ||
| 230 | export const OrchestrationReadModel = Schema.Struct({ | |
| 231 | snapshotSequence: NonNegativeInt, | |
| 232 | projects: Schema.Array(OrchestrationProject), | |
| 233 | threads: Schema.Array(OrchestrationThread), | |
| 234 | updatedAt: IsoDateTime, | |
| 235 | }); | |
| 236 | export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; | |
| 237 | ||
| 238 | export const ProjectCreateCommand = Schema.Struct({ | |
| 239 | type: Schema.Literal("project.create"), | |
| 240 | commandId: CommandId, | |
| 241 | projectId: ProjectId, | |
| 242 | title: TrimmedNonEmptyString, | |
| 243 | workspaceRoot: TrimmedNonEmptyString, | |
| 244 | defaultModel: Schema.optional(TrimmedNonEmptyString), | |
| 245 | createdAt: IsoDateTime, | |
| 246 | }); | |
| 247 | ||
| 248 | export const ProjectMetaUpdateCommand = Schema.Struct({ | |
| 249 | type: Schema.Literal("project.meta.update"), | |
| 250 | commandId: CommandId, | |
| 251 | projectId: ProjectId, | |
| 252 | title: Schema.optional(TrimmedNonEmptyString), | |
| 253 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | |
| 254 | defaultModel: Schema.optional(TrimmedNonEmptyString), | |
| 255 | scripts: Schema.optional(Schema.Array(ProjectScript)), | |
| 256 | }); | |
| 257 | ||
| 258 | export const ProjectDeleteCommand = Schema.Struct({ | |
| 259 | type: Schema.Literal("project.delete"), | |
| 260 | commandId: CommandId, | |
| 261 | projectId: ProjectId, | |
| 262 | }); | |
| 263 | ||
| 264 | export const ThreadCreateCommand = Schema.Struct({ | |
| 265 | type: Schema.Literal("thread.create"), | |
| 266 | commandId: CommandId, | |
| 267 | threadId: ThreadId, | |
| 268 | projectId: ProjectId, | |
| 269 | title: TrimmedNonEmptyString, | |
| 270 | model: TrimmedNonEmptyString, | |
| 271 | branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 272 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 273 | createdAt: IsoDateTime, | |
| 274 | }); | |
| 275 | ||
| 276 | export const ThreadDeleteCommand = Schema.Struct({ | |
| 277 | type: Schema.Literal("thread.delete"), | |
| 278 | commandId: CommandId, | |
| 279 | threadId: ThreadId, | |
| 280 | }); | |
| 281 | ||
| 282 | export const ThreadMetaUpdateCommand = Schema.Struct({ | |
| 283 | type: Schema.Literal("thread.meta.update"), | |
| 284 | commandId: CommandId, | |
| 285 | threadId: ThreadId, | |
| 286 | title: Schema.optional(TrimmedNonEmptyString), | |
| 287 | model: Schema.optional(TrimmedNonEmptyString), | |
| 288 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 289 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 290 | }); | |
| 291 | ||
| 292 | export const ThreadTurnStartCommand = Schema.Struct({ | |
| 293 | type: Schema.Literal("thread.turn.start"), | |
| 294 | commandId: CommandId, | |
| 295 | threadId: ThreadId, | |
| 296 | message: Schema.Struct({ | |
| 297 | messageId: MessageId, | |
| 298 | role: Schema.Literal("user"), | |
| 299 | text: Schema.String, | |
| 300 | attachments: Schema.Array(ChatAttachment), | |
| 301 | }), | |
| 302 | model: Schema.optional(TrimmedNonEmptyString), | |
| 303 | effort: Schema.optional(TrimmedNonEmptyString), | |
| 304 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| 305 | approvalPolicy: ProviderApprovalPolicy, | |
| 306 | sandboxMode: ProviderSandboxMode, | |
| 307 | createdAt: IsoDateTime, | |
| 308 | }); | |
| 309 | ||
| 310 | export const ThreadTurnInterruptCommand = Schema.Struct({ | |
| 311 | type: Schema.Literal("thread.turn.interrupt"), | |
| 312 | commandId: CommandId, | |
| 313 | threadId: ThreadId, | |
| 314 | turnId: Schema.optional(TurnId), | |
| 315 | createdAt: IsoDateTime, | |
| 316 | }); | |
| 317 | ||
| 318 | export const ThreadApprovalRespondCommand = Schema.Struct({ | |
| 319 | type: Schema.Literal("thread.approval.respond"), | |
| 320 | commandId: CommandId, | |
| 321 | threadId: ThreadId, | |
| 322 | requestId: ApprovalRequestId, | |
| 323 | decision: ProviderApprovalDecision, | |
| 324 | createdAt: IsoDateTime, | |
| 325 | }); | |
| 326 | ||
| 327 | export const ThreadCheckpointRevertCommand = Schema.Struct({ | |
| 328 | type: Schema.Literal("thread.checkpoint.revert"), | |
| 329 | commandId: CommandId, | |
| 330 | threadId: ThreadId, | |
| 331 | turnCount: NonNegativeInt, | |
| 332 | createdAt: IsoDateTime, | |
| 333 | }); | |
| 334 | ||
| 335 | export const ThreadSessionStopCommand = Schema.Struct({ | |
| 336 | type: Schema.Literal("thread.session.stop"), | |
| 337 | commandId: CommandId, | |
| 338 | threadId: ThreadId, | |
| 339 | createdAt: IsoDateTime, | |
| 340 | }); | |
| 341 | ||
| 342 | export const ClientOrchestrationCommand = Schema.Union([ | |
| 343 | ProjectCreateCommand, | |
| 344 | ProjectMetaUpdateCommand, | |
| 345 | ProjectDeleteCommand, | |
| 346 | ThreadCreateCommand, | |
| 347 | ThreadDeleteCommand, | |
| 348 | ThreadMetaUpdateCommand, | |
| 349 | ThreadTurnStartCommand, | |
| 350 | ThreadTurnInterruptCommand, | |
| 351 | ThreadApprovalRespondCommand, | |
| 352 | ThreadCheckpointRevertCommand, | |
| 353 | ThreadSessionStopCommand, | |
| 354 | ]); | |
| 355 | export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; | |
| 356 | ||
| 357 | export const ThreadSessionSetCommand = Schema.Struct({ | |
| 358 | type: Schema.Literal("thread.session.set"), | |
| 359 | commandId: CommandId, | |
| 360 | threadId: ThreadId, | |
| 361 | session: OrchestrationSession, | |
| 362 | createdAt: IsoDateTime, | |
| 363 | }); | |
| 364 | ||
| 365 | export const ThreadMessageAssistantDeltaCommand = Schema.Struct({ | |
| 366 | type: Schema.Literal("thread.message.assistant.delta"), | |
| 367 | commandId: CommandId, | |
| 368 | threadId: ThreadId, | |
| 369 | messageId: MessageId, | |
| 370 | delta: Schema.String, | |
| 371 | turnId: Schema.optional(TurnId), | |
| 372 | createdAt: IsoDateTime, | |
| 373 | }); | |
| 374 | ||
| 375 | export const ThreadMessageAssistantCompleteCommand = Schema.Struct({ | |
| 376 | type: Schema.Literal("thread.message.assistant.complete"), | |
| 377 | commandId: CommandId, | |
| 378 | threadId: ThreadId, | |
| 379 | messageId: MessageId, | |
| 380 | turnId: Schema.optional(TurnId), | |
| 381 | createdAt: IsoDateTime, | |
| 382 | }); | |
| 383 | ||
| 384 | export const ThreadTurnDiffCompleteCommand = Schema.Struct({ | |
| 385 | type: Schema.Literal("thread.turn.diff.complete"), | |
| 386 | commandId: CommandId, | |
| 387 | threadId: ThreadId, | |
| 388 | turnId: TurnId, | |
| 389 | completedAt: IsoDateTime, | |
| 390 | checkpointRef: CheckpointRef, | |
| 391 | status: OrchestrationCheckpointStatus, | |
| 392 | files: Schema.Array(OrchestrationCheckpointFile), | |
| 393 | assistantMessageId: Schema.optional(MessageId), | |
| 394 | checkpointTurnCount: NonNegativeInt, | |
| 395 | createdAt: IsoDateTime, | |
| 396 | }); | |
| 397 | ||
| 398 | export const ThreadActivityAppendCommand = Schema.Struct({ | |
| 399 | type: Schema.Literal("thread.activity.append"), | |
| 400 | commandId: CommandId, | |
| 401 | threadId: ThreadId, | |
| 402 | activity: OrchestrationThreadActivity, | |
| 403 | createdAt: IsoDateTime, | |
| 404 | }); | |
| 405 | ||
| 406 | export const ThreadRevertCompleteCommand = Schema.Struct({ | |
| 407 | type: Schema.Literal("thread.revert.complete"), | |
| 408 | commandId: CommandId, | |
| 409 | threadId: ThreadId, | |
| 410 | turnCount: NonNegativeInt, | |
| 411 | createdAt: IsoDateTime, | |
| 412 | }); | |
| 413 | ||
| 414 | export const InternalOrchestrationCommand = Schema.Union([ | |
| 415 | ThreadSessionSetCommand, | |
| 416 | ThreadMessageAssistantDeltaCommand, | |
| 417 | ThreadMessageAssistantCompleteCommand, | |
| 418 | ThreadTurnDiffCompleteCommand, | |
| 419 | ThreadActivityAppendCommand, | |
| 420 | ThreadRevertCompleteCommand, | |
| 421 | ]); | |
| 422 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; | |
| 423 | ||
| 424 | export const OrchestrationCommand = Schema.Union([ | |
| 425 | ClientOrchestrationCommand, | |
| 426 | InternalOrchestrationCommand, | |
| 427 | ]); | |
| 428 | export type OrchestrationCommand = typeof OrchestrationCommand.Type; | |
| 429 | ||
| 430 | export const OrchestrationEventType = Schema.Literals([ | |
| 431 | "project.created", | |
| 432 | "project.meta-updated", | |
| 433 | "project.deleted", | |
| 434 | "thread.created", | |
| 435 | "thread.deleted", | |
| 436 | "thread.meta-updated", | |
| 437 | "thread.message-sent", | |
| 438 | "thread.turn-start-requested", | |
| 439 | "thread.turn-interrupt-requested", | |
| 440 | "thread.approval-response-requested", | |
| 441 | "thread.checkpoint-revert-requested", | |
| 442 | "thread.reverted", | |
| 443 | "thread.session-stop-requested", | |
| 444 | "thread.session-set", | |
| 445 | "thread.turn-diff-completed", | |
| 446 | "thread.activity-appended", | |
| 447 | ]); | |
| 448 | export type OrchestrationEventType = typeof OrchestrationEventType.Type; | |
| 449 | ||
| 450 | export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); | |
| 451 | export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; | |
| 452 | export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); | |
| 453 | ||
| 454 | export const ProjectCreatedPayload = Schema.Struct({ | |
| 455 | projectId: ProjectId, | |
| 456 | title: TrimmedNonEmptyString, | |
| 457 | workspaceRoot: TrimmedNonEmptyString, | |
| 458 | defaultModel: Schema.NullOr(TrimmedNonEmptyString), | |
| 459 | scripts: Schema.Array(ProjectScript), | |
| 460 | createdAt: IsoDateTime, | |
| 461 | updatedAt: IsoDateTime, | |
| 462 | }); | |
| 463 | ||
| 464 | export const ProjectMetaUpdatedPayload = Schema.Struct({ | |
| 465 | projectId: ProjectId, | |
| 466 | title: Schema.optional(TrimmedNonEmptyString), | |
| 467 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | |
| 468 | defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 469 | scripts: Schema.optional(Schema.Array(ProjectScript)), | |
| 470 | updatedAt: IsoDateTime, | |
| 471 | }); | |
| 472 | ||
| 473 | export const ProjectDeletedPayload = Schema.Struct({ | |
| 474 | projectId: ProjectId, | |
| 475 | deletedAt: IsoDateTime, | |
| 476 | }); | |
| 477 | ||
| 478 | export const ThreadCreatedPayload = Schema.Struct({ | |
| 479 | threadId: ThreadId, | |
| 480 | projectId: ProjectId, | |
| 481 | title: TrimmedNonEmptyString, | |
| 482 | model: TrimmedNonEmptyString, | |
| 483 | branch: Schema.NullOr(TrimmedNonEmptyString), | |
| 484 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | |
| 485 | createdAt: IsoDateTime, | |
| 486 | updatedAt: IsoDateTime, | |
| 487 | }); | |
| 488 | ||
| 489 | export const ThreadDeletedPayload = Schema.Struct({ | |
| 490 | threadId: ThreadId, | |
| 491 | deletedAt: IsoDateTime, | |
| 492 | }); | |
| 493 | ||
| 494 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | |
| 495 | threadId: ThreadId, | |
| 496 | title: Schema.optional(TrimmedNonEmptyString), | |
| 497 | model: Schema.optional(TrimmedNonEmptyString), | |
| 498 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 499 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 500 | updatedAt: IsoDateTime, | |
| 501 | }); | |
| 502 | ||
| 503 | export const ThreadMessageSentPayload = Schema.Struct({ | |
| 504 | threadId: ThreadId, | |
| 505 | messageId: MessageId, | |
| 506 | role: OrchestrationMessageRole, | |
| 507 | text: Schema.String, | |
| 508 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | |
| 509 | turnId: Schema.NullOr(TurnId), | |
| 510 | streaming: Schema.Boolean, | |
| 511 | createdAt: IsoDateTime, | |
| 512 | updatedAt: IsoDateTime, | |
| 513 | }); | |
| 514 | ||
| 515 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ | |
| 516 | threadId: ThreadId, | |
| 517 | messageId: MessageId, | |
| 518 | model: Schema.optional(TrimmedNonEmptyString), | |
| 519 | effort: Schema.optional(TrimmedNonEmptyString), | |
| 520 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| 521 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 522 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 523 | createdAt: IsoDateTime, | |
| 524 | }); | |
| 525 | ||
| 526 | export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ | |
| 527 | threadId: ThreadId, | |
| 528 | turnId: Schema.optional(TurnId), | |
| 529 | createdAt: IsoDateTime, | |
| 530 | }); | |
| 531 | ||
| 532 | export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ | |
| 533 | threadId: ThreadId, | |
| 534 | requestId: ApprovalRequestId, | |
| 535 | decision: ProviderApprovalDecision, | |
| 536 | createdAt: IsoDateTime, | |
| 537 | }); | |
| 538 | ||
| 539 | export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ | |
| 540 | threadId: ThreadId, | |
| 541 | turnCount: NonNegativeInt, | |
| 542 | createdAt: IsoDateTime, | |
| 543 | }); | |
| 544 | ||
| 545 | export const ThreadRevertedPayload = Schema.Struct({ | |
| 546 | threadId: ThreadId, | |
| 547 | turnCount: NonNegativeInt, | |
| 548 | }); | |
| 549 | ||
| 550 | export const ThreadSessionStopRequestedPayload = Schema.Struct({ | |
| 551 | threadId: ThreadId, | |
| 552 | createdAt: IsoDateTime, | |
| 553 | }); | |
| 554 | ||
| 555 | export const ThreadSessionSetPayload = Schema.Struct({ | |
| 556 | threadId: ThreadId, | |
| 557 | session: OrchestrationSession, | |
| 558 | }); | |
| 559 | ||
| 560 | export const ThreadTurnDiffCompletedPayload = Schema.Struct({ | |
| 561 | threadId: ThreadId, | |
| 562 | turnId: TurnId, | |
| 563 | checkpointTurnCount: NonNegativeInt, | |
| 564 | checkpointRef: CheckpointRef, | |
| 565 | status: OrchestrationCheckpointStatus, | |
| 566 | files: Schema.Array(OrchestrationCheckpointFile), | |
| 567 | assistantMessageId: Schema.NullOr(MessageId), | |
| 568 | completedAt: IsoDateTime, | |
| 569 | }); | |
| 570 | ||
| 571 | export const ThreadActivityAppendedPayload = Schema.Struct({ | |
| 572 | threadId: ThreadId, | |
| 573 | activity: OrchestrationThreadActivity, | |
| 574 | }); | |
| 575 | ||
| 576 | export const OrchestrationEventMetadata = Schema.Struct({ | |
| 577 | providerSessionId: Schema.optional(ProviderSessionId), | |
| 578 | providerThreadId: Schema.optional(ProviderThreadId), | |
| 579 | providerTurnId: Schema.optional(ProviderTurnId), | |
| 580 | providerItemId: Schema.optional(ProviderItemId), | |
| 581 | adapterKey: Schema.optional(TrimmedNonEmptyString), | |
| 582 | requestId: Schema.optional(ApprovalRequestId), | |
| 583 | ingestedAt: Schema.optional(IsoDateTime), | |
| 584 | }); | |
| 585 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; | |
| 586 | ||
| 587 | const EventBaseFields = { | |
| 588 | sequence: NonNegativeInt, | |
| 589 | eventId: EventId, | |
| 590 | aggregateKind: OrchestrationAggregateKind, | |
| 591 | aggregateId: Schema.Union([ProjectId, ThreadId]), | |
| 592 | occurredAt: IsoDateTime, | |
| 593 | commandId: Schema.NullOr(CommandId), | |
| 594 | causationEventId: Schema.NullOr(EventId), | |
| 595 | correlationId: Schema.NullOr(CommandId), | |
| 596 | metadata: OrchestrationEventMetadata, | |
| 597 | } as const; | |
| 598 | ||
| 599 | const PersistedEventBaseFields = { | |
| 600 | sequence: NonNegativeInt, | |
| 601 | eventId: EventId, | |
| 602 | aggregateKind: OrchestrationAggregateKind, | |
| 603 | streamId: Schema.Union([ProjectId, ThreadId]), | |
| 604 | streamVersion: NonNegativeInt, | |
| 605 | occurredAt: IsoDateTime, | |
| 606 | commandId: Schema.NullOr(CommandId), | |
| 607 | causationEventId: Schema.NullOr(EventId), | |
| 608 | correlationId: Schema.NullOr(CommandId), | |
| 609 | actorKind: OrchestrationActorKind, | |
| 610 | metadata: OrchestrationEventMetadata, | |
| 611 | } as const; | |
| 612 | ||
| 613 | export const OrchestrationEvent = Schema.Union([ | |
| 614 | Schema.Struct({ | |
| 615 | ...EventBaseFields, | |
| 616 | type: Schema.Literal("project.created"), | |
| 617 | payload: ProjectCreatedPayload, | |
| 618 | }), | |
| 619 | Schema.Struct({ | |
| 620 | ...EventBaseFields, | |
| 621 | type: Schema.Literal("project.meta-updated"), | |
| 622 | payload: ProjectMetaUpdatedPayload, | |
| 623 | }), | |
| 624 | Schema.Struct({ | |
| 625 | ...EventBaseFields, | |
| 626 | type: Schema.Literal("project.deleted"), | |
| 627 | payload: ProjectDeletedPayload, | |
| 628 | }), | |
| 629 | Schema.Struct({ | |
| 630 | ...EventBaseFields, | |
| 631 | type: Schema.Literal("thread.created"), | |
| 632 | payload: ThreadCreatedPayload, | |
| 633 | }), | |
| 634 | Schema.Struct({ | |
| 635 | ...EventBaseFields, | |
| 636 | type: Schema.Literal("thread.deleted"), | |
| 637 | payload: ThreadDeletedPayload, | |
| 638 | }), | |
| 639 | Schema.Struct({ | |
| 640 | ...EventBaseFields, | |
| 641 | type: Schema.Literal("thread.meta-updated"), | |
| 642 | payload: ThreadMetaUpdatedPayload, | |
| 643 | }), | |
| 644 | Schema.Struct({ | |
| 645 | ...EventBaseFields, | |
| 646 | type: Schema.Literal("thread.message-sent"), | |
| 647 | payload: ThreadMessageSentPayload, | |
| 648 | }), | |
| 649 | Schema.Struct({ | |
| 650 | ...EventBaseFields, | |
| 651 | type: Schema.Literal("thread.turn-start-requested"), | |
| 652 | payload: ThreadTurnStartRequestedPayload, | |
| 653 | }), | |
| 654 | Schema.Struct({ | |
| 655 | ...EventBaseFields, | |
| 656 | type: Schema.Literal("thread.turn-interrupt-requested"), | |
| 657 | payload: ThreadTurnInterruptRequestedPayload, | |
| 658 | }), | |
| 659 | Schema.Struct({ | |
| 660 | ...EventBaseFields, | |
| 661 | type: Schema.Literal("thread.approval-response-requested"), | |
| 662 | payload: ThreadApprovalResponseRequestedPayload, | |
| 663 | }), | |
| 664 | Schema.Struct({ | |
| 665 | ...EventBaseFields, | |
| 666 | type: Schema.Literal("thread.checkpoint-revert-requested"), | |
| 667 | payload: ThreadCheckpointRevertRequestedPayload, | |
| 668 | }), | |
| 669 | Schema.Struct({ | |
| 670 | ...EventBaseFields, | |
| 671 | type: Schema.Literal("thread.reverted"), | |
| 672 | payload: ThreadRevertedPayload, | |
| 673 | }), | |
| 674 | Schema.Struct({ | |
| 675 | ...EventBaseFields, | |
| 676 | type: Schema.Literal("thread.session-stop-requested"), | |
| 677 | payload: ThreadSessionStopRequestedPayload, | |
| 678 | }), | |
| 679 | Schema.Struct({ | |
| 680 | ...EventBaseFields, | |
| 681 | type: Schema.Literal("thread.session-set"), | |
| 682 | payload: ThreadSessionSetPayload, | |
| 683 | }), | |
| 684 | Schema.Struct({ | |
| 685 | ...EventBaseFields, | |
| 686 | type: Schema.Literal("thread.turn-diff-completed"), | |
| 687 | payload: ThreadTurnDiffCompletedPayload, | |
| 688 | }), | |
| 689 | Schema.Struct({ | |
| 690 | ...EventBaseFields, | |
| 691 | type: Schema.Literal("thread.activity-appended"), | |
| 692 | payload: ThreadActivityAppendedPayload, | |
| 693 | }), | |
| 694 | ]); | |
| 695 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; | |
| 696 | ||
| 697 | export const OrchestrationPersistedEvent = Schema.Union([ | |
| 698 | Schema.Struct({ | |
| 699 | ...PersistedEventBaseFields, | |
| 700 | eventType: Schema.Literal("project.created"), | |
| 701 | payload: ProjectCreatedPayload, | |
| 702 | }), | |
| 703 | Schema.Struct({ | |
| 704 | ...PersistedEventBaseFields, | |
| 705 | eventType: Schema.Literal("project.meta-updated"), | |
| 706 | payload: ProjectMetaUpdatedPayload, | |
| 707 | }), | |
| 708 | Schema.Struct({ | |
| 709 | ...PersistedEventBaseFields, | |
| 710 | eventType: Schema.Literal("project.deleted"), | |
| 711 | payload: ProjectDeletedPayload, | |
| 712 | }), | |
| 713 | Schema.Struct({ | |
| 714 | ...PersistedEventBaseFields, | |
| 715 | eventType: Schema.Literal("thread.created"), | |
| 716 | payload: ThreadCreatedPayload, | |
| 717 | }), | |
| 718 | Schema.Struct({ | |
| 719 | ...PersistedEventBaseFields, | |
| 720 | eventType: Schema.Literal("thread.deleted"), | |
| 721 | payload: ThreadDeletedPayload, | |
| 722 | }), | |
| 723 | Schema.Struct({ | |
| 724 | ...PersistedEventBaseFields, | |
| 725 | eventType: Schema.Literal("thread.meta-updated"), | |
| 726 | payload: ThreadMetaUpdatedPayload, | |
| 727 | }), | |
| 728 | Schema.Struct({ | |
| 729 | ...PersistedEventBaseFields, | |
| 730 | eventType: Schema.Literal("thread.message-sent"), | |
| 731 | payload: ThreadMessageSentPayload, | |
| 732 | }), | |
| 733 | Schema.Struct({ | |
| 734 | ...PersistedEventBaseFields, | |
| 735 | eventType: Schema.Literal("thread.turn-start-requested"), | |
| 736 | payload: ThreadTurnStartRequestedPayload, | |
| 737 | }), | |
| 738 | Schema.Struct({ | |
| 739 | ...PersistedEventBaseFields, | |
| 740 | eventType: Schema.Literal("thread.turn-interrupt-requested"), | |
| 741 | payload: ThreadTurnInterruptRequestedPayload, | |
| 742 | }), | |
| 743 | Schema.Struct({ | |
| 744 | ...PersistedEventBaseFields, | |
| 745 | eventType: Schema.Literal("thread.approval-response-requested"), | |
| 746 | payload: ThreadApprovalResponseRequestedPayload, | |
| 747 | }), | |
| 748 | Schema.Struct({ | |
| 749 | ...PersistedEventBaseFields, | |
| 750 | eventType: Schema.Literal("thread.checkpoint-revert-requested"), | |
| 751 | payload: ThreadCheckpointRevertRequestedPayload, | |
| 752 | }), | |
| 753 | Schema.Struct({ | |
| 754 | ...PersistedEventBaseFields, | |
| 755 | eventType: Schema.Literal("thread.reverted"), | |
| 756 | payload: ThreadRevertedPayload, | |
| 757 | }), | |
| 758 | Schema.Struct({ | |
| 759 | ...PersistedEventBaseFields, | |
| 760 | eventType: Schema.Literal("thread.session-stop-requested"), | |
| 761 | payload: ThreadSessionStopRequestedPayload, | |
| 762 | }), | |
| 763 | Schema.Struct({ | |
| 764 | ...PersistedEventBaseFields, | |
| 765 | eventType: Schema.Literal("thread.session-set"), | |
| 766 | payload: ThreadSessionSetPayload, | |
| 767 | }), | |
| 768 | Schema.Struct({ | |
| 769 | ...PersistedEventBaseFields, | |
| 770 | eventType: Schema.Literal("thread.turn-diff-completed"), | |
| 771 | payload: ThreadTurnDiffCompletedPayload, | |
| 772 | }), | |
| 773 | Schema.Struct({ | |
| 774 | ...PersistedEventBaseFields, | |
| 775 | eventType: Schema.Literal("thread.activity-appended"), | |
| 776 | payload: ThreadActivityAppendedPayload, | |
| 777 | }), | |
| 778 | ]); | |
| 779 | export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type; | |
| 780 | ||
| 781 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); | |
| 782 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; | |
| 783 | ||
| 784 | export const TurnCountRange = Schema.Struct({ | |
| 785 | fromTurnCount: NonNegativeInt, | |
| 786 | toTurnCount: NonNegativeInt, | |
| 787 | }).check( | |
| 788 | Schema.makeFilter( | |
| 789 | (input) => | |
| 790 | input.fromTurnCount <= input.toTurnCount || | |
| 791 | new SchemaIssue.InvalidValue(Option.some(input.fromTurnCount), { | |
| 792 | message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 793 | }), | |
| 794 | { identifier: "OrchestrationTurnDiffRange" }, | |
| 795 | ), | |
| 796 | ); | |
| 797 | ||
| 798 | export const ThreadTurnDiff = TurnCountRange.mapFields( | |
| 799 | Struct.assign({ | |
| 800 | threadId: ThreadId, | |
| 801 | diff: Schema.String, | |
| 802 | }), | |
| 803 | { unsafePreserveChecks: true }, | |
| 804 | ); | |
| 805 | ||
| 806 | export const ProviderSessionRuntimeStatus = Schema.Literals([ | |
| 807 | "starting", | |
| 808 | "running", | |
| 809 | "stopped", | |
| 810 | "error", | |
| 811 | ]); | |
| 812 | export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type; | |
| 813 | ||
| 814 | export const ProjectionThreadTurnStatus = Schema.Literals([ | |
| 815 | "running", | |
| 816 | "completed", | |
| 817 | "interrupted", | |
| 818 | "error", | |
| 819 | ]); | |
| 820 | export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type; | |
| 821 | ||
| 822 | export const ProjectionCheckpointRow = Schema.Struct({ | |
| 823 | threadId: ThreadId, | |
| 824 | turnId: TurnId, | |
| 825 | checkpointTurnCount: NonNegativeInt, | |
| 826 | checkpointRef: CheckpointRef, | |
| 827 | status: OrchestrationCheckpointStatus, | |
| 828 | files: Schema.Array(OrchestrationCheckpointFile), | |
| 829 | assistantMessageId: Schema.NullOr(MessageId), | |
| 830 | completedAt: IsoDateTime, | |
| 831 | }); | |
| 832 | export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type; | |
| 833 | ||
| 834 | export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]); | |
| 835 | export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type; | |
| 836 | ||
| 837 | export const ProjectionPendingApprovalDecision = Schema.NullOr(ProviderApprovalDecision); | |
| 838 | export type ProjectionPendingApprovalDecision = typeof ProjectionPendingApprovalDecision.Type; | |
| 839 | ||
| 840 | export const DispatchResult = Schema.Struct({ | |
| 841 | sequence: NonNegativeInt, | |
| 842 | }); | |
| 843 | export type DispatchResult = typeof DispatchResult.Type; | |
| 844 | ||
| 845 | export const OrchestrationGetSnapshotInput = Schema.Struct({}); | |
| 846 | export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; | |
| 847 | export const OrchestrationGetSnapshotResult = OrchestrationReadModel; | |
| 848 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; | |
| 849 | ||
| 850 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( | |
| 851 | Struct.assign({ threadId: ThreadId }), | |
| 852 | { unsafePreserveChecks: true }, | |
| 853 | ); | |
| 854 | export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; | |
| 855 | ||
| 856 | export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; | |
| 857 | export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; | |
| 858 | ||
| 859 | export const OrchestrationGetFullThreadDiffInput = Schema.Struct({ | |
| 860 | threadId: ThreadId, | |
| 861 | toTurnCount: NonNegativeInt, | |
| 862 | }); | |
| 863 | export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type; | |
| 864 | ||
| 865 | export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; | |
| 866 | export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; | |
| 867 | ||
| 868 | export const OrchestrationReplayEventsInput = Schema.Struct({ | |
| 869 | fromSequenceExclusive: NonNegativeInt, | |
| 870 | }); | |
| 871 | export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type; | |
| 872 | ||
| 873 | export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); | |
| 874 | export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; | |
| 875 | ||
| 876 | export const OrchestrationRpcSchemas = { | |
| 877 | getSnapshot: { | |
| 878 | input: OrchestrationGetSnapshotInput, | |
| 879 | output: OrchestrationGetSnapshotResult, | |
| 880 | }, | |
| 881 | dispatchCommand: { | |
| 882 | input: ClientOrchestrationCommand, | |
| 883 | output: DispatchResult, | |
| 884 | }, | |
| 885 | getTurnDiff: { | |
| 886 | input: OrchestrationGetTurnDiffInput, | |
| 887 | output: OrchestrationGetTurnDiffResult, | |
| 888 | }, | |
| 889 | getFullThreadDiff: { | |
| 890 | input: OrchestrationGetFullThreadDiffInput, | |
| 891 | output: OrchestrationGetFullThreadDiffResult, | |
| 892 | }, | |
| 893 | replayEvents: { | |
| 894 | input: OrchestrationReplayEventsInput, | |
| 895 | output: OrchestrationReplayEventsResult, | |
| 896 | }, | |
| 897 | } as const; |
packages/contracts/src/project.test.ts deleted-147| ... | ... | @@ -1,147 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | projectAddInputSchema, | |
| 5 | projectAddResultSchema, | |
| 6 | projectSearchEntriesInputSchema, | |
| 7 | projectSearchEntriesResultSchema, | |
| 8 | projectListResultSchema, | |
| 9 | projectRemoveInputSchema, | |
| 10 | projectUpdateScriptsInputSchema, | |
| 11 | projectUpdateScriptsResultSchema, | |
| 12 | } from "./project"; | |
| 13 | ||
| 14 | describe("project contracts", () => { | |
| 15 | it("parses project list result", () => { | |
| 16 | const result = projectListResultSchema.parse([ | |
| 17 | { | |
| 18 | id: "project-1", | |
| 19 | cwd: "/tmp/project", | |
| 20 | name: "project", | |
| 21 | scripts: [], | |
| 22 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 23 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 24 | }, | |
| 25 | ]); | |
| 26 | ||
| 27 | expect(result).toHaveLength(1); | |
| 28 | expect(result[0]?.id).toBe("project-1"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("trims add input cwd", () => { | |
| 32 | const parsed = projectAddInputSchema.parse({ cwd: " /tmp/project " }); | |
| 33 | expect(parsed.cwd).toBe("/tmp/project"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("requires add result created flag", () => { | |
| 37 | const parsed = projectAddResultSchema.parse({ | |
| 38 | project: { | |
| 39 | id: "project-1", | |
| 40 | cwd: "/tmp/project", | |
| 41 | name: "project", | |
| 42 | scripts: [], | |
| 43 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 44 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 45 | }, | |
| 46 | created: true, | |
| 47 | }); | |
| 48 | expect(parsed.created).toBe(true); | |
| 49 | }); | |
| 50 | ||
| 51 | it("parses remove input", () => { | |
| 52 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); | |
| 53 | expect(parsed.id).toBe("project-1"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("parses workspace entry search input with defaults", () => { | |
| 57 | const parsed = projectSearchEntriesInputSchema.parse({ | |
| 58 | cwd: " /tmp/project ", | |
| 59 | query: " src ", | |
| 60 | }); | |
| 61 | ||
| 62 | expect(parsed).toEqual({ | |
| 63 | cwd: "/tmp/project", | |
| 64 | query: "src", | |
| 65 | limit: 80, | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | it("parses workspace entry search result", () => { | |
| 70 | const parsed = projectSearchEntriesResultSchema.parse({ | |
| 71 | entries: [ | |
| 72 | { | |
| 73 | path: "src/components", | |
| 74 | kind: "directory", | |
| 75 | parentPath: "src", | |
| 76 | }, | |
| 77 | { | |
| 78 | path: "src/components/Button.tsx", | |
| 79 | kind: "file", | |
| 80 | parentPath: "src/components", | |
| 81 | }, | |
| 82 | ], | |
| 83 | truncated: false, | |
| 84 | }); | |
| 85 | ||
| 86 | expect(parsed.entries).toHaveLength(2); | |
| 87 | expect(parsed.entries[0]?.kind).toBe("directory"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("parses update scripts input", () => { | |
| 91 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 92 | id: "project-1", | |
| 93 | scripts: [ | |
| 94 | { | |
| 95 | id: "test", | |
| 96 | name: "Test", | |
| 97 | command: "bun test", | |
| 98 | icon: "test", | |
| 99 | runOnWorktreeCreate: false, | |
| 100 | }, | |
| 101 | ], | |
| 102 | }); | |
| 103 | ||
| 104 | expect(parsed.scripts).toHaveLength(1); | |
| 105 | expect(parsed.scripts[0]?.id).toBe("test"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("parses debug icon in scripts", () => { | |
| 109 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 110 | id: "project-1", | |
| 111 | scripts: [ | |
| 112 | { | |
| 113 | id: "debug", | |
| 114 | name: "Debug", | |
| 115 | command: "bun --inspect test", | |
| 116 | icon: "debug", | |
| 117 | runOnWorktreeCreate: false, | |
| 118 | }, | |
| 119 | ], | |
| 120 | }); | |
| 121 | ||
| 122 | expect(parsed.scripts[0]?.icon).toBe("debug"); | |
| 123 | }); | |
| 124 | ||
| 125 | it("parses update scripts result", () => { | |
| 126 | const parsed = projectUpdateScriptsResultSchema.parse({ | |
| 127 | project: { | |
| 128 | id: "project-1", | |
| 129 | cwd: "/tmp/project", | |
| 130 | name: "project", | |
| 131 | scripts: [ | |
| 132 | { | |
| 133 | id: "setup", | |
| 134 | name: "Setup", | |
| 135 | command: "bun install", | |
| 136 | icon: "configure", | |
| 137 | runOnWorktreeCreate: true, | |
| 138 | }, | |
| 139 | ], | |
| 140 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 141 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 142 | }, | |
| 143 | }); | |
| 144 | ||
| 145 | expect(parsed.project.scripts[0]?.id).toBe("setup"); | |
| 146 | }); | |
| 147 | }); |
packages/contracts/src/project.ts+17-125| ... | ... | @@ -1,134 +1,26 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = [ | |
| 4 | "play", | |
| 5 | "test", | |
| 6 | "lint", | |
| 7 | "configure", | |
| 8 | "build", | |
| 9 | "debug", | |
| 10 | ] as const; | |
| 11 | ||
| 12 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); | |
| 13 | ||
| 14 | export const projectScriptSchema = z | |
| 15 | .object({ | |
| 16 | id: z | |
| 17 | .string() | |
| 18 | .trim() | |
| 19 | .min(1) | |
| 20 | .max(64) | |
| 21 | .regex(/^[a-z0-9][a-z0-9-]*$/), | |
| 22 | name: z.string().trim().min(1).max(64), | |
| 23 | command: z.string().trim().min(1).max(10_000), | |
| 24 | icon: projectScriptIconSchema, | |
| 25 | runOnWorktreeCreate: z.boolean().default(false), | |
| 26 | }) | |
| 27 | .strict(); | |
| 28 | ||
| 29 | export const projectScriptsSchema = z.array(projectScriptSchema).max(64); | |
| 30 | ||
| 31 | export const projectRecordSchema = z.object({ | |
| 32 | id: z.string().min(1), | |
| 33 | cwd: z.string().min(1), | |
| 34 | name: z.string().min(1), | |
| 35 | scripts: projectScriptsSchema.default([]), | |
| 36 | createdAt: z.string().datetime(), | |
| 37 | updatedAt: z.string().datetime(), | |
| 38 | }); | |
| 39 | ||
| 40 | export const projectListResultSchema = z.array(projectRecordSchema); | |
| 41 | ||
| 42 | export const projectAddInputSchema = z.object({ | |
| 43 | cwd: z.string().trim().min(1), | |
| 44 | }); | |
| 45 | ||
| 46 | export const projectAddResultSchema = z.object({ | |
| 47 | project: projectRecordSchema, | |
| 48 | created: z.boolean(), | |
| 49 | }); | |
| 50 | ||
| 51 | export const projectRemoveInputSchema = z.object({ | |
| 52 | id: z.string().min(1), | |
| 53 | }); | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 54 | 3 | |
| 55 | 4 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 56 | 5 | |
| 57 | export const projectSearchEntriesInputSchema = z.object({ | |
| 58 | cwd: z.string().trim().min(1), | |
| 59 | query: z.string().trim().max(256).default(""), | |
| 60 | limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), | |
| 61 | }); | |
| 62 | ||
| 63 | export const projectEntryKindSchema = z.enum(["file", "directory"]); | |
| 64 | ||
| 65 | export const projectEntrySchema = z.object({ | |
| 66 | path: z.string().min(1), | |
| 67 | kind: projectEntryKindSchema, | |
| 68 | parentPath: z.string().optional(), | |
| 6 | export const ProjectSearchEntriesInput = Schema.Struct({ | |
| 7 | cwd: TrimmedNonEmptyString, | |
| 8 | query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), | |
| 9 | limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), | |
| 69 | 10 | }); |
| 11 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; | |
| 70 | 12 | |
| 71 | export const projectSearchEntriesResultSchema = z.object({ | |
| 72 | entries: z.array(projectEntrySchema), | |
| 73 | truncated: z.boolean(), | |
| 74 | }); | |
| 13 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); | |
| 75 | 14 | |
| 76 | export const projectUpdateScriptsInputSchema = z.object({ | |
| 77 | id: z.string().min(1), | |
| 78 | scripts: projectScriptsSchema, | |
| 15 | export const ProjectEntry = Schema.Struct({ | |
| 16 | path: TrimmedNonEmptyString, | |
| 17 | kind: ProjectEntryKind, | |
| 18 | parentPath: Schema.optional(TrimmedNonEmptyString), | |
| 79 | 19 | }); |
| 20 | export type ProjectEntry = typeof ProjectEntry.Type; | |
| 80 | 21 | |
| 81 | export const projectUpdateScriptsResultSchema = z.object({ | |
| 82 | project: projectRecordSchema, | |
| 22 | export const ProjectSearchEntriesResult = Schema.Struct({ | |
| 23 | entries: Schema.Array(ProjectEntry), | |
| 24 | truncated: Schema.Boolean, | |
| 83 | 25 | }); |
| 84 | ||
| 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; | |
| 86 | export type ProjectScript = z.infer<typeof projectScriptSchema>; | |
| 87 | ||
| 88 | /** | |
| 89 | * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true` | |
| 90 | * flag (the first one wins). Fields are trimmed defensively so this is safe to | |
| 91 | * call on data that hasn't been through Zod parsing. | |
| 92 | */ | |
| 93 | export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 94 | const seenIds = new Set<string>(); | |
| 95 | const deduped: ProjectScript[] = []; | |
| 96 | for (const script of scripts) { | |
| 97 | const id = script.id.trim(); | |
| 98 | if (id.length === 0 || seenIds.has(id)) continue; | |
| 99 | seenIds.add(id); | |
| 100 | deduped.push({ | |
| 101 | ...script, | |
| 102 | id, | |
| 103 | name: script.name.trim(), | |
| 104 | command: script.command.trim(), | |
| 105 | }); | |
| 106 | } | |
| 107 | ||
| 108 | let setupAssigned = false; | |
| 109 | const normalized: ProjectScript[] = []; | |
| 110 | for (const script of deduped) { | |
| 111 | if (!script.runOnWorktreeCreate) { | |
| 112 | normalized.push(script); | |
| 113 | continue; | |
| 114 | } | |
| 115 | if (!setupAssigned) { | |
| 116 | setupAssigned = true; | |
| 117 | normalized.push(script); | |
| 118 | continue; | |
| 119 | } | |
| 120 | normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 121 | } | |
| 122 | return normalized; | |
| 123 | } | |
| 124 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | |
| 125 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | |
| 126 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | |
| 127 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; | |
| 128 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; | |
| 129 | export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>; | |
| 130 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; | |
| 131 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; | |
| 132 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; | |
| 133 | export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>; | |
| 134 | export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>; | |
| 26 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
packages/contracts/src/provider.test.ts deleted-283| ... | ... | @@ -1,283 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 5 | providerCheckpointSchema, | |
| 6 | providerEventSchema, | |
| 7 | providerGetCheckpointDiffInputSchema, | |
| 8 | providerGetCheckpointDiffResultSchema, | |
| 9 | providerListCheckpointsInputSchema, | |
| 10 | providerListCheckpointsResultSchema, | |
| 11 | providerRevertToCheckpointInputSchema, | |
| 12 | providerRevertToCheckpointResultSchema, | |
| 13 | providerRespondToRequestInputSchema, | |
| 14 | providerSendTurnInputSchema, | |
| 15 | providerSessionStartInputSchema, | |
| 16 | } from "./provider"; | |
| 17 | ||
| 18 | describe("providerSessionStartInputSchema", () => { | |
| 19 | it("defaults to codex with safe policies", () => { | |
| 20 | const parsed = providerSessionStartInputSchema.parse({}); | |
| 21 | expect(parsed.provider).toBe("codex"); | |
| 22 | expect(parsed.approvalPolicy).toBe("never"); | |
| 23 | expect(parsed.sandboxMode).toBe("workspace-write"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("accepts optional resumeThreadId", () => { | |
| 27 | const parsed = providerSessionStartInputSchema.parse({ | |
| 28 | resumeThreadId: "thread_123", | |
| 29 | }); | |
| 30 | expect(parsed.resumeThreadId).toBe("thread_123"); | |
| 31 | }); | |
| 32 | ||
| 33 | it("rejects blank resumeThreadId", () => { | |
| 34 | expect(() => | |
| 35 | providerSessionStartInputSchema.parse({ | |
| 36 | resumeThreadId: " ", | |
| 37 | }), | |
| 38 | ).toThrow(); | |
| 39 | }); | |
| 40 | ||
| 41 | it("accepts optional codex binary and home path overrides", () => { | |
| 42 | const parsed = providerSessionStartInputSchema.parse({ | |
| 43 | codexBinaryPath: "/opt/codex/bin/codex", | |
| 44 | codexHomePath: "/Users/theo/.codex", | |
| 45 | }); | |
| 46 | expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); | |
| 47 | expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); | |
| 48 | }); | |
| 49 | ||
| 50 | it("rejects blank codex overrides", () => { | |
| 51 | expect(() => | |
| 52 | providerSessionStartInputSchema.parse({ | |
| 53 | codexBinaryPath: " ", | |
| 54 | }), | |
| 55 | ).toThrow(); | |
| 56 | expect(() => | |
| 57 | providerSessionStartInputSchema.parse({ | |
| 58 | codexHomePath: " ", | |
| 59 | }), | |
| 60 | ).toThrow(); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("providerSendTurnInputSchema", () => { | |
| 65 | it("trims input text and optional model/effort", () => { | |
| 66 | const parsed = providerSendTurnInputSchema.parse({ | |
| 67 | sessionId: "sess_1", | |
| 68 | input: " summarize this repo ", | |
| 69 | model: " gpt-5.2-codex ", | |
| 70 | effort: " high ", | |
| 71 | }); | |
| 72 | expect(parsed.input).toBe("summarize this repo"); | |
| 73 | expect(parsed.attachments).toEqual([]); | |
| 74 | expect(parsed.model).toBe("gpt-5.2-codex"); | |
| 75 | expect(parsed.effort).toBe("high"); | |
| 76 | }); | |
| 77 | ||
| 78 | it("accepts image-only turns", () => { | |
| 79 | const parsed = providerSendTurnInputSchema.parse({ | |
| 80 | sessionId: "sess_1", | |
| 81 | attachments: [ | |
| 82 | { | |
| 83 | type: "image", | |
| 84 | name: "diagram.png", | |
| 85 | mimeType: "image/png", | |
| 86 | sizeBytes: 1_024, | |
| 87 | dataUrl: "data:image/png;base64,AAAA", | |
| 88 | }, | |
| 89 | ], | |
| 90 | }); | |
| 91 | expect(parsed.input).toBeUndefined(); | |
| 92 | expect(parsed.attachments).toHaveLength(1); | |
| 93 | }); | |
| 94 | ||
| 95 | it("rejects turns with neither text nor attachments", () => { | |
| 96 | expect(() => | |
| 97 | providerSendTurnInputSchema.parse({ | |
| 98 | sessionId: "sess_1", | |
| 99 | }), | |
| 100 | ).toThrow(); | |
| 101 | }); | |
| 102 | ||
| 103 | it("rejects non-image data urls", () => { | |
| 104 | expect(() => | |
| 105 | providerSendTurnInputSchema.parse({ | |
| 106 | sessionId: "sess_1", | |
| 107 | attachments: [ | |
| 108 | { | |
| 109 | type: "image", | |
| 110 | name: "not-image.txt", | |
| 111 | mimeType: "text/plain", | |
| 112 | sizeBytes: 25, | |
| 113 | dataUrl: "data:text/plain;base64,QQ==", | |
| 114 | }, | |
| 115 | ], | |
| 116 | }), | |
| 117 | ).toThrow(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("rejects more than the max attachment count", () => { | |
| 121 | expect(() => | |
| 122 | providerSendTurnInputSchema.parse({ | |
| 123 | sessionId: "sess_1", | |
| 124 | attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ | |
| 125 | type: "image" as const, | |
| 126 | name: `image-${index}.png`, | |
| 127 | mimeType: "image/png", | |
| 128 | sizeBytes: 1_024, | |
| 129 | dataUrl: "data:image/png;base64,AAAA", | |
| 130 | })), | |
| 131 | }), | |
| 132 | ).toThrow(); | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | describe("providerEventSchema", () => { | |
| 137 | it("accepts notification events with routing metadata", () => { | |
| 138 | const parsed = providerEventSchema.parse({ | |
| 139 | id: "evt_1", | |
| 140 | kind: "notification", | |
| 141 | provider: "codex", | |
| 142 | sessionId: "sess_1", | |
| 143 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 144 | method: "item/agentMessage/delta", | |
| 145 | threadId: "thr_1", | |
| 146 | turnId: "turn_1", | |
| 147 | itemId: "item_1", | |
| 148 | textDelta: "hi", | |
| 149 | }); | |
| 150 | expect(parsed.method).toBe("item/agentMessage/delta"); | |
| 151 | }); | |
| 152 | ||
| 153 | it("accepts request approval metadata", () => { | |
| 154 | const parsed = providerEventSchema.parse({ | |
| 155 | id: "evt_2", | |
| 156 | kind: "request", | |
| 157 | provider: "codex", | |
| 158 | sessionId: "sess_1", | |
| 159 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 160 | method: "item/commandExecution/requestApproval", | |
| 161 | requestId: "req_123", | |
| 162 | requestKind: "command", | |
| 163 | }); | |
| 164 | expect(parsed.requestId).toBe("req_123"); | |
| 165 | expect(parsed.requestKind).toBe("command"); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | describe("providerRespondToRequestInputSchema", () => { | |
| 170 | it("accepts valid decisions", () => { | |
| 171 | const parsed = providerRespondToRequestInputSchema.parse({ | |
| 172 | sessionId: "sess_1", | |
| 173 | requestId: "req_1", | |
| 174 | decision: "acceptForSession", | |
| 175 | }); | |
| 176 | expect(parsed.decision).toBe("acceptForSession"); | |
| 177 | }); | |
| 178 | ||
| 179 | it("rejects unknown decisions", () => { | |
| 180 | expect(() => | |
| 181 | providerRespondToRequestInputSchema.parse({ | |
| 182 | sessionId: "sess_1", | |
| 183 | requestId: "req_1", | |
| 184 | decision: "always", | |
| 185 | }), | |
| 186 | ).toThrow(); | |
| 187 | }); | |
| 188 | }); | |
| 189 | ||
| 190 | describe("provider checkpoint schemas", () => { | |
| 191 | it("accepts list checkpoint inputs", () => { | |
| 192 | const parsed = providerListCheckpointsInputSchema.parse({ | |
| 193 | sessionId: "sess_1", | |
| 194 | }); | |
| 195 | expect(parsed.sessionId).toBe("sess_1"); | |
| 196 | }); | |
| 197 | ||
| 198 | it("accepts checkpoint records and list results", () => { | |
| 199 | const checkpoint = providerCheckpointSchema.parse({ | |
| 200 | id: "turn_1", | |
| 201 | turnCount: 1, | |
| 202 | messageCount: 2, | |
| 203 | label: "Turn 1", | |
| 204 | preview: "Summarize this file", | |
| 205 | isCurrent: true, | |
| 206 | }); | |
| 207 | expect(checkpoint.turnCount).toBe(1); | |
| 208 | ||
| 209 | const listResult = providerListCheckpointsResultSchema.parse({ | |
| 210 | threadId: "thr_1", | |
| 211 | checkpoints: [checkpoint], | |
| 212 | }); | |
| 213 | expect(listResult.checkpoints).toHaveLength(1); | |
| 214 | }); | |
| 215 | ||
| 216 | it("accepts revert checkpoint input/result", () => { | |
| 217 | const input = providerRevertToCheckpointInputSchema.parse({ | |
| 218 | sessionId: "sess_1", | |
| 219 | turnCount: 2, | |
| 220 | }); | |
| 221 | expect(input.turnCount).toBe(2); | |
| 222 | ||
| 223 | const result = providerRevertToCheckpointResultSchema.parse({ | |
| 224 | threadId: "thr_1", | |
| 225 | turnCount: 2, | |
| 226 | messageCount: 4, | |
| 227 | rolledBackTurns: 1, | |
| 228 | checkpoints: [ | |
| 229 | { | |
| 230 | id: "root", | |
| 231 | turnCount: 0, | |
| 232 | messageCount: 0, | |
| 233 | label: "Start of conversation", | |
| 234 | isCurrent: false, | |
| 235 | }, | |
| 236 | { | |
| 237 | id: "turn_2", | |
| 238 | turnCount: 2, | |
| 239 | messageCount: 4, | |
| 240 | label: "Turn 2", | |
| 241 | isCurrent: true, | |
| 242 | }, | |
| 243 | ], | |
| 244 | }); | |
| 245 | expect(result.rolledBackTurns).toBe(1); | |
| 246 | }); | |
| 247 | ||
| 248 | it("rejects negative turn counts", () => { | |
| 249 | expect(() => | |
| 250 | providerRevertToCheckpointInputSchema.parse({ | |
| 251 | sessionId: "sess_1", | |
| 252 | turnCount: -1, | |
| 253 | }), | |
| 254 | ).toThrow(); | |
| 255 | }); | |
| 256 | ||
| 257 | it("accepts checkpoint diff input/result", () => { | |
| 258 | const input = providerGetCheckpointDiffInputSchema.parse({ | |
| 259 | sessionId: "sess_1", | |
| 260 | fromTurnCount: 1, | |
| 261 | toTurnCount: 2, | |
| 262 | }); | |
| 263 | expect(input.fromTurnCount).toBe(1); | |
| 264 | ||
| 265 | const result = providerGetCheckpointDiffResultSchema.parse({ | |
| 266 | threadId: "thr_1", | |
| 267 | fromTurnCount: 1, | |
| 268 | toTurnCount: 2, | |
| 269 | diff: "diff --git a/src/app.ts b/src/app.ts", | |
| 270 | }); | |
| 271 | expect(result.toTurnCount).toBe(2); | |
| 272 | }); | |
| 273 | ||
| 274 | it("rejects checkpoint diff ranges where start is after end", () => { | |
| 275 | expect(() => | |
| 276 | providerGetCheckpointDiffInputSchema.parse({ | |
| 277 | sessionId: "sess_1", | |
| 278 | fromTurnCount: 3, | |
| 279 | toTurnCount: 2, | |
| 280 | }), | |
| 281 | ).toThrow(); | |
| 282 | }); | |
| 283 | }); |
packages/contracts/src/provider.ts+144-191| ... | ... | @@ -1,221 +1,174 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const providerKindSchema = z.enum(["codex", "claudeCode"]); | |
| 4 | ||
| 5 | export const providerApprovalPolicySchema = z.enum([ | |
| 6 | "untrusted", | |
| 7 | "on-failure", | |
| 8 | "on-request", | |
| 9 | "never", | |
| 10 | ]); | |
| 11 | ||
| 12 | export const providerSandboxModeSchema = z.enum([ | |
| 13 | "read-only", | |
| 14 | "workspace-write", | |
| 15 | "danger-full-access", | |
| 16 | ]); | |
| 17 | ||
| 18 | export const providerRequestKindSchema = z.enum(["command", "file-change"]); | |
| 19 | ||
| 20 | export const providerApprovalDecisionSchema = z.enum([ | |
| 21 | "accept", | |
| 22 | "acceptForSession", | |
| 23 | "decline", | |
| 24 | "cancel", | |
| 25 | ]); | |
| 26 | ||
| 27 | export const providerSessionStatusSchema = z.enum([ | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | import { | |
| 4 | ApprovalRequestId, | |
| 5 | EventId, | |
| 6 | IsoDateTime, | |
| 7 | NonNegativeInt, | |
| 8 | ProviderItemId, | |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | ThreadId, | |
| 13 | } from "./baseSchemas"; | |
| 14 | import { | |
| 15 | ChatAttachment, | |
| 16 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 17 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 18 | ProviderApprovalDecision, | |
| 19 | ProviderApprovalPolicy, | |
| 20 | ProviderKind, | |
| 21 | ProviderRequestKind, | |
| 22 | ProviderSandboxMode, | |
| 23 | TurnCountRange, | |
| 24 | } from "./orchestration"; | |
| 25 | ||
| 26 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | |
| 27 | ||
| 28 | export const ProviderSessionStatus = Schema.Literals([ | |
| 28 | 29 | "connecting", |
| 29 | 30 | "ready", |
| 30 | 31 | "running", |
| 31 | 32 | "error", |
| 32 | 33 | "closed", |
| 33 | 34 | ]); |
| 34 | ||
| 35 | export const providerSessionSchema = z.object({ | |
| 36 | sessionId: z.string().min(1), | |
| 37 | provider: providerKindSchema, | |
| 38 | status: providerSessionStatusSchema, | |
| 39 | cwd: z.string().min(1).optional(), | |
| 40 | model: z.string().min(1).optional(), | |
| 41 | threadId: z.string().min(1).optional(), | |
| 42 | activeTurnId: z.string().min(1).optional(), | |
| 43 | createdAt: z.string().datetime(), | |
| 44 | updatedAt: z.string().datetime(), | |
| 45 | lastError: z.string().min(1).optional(), | |
| 35 | export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; | |
| 36 | ||
| 37 | export const ProviderSession = Schema.Struct({ | |
| 38 | sessionId: ProviderSessionId, | |
| 39 | provider: ProviderKind, | |
| 40 | status: ProviderSessionStatus, | |
| 41 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 42 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 43 | threadId: Schema.optional(ProviderThreadId), | |
| 44 | resumeCursor: Schema.optional(Schema.Unknown), | |
| 45 | activeTurnId: Schema.optional(ProviderTurnId), | |
| 46 | createdAt: IsoDateTime, | |
| 47 | updatedAt: IsoDateTime, | |
| 48 | lastError: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 46 | 49 | }); |
| 47 | ||
| 48 | export const providerSessionStartInputSchema = z.object({ | |
| 49 | provider: providerKindSchema.default("codex"), | |
| 50 | cwd: z.string().min(1).optional(), | |
| 51 | model: z.string().trim().min(1).optional(), | |
| 52 | resumeThreadId: z.string().trim().min(1).optional(), | |
| 53 | codexBinaryPath: z.string().trim().min(1).optional(), | |
| 54 | codexHomePath: z.string().trim().min(1).optional(), | |
| 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), | |
| 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), | |
| 50 | export type ProviderSession = typeof ProviderSession.Type; | |
| 51 | ||
| 52 | export const ProviderSessionStartInput = Schema.Struct({ | |
| 53 | provider: Schema.optional(ProviderKind), | |
| 54 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 55 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 56 | resumeThreadId: Schema.optional(ProviderThreadId), | |
| 57 | resumeCursor: Schema.optional(Schema.Unknown), | |
| 58 | codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 59 | codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 60 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | |
| 61 | sandboxMode: Schema.optional(ProviderSandboxMode), | |
| 57 | 62 | }); |
| 58 | ||
| 59 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | |
| 60 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 61 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | |
| 62 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | |
| 63 | ||
| 64 | export const providerSendTurnImageAttachmentSchema = z.object({ | |
| 65 | type: z.literal("image"), | |
| 66 | name: z.string().trim().min(1).max(255), | |
| 67 | mimeType: z | |
| 68 | .string() | |
| 69 | .trim() | |
| 70 | .min(1) | |
| 71 | .max(100) | |
| 72 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), | |
| 73 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | |
| 74 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | |
| 63 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; | |
| 64 | ||
| 65 | export const ProviderSendTurnInput = Schema.Struct({ | |
| 66 | sessionId: ProviderSessionId, | |
| 67 | input: Schema.optional( | |
| 68 | TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), | |
| 69 | ), | |
| 70 | attachments: Schema.optional( | |
| 71 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | |
| 72 | ), | |
| 73 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 74 | effort: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 75 | 75 | }); |
| 76 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; | |
| 76 | 77 | |
| 77 | export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ | |
| 78 | providerSendTurnImageAttachmentSchema, | |
| 79 | ]); | |
| 80 | ||
| 81 | export const providerSendTurnInputSchema = z | |
| 82 | .object({ | |
| 83 | sessionId: z.string().min(1), | |
| 84 | input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), | |
| 85 | attachments: z | |
| 86 | .array(providerSendTurnAttachmentInputSchema) | |
| 87 | .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS) | |
| 88 | .default([]), | |
| 89 | model: z.string().trim().min(1).optional(), | |
| 90 | effort: z.string().trim().min(1).optional(), | |
| 91 | }) | |
| 92 | .superRefine((value, ctx) => { | |
| 93 | if (!value.input && value.attachments.length === 0) { | |
| 94 | ctx.addIssue({ | |
| 95 | code: z.ZodIssueCode.custom, | |
| 96 | message: "Either input text or at least one attachment is required", | |
| 97 | path: ["input"], | |
| 98 | }); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | export const providerTurnStartResultSchema = z.object({ | |
| 103 | threadId: z.string().min(1), | |
| 104 | turnId: z.string().min(1), | |
| 78 | export const ProviderTurnStartResult = Schema.Struct({ | |
| 79 | threadId: ProviderThreadId, | |
| 80 | turnId: ProviderTurnId, | |
| 81 | resumeCursor: Schema.optional(Schema.Unknown), | |
| 105 | 82 | }); |
| 83 | export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; | |
| 106 | 84 | |
| 107 | export const providerInterruptTurnInputSchema = z.object({ | |
| 108 | sessionId: z.string().min(1), | |
| 109 | turnId: z.string().min(1).optional(), | |
| 85 | export const ProviderInterruptTurnInput = Schema.Struct({ | |
| 86 | sessionId: ProviderSessionId, | |
| 87 | turnId: Schema.optional(ProviderTurnId), | |
| 110 | 88 | }); |
| 89 | export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; | |
| 111 | 90 | |
| 112 | export const providerStopSessionInputSchema = z.object({ | |
| 113 | sessionId: z.string().min(1), | |
| 91 | export const ProviderStopSessionInput = Schema.Struct({ | |
| 92 | sessionId: ProviderSessionId, | |
| 114 | 93 | }); |
| 94 | export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; | |
| 115 | 95 | |
| 116 | export const providerListCheckpointsInputSchema = z.object({ | |
| 117 | sessionId: z.string().min(1), | |
| 96 | export const ProviderListCheckpointsInput = Schema.Struct({ | |
| 97 | sessionId: ProviderSessionId, | |
| 118 | 98 | }); |
| 119 | ||
| 120 | export const providerCheckpointSchema = z.object({ | |
| 121 | id: z.string().min(1), | |
| 122 | turnCount: z.number().int().min(0), | |
| 123 | messageCount: z.number().int().min(0), | |
| 124 | label: z.string().min(1), | |
| 125 | preview: z.string().min(1).optional(), | |
| 126 | isCurrent: z.boolean(), | |
| 99 | export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; | |
| 100 | ||
| 101 | export const ProviderCheckpoint = Schema.Struct({ | |
| 102 | id: TrimmedNonEmptyStringSchema, | |
| 103 | turnCount: NonNegativeInt, | |
| 104 | messageCount: NonNegativeInt, | |
| 105 | label: TrimmedNonEmptyStringSchema, | |
| 106 | preview: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 107 | isCurrent: Schema.Boolean, | |
| 127 | 108 | }); |
| 109 | export type ProviderCheckpoint = typeof ProviderCheckpoint.Type; | |
| 128 | 110 | |
| 129 | export const providerListCheckpointsResultSchema = z.object({ | |
| 130 | threadId: z.string().min(1), | |
| 131 | checkpoints: z.array(providerCheckpointSchema), | |
| 111 | export const ProviderListCheckpointsResult = Schema.Struct({ | |
| 112 | threadId: ThreadId, | |
| 113 | checkpoints: Schema.Array(ProviderCheckpoint), | |
| 132 | 114 | }); |
| 115 | export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; | |
| 133 | 116 | |
| 134 | export const providerRevertToCheckpointInputSchema = z.object({ | |
| 135 | sessionId: z.string().min(1), | |
| 136 | turnCount: z.number().int().min(0), | |
| 117 | export const ProviderRevertToCheckpointInput = Schema.Struct({ | |
| 118 | sessionId: ProviderSessionId, | |
| 119 | turnCount: NonNegativeInt, | |
| 137 | 120 | }); |
| 138 | ||
| 139 | export const providerRevertToCheckpointResultSchema = z.object({ | |
| 140 | threadId: z.string().min(1), | |
| 141 | turnCount: z.number().int().min(0), | |
| 142 | messageCount: z.number().int().min(0), | |
| 143 | rolledBackTurns: z.number().int().min(0), | |
| 144 | checkpoints: z.array(providerCheckpointSchema), | |
| 121 | export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; | |
| 122 | ||
| 123 | export const ProviderRevertToCheckpointResult = Schema.Struct({ | |
| 124 | threadId: ThreadId, | |
| 125 | turnCount: NonNegativeInt, | |
| 126 | messageCount: NonNegativeInt, | |
| 127 | rolledBackTurns: NonNegativeInt, | |
| 128 | checkpoints: Schema.Array(ProviderCheckpoint), | |
| 145 | 129 | }); |
| 130 | export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; | |
| 146 | 131 | |
| 147 | export const providerGetCheckpointDiffInputSchema = z | |
| 148 | .object({ | |
| 149 | sessionId: z.string().min(1), | |
| 150 | fromTurnCount: z.number().int().min(0), | |
| 151 | toTurnCount: z.number().int().min(0), | |
| 152 | }) | |
| 153 | .superRefine((value, ctx) => { | |
| 154 | if (value.fromTurnCount > value.toTurnCount) { | |
| 155 | ctx.addIssue({ | |
| 156 | code: z.ZodIssueCode.custom, | |
| 157 | message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 158 | path: ["fromTurnCount"], | |
| 159 | }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 163 | export const providerGetCheckpointDiffResultSchema = z.object({ | |
| 164 | threadId: z.string().min(1), | |
| 165 | fromTurnCount: z.number().int().min(0), | |
| 166 | toTurnCount: z.number().int().min(0), | |
| 167 | diff: z.string(), | |
| 132 | export const ProviderGetCheckpointDiffInput = Schema.Struct({ | |
| 133 | sessionId: ProviderSessionId, | |
| 134 | ...TurnCountRange.fields, | |
| 168 | 135 | }); |
| 136 | export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; | |
| 169 | 137 | |
| 170 | export const providerRespondToRequestInputSchema = z.object({ | |
| 171 | sessionId: z.string().min(1), | |
| 172 | requestId: z.string().min(1), | |
| 173 | decision: providerApprovalDecisionSchema, | |
| 138 | export const ProviderGetCheckpointDiffResult = Schema.Struct({ | |
| 139 | threadId: ThreadId, | |
| 140 | ...TurnCountRange.fields, | |
| 141 | diff: Schema.String, | |
| 174 | 142 | }); |
| 143 | export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; | |
| 175 | 144 | |
| 176 | export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); | |
| 177 | ||
| 178 | export const providerEventSchema = z.object({ | |
| 179 | id: z.string().min(1), | |
| 180 | kind: providerEventKindSchema, | |
| 181 | provider: providerKindSchema, | |
| 182 | sessionId: z.string().min(1), | |
| 183 | createdAt: z.string().datetime(), | |
| 184 | method: z.string().min(1), | |
| 185 | message: z.string().min(1).optional(), | |
| 186 | threadId: z.string().min(1).optional(), | |
| 187 | turnId: z.string().min(1).optional(), | |
| 188 | itemId: z.string().min(1).optional(), | |
| 189 | requestId: z.string().min(1).optional(), | |
| 190 | requestKind: providerRequestKindSchema.optional(), | |
| 191 | textDelta: z.string().optional(), | |
| 192 | payload: z.unknown().optional(), | |
| 145 | export const ProviderRespondToRequestInput = Schema.Struct({ | |
| 146 | sessionId: ProviderSessionId, | |
| 147 | requestId: ApprovalRequestId, | |
| 148 | decision: ProviderApprovalDecision, | |
| 149 | }); | |
| 150 | export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; | |
| 151 | ||
| 152 | export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); | |
| 153 | export type ProviderEventKind = typeof ProviderEventKind.Type; | |
| 154 | ||
| 155 | export const ProviderEvent = Schema.Struct({ | |
| 156 | id: EventId, | |
| 157 | kind: ProviderEventKind, | |
| 158 | provider: ProviderKind, | |
| 159 | sessionId: ProviderSessionId, | |
| 160 | createdAt: IsoDateTime, | |
| 161 | method: TrimmedNonEmptyStringSchema, | |
| 162 | message: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 163 | threadId: Schema.optional(ProviderThreadId), | |
| 164 | turnId: Schema.optional(ProviderTurnId), | |
| 165 | itemId: Schema.optional(ProviderItemId), | |
| 166 | requestId: Schema.optional(ApprovalRequestId), | |
| 167 | requestKind: Schema.optional(ProviderRequestKind), | |
| 168 | textDelta: Schema.optional(Schema.String), | |
| 169 | payload: Schema.optional(Schema.Unknown), | |
| 193 | 170 | }); |
| 171 | export type ProviderEvent = typeof ProviderEvent.Type; | |
| 194 | 172 | |
| 195 | export type ProviderKind = z.infer<typeof providerKindSchema>; | |
| 196 | export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>; | |
| 197 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; | |
| 198 | export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>; | |
| 199 | export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>; | |
| 200 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | |
| 201 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | |
| 202 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; | |
| 203 | export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>; | |
| 204 | export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>; | |
| 205 | export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>; | |
| 206 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | |
| 207 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; | |
| 208 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; | |
| 209 | export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>; | |
| 210 | export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>; | |
| 211 | export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>; | |
| 212 | export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>; | |
| 213 | export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>; | |
| 214 | export type ProviderRevertToCheckpointResult = z.infer< | |
| 215 | typeof providerRevertToCheckpointResultSchema | |
| 216 | >; | |
| 217 | export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>; | |
| 218 | export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>; | |
| 219 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; | |
| 220 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | |
| 221 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | |
| 173 | export type ProviderSendTurnAttachment = typeof ChatAttachment.Type; | |
| 174 | export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type; |
packages/contracts/src/providerRuntime.ts created+213| ... | ... | @@ -0,0 +1,213 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | ||
| 4 | import { | |
| 5 | ApprovalRequestId, | |
| 6 | EventId, | |
| 7 | NonNegativeInt, | |
| 8 | ProviderItemId, | |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 12 | IsoDateTime, | |
| 13 | } from "./baseSchemas"; | |
| 14 | import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration"; | |
| 15 | ||
| 16 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | |
| 17 | ||
| 18 | export const ProviderRuntimeToolKind = Schema.Union([ProviderRequestKind, Schema.Literal("other")]); | |
| 19 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; | |
| 20 | ||
| 21 | export const ProviderRuntimeTurnStatus = Schema.Literals([ | |
| 22 | "completed", | |
| 23 | "failed", | |
| 24 | "cancelled", | |
| 25 | "interrupted", | |
| 26 | ]); | |
| 27 | export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type; | |
| 28 | ||
| 29 | export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ | |
| 30 | type: Schema.Literal("session.started"), | |
| 31 | eventId: EventId, | |
| 32 | provider: ProviderKind, | |
| 33 | sessionId: ProviderSessionId, | |
| 34 | createdAt: IsoDateTime, | |
| 35 | threadId: Schema.optional(ProviderThreadId), | |
| 36 | message: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 37 | }); | |
| 38 | export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; | |
| 39 | ||
| 40 | export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ | |
| 41 | type: Schema.Literal("session.exited"), | |
| 42 | eventId: EventId, | |
| 43 | provider: ProviderKind, | |
| 44 | sessionId: ProviderSessionId, | |
| 45 | createdAt: IsoDateTime, | |
| 46 | threadId: Schema.optional(ProviderThreadId), | |
| 47 | message: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 48 | }); | |
| 49 | export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; | |
| 50 | ||
| 51 | export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ | |
| 52 | type: Schema.Literal("thread.started"), | |
| 53 | eventId: EventId, | |
| 54 | provider: ProviderKind, | |
| 55 | sessionId: ProviderSessionId, | |
| 56 | createdAt: IsoDateTime, | |
| 57 | threadId: ProviderThreadId, | |
| 58 | }); | |
| 59 | export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; | |
| 60 | ||
| 61 | export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ | |
| 62 | type: Schema.Literal("turn.started"), | |
| 63 | eventId: EventId, | |
| 64 | provider: ProviderKind, | |
| 65 | sessionId: ProviderSessionId, | |
| 66 | createdAt: IsoDateTime, | |
| 67 | threadId: Schema.optional(ProviderThreadId), | |
| 68 | turnId: ProviderTurnId, | |
| 69 | }); | |
| 70 | export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; | |
| 71 | ||
| 72 | export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ | |
| 73 | type: Schema.Literal("turn.completed"), | |
| 74 | eventId: EventId, | |
| 75 | provider: ProviderKind, | |
| 76 | sessionId: ProviderSessionId, | |
| 77 | createdAt: IsoDateTime, | |
| 78 | threadId: Schema.optional(ProviderThreadId), | |
| 79 | turnId: Schema.optional(ProviderTurnId), | |
| 80 | status: Schema.optional(ProviderRuntimeTurnStatus), | |
| 81 | errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 82 | }); | |
| 83 | export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; | |
| 84 | ||
| 85 | export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({ | |
| 86 | type: Schema.Literal("message.delta"), | |
| 87 | eventId: EventId, | |
| 88 | provider: ProviderKind, | |
| 89 | sessionId: ProviderSessionId, | |
| 90 | createdAt: IsoDateTime, | |
| 91 | threadId: Schema.optional(ProviderThreadId), | |
| 92 | turnId: Schema.optional(ProviderTurnId), | |
| 93 | itemId: Schema.optional(ProviderItemId), | |
| 94 | delta: Schema.String, | |
| 95 | }); | |
| 96 | export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type; | |
| 97 | ||
| 98 | export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({ | |
| 99 | type: Schema.Literal("message.completed"), | |
| 100 | eventId: EventId, | |
| 101 | provider: ProviderKind, | |
| 102 | sessionId: ProviderSessionId, | |
| 103 | createdAt: IsoDateTime, | |
| 104 | itemId: ProviderItemId, | |
| 105 | threadId: Schema.optional(ProviderThreadId), | |
| 106 | turnId: Schema.optional(ProviderTurnId), | |
| 107 | }); | |
| 108 | export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type; | |
| 109 | ||
| 110 | export const ProviderRuntimeToolStartedEvent = Schema.Struct({ | |
| 111 | type: Schema.Literal("tool.started"), | |
| 112 | eventId: EventId, | |
| 113 | provider: ProviderKind, | |
| 114 | sessionId: ProviderSessionId, | |
| 115 | createdAt: IsoDateTime, | |
| 116 | threadId: Schema.optional(ProviderThreadId), | |
| 117 | turnId: Schema.optional(ProviderTurnId), | |
| 118 | itemId: Schema.optional(ProviderItemId), | |
| 119 | toolKind: ProviderRuntimeToolKind, | |
| 120 | title: TrimmedNonEmptyStringSchema, | |
| 121 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 122 | }); | |
| 123 | export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; | |
| 124 | ||
| 125 | export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ | |
| 126 | type: Schema.Literal("tool.completed"), | |
| 127 | eventId: EventId, | |
| 128 | provider: ProviderKind, | |
| 129 | sessionId: ProviderSessionId, | |
| 130 | createdAt: IsoDateTime, | |
| 131 | threadId: Schema.optional(ProviderThreadId), | |
| 132 | turnId: Schema.optional(ProviderTurnId), | |
| 133 | itemId: Schema.optional(ProviderItemId), | |
| 134 | toolKind: ProviderRuntimeToolKind, | |
| 135 | title: TrimmedNonEmptyStringSchema, | |
| 136 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 137 | }); | |
| 138 | export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; | |
| 139 | ||
| 140 | export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ | |
| 141 | type: Schema.Literal("approval.requested"), | |
| 142 | eventId: EventId, | |
| 143 | provider: ProviderKind, | |
| 144 | sessionId: ProviderSessionId, | |
| 145 | createdAt: IsoDateTime, | |
| 146 | threadId: Schema.optional(ProviderThreadId), | |
| 147 | turnId: Schema.optional(ProviderTurnId), | |
| 148 | itemId: Schema.optional(ProviderItemId), | |
| 149 | requestId: ApprovalRequestId, | |
| 150 | requestKind: ProviderRequestKind, | |
| 151 | detail: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 152 | }); | |
| 153 | export type ProviderRuntimeApprovalRequestedEvent = | |
| 154 | typeof ProviderRuntimeApprovalRequestedEvent.Type; | |
| 155 | ||
| 156 | export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({ | |
| 157 | type: Schema.Literal("approval.resolved"), | |
| 158 | eventId: EventId, | |
| 159 | provider: ProviderKind, | |
| 160 | sessionId: ProviderSessionId, | |
| 161 | createdAt: IsoDateTime, | |
| 162 | threadId: Schema.optional(ProviderThreadId), | |
| 163 | turnId: Schema.optional(ProviderTurnId), | |
| 164 | itemId: Schema.optional(ProviderItemId), | |
| 165 | requestId: ApprovalRequestId, | |
| 166 | requestKind: Schema.optional(ProviderRequestKind), | |
| 167 | decision: Schema.optional(ProviderApprovalDecision), | |
| 168 | }); | |
| 169 | export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type; | |
| 170 | ||
| 171 | export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({ | |
| 172 | type: Schema.Literal("checkpoint.captured"), | |
| 173 | eventId: EventId, | |
| 174 | provider: ProviderKind, | |
| 175 | sessionId: ProviderSessionId, | |
| 176 | createdAt: IsoDateTime, | |
| 177 | threadId: ProviderThreadId, | |
| 178 | turnId: Schema.optional(ProviderTurnId), | |
| 179 | turnCount: NonNegativeInt, | |
| 180 | status: Schema.optional(ProviderRuntimeTurnStatus), | |
| 181 | }); | |
| 182 | export type ProviderRuntimeCheckpointCapturedEvent = | |
| 183 | typeof ProviderRuntimeCheckpointCapturedEvent.Type; | |
| 184 | ||
| 185 | export const ProviderRuntimeErrorEvent = Schema.Struct({ | |
| 186 | type: Schema.Literal("runtime.error"), | |
| 187 | eventId: EventId, | |
| 188 | provider: ProviderKind, | |
| 189 | sessionId: ProviderSessionId, | |
| 190 | createdAt: IsoDateTime, | |
| 191 | threadId: Schema.optional(ProviderThreadId), | |
| 192 | turnId: Schema.optional(ProviderTurnId), | |
| 193 | itemId: Schema.optional(ProviderItemId), | |
| 194 | message: TrimmedNonEmptyStringSchema, | |
| 195 | }); | |
| 196 | export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; | |
| 197 | ||
| 198 | export const ProviderRuntimeEvent = Schema.Union([ | |
| 199 | ProviderRuntimeSessionStartedEvent, | |
| 200 | ProviderRuntimeSessionExitedEvent, | |
| 201 | ProviderRuntimeThreadStartedEvent, | |
| 202 | ProviderRuntimeTurnStartedEvent, | |
| 203 | ProviderRuntimeTurnCompletedEvent, | |
| 204 | ProviderRuntimeMessageDeltaEvent, | |
| 205 | ProviderRuntimeMessageCompletedEvent, | |
| 206 | ProviderRuntimeToolStartedEvent, | |
| 207 | ProviderRuntimeToolCompletedEvent, | |
| 208 | ProviderRuntimeApprovalRequestedEvent, | |
| 209 | ProviderRuntimeApprovalResolvedEvent, | |
| 210 | ProviderRuntimeCheckpointCapturedEvent, | |
| 211 | ProviderRuntimeErrorEvent, | |
| 212 | ]); | |
| 213 | export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type; |
packages/contracts/src/server.ts+41-11| ... | ... | @@ -1,17 +1,47 @@ |
| 1 | import { z } from "zod"; | |
| 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | |
| 3 | 4 | |
| 4 | export const serverConfigSchema = z.object({ | |
| 5 | cwd: z.string().min(1), | |
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 5 | export const KeybindingsMalformedConfigIssue = Schema.Struct({ | |
| 6 | kind: Schema.Literal("keybindings.malformed-config"), | |
| 7 | message: TrimmedNonEmptyString, | |
| 7 | 8 | }); |
| 9 | export type KeybindingsMalformedConfigIssue = typeof KeybindingsMalformedConfigIssue.Type; | |
| 8 | 10 | |
| 9 | export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; | |
| 11 | export const KeybindingsInvalidEntryIssue = Schema.Struct({ | |
| 12 | kind: Schema.Literal("keybindings.invalid-entry"), | |
| 13 | message: TrimmedNonEmptyString, | |
| 14 | index: Schema.Number, | |
| 15 | }); | |
| 16 | export type KeybindingsInvalidEntryIssue = typeof KeybindingsInvalidEntryIssue.Type; | |
| 17 | ||
| 18 | export const ServerConfigIssue = Schema.Union([ | |
| 19 | KeybindingsMalformedConfigIssue, | |
| 20 | KeybindingsInvalidEntryIssue, | |
| 21 | ]); | |
| 22 | export type ServerConfigIssue = typeof ServerConfigIssue.Type; | |
| 10 | 23 | |
| 11 | export const serverUpsertKeybindingResultSchema = z.object({ | |
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 24 | export const ServerConfigIssues = Schema.Array(ServerConfigIssue); | |
| 25 | export type ServerConfigIssues = typeof ServerConfigIssues.Type; | |
| 26 | ||
| 27 | export const ServerConfig = Schema.Struct({ | |
| 28 | cwd: TrimmedNonEmptyString, | |
| 29 | keybindingsConfigPath: TrimmedNonEmptyString, | |
| 30 | keybindings: ResolvedKeybindingsConfig, | |
| 31 | issues: ServerConfigIssues, | |
| 13 | 32 | }); |
| 33 | export type ServerConfig = typeof ServerConfig.Type; | |
| 34 | ||
| 35 | export const ServerUpsertKeybindingInput = KeybindingRule; | |
| 36 | export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; | |
| 14 | 37 | |
| 15 | export type ServerConfig = z.infer<typeof serverConfigSchema>; | |
| 16 | export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>; | |
| 17 | export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>; | |
| 38 | export const ServerUpsertKeybindingResult = Schema.Struct({ | |
| 39 | keybindings: ResolvedKeybindingsConfig, | |
| 40 | issues: ServerConfigIssues, | |
| 41 | }); | |
| 42 | export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; | |
| 43 | ||
| 44 | export const ServerConfigUpdatedPayload = Schema.Struct({ | |
| 45 | issues: ServerConfigIssues, | |
| 46 | }); | |
| 47 | export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; |
packages/contracts/src/terminal.test.ts+127-102| ... | ... | @@ -1,40 +1,56 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | DEFAULT_TERMINAL_ID, |
| 5 | terminalClearInputSchema, | |
| 6 | terminalCloseInputSchema, | |
| 7 | terminalEventSchema, | |
| 8 | terminalOpenInputSchema, | |
| 9 | terminalResizeInputSchema, | |
| 10 | terminalSessionSnapshotSchema, | |
| 11 | terminalThreadInputSchema, | |
| 12 | terminalWriteInputSchema, | |
| 6 | TerminalClearInput, | |
| 7 | TerminalCloseInput, | |
| 8 | TerminalEvent, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalSessionSnapshot, | |
| 12 | TerminalThreadInput, | |
| 13 | TerminalWriteInput, | |
| 13 | 14 | } from "./terminal"; |
| 14 | 15 | |
| 15 | describe("terminalOpenInputSchema", () => { | |
| 16 | function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> { | |
| 17 | return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>; | |
| 18 | } | |
| 19 | ||
| 20 | function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean { | |
| 21 | try { | |
| 22 | Schema.decodeUnknownSync(schema as never)(input); | |
| 23 | return true; | |
| 24 | } catch { | |
| 25 | return false; | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | describe("TerminalOpenInput", () => { | |
| 16 | 30 | it("accepts valid open input", () => { |
| 17 | const result = terminalOpenInputSchema.safeParse({ | |
| 18 | threadId: "thread-1", | |
| 19 | cwd: "/tmp/project", | |
| 20 | cols: 120, | |
| 21 | rows: 40, | |
| 22 | }); | |
| 23 | expect(result.success).toBe(true); | |
| 31 | expect( | |
| 32 | decodes(TerminalOpenInput, { | |
| 33 | threadId: "thread-1", | |
| 34 | cwd: "/tmp/project", | |
| 35 | cols: 120, | |
| 36 | rows: 40, | |
| 37 | }), | |
| 38 | ).toBe(true); | |
| 24 | 39 | }); |
| 25 | 40 | |
| 26 | 41 | it("rejects invalid bounds", () => { |
| 27 | const result = terminalOpenInputSchema.safeParse({ | |
| 28 | threadId: "thread-1", | |
| 29 | cwd: "/tmp/project", | |
| 30 | cols: 10, | |
| 31 | rows: 2, | |
| 32 | }); | |
| 33 | expect(result.success).toBe(false); | |
| 42 | expect( | |
| 43 | decodes(TerminalOpenInput, { | |
| 44 | threadId: "thread-1", | |
| 45 | cwd: "/tmp/project", | |
| 46 | cols: 10, | |
| 47 | rows: 2, | |
| 48 | }), | |
| 49 | ).toBe(false); | |
| 34 | 50 | }); |
| 35 | 51 | |
| 36 | 52 | it("defaults terminalId when missing", () => { |
| 37 | const parsed = terminalOpenInputSchema.parse({ | |
| 53 | const parsed = decodeSync(TerminalOpenInput, { | |
| 38 | 54 | threadId: "thread-1", |
| 39 | 55 | cwd: "/tmp/project", |
| 40 | 56 | cols: 100, |
| ... | ... | @@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => { |
| 44 | 60 | }); |
| 45 | 61 | |
| 46 | 62 | it("accepts optional env overrides", () => { |
| 47 | const parsed = terminalOpenInputSchema.parse({ | |
| 63 | const parsed = decodeSync(TerminalOpenInput, { | |
| 48 | 64 | threadId: "thread-1", |
| 49 | 65 | cwd: "/tmp/project", |
| 50 | 66 | cols: 100, |
| ... | ... | @@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => { |
| 61 | 77 | }); |
| 62 | 78 | |
| 63 | 79 | it("rejects invalid env keys", () => { |
| 64 | const result = terminalOpenInputSchema.safeParse({ | |
| 65 | threadId: "thread-1", | |
| 66 | cwd: "/tmp/project", | |
| 67 | cols: 100, | |
| 68 | rows: 24, | |
| 69 | env: { | |
| 70 | "bad-key": "1", | |
| 71 | }, | |
| 72 | }); | |
| 73 | expect(result.success).toBe(false); | |
| 80 | expect( | |
| 81 | decodes(TerminalOpenInput, { | |
| 82 | threadId: "thread-1", | |
| 83 | cwd: "/tmp/project", | |
| 84 | cols: 100, | |
| 85 | rows: 24, | |
| 86 | env: { | |
| 87 | "bad-key": "1", | |
| 88 | }, | |
| 89 | }), | |
| 90 | ).toBe(false); | |
| 74 | 91 | }); |
| 75 | 92 | }); |
| 76 | 93 | |
| 77 | describe("terminalWriteInputSchema", () => { | |
| 94 | describe("TerminalWriteInput", () => { | |
| 78 | 95 | it("accepts non-empty data", () => { |
| 79 | const result = terminalWriteInputSchema.safeParse({ | |
| 80 | threadId: "thread-1", | |
| 81 | data: "echo hello\n", | |
| 82 | }); | |
| 83 | expect(result.success).toBe(true); | |
| 96 | expect( | |
| 97 | decodes(TerminalWriteInput, { | |
| 98 | threadId: "thread-1", | |
| 99 | data: "echo hello\n", | |
| 100 | }), | |
| 101 | ).toBe(true); | |
| 84 | 102 | }); |
| 85 | 103 | |
| 86 | 104 | it("rejects empty data", () => { |
| 87 | const result = terminalWriteInputSchema.safeParse({ | |
| 88 | threadId: "thread-1", | |
| 89 | data: "", | |
| 90 | }); | |
| 91 | expect(result.success).toBe(false); | |
| 105 | expect( | |
| 106 | decodes(TerminalWriteInput, { | |
| 107 | threadId: "thread-1", | |
| 108 | data: "", | |
| 109 | }), | |
| 110 | ).toBe(false); | |
| 92 | 111 | }); |
| 93 | 112 | }); |
| 94 | 113 | |
| 95 | describe("terminalThreadInputSchema", () => { | |
| 114 | describe("TerminalThreadInput", () => { | |
| 96 | 115 | it("trims thread ids", () => { |
| 97 | const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " }); | |
| 116 | const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " }); | |
| 98 | 117 | expect(parsed.threadId).toBe("thread-1"); |
| 99 | 118 | }); |
| 100 | 119 | }); |
| 101 | 120 | |
| 102 | describe("terminalResizeInputSchema", () => { | |
| 121 | describe("TerminalResizeInput", () => { | |
| 103 | 122 | it("accepts valid size", () => { |
| 104 | const result = terminalResizeInputSchema.safeParse({ | |
| 105 | threadId: "thread-1", | |
| 106 | cols: 80, | |
| 107 | rows: 24, | |
| 108 | }); | |
| 109 | expect(result.success).toBe(true); | |
| 123 | expect( | |
| 124 | decodes(TerminalResizeInput, { | |
| 125 | threadId: "thread-1", | |
| 126 | cols: 80, | |
| 127 | rows: 24, | |
| 128 | }), | |
| 129 | ).toBe(true); | |
| 110 | 130 | }); |
| 111 | 131 | }); |
| 112 | 132 | |
| 113 | describe("terminalClearInputSchema", () => { | |
| 133 | describe("TerminalClearInput", () => { | |
| 114 | 134 | it("defaults terminal id", () => { |
| 115 | const parsed = terminalClearInputSchema.parse({ | |
| 135 | const parsed = decodeSync(TerminalClearInput, { | |
| 116 | 136 | threadId: "thread-1", |
| 117 | 137 | }); |
| 118 | 138 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 119 | 139 | }); |
| 120 | 140 | }); |
| 121 | 141 | |
| 122 | describe("terminalCloseInputSchema", () => { | |
| 142 | describe("TerminalCloseInput", () => { | |
| 123 | 143 | it("accepts optional deleteHistory", () => { |
| 124 | const result = terminalCloseInputSchema.safeParse({ | |
| 125 | threadId: "thread-1", | |
| 126 | deleteHistory: true, | |
| 127 | }); | |
| 128 | expect(result.success).toBe(true); | |
| 144 | expect( | |
| 145 | decodes(TerminalCloseInput, { | |
| 146 | threadId: "thread-1", | |
| 147 | deleteHistory: true, | |
| 148 | }), | |
| 149 | ).toBe(true); | |
| 129 | 150 | }); |
| 130 | 151 | }); |
| 131 | 152 | |
| 132 | describe("terminalSessionSnapshotSchema", () => { | |
| 153 | describe("TerminalSessionSnapshot", () => { | |
| 133 | 154 | it("accepts running snapshots", () => { |
| 134 | const result = terminalSessionSnapshotSchema.safeParse({ | |
| 135 | threadId: "thread-1", | |
| 136 | terminalId: DEFAULT_TERMINAL_ID, | |
| 137 | cwd: "/tmp/project", | |
| 138 | status: "running", | |
| 139 | pid: 1234, | |
| 140 | history: "hello\n", | |
| 141 | exitCode: null, | |
| 142 | exitSignal: null, | |
| 143 | updatedAt: new Date().toISOString(), | |
| 144 | }); | |
| 145 | expect(result.success).toBe(true); | |
| 155 | expect( | |
| 156 | decodes(TerminalSessionSnapshot, { | |
| 157 | threadId: "thread-1", | |
| 158 | terminalId: DEFAULT_TERMINAL_ID, | |
| 159 | cwd: "/tmp/project", | |
| 160 | status: "running", | |
| 161 | pid: 1234, | |
| 162 | history: "hello\n", | |
| 163 | exitCode: null, | |
| 164 | exitSignal: null, | |
| 165 | updatedAt: new Date().toISOString(), | |
| 166 | }), | |
| 167 | ).toBe(true); | |
| 146 | 168 | }); |
| 147 | 169 | }); |
| 148 | 170 | |
| 149 | describe("terminalEventSchema", () => { | |
| 171 | describe("TerminalEvent", () => { | |
| 150 | 172 | it("accepts output events", () => { |
| 151 | const result = terminalEventSchema.safeParse({ | |
| 152 | type: "output", | |
| 153 | threadId: "thread-1", | |
| 154 | terminalId: DEFAULT_TERMINAL_ID, | |
| 155 | createdAt: new Date().toISOString(), | |
| 156 | data: "line\n", | |
| 157 | }); | |
| 158 | expect(result.success).toBe(true); | |
| 173 | expect( | |
| 174 | decodes(TerminalEvent, { | |
| 175 | type: "output", | |
| 176 | threadId: "thread-1", | |
| 177 | terminalId: DEFAULT_TERMINAL_ID, | |
| 178 | createdAt: new Date().toISOString(), | |
| 179 | data: "line\n", | |
| 180 | }), | |
| 181 | ).toBe(true); | |
| 159 | 182 | }); |
| 160 | 183 | |
| 161 | 184 | it("accepts exited events", () => { |
| 162 | const result = terminalEventSchema.safeParse({ | |
| 163 | type: "exited", | |
| 164 | threadId: "thread-1", | |
| 165 | terminalId: DEFAULT_TERMINAL_ID, | |
| 166 | createdAt: new Date().toISOString(), | |
| 167 | exitCode: 0, | |
| 168 | exitSignal: null, | |
| 169 | }); | |
| 170 | expect(result.success).toBe(true); | |
| 185 | expect( | |
| 186 | decodes(TerminalEvent, { | |
| 187 | type: "exited", | |
| 188 | threadId: "thread-1", | |
| 189 | terminalId: DEFAULT_TERMINAL_ID, | |
| 190 | createdAt: new Date().toISOString(), | |
| 191 | exitCode: 0, | |
| 192 | exitSignal: null, | |
| 193 | }), | |
| 194 | ).toBe(true); | |
| 171 | 195 | }); |
| 172 | 196 | |
| 173 | 197 | it("accepts activity events", () => { |
| 174 | const result = terminalEventSchema.safeParse({ | |
| 175 | type: "activity", | |
| 176 | threadId: "thread-1", | |
| 177 | terminalId: DEFAULT_TERMINAL_ID, | |
| 178 | createdAt: new Date().toISOString(), | |
| 179 | hasRunningSubprocess: true, | |
| 180 | }); | |
| 181 | expect(result.success).toBe(true); | |
| 198 | expect( | |
| 199 | decodes(TerminalEvent, { | |
| 200 | type: "activity", | |
| 201 | threadId: "thread-1", | |
| 202 | terminalId: DEFAULT_TERMINAL_ID, | |
| 203 | createdAt: new Date().toISOString(), | |
| 204 | hasRunningSubprocess: true, | |
| 205 | }), | |
| 206 | ).toBe(true); | |
| 182 | 207 | }); |
| 183 | 208 | }); |
packages/contracts/src/terminal.ts+116-85| ... | ... | @@ -1,119 +1,150 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 2 | 3 | |
| 3 | 4 | export const DEFAULT_TERMINAL_ID = "default"; |
| 4 | 5 | |
| 5 | const terminalColsSchema = z.number().int().min(20).max(400); | |
| 6 | const terminalRowsSchema = z.number().int().min(5).max(200); | |
| 7 | const terminalIdSchema = z.string().trim().min(1).max(128); | |
| 8 | const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); | |
| 9 | const terminalEnvValueSchema = z.string().max(8_192); | |
| 10 | const terminalEnvSchema = z | |
| 11 | .record(terminalEnvKeySchema, terminalEnvValueSchema) | |
| 12 | .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); | |
| 13 | ||
| 14 | export const terminalThreadInputSchema = z.object({ | |
| 15 | threadId: z.string().trim().min(1), | |
| 6 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | |
| 7 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( | |
| 8 | Schema.isLessThanOrEqualTo(400), | |
| 9 | ); | |
| 10 | const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( | |
| 11 | Schema.isLessThanOrEqualTo(200), | |
| 12 | ); | |
| 13 | const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); | |
| 14 | const TerminalEnvKeySchema = Schema.String.check( | |
| 15 | Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/), | |
| 16 | ).check(Schema.isMaxLength(128)); | |
| 17 | const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192)); | |
| 18 | const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check( | |
| 19 | Schema.isMaxProperties(128), | |
| 20 | ); | |
| 21 | ||
| 22 | const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( | |
| 23 | Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID), | |
| 24 | ); | |
| 25 | ||
| 26 | export const TerminalThreadInput = Schema.Struct({ | |
| 27 | threadId: TrimmedNonEmptyStringSchema, | |
| 16 | 28 | }); |
| 29 | export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>; | |
| 17 | 30 | |
| 18 | export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ | |
| 19 | terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), | |
| 31 | export const TerminalSessionInput = Schema.Struct({ | |
| 32 | ...TerminalThreadInput.fields, | |
| 33 | terminalId: TerminalIdWithDefaultSchema, | |
| 20 | 34 | }); |
| 21 | ||
| 22 | export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ | |
| 23 | cwd: z.string().trim().min(1), | |
| 24 | cols: terminalColsSchema.optional(), | |
| 25 | rows: terminalRowsSchema.optional(), | |
| 26 | env: terminalEnvSchema.optional(), | |
| 35 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; | |
| 36 | ||
| 37 | export const TerminalOpenInput = Schema.Struct({ | |
| 38 | ...TerminalSessionInput.fields, | |
| 39 | cwd: TrimmedNonEmptyStringSchema, | |
| 40 | cols: Schema.optional(TerminalColsSchema), | |
| 41 | rows: Schema.optional(TerminalRowsSchema), | |
| 42 | env: Schema.optional(TerminalEnvSchema), | |
| 27 | 43 | }); |
| 44 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; | |
| 28 | 45 | |
| 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ | |
| 30 | data: z.string().min(1).max(65_536), | |
| 46 | export const TerminalWriteInput = Schema.Struct({ | |
| 47 | ...TerminalSessionInput.fields, | |
| 48 | data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), | |
| 31 | 49 | }); |
| 50 | export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>; | |
| 32 | 51 | |
| 33 | export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ | |
| 34 | cols: terminalColsSchema, | |
| 35 | rows: terminalRowsSchema, | |
| 52 | export const TerminalResizeInput = Schema.Struct({ | |
| 53 | ...TerminalSessionInput.fields, | |
| 54 | cols: TerminalColsSchema, | |
| 55 | rows: TerminalRowsSchema, | |
| 36 | 56 | }); |
| 57 | export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>; | |
| 37 | 58 | |
| 38 | export const terminalClearInputSchema = terminalSessionInputSchema; | |
| 59 | export const TerminalClearInput = TerminalSessionInput; | |
| 60 | export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>; | |
| 39 | 61 | |
| 40 | export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ | |
| 41 | terminalId: terminalIdSchema.optional(), | |
| 42 | deleteHistory: z.boolean().optional(), | |
| 62 | export const TerminalRestartInput = Schema.Struct({ | |
| 63 | ...TerminalThreadInput.fields, | |
| 64 | cwd: TrimmedNonEmptyStringSchema, | |
| 65 | cols: TerminalColsSchema, | |
| 66 | rows: TerminalRowsSchema, | |
| 67 | env: Schema.optional(TerminalEnvSchema), | |
| 43 | 68 | }); |
| 44 | 69 | |
| 45 | export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]); | |
| 46 | ||
| 47 | export const terminalSessionSnapshotSchema = z.object({ | |
| 48 | threadId: z.string().min(1), | |
| 49 | terminalId: z.string().min(1), | |
| 50 | cwd: z.string().min(1), | |
| 51 | status: terminalSessionStatusSchema, | |
| 52 | pid: z.number().int().positive().nullable(), | |
| 53 | history: z.string(), | |
| 54 | exitCode: z.number().int().nullable(), | |
| 55 | exitSignal: z.number().int().nullable(), | |
| 56 | updatedAt: z.string().datetime(), | |
| 70 | export const TerminalCloseInput = Schema.Struct({ | |
| 71 | ...TerminalThreadInput.fields, | |
| 72 | terminalId: Schema.optional(TerminalIdSchema), | |
| 73 | deleteHistory: Schema.optional(Schema.Boolean), | |
| 57 | 74 | }); |
| 75 | export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>; | |
| 76 | ||
| 77 | export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); | |
| 78 | export type TerminalSessionStatus = typeof TerminalSessionStatus.Type; | |
| 79 | ||
| 80 | export const TerminalSessionSnapshot = Schema.Struct({ | |
| 81 | threadId: Schema.String.check(Schema.isNonEmpty()), | |
| 82 | terminalId: Schema.String.check(Schema.isNonEmpty()), | |
| 83 | cwd: Schema.String.check(Schema.isNonEmpty()), | |
| 84 | status: TerminalSessionStatus, | |
| 85 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), | |
| 86 | history: Schema.String, | |
| 87 | exitCode: Schema.NullOr(Schema.Int), | |
| 88 | exitSignal: Schema.NullOr(Schema.Int), | |
| 89 | updatedAt: Schema.String, | |
| 90 | }); | |
| 91 | export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; | |
| 58 | 92 | |
| 59 | const terminalEventBaseSchema = z.object({ | |
| 60 | threadId: z.string().min(1), | |
| 61 | terminalId: z.string().min(1), | |
| 62 | createdAt: z.string().datetime(), | |
| 93 | const TerminalEventBaseSchema = Schema.Struct({ | |
| 94 | threadId: Schema.String.check(Schema.isNonEmpty()), | |
| 95 | terminalId: Schema.String.check(Schema.isNonEmpty()), | |
| 96 | createdAt: Schema.String, | |
| 63 | 97 | }); |
| 64 | 98 | |
| 65 | export const terminalStartedEventSchema = terminalEventBaseSchema.extend({ | |
| 66 | type: z.literal("started"), | |
| 67 | snapshot: terminalSessionSnapshotSchema, | |
| 99 | export const TerminalStartedEvent = Schema.Struct({ | |
| 100 | ...TerminalEventBaseSchema.fields, | |
| 101 | type: Schema.Literal("started"), | |
| 102 | snapshot: TerminalSessionSnapshot, | |
| 68 | 103 | }); |
| 69 | 104 | |
| 70 | export const terminalOutputEventSchema = terminalEventBaseSchema.extend({ | |
| 71 | type: z.literal("output"), | |
| 72 | data: z.string(), | |
| 105 | export const TerminalOutputEvent = Schema.Struct({ | |
| 106 | ...TerminalEventBaseSchema.fields, | |
| 107 | type: Schema.Literal("output"), | |
| 108 | data: Schema.String, | |
| 73 | 109 | }); |
| 74 | 110 | |
| 75 | export const terminalExitedEventSchema = terminalEventBaseSchema.extend({ | |
| 76 | type: z.literal("exited"), | |
| 77 | exitCode: z.number().int().nullable(), | |
| 78 | exitSignal: z.number().int().nullable(), | |
| 111 | export const TerminalExitedEvent = Schema.Struct({ | |
| 112 | ...TerminalEventBaseSchema.fields, | |
| 113 | type: Schema.Literal("exited"), | |
| 114 | exitCode: Schema.NullOr(Schema.Int), | |
| 115 | exitSignal: Schema.NullOr(Schema.Int), | |
| 79 | 116 | }); |
| 80 | 117 | |
| 81 | export const terminalErrorEventSchema = terminalEventBaseSchema.extend({ | |
| 82 | type: z.literal("error"), | |
| 83 | message: z.string().min(1), | |
| 118 | export const TerminalErrorEvent = Schema.Struct({ | |
| 119 | ...TerminalEventBaseSchema.fields, | |
| 120 | type: Schema.Literal("error"), | |
| 121 | message: Schema.String.check(Schema.isNonEmpty()), | |
| 84 | 122 | }); |
| 85 | 123 | |
| 86 | export const terminalClearedEventSchema = terminalEventBaseSchema.extend({ | |
| 87 | type: z.literal("cleared"), | |
| 124 | export const TerminalClearedEvent = Schema.Struct({ | |
| 125 | ...TerminalEventBaseSchema.fields, | |
| 126 | type: Schema.Literal("cleared"), | |
| 88 | 127 | }); |
| 89 | 128 | |
| 90 | export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({ | |
| 91 | type: z.literal("restarted"), | |
| 92 | snapshot: terminalSessionSnapshotSchema, | |
| 129 | export const TerminalRestartedEvent = Schema.Struct({ | |
| 130 | ...TerminalEventBaseSchema.fields, | |
| 131 | type: Schema.Literal("restarted"), | |
| 132 | snapshot: TerminalSessionSnapshot, | |
| 93 | 133 | }); |
| 94 | 134 | |
| 95 | export const terminalActivityEventSchema = terminalEventBaseSchema.extend({ | |
| 96 | type: z.literal("activity"), | |
| 97 | hasRunningSubprocess: z.boolean(), | |
| 135 | export const TerminalActivityEvent = Schema.Struct({ | |
| 136 | ...TerminalEventBaseSchema.fields, | |
| 137 | type: Schema.Literal("activity"), | |
| 138 | hasRunningSubprocess: Schema.Boolean, | |
| 98 | 139 | }); |
| 99 | 140 | |
| 100 | export const terminalEventSchema = z.discriminatedUnion("type", [ | |
| 101 | terminalStartedEventSchema, | |
| 102 | terminalOutputEventSchema, | |
| 103 | terminalExitedEventSchema, | |
| 104 | terminalErrorEventSchema, | |
| 105 | terminalClearedEventSchema, | |
| 106 | terminalRestartedEventSchema, | |
| 107 | terminalActivityEventSchema, | |
| 141 | export const TerminalEvent = Schema.Union([ | |
| 142 | TerminalStartedEvent, | |
| 143 | TerminalOutputEvent, | |
| 144 | TerminalExitedEvent, | |
| 145 | TerminalErrorEvent, | |
| 146 | TerminalClearedEvent, | |
| 147 | TerminalRestartedEvent, | |
| 148 | TerminalActivityEvent, | |
| 108 | 149 | ]); |
| 109 | ||
| 110 | export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>; | |
| 111 | export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>; | |
| 112 | export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>; | |
| 113 | export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>; | |
| 114 | export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>; | |
| 115 | export type TerminalClearInput = z.input<typeof terminalClearInputSchema>; | |
| 116 | export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>; | |
| 117 | export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>; | |
| 118 | export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>; | |
| 119 | export type TerminalEvent = z.infer<typeof terminalEventSchema>; | |
| 150 | export type TerminalEvent = typeof TerminalEvent.Type; |
packages/contracts/src/todo.test.ts deleted-52| ... | ... | @@ -1,52 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { newTodoInputSchema, todoSchema } from "./todo"; | |
| 4 | ||
| 5 | describe("todoSchema", () => { | |
| 6 | it("accepts a valid todo", () => { | |
| 7 | const result = todoSchema.safeParse({ | |
| 8 | id: "abc-123", | |
| 9 | title: "Buy milk", | |
| 10 | completed: false, | |
| 11 | createdAt: "2025-01-01T00:00:00.000Z", | |
| 12 | }); | |
| 13 | expect(result.success).toBe(true); | |
| 14 | }); | |
| 15 | ||
| 16 | it("rejects a todo with empty id", () => { | |
| 17 | const result = todoSchema.safeParse({ | |
| 18 | id: "", | |
| 19 | title: "Buy milk", | |
| 20 | completed: false, | |
| 21 | createdAt: "2025-01-01T00:00:00.000Z", | |
| 22 | }); | |
| 23 | expect(result.success).toBe(false); | |
| 24 | }); | |
| 25 | ||
| 26 | it("rejects a todo with empty title", () => { | |
| 27 | const result = todoSchema.safeParse({ | |
| 28 | id: "abc-123", | |
| 29 | title: "", | |
| 30 | completed: false, | |
| 31 | createdAt: "2025-01-01T00:00:00.000Z", | |
| 32 | }); | |
| 33 | expect(result.success).toBe(false); | |
| 34 | }); | |
| 35 | }); | |
| 36 | ||
| 37 | describe("newTodoInputSchema", () => { | |
| 38 | it("accepts valid input", () => { | |
| 39 | const result = newTodoInputSchema.safeParse({ title: "Buy milk" }); | |
| 40 | expect(result.success).toBe(true); | |
| 41 | }); | |
| 42 | ||
| 43 | it("trims whitespace", () => { | |
| 44 | const result = newTodoInputSchema.parse({ title: " Buy milk " }); | |
| 45 | expect(result.title).toBe("Buy milk"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("rejects empty title after trimming", () => { | |
| 49 | const result = newTodoInputSchema.safeParse({ title: " " }); | |
| 50 | expect(result.success).toBe(false); | |
| 51 | }); | |
| 52 | }); |
packages/contracts/src/todo.ts deleted-19| ... | ... | @@ -1,19 +0,0 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const todoSchema = z.object({ | |
| 4 | id: z.string().min(1), | |
| 5 | title: z.string().min(1).max(280), | |
| 6 | completed: z.boolean(), | |
| 7 | createdAt: z.string().datetime(), | |
| 8 | }); | |
| 9 | ||
| 10 | export const todoListSchema = z.array(todoSchema); | |
| 11 | ||
| 12 | export const newTodoInputSchema = z.object({ | |
| 13 | title: z.string().trim().min(1).max(280), | |
| 14 | }); | |
| 15 | ||
| 16 | export const todoIdSchema = z.string().min(1); | |
| 17 | ||
| 18 | export type Todo = z.infer<typeof todoSchema>; | |
| 19 | export type NewTodoInput = z.infer<typeof newTodoInputSchema>; |
packages/contracts/src/ws.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Schema } from "effect"; | |
| 3 | ||
| 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; | |
| 5 | import { WebSocketRequest } from "./ws"; | |
| 6 | ||
| 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | |
| 8 | ||
| 9 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => | |
| 10 | Effect.gen(function* () { | |
| 11 | const parsed = yield* decodeWebSocketRequest({ | |
| 12 | id: "req-1", | |
| 13 | body: { | |
| 14 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 15 | threadId: "thread-1", | |
| 16 | fromTurnCount: 1, | |
| 17 | toTurnCount: 2, | |
| 18 | }, | |
| 19 | }); | |
| 20 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | |
| 21 | }), | |
| 22 | ); | |
| 23 | ||
| 24 | it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () => | |
| 25 | Effect.gen(function* () { | |
| 26 | const result = yield* Effect.exit( | |
| 27 | decodeWebSocketRequest({ | |
| 28 | id: "req-1", | |
| 29 | body: { | |
| 30 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 31 | threadId: "thread-1", | |
| 32 | fromTurnCount: 3, | |
| 33 | toTurnCount: 2, | |
| 34 | }, | |
| 35 | }), | |
| 36 | ); | |
| 37 | assert.strictEqual(result._tag, "Failure"); | |
| 38 | }), | |
| 39 | ); | |
| 40 | ||
| 41 | it.effect("trims websocket request id and nested orchestration ids", () => | |
| 42 | Effect.gen(function* () { | |
| 43 | const parsed = yield* decodeWebSocketRequest({ | |
| 44 | id: " req-1 ", | |
| 45 | body: { | |
| 46 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 47 | threadId: " thread-1 ", | |
| 48 | fromTurnCount: 0, | |
| 49 | toTurnCount: 0, | |
| 50 | }, | |
| 51 | }); | |
| 52 | assert.strictEqual(parsed.id, "req-1"); | |
| 53 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | |
| 54 | if (parsed.body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) { | |
| 55 | assert.strictEqual(parsed.body.threadId, "thread-1"); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); |
packages/contracts/src/ws.ts+111-46| ... | ... | @@ -1,25 +1,45 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema, Struct } from "effect"; | |
| 2 | import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | ||
| 4 | import { | |
| 5 | ClientOrchestrationCommand, | |
| 6 | OrchestrationGetFullThreadDiffInput, | |
| 7 | ORCHESTRATION_WS_METHODS, | |
| 8 | OrchestrationGetSnapshotInput, | |
| 9 | OrchestrationGetTurnDiffInput, | |
| 10 | OrchestrationReplayEventsInput, | |
| 11 | } from "./orchestration"; | |
| 12 | import { | |
| 13 | GitCheckoutInput, | |
| 14 | GitCreateBranchInput, | |
| 15 | GitCreateWorktreeInput, | |
| 16 | GitInitInput, | |
| 17 | GitListBranchesInput, | |
| 18 | GitPullInput, | |
| 19 | GitRemoveWorktreeInput, | |
| 20 | GitRunStackedActionInput, | |
| 21 | GitStatusInput, | |
| 22 | } from "./git"; | |
| 23 | import { | |
| 24 | TerminalClearInput, | |
| 25 | TerminalCloseInput, | |
| 26 | TerminalOpenInput, | |
| 27 | TerminalResizeInput, | |
| 28 | TerminalRestartInput, | |
| 29 | TerminalWriteInput, | |
| 30 | } from "./terminal"; | |
| 31 | import { KeybindingRule } from "./keybindings"; | |
| 32 | import { ProjectSearchEntriesInput } from "./project"; | |
| 33 | import { OpenInEditorInput } from "./editor"; | |
| 2 | 34 | |
| 3 | 35 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 4 | 36 | |
| 5 | 37 | export const WS_METHODS = { |
| 6 | // Provider methods (mirrors NativeApi.providers) | |
| 7 | providersStartSession: "providers.startSession", | |
| 8 | providersSendTurn: "providers.sendTurn", | |
| 9 | providersInterruptTurn: "providers.interruptTurn", | |
| 10 | providersRespondToRequest: "providers.respondToRequest", | |
| 11 | providersStopSession: "providers.stopSession", | |
| 12 | providersListSessions: "providers.listSessions", | |
| 13 | providersListCheckpoints: "providers.listCheckpoints", | |
| 14 | providersGetCheckpointDiff: "providers.getCheckpointDiff", | |
| 15 | providersRevertToCheckpoint: "providers.revertToCheckpoint", | |
| 16 | ||
| 17 | 38 | // Project registry methods |
| 18 | 39 | projectsList: "projects.list", |
| 19 | 40 | projectsAdd: "projects.add", |
| 20 | 41 | projectsRemove: "projects.remove", |
| 21 | 42 | projectsSearchEntries: "projects.searchEntries", |
| 22 | projectsUpdateScripts: "projects.updateScripts", | |
| 23 | 43 | |
| 24 | 44 | // Shell methods |
| 25 | 45 | shellOpenInEditor: "shell.openInEditor", |
| ... | ... | @@ -51,54 +71,99 @@ export const WS_METHODS = { |
| 51 | 71 | // ── Push Event Channels ────────────────────────────────────────────── |
| 52 | 72 | |
| 53 | 73 | export const WS_CHANNELS = { |
| 54 | providerEvent: "providers.event", | |
| 55 | 74 | terminalEvent: "terminal.event", |
| 56 | 75 | serverWelcome: "server.welcome", |
| 76 | serverConfigUpdated: "server.configUpdated", | |
| 57 | 77 | } as const; |
| 58 | 78 | |
| 59 | // ── Client → Server (request) ──────────────────────────────────────── | |
| 60 | ||
| 61 | export const wsRequestSchema = z.object({ | |
| 62 | id: z.string().min(1), | |
| 63 | method: z.string().min(1), | |
| 64 | params: z.unknown().optional(), | |
| 65 | }); | |
| 66 | ||
| 67 | export type WsRequest = z.infer<typeof wsRequestSchema>; | |
| 79 | // -- Tagged Union of all request body schemas ───────────────────────── | |
| 80 | ||
| 81 | const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>( | |
| 82 | tag: Tag, | |
| 83 | schema: Schema.Struct<Fields>, | |
| 84 | ) => | |
| 85 | schema.mapFields( | |
| 86 | Struct.assign({ _tag: Schema.tag(tag) }), | |
| 87 | // PreserveChecks is safe here. No existing schema should have checks depending on the tag | |
| 88 | { unsafePreserveChecks: true }, | |
| 89 | ); | |
| 90 | ||
| 91 | export const WebSocketRequestBody = Schema.Union([ | |
| 92 | // Orchestration methods | |
| 93 | tagRequestBody( | |
| 94 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 95 | Schema.Struct({ command: ClientOrchestrationCommand }), | |
| 96 | ), | |
| 97 | tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput), | |
| 98 | tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput), | |
| 99 | tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput), | |
| 100 | tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput), | |
| 101 | ||
| 102 | // Project Search | |
| 103 | tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), | |
| 68 | 104 | |
| 69 | // ── Server → Client (response to request) ──────────────────────────── | |
| 105 | // Shell methods | |
| 106 | tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), | |
| 70 | 107 | |
| 71 | export const wsResponseSchema = z.object({ | |
| 72 | id: z.string().min(1), | |
| 73 | result: z.unknown().optional(), | |
| 74 | error: z | |
| 75 | .object({ | |
| 76 | message: z.string(), | |
| 77 | }) | |
| 78 | .optional(), | |
| 79 | }); | |
| 108 | // Git methods | |
| 109 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), | |
| 110 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), | |
| 111 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | |
| 112 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | |
| 113 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | |
| 114 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | |
| 115 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), | |
| 116 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), | |
| 117 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), | |
| 80 | 118 | |
| 81 | export type WsResponse = z.infer<typeof wsResponseSchema>; | |
| 119 | // Terminal methods | |
| 120 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), | |
| 121 | tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), | |
| 122 | tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput), | |
| 123 | tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput), | |
| 124 | tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), | |
| 125 | tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), | |
| 82 | 126 | |
| 83 | // ── Server → Client (push event) ───────────────────────────────────── | |
| 127 | // Server meta | |
| 128 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), | |
| 129 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), | |
| 130 | ]); | |
| 84 | 131 | |
| 85 | export const wsPushSchema = z.object({ | |
| 86 | type: z.literal("push"), | |
| 87 | channel: z.string().min(1), | |
| 88 | data: z.unknown(), | |
| 132 | export const WebSocketRequest = Schema.Struct({ | |
| 133 | id: TrimmedNonEmptyString, | |
| 134 | body: WebSocketRequestBody, | |
| 89 | 135 | }); |
| 136 | export type WebSocketRequest = typeof WebSocketRequest.Type; | |
| 137 | ||
| 138 | export const WebSocketResponse = Schema.Struct({ | |
| 139 | id: TrimmedNonEmptyString, | |
| 140 | result: Schema.optional(Schema.Unknown), | |
| 141 | error: Schema.optional( | |
| 142 | Schema.Struct({ | |
| 143 | message: Schema.String, | |
| 144 | }), | |
| 145 | ), | |
| 146 | }); | |
| 147 | export type WebSocketResponse = typeof WebSocketResponse.Type; | |
| 90 | 148 | |
| 91 | export type WsPush = z.infer<typeof wsPushSchema>; | |
| 149 | export const WsPush = Schema.Struct({ | |
| 150 | type: Schema.Literal("push"), | |
| 151 | channel: TrimmedNonEmptyString, | |
| 152 | data: Schema.Unknown, | |
| 153 | }); | |
| 154 | export type WsPush = typeof WsPush.Type; | |
| 92 | 155 | |
| 93 | 156 | // ── Union of all server → client messages ───────────────────────────── |
| 94 | 157 | |
| 95 | export type WsServerMessage = WsResponse | WsPush; | |
| 158 | export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); | |
| 159 | export type WsResponse = typeof WsResponse.Type; | |
| 96 | 160 | |
| 97 | 161 | // ── Server welcome payload ─────────────────────────────────────────── |
| 98 | 162 | |
| 99 | export const wsWelcomePayloadSchema = z.object({ | |
| 100 | cwd: z.string().min(1), | |
| 101 | projectName: z.string().min(1), | |
| 163 | export const WsWelcomePayload = Schema.Struct({ | |
| 164 | cwd: TrimmedNonEmptyString, | |
| 165 | projectName: TrimmedNonEmptyString, | |
| 166 | bootstrapProjectId: Schema.optional(ProjectId), | |
| 167 | bootstrapThreadId: Schema.optional(ThreadId), | |
| 102 | 168 | }); |
| 103 | ||
| 104 | export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>; | |
| 169 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |
packages/contracts/tsconfig.json+14| ... | ... | @@ -1,4 +1,18 @@ |
| 1 | 1 | { |
| 2 | 2 | "extends": "../../tsconfig.base.json", |
| 3 | "compilerOptions": { | |
| 4 | "plugins": [ | |
| 5 | { | |
| 6 | "name": "@effect/language-service", | |
| 7 | "namespaceImportPackages": ["@effect/platform-node"], | |
| 8 | "diagnosticSeverity": { | |
| 9 | "importFromBarrel": "error", | |
| 10 | "anyUnknownInErrorContext": "warning", | |
| 11 | "instanceOfSchema": "warning", | |
| 12 | "deterministicKeys": "warning" | |
| 13 | } | |
| 14 | } | |
| 15 | ] | |
| 16 | }, | |
| 3 | 17 | "include": ["src"] |
| 4 | 18 | } |
scripts/build-desktop-dmg.mjs+51-2| ... | ... | @@ -129,7 +129,14 @@ async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { |
| 129 | 129 | for (const size of iconSizes) { |
| 130 | 130 | await run( |
| 131 | 131 | "sips", |
| 132 | ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)], | |
| 132 | [ | |
| 133 | "-z", | |
| 134 | String(size), | |
| 135 | String(size), | |
| 136 | sourcePng, | |
| 137 | "--out", | |
| 138 | path.join(iconsetDir, `icon_${size}x${size}.png`), | |
| 139 | ], | |
| 133 | 140 | { stdio: "ignore" }, |
| 134 | 141 | ); |
| 135 | 142 | const retinaSize = size * 2; |
| ... | ... | @@ -199,6 +206,42 @@ async function validateBundledClientAssets(clientDir) { |
| 199 | 206 | } |
| 200 | 207 | } |
| 201 | 208 | |
| 209 | function readWorkspaceCatalog(rootPackageJson) { | |
| 210 | const workspaces = rootPackageJson?.workspaces; | |
| 211 | if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { | |
| 212 | return {}; | |
| 213 | } | |
| 214 | ||
| 215 | const catalog = workspaces.catalog; | |
| 216 | if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { | |
| 217 | return {}; | |
| 218 | } | |
| 219 | ||
| 220 | return catalog; | |
| 221 | } | |
| 222 | ||
| 223 | function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { | |
| 224 | return Object.fromEntries( | |
| 225 | Object.entries(dependencies).map(([dependencyName, spec]) => { | |
| 226 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | |
| 227 | return [dependencyName, spec]; | |
| 228 | } | |
| 229 | ||
| 230 | const catalogKey = spec.slice("catalog:".length).trim(); | |
| 231 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | |
| 232 | const resolvedSpec = catalog[lookupKey]; | |
| 233 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | |
| 234 | fail( | |
| 235 | `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + | |
| 236 | `Expected key '${lookupKey}' in root workspace catalog.`, | |
| 237 | ); | |
| 238 | } | |
| 239 | ||
| 240 | return [dependencyName, resolvedSpec]; | |
| 241 | }), | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 202 | 245 | async function main() { |
| 203 | 246 | const options = parseArgs(process.argv.slice(2)); |
| 204 | 247 | if (options.help) { |
| ... | ... | @@ -210,6 +253,7 @@ async function main() { |
| 210 | 253 | fail("This script only builds macOS .dmg artifacts and must run on macOS."); |
| 211 | 254 | } |
| 212 | 255 | |
| 256 | const rootPkg = await readJson(path.join(repoRoot, "package.json")); | |
| 213 | 257 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); |
| 214 | 258 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); |
| 215 | 259 | |
| ... | ... | @@ -222,6 +266,11 @@ async function main() { |
| 222 | 266 | if (!dependencies || Object.keys(dependencies).length === 0) { |
| 223 | 267 | fail("Could not resolve production dependencies from apps/server/package.json."); |
| 224 | 268 | } |
| 269 | const resolvedDependencies = resolveCatalogDependencies( | |
| 270 | dependencies, | |
| 271 | readWorkspaceCatalog(rootPkg), | |
| 272 | "apps/server", | |
| 273 | ); | |
| 225 | 274 | |
| 226 | 275 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; |
| 227 | 276 | const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); |
| ... | ... | @@ -284,7 +333,7 @@ async function main() { |
| 284 | 333 | category: "public.app-category.developer-tools", |
| 285 | 334 | }, |
| 286 | 335 | }, |
| 287 | dependencies, | |
| 336 | dependencies: resolvedDependencies, | |
| 288 | 337 | devDependencies: { |
| 289 | 338 | electron: electronVersion, |
| 290 | 339 | }, |
scripts/dev-runner.mjs+176-26| ... | ... | @@ -1,8 +1,12 @@ |
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | import { homedir } from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { pathToFileURL } from "node:url"; | |
| 2 | 5 | |
| 3 | 6 | const BASE_SERVER_PORT = 3773; |
| 4 | const BASE_WEB_PORT = 5173; | |
| 7 | const BASE_WEB_PORT = 5733; | |
| 5 | 8 | const MAX_HASH_OFFSET = 3000; |
| 9 | export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev"); | |
| 6 | 10 | const MODE_ARGS = { |
| 7 | 11 | dev: [ |
| 8 | 12 | "run", |
| ... | ... | @@ -17,6 +21,21 @@ const MODE_ARGS = { |
| 17 | 21 | "dev:web": ["run", "dev", "--filter=@t3tools/web"], |
| 18 | 22 | "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"], |
| 19 | 23 | }; |
| 24 | const FORWARDED_ENV_FLAGS = { | |
| 25 | "state-dir": { envName: "T3CODE_STATE_DIR", expectsValue: true }, | |
| 26 | "auth-token": { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, | |
| 27 | token: { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, | |
| 28 | "no-browser": { envName: "T3CODE_NO_BROWSER", expectsValue: false }, | |
| 29 | "auto-bootstrap-project-from-cwd": { | |
| 30 | envName: "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", | |
| 31 | expectsValue: false, | |
| 32 | }, | |
| 33 | "log-websocket-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, | |
| 34 | "log-ws-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, | |
| 35 | host: { envName: "T3CODE_HOST", expectsValue: true }, | |
| 36 | port: { envName: "T3CODE_PORT", expectsValue: true }, | |
| 37 | "dev-url": { envName: "VITE_DEV_SERVER_URL", expectsValue: true }, | |
| 38 | }; | |
| 20 | 39 | |
| 21 | 40 | function parseInteger(value, envName) { |
| 22 | 41 | const parsed = Number(value); |
| ... | ... | @@ -35,8 +54,8 @@ function hashSeed(seed) { |
| 35 | 54 | return hash >>> 0; |
| 36 | 55 | } |
| 37 | 56 | |
| 38 | function resolveOffset() { | |
| 39 | const explicitOffset = process.env.T3CODE_PORT_OFFSET?.trim(); | |
| 57 | export function resolveOffset(env = process.env) { | |
| 58 | const explicitOffset = env.T3CODE_PORT_OFFSET?.trim(); | |
| 40 | 59 | if (explicitOffset) { |
| 41 | 60 | const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET"); |
| 42 | 61 | if (parsed < 0) { |
| ... | ... | @@ -45,7 +64,7 @@ function resolveOffset() { |
| 45 | 64 | return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; |
| 46 | 65 | } |
| 47 | 66 | |
| 48 | const seed = process.env.T3CODE_DEV_INSTANCE?.trim(); | |
| 67 | const seed = env.T3CODE_DEV_INSTANCE?.trim(); | |
| 49 | 68 | if (!seed) { |
| 50 | 69 | return { offset: 0, source: "default ports" }; |
| 51 | 70 | } |
| ... | ... | @@ -58,9 +77,141 @@ function resolveOffset() { |
| 58 | 77 | return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; |
| 59 | 78 | } |
| 60 | 79 | |
| 61 | function main() { | |
| 62 | const mode = process.argv[2]; | |
| 63 | const isDryRun = process.argv.includes("--dry-run"); | |
| 80 | function parseLongFlag(raw) { | |
| 81 | if (!raw.startsWith("--")) return null; | |
| 82 | const normalized = raw.slice(2); | |
| 83 | const equalsIndex = normalized.indexOf("="); | |
| 84 | if (equalsIndex === -1) { | |
| 85 | return { name: normalized, value: undefined }; | |
| 86 | } | |
| 87 | return { | |
| 88 | name: normalized.slice(0, equalsIndex), | |
| 89 | value: normalized.slice(equalsIndex + 1), | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | function toBooleanEnvValue(value) { | |
| 94 | if (value === undefined) return "1"; | |
| 95 | const normalized = value.trim().toLowerCase(); | |
| 96 | if (normalized === "false" || normalized === "0" || normalized === "no") return "0"; | |
| 97 | return "1"; | |
| 98 | } | |
| 99 | ||
| 100 | export function parseDevRunnerArgs(rawArgs) { | |
| 101 | const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs.slice(); | |
| 102 | const envOverrides = {}; | |
| 103 | const turboArgs = []; | |
| 104 | let isDryRun = false; | |
| 105 | ||
| 106 | for (let index = 0; index < args.length; index += 1) { | |
| 107 | const arg = args[index]; | |
| 108 | if (arg === "--dry-run") { | |
| 109 | isDryRun = true; | |
| 110 | continue; | |
| 111 | } | |
| 112 | ||
| 113 | if (arg === "--") { | |
| 114 | turboArgs.push(...args.slice(index + 1)); | |
| 115 | break; | |
| 116 | } | |
| 117 | ||
| 118 | const parsedFlag = parseLongFlag(arg); | |
| 119 | if (!parsedFlag) { | |
| 120 | turboArgs.push(arg); | |
| 121 | continue; | |
| 122 | } | |
| 123 | ||
| 124 | const definition = FORWARDED_ENV_FLAGS[parsedFlag.name]; | |
| 125 | if (!definition) { | |
| 126 | turboArgs.push(arg); | |
| 127 | continue; | |
| 128 | } | |
| 129 | ||
| 130 | if (definition.expectsValue) { | |
| 131 | let value = parsedFlag.value; | |
| 132 | if (value === undefined) { | |
| 133 | const next = args[index + 1]; | |
| 134 | if (next === undefined || next === "--" || next.startsWith("-")) { | |
| 135 | throw new Error(`Missing value for --${parsedFlag.name}`); | |
| 136 | } | |
| 137 | value = next; | |
| 138 | index += 1; | |
| 139 | } | |
| 140 | envOverrides[definition.envName] = value; | |
| 141 | continue; | |
| 142 | } | |
| 143 | ||
| 144 | envOverrides[definition.envName] = toBooleanEnvValue(parsedFlag.value); | |
| 145 | } | |
| 146 | ||
| 147 | return { | |
| 148 | isDryRun, | |
| 149 | envOverrides, | |
| 150 | turboArgs, | |
| 151 | }; | |
| 152 | } | |
| 153 | ||
| 154 | function resolveStateDir(env, envOverrides) { | |
| 155 | const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); | |
| 156 | if (overrideValue) { | |
| 157 | // Resolve relative paths against cwd (monorepo root) before turbo changes directories | |
| 158 | return path.resolve(overrideValue); | |
| 159 | } | |
| 160 | ||
| 161 | const envValue = env?.T3CODE_STATE_DIR?.trim(); | |
| 162 | if (envValue) { | |
| 163 | return path.resolve(envValue); | |
| 164 | } | |
| 165 | ||
| 166 | return DEFAULT_DEV_STATE_DIR; | |
| 167 | } | |
| 168 | ||
| 169 | export function createDevRunnerEnv({ mode, env, offset, envOverrides }) { | |
| 170 | const serverPort = BASE_SERVER_PORT + offset; | |
| 171 | const webPort = BASE_WEB_PORT + offset; | |
| 172 | ||
| 173 | const output = { | |
| 174 | ...env, | |
| 175 | T3CODE_PORT: String(serverPort), | |
| 176 | PORT: String(webPort), | |
| 177 | ELECTRON_RENDERER_PORT: String(webPort), | |
| 178 | VITE_WS_URL: `ws://localhost:${serverPort}`, | |
| 179 | VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, | |
| 180 | ...envOverrides, | |
| 181 | }; | |
| 182 | ||
| 183 | output.T3CODE_STATE_DIR = resolveStateDir(env, envOverrides); | |
| 184 | ||
| 185 | const parsedServerPort = Number(output.T3CODE_PORT); | |
| 186 | if (!Number.isInteger(parsedServerPort) || parsedServerPort < 1 || parsedServerPort > 65535) { | |
| 187 | throw new Error( | |
| 188 | `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, | |
| 189 | ); | |
| 190 | } | |
| 191 | output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; | |
| 192 | ||
| 193 | if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { | |
| 194 | // Running server/web in browser mode should not inherit desktop launcher state. | |
| 195 | output.T3CODE_MODE = "web"; | |
| 196 | if (!("T3CODE_NO_BROWSER" in envOverrides)) { | |
| 197 | delete output.T3CODE_NO_BROWSER; | |
| 198 | } | |
| 199 | if (!("T3CODE_AUTH_TOKEN" in envOverrides)) { | |
| 200 | delete output.T3CODE_AUTH_TOKEN; | |
| 201 | } | |
| 202 | delete output.T3CODE_DESKTOP_WS_URL; | |
| 203 | } | |
| 204 | ||
| 205 | if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { | |
| 206 | output.T3CODE_LOG_WS_EVENTS = "1"; | |
| 207 | } | |
| 208 | ||
| 209 | return output; | |
| 210 | } | |
| 211 | ||
| 212 | export function runDevRunner(argv = process.argv) { | |
| 213 | const mode = argv[2]; | |
| 214 | const parsedArgs = parseDevRunnerArgs(argv.slice(3)); | |
| 64 | 215 | if (!mode || !(mode in MODE_ARGS)) { |
| 65 | 216 | const supportedModes = Object.keys(MODE_ARGS).join(", "); |
| 66 | 217 | throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`); |
| ... | ... | @@ -76,28 +227,23 @@ function main() { |
| 76 | 227 | ); |
| 77 | 228 | } |
| 78 | 229 | |
| 79 | const env = { | |
| 80 | ...process.env, | |
| 81 | T3CODE_PORT: String(serverPort), | |
| 82 | PORT: String(webPort), | |
| 83 | ELECTRON_RENDERER_PORT: String(webPort), | |
| 84 | VITE_WS_URL: `ws://localhost:${serverPort}`, | |
| 85 | VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, | |
| 86 | }; | |
| 87 | if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) { | |
| 88 | env.T3CODE_LOG_WS_EVENTS = "1"; | |
| 89 | } | |
| 230 | const env = createDevRunnerEnv({ | |
| 231 | mode, | |
| 232 | env: process.env, | |
| 233 | offset, | |
| 234 | envOverrides: parsedArgs.envOverrides, | |
| 235 | }); | |
| 90 | 236 | |
| 91 | 237 | console.info( |
| 92 | `[dev-runner] mode=${mode} source=${source} serverPort=${serverPort} webPort=${webPort}`, | |
| 238 | `[dev-runner] mode=${mode} source=${source} serverPort=${serverPort} webPort=${webPort} stateDir=${env.T3CODE_STATE_DIR}`, | |
| 93 | 239 | ); |
| 94 | 240 | |
| 95 | if (isDryRun) { | |
| 241 | if (parsedArgs.isDryRun) { | |
| 96 | 242 | return; |
| 97 | 243 | } |
| 98 | 244 | |
| 99 | 245 | const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; |
| 100 | const child = spawn(command, MODE_ARGS[mode], { | |
| 246 | const child = spawn(command, [...MODE_ARGS[mode], ...parsedArgs.turboArgs], { | |
| 101 | 247 | stdio: "inherit", |
| 102 | 248 | env, |
| 103 | 249 | }); |
| ... | ... | @@ -116,9 +262,13 @@ function main() { |
| 116 | 262 | }); |
| 117 | 263 | } |
| 118 | 264 | |
| 119 | try { | |
| 120 | main(); | |
| 121 | } catch (error) { | |
| 122 | console.error("[dev-runner]", error); | |
| 123 | process.exit(1); | |
| 265 | const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; | |
| 266 | ||
| 267 | if (import.meta.url === entrypointPath) { | |
| 268 | try { | |
| 269 | runDevRunner(); | |
| 270 | } catch (error) { | |
| 271 | console.error("[dev-runner]", error); | |
| 272 | process.exit(1); | |
| 273 | } | |
| 124 | 274 | } |
scripts/dev-runner.test.ts created+125| ... | ... | @@ -0,0 +1,125 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | DEFAULT_DEV_STATE_DIR, | |
| 5 | createDevRunnerEnv, | |
| 6 | parseDevRunnerArgs, | |
| 7 | } from "./dev-runner.mjs"; | |
| 8 | ||
| 9 | describe("parseDevRunnerArgs", () => { | |
| 10 | it("maps supported server flags to env overrides and keeps other flags for turbo", () => { | |
| 11 | const parsed = parseDevRunnerArgs([ | |
| 12 | "--", | |
| 13 | "--state-dir", | |
| 14 | "~/alt-state", | |
| 15 | "--token=abc123", | |
| 16 | "--no-browser=false", | |
| 17 | "--dry-run", | |
| 18 | "--continue", | |
| 19 | ]); | |
| 20 | ||
| 21 | expect(parsed.isDryRun).toBe(true); | |
| 22 | expect(parsed.envOverrides).toEqual({ | |
| 23 | T3CODE_STATE_DIR: "~/alt-state", | |
| 24 | T3CODE_AUTH_TOKEN: "abc123", | |
| 25 | T3CODE_NO_BROWSER: "0", | |
| 26 | }); | |
| 27 | expect(parsed.turboArgs).toEqual(["--continue"]); | |
| 28 | }); | |
| 29 | ||
| 30 | it("throws when a value flag is missing its value", () => { | |
| 31 | expect(() => parseDevRunnerArgs(["--state-dir"])).toThrow("Missing value for --state-dir"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("throws when a value flag is followed by another flag token", () => { | |
| 35 | expect(() => parseDevRunnerArgs(["--state-dir", "--continue"])).toThrow( | |
| 36 | "Missing value for --state-dir", | |
| 37 | ); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("createDevRunnerEnv", () => { | |
| 42 | it("defaults state dir to ~/.t3/dev when not provided", () => { | |
| 43 | const env = createDevRunnerEnv({ | |
| 44 | mode: "dev", | |
| 45 | env: {}, | |
| 46 | offset: 0, | |
| 47 | envOverrides: {}, | |
| 48 | }); | |
| 49 | ||
| 50 | expect(env.T3CODE_STATE_DIR).toBe(DEFAULT_DEV_STATE_DIR); | |
| 51 | }); | |
| 52 | ||
| 53 | it("uses existing env state dir when --state-dir is not provided", () => { | |
| 54 | const env = createDevRunnerEnv({ | |
| 55 | mode: "dev:server", | |
| 56 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 57 | offset: 0, | |
| 58 | envOverrides: {}, | |
| 59 | }); | |
| 60 | ||
| 61 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); | |
| 62 | }); | |
| 63 | ||
| 64 | it("lets --state-dir override existing env state dir", () => { | |
| 65 | const env = createDevRunnerEnv({ | |
| 66 | mode: "dev:server", | |
| 67 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 68 | offset: 0, | |
| 69 | envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" }, | |
| 70 | }); | |
| 71 | ||
| 72 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/override-state"); | |
| 73 | }); | |
| 74 | ||
| 75 | it("treats whitespace-only --state-dir as missing and falls back to env/default", () => { | |
| 76 | const env = createDevRunnerEnv({ | |
| 77 | mode: "dev:server", | |
| 78 | env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, | |
| 79 | offset: 0, | |
| 80 | envOverrides: { T3CODE_STATE_DIR: " " }, | |
| 81 | }); | |
| 82 | ||
| 83 | expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); | |
| 84 | }); | |
| 85 | ||
| 86 | it("recomputes websocket url when port is overridden", () => { | |
| 87 | const env = createDevRunnerEnv({ | |
| 88 | mode: "dev", | |
| 89 | env: {}, | |
| 90 | offset: 0, | |
| 91 | envOverrides: { T3CODE_PORT: "4222" }, | |
| 92 | }); | |
| 93 | ||
| 94 | expect(env.T3CODE_PORT).toBe("4222"); | |
| 95 | expect(env.VITE_WS_URL).toBe("ws://localhost:4222"); | |
| 96 | }); | |
| 97 | ||
| 98 | it("keeps explicitly forwarded web-mode flags", () => { | |
| 99 | const env = createDevRunnerEnv({ | |
| 100 | mode: "dev", | |
| 101 | env: { | |
| 102 | T3CODE_AUTH_TOKEN: "desktop-token", | |
| 103 | }, | |
| 104 | offset: 0, | |
| 105 | envOverrides: { | |
| 106 | T3CODE_NO_BROWSER: "1", | |
| 107 | T3CODE_AUTH_TOKEN: "cli-token", | |
| 108 | }, | |
| 109 | }); | |
| 110 | ||
| 111 | expect(env.T3CODE_NO_BROWSER).toBe("1"); | |
| 112 | expect(env.T3CODE_AUTH_TOKEN).toBe("cli-token"); | |
| 113 | }); | |
| 114 | ||
| 115 | it("fails fast for invalid port overrides", () => { | |
| 116 | expect(() => | |
| 117 | createDevRunnerEnv({ | |
| 118 | mode: "dev", | |
| 119 | env: {}, | |
| 120 | offset: 0, | |
| 121 | envOverrides: { T3CODE_PORT: "not-a-port" }, | |
| 122 | }), | |
| 123 | ).toThrow("Invalid T3CODE_PORT override"); | |
| 124 | }); | |
| 125 | }); |
turbo.json+3-1| ... | ... | @@ -25,10 +25,12 @@ |
| 25 | 25 | }, |
| 26 | 26 | "typecheck": { |
| 27 | 27 | "dependsOn": ["^typecheck"], |
| 28 | "outputs": [] | |
| 28 | "outputs": [], | |
| 29 | "cache": false | |
| 29 | 30 | }, |
| 30 | 31 | "test": { |
| 31 | 32 | "dependsOn": ["^build"], |
| 33 | "cache": false, | |
| 32 | 34 | "outputs": [] |
| 33 | 35 | } |
| 34 | 36 | } |