authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 18:21:47-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 18:21:47-08:00
loge8dfc42f444600237beb2fa7772c93b618307e32
tree4e9d13f3a9e1e3bd82c18e9774f27ab063c05981
parent37e17e9207125a357f8121799a6f5ff44d90eff5
parent21c823ecdc63ebfae0226f174f639191a971cd85

Merge origin/main into codething/4efb5794

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:
99jobs:
1010 quality:
1111 name: Lint, Typecheck, Test, Build
12 runs-on: ubuntu-latest
12 runs-on: blacksmith-4vcpu-ubuntu-2404
1313 steps:
1414 - name: Checkout
1515 uses: actions/checkout@v4
......@@ -17,7 +17,12 @@ jobs:
1717 - name: Setup Bun
1818 uses: oven-sh/setup-bun@v2
1919 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
2126
2227 - name: Cache Bun and Turbo
2328 uses: actions/cache@v4
.gitignore+1
......@@ -11,3 +11,4 @@ packages/*/dist
1111build/
1212.logs/
1313release/
14.t3
\ No newline at end of file
.oxfmtrc.json+2-1
......@@ -6,7 +6,8 @@
66 "dist-electron",
77 "node_modules",
88 "bun.lock",
9 "*.tsbuildinfo"
9 "*.tsbuildinfo",
10 "**/routeTree.gen.ts"
1011 ],
1112 "experimentalSortPackageJson": {}
1213}
.oxlintrc.json+3-1
......@@ -15,6 +15,8 @@
1515 "perf": "warn"
1616 },
1717 "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"
1921 }
2022}
.plans/11-effect.md created+42
......@@ -0,0 +1,42 @@
1
2
3PR 1: Service contracts + error taxonomy
4Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs.
5Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each).
6No behavior change yet, just interfaces and compile-time wiring points.
7PR 2: CheckpointStore Effect adapter
8Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter).
9Map all thrown/Promise errors to tagged errors.
10Add service tests proving parity for isGitRepository, capture, restore, diff, prune.
11PR 3: CodexService Effect adapter
12Wrap current CodexAppServerManager behind CodexServiceLive (adapter).
13Convert public API to Effect return types with typed errors.
14Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API.
15PR 4: ProviderService Effect adapter
16Wrap current ProviderManager behind ProviderServiceLive (adapter).
17Provider methods become Effect methods with typed errors.
18Route emitted provider events through an Effect PubSub surface.
19PR 5: wsServer migration to Effect services
20Stop instantiating provider/codex classes directly in wsServer.
21Resolve ProviderService (and related services) from one runtime/layer graph.
22Keep WS contract behavior identical.
23PR 6: Native CheckpointStore implementation
24Refactor checkpoint internals from Promise/throws to native Effect.
25Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue).
26Keep adapter tests plus new failure-path tests.
27PR 7: Codex transport/RPC core as native Effect
28Split codex into scoped process layer + RPC request/response layer + session registry.
29Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics.
30Keep protocol behavior and ordering guarantees.
31PR 8: Codex protocol decoding hardening
32Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes.
33Map decode failures to typed tagged errors (with root cause).
34Add regression tests for malformed/partial protocol messages.
35PR 9: Native ProviderService orchestration
36Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies.
37Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services.
38Remove throw-based flow entirely from provider path.
39PR 10: Cleanup + deprecation removal
40Remove legacy class implementations/adapters once parity is proven.
41Finalize layer composition and startup graph docs.
42Add 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
3Current 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
3Goal:
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
15Build 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
31Use 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
381) `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
462) 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
573) 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
654) 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
755) 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
856) 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
94Add 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
103PR A:
104- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup).
105
106PR B:
107- Core ProviderService integration specs (cases 1-4).
108
109PR 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
3Goal:
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
41wsServer -- 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
79OrchestrationEventStore ------> SQLite
80OrchestrationProjectionService -> SQLite
81ProviderSessionRegistry ------> SQLite
82CheckpointCatalog ------> 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 @@
1Rewrite `createServer` and `index.ts` to be Effect native.
2
3Maybe 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
26Exit 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
31Related 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
36Primary files:
37- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
38- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`
39- `apps/server/src/wsServer.ts`
40
41Exit 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
48Related 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
53Primary 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
59Exit 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
66Related 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
72Primary 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
79Exit 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
86Related bug groups solved together:
87- Unbounded in-memory dedup sets/maps.
88- Missing cleanup/lifecycle protections in long-lived effects/resources.
89
90Primary files:
91- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`
92- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
93- `apps/server/src/config.ts`
94
95Exit 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
101Related 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
107Primary 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
115Exit 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
121Related 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
126Primary 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
132Exit 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
142Exit 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
5This is the working checklist for remediation execution.
6
7Status 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
14Counts: 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
5The 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
71. `orchestration.dispatchCommand` (client intent)
82. `OrchestrationEngine` (decide + persist + publish domain events)
93. `ProviderCommandReactor` (domain intent -> `ProviderService`)
104. `ProviderService` (adapter routing + canonical runtime stream)
115. `ProviderRuntimeIngestion` (provider runtime -> internal orchestration commands)
126. `orchestration.domainEvent` (single push channel consumed by web)
13
14Claude integration must plug into this path instead of reintroducing legacy provider-specific flows.
15
16---
17
18## Current constraints to design around
19
201. Provider runtime ingestion expects canonical `ProviderRuntimeEvent` shapes, not provider-native payloads.
212. `ProviderService.startSession` currently defaults to `provider: "codex"` unless explicitly provided.
223. `thread.turn.start` has no provider field today, so first-turn provider selection cannot be explicitly requested.
234. `ProviderService` requires adapter `startSession()` to return a `ProviderSession` with `threadId`.
245. Checkpoint revert currently calls `providerService.rollbackConversation()`, so Claude adapter needs a rollback strategy compatible with current reactor behavior.
256. Web currently marks Claude as unavailable (`"Claude Code (soon)"`) and model picker is Codex-only.
26
27---
28
29## Architecture target
30
31Add 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
33Key decisions:
34
351. Keep orchestration provider-agnostic; adapt Claude inside adapter/layer boundaries.
362. Use the existing canonical runtime stream (`ProviderRuntimeEvent`) as the only ingestion contract.
373. Keep provider session routing in `ProviderService` and `ProviderSessionDirectory`.
384. 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
46Update `packages/contracts/src/model.ts` so model resolution can be provider-aware instead of Codex-only.
47
48Expected outcomes:
49
501. Introduce provider-scoped model lists (Codex + Claude).
512. Add helpers that resolve model by provider.
523. Preserve backwards compatibility for existing Codex defaults.
53
54### 1.2 Turn-start provider intent
55
56Update `packages/contracts/src/orchestration.ts`:
57
581. Add optional `provider: ProviderKind` to `ThreadTurnStartCommand`.
592. Carry provider through `ThreadTurnStartRequestedPayload`.
603. Keep existing command valid when provider is omitted.
61
62This 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
66Update `packages/contracts/src/provider.ts`:
67
681. Extend `ProviderSessionStartInput` with optional Claude-specific fields (for example `claudeBinaryPath`, `permissionMode`, `maxThinkingTokens`).
692. Keep fields optional so current call sites remain valid.
703. Continue using generic `resumeThreadId` + `resumeCursor` as the cross-provider recovery mechanism.
71
72### 1.4 Contract tests
73
74Update/add tests in `packages/contracts/src/*.test.ts` for:
75
761. New command payload shape.
772. Provider-aware model resolution behavior.
783. 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
86Create:
87
881. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts`
892. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts`
90
91Adapter must implement `ProviderAdapterShape<ProviderAdapterError>`.
92
93### 2.1.a SDK dependency and baseline config
94
95Add server dependency:
96
971. `@anthropic-ai/claude-agent-sdk`
98
99Baseline adapter options to support from day one:
100
1011. `cwd`
1022. `model`
1033. `pathToClaudeCodeExecutable` (from `claudeBinaryPath`)
1044. `permissionMode`
1055. `maxThinkingTokens`
1066. `resume`
1077. `resumeSessionAt`
1088. `includePartialMessages`
1099. `canUseTool`
11010. `hooks`
11111. `env` and `additionalDirectories` (if needed for sandbox/workspace parity)
112
113### 2.2 Claude runtime bridge
114
115Implement a Claude runtime bridge (either directly in adapter layer or via dedicated manager file) that wraps Agent SDK query lifecycle.
116
117Required capabilities:
118
1191. Long-lived session context per adapter session.
1202. Multi-turn input queue.
1213. Interrupt support.
1224. Approval request/response bridge.
1235. Resume support via `resumeThreadId` / `resumeCursor`.
124
125#### 2.2.a Agent SDK details to preserve
126
127The adapter should explicitly rely on these SDK capabilities:
128
1291. `query()` returns an async iterable message stream and control methods (`interrupt`, `setModel`, `setPermissionMode`, `setMaxThinkingTokens`, account/status helpers).
1302. Multi-turn input is supported via async-iterable prompt input.
1313. Tool approval decisions are provided via `canUseTool`.
1324. Resume support uses `resume` and optional `resumeSessionAt`.
1335. 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
138import { query } from "@anthropic-ai/claude-agent-sdk";
139import { Effect } from "effect";
140
141const 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
178Preferred when available in the pinned Effect version:
179
180```ts
181const 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
193Portable fallback (already aligned with current server patterns):
194
195```ts
196const queue = yield* Queue.unbounded<ClaudeSdkMessage>();
197yield* 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);
213const sdkMessageStream = Stream.fromQueue(queue);
214```
215
216#### 2.2.d Multi-turn prompt queue pattern (Effect)
217
218Use an Effect queue as the single input boundary:
219
220```ts
221const promptQueue = yield* Queue.unbounded<ClaudePromptEnvelope>();
222
223const 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
242Map SDK approval checks to existing orchestration approval flows:
243
244```ts
245const 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
279If 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
282hooks: {
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
306Adapter should expose SDK controls through existing service methods and runtime payload updates:
307
3081. `interruptTurn()` -> `result.interrupt()`
3092. Model override on send turn -> `result.setModel(model)` before enqueuing prompt
3103. Runtime mode changes -> `result.setPermissionMode(mode)` if we support live mode switching
3114. Thinking budget updates -> `result.setMaxThinkingTokens(tokens)` when configured
312
313### 2.3 Canonical event mapping
314
315Map Claude SDK events to `ProviderRuntimeEvent`:
316
3171. `session.started` / `session.exited`
3182. `turn.started` / `turn.completed`
3193. `message.delta` / `message.completed`
3204. `tool.started` / `tool.completed`
3215. `approval.requested` / `approval.resolved`
3226. `runtime.error`
323
324No provider-native event methods should leak beyond the adapter boundary.
325
326Reference 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
344Define explicit adapter semantics:
345
3461. `sessionId`: adapter-owned stable session id.
3472. `threadId`: Claude conversation/session identifier returned as `ProviderThreadId`.
3483. `resumeCursor`: provider-specific cursor (for example message id) needed for precise recovery/rollback.
349
350### 2.5 Rollback/read strategy
351
352Implement `readThread()` and `rollbackThread()` in a way compatible with `CheckpointReactor`.
353
354Preferred:
355
3561. Track per-turn resume cursors and reconstruct session state on rollback.
3572. Keep adapter `sessionId` stable across internal rehydration/restart.
358
359Fallback (explicitly documented if chosen):
360
3611. Filesystem revert succeeds even when conversation rewind is partial.
3622. Reactor behavior is updated to surface a clear warning activity instead of silent drift.
363
364### 2.6 Adapter unit tests
365
366Add tests mirroring Codex adapter coverage:
367
3681. Provider validation (`provider === "claudeCode"`).
3692. Event mapping to canonical runtime events.
3703. Approval request lifecycle.
3714. Resume/recovery behavior.
3725. Rollback behavior.
373
374---
375
376## Phase 3: Register adapter in runtime composition
377
378### 3.1 Adapter registry wiring
379
380Update:
381
3821. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts`
383
384Register both Codex and Claude adapters.
385
386### 3.2 Server layer composition
387
388Update:
389
3901. `apps/server/src/serverLayers.ts`
391
392Provide `ClaudeCodeAdapterLive` alongside Codex in `makeServerProviderLayer()`.
393
394### 3.3 Composition tests
395
396Update integration tests to ensure:
397
3981. Registry exposes Claude provider.
3992. Provider service can route Claude sessions.
400
401---
402
403## Phase 4: Orchestration command/reactor updates
404
405### 4.1 Decider propagation
406
407Update `apps/server/src/orchestration/decider.ts`:
408
4091. Carry optional `provider` from `thread.turn.start` command into `thread.turn-start-requested` event payload.
410
411### 4.2 ProviderCommandReactor provider selection
412
413Update `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`:
414
4151. Prefer provider from turn-start event payload when starting a new session.
4162. Fallback to existing thread session provider when payload omitted.
4173. Fallback to default provider only when neither is present.
418
419Switch behavior policy (explicit in implementation):
420
4211. If active session provider differs from requested provider, stop and recreate session before sending turn.
4222. Keep current provider when request omits provider.
423
424### 4.3 Reactor/invariant tests
425
426Update/add tests in:
427
4281. `apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts`
4292. `apps/server/src/orchestration/*.test.ts` as needed
430
431Validate 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
439Update `apps/web/src/session-logic.ts`:
440
4411. Mark Claude provider option as available.
442
443### 5.2 Provider selection in composer
444
445Update `apps/web/src/components/ChatView.tsx`:
446
4471. Add provider picker near model/runtime controls.
4482. Include selected provider in `thread.turn.start` command payload.
4493. 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
453Update model picker usage so model options reflect selected provider.
454
455### 5.4 Web tests
456
457Update/add tests for:
458
4591. Provider picker interactions.
4602. Turn-start command includes provider.
4613. Model list switches by provider.
462
463---
464
465## Phase 6: Checkpoint and revert compatibility
466
467### 6.1 Validate checkpoint reactor expectations
468
469Exercise `apps/server/src/orchestration/Layers/CheckpointReactor.ts` flows with Claude:
470
4711. Turn baseline capture.
4722. Turn completion checkpoint capture.
4733. Revert + provider rollback coordination.
474
475### 6.2 Policy for partial rollback support
476
477If Claude cannot support full conversation rewind at parity with Codex:
478
4791. Document behavior clearly.
4802. Emit explicit thread activity for degraded rollback behavior.
4813. Ensure filesystem state remains correct and predictable.
482
483### 6.3 Checkpoint tests
484
485Add/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
493Add integration coverage for:
494
4951. `thread.turn.start` with `provider: "claudeCode"` from fresh thread.
4962. Session recovery after restart (provider session alias/recovery path).
4973. Approval request/respond flow.
4984. Interrupt behavior.
4995. Revert flow with rollback.
500
501### 7.2 WS behavior regression check
502
503Ensure no contract regressions:
504
5051. Client still consumes only `orchestration.domainEvent`.
5062. No new provider-specific WS channels introduced.
507
508### 7.3 Logging
509
510Confirm both native and canonical provider logs remain useful with multi-adapter setup:
511
5121. `provider-native.ndjson`
5132. `provider-canonical.ndjson`
514
515---
516
517## File checklist
518
519Likely files to touch:
520
5211. `packages/contracts/src/model.ts`
5222. `packages/contracts/src/orchestration.ts`
5233. `packages/contracts/src/provider.ts`
5244. `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` (new)
5255. `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` (new)
5266. `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts`
5277. `apps/server/src/serverLayers.ts`
5288. `apps/server/src/orchestration/decider.ts`
5299. `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`
53010. `apps/web/src/session-logic.ts`
53111. `apps/web/src/components/ChatView.tsx`
53212. 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
5381. Contracts for provider selection + models.
5392. Claude adapter + unit tests.
5403. Registry/layer wiring.
5414. Reactor updates for provider-aware session start.
5425. Web provider picker + provider-aware models.
5436. Checkpoint/revert compatibility.
5447. End-to-end integration tests and stabilization.
545
546This 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
3Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft.
4
5Execution 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
12Work 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
19Deliverables:
20- Complete matrix file with no unclassified rows.
21- Single source checklist used by all later steps.
22
23Breakage allowed:
24- No code changes required yet.
25
26Exit criteria:
27- Every requirement in `7.1`-`7.4` has exactly one matrix row.
28
29## 2. Hard cutover migrations (replace current migration set)
30Work 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
49Deliverables:
50- New 5-file migration chain.
51- Updated migration loader references only new migrations.
52
53Breakage allowed:
54- Repositories/services can be temporarily broken due to removed old tables.
55
56Exit criteria:
57- Fresh DB initializes with only canonical tables plus migration bookkeeping.
58
59## 3. Align persistence row/request schemas to DB 1:1
60Work 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
67Deliverables:
68- Canonical row/request schemas committed.
69- Zero references to legacy row schemas in active code paths.
70
71Breakage allowed:
72- Runtime can still fail while query layers are being rewired.
73
74Exit 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
78Work 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
86Deliverables:
87- Event store append/replay fully aligned with canonical envelope.
88
89Breakage allowed:
90- Command dispatch flow can be partially broken until receipts/projectors are updated.
91
92Exit criteria:
93- Event store no longer depends on legacy event table shape.
94
95## 5. Add command receipt idempotency
96Work 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
104Deliverables:
105- Dispatch path with idempotency behavior wired through receipts.
106
107Breakage allowed:
108- Snapshot/read model may still be inconsistent until projectors are fully wired.
109
110Exit criteria:
111- Duplicate command IDs no longer create duplicate events.
112
113## 6. Build DB-backed projection pipeline
114Work 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
122Deliverables:
123- Persistent projector pipeline writing all `projection_*` tables.
124
125Breakage allowed:
126- Web/API layer may still read old in-memory model until step 7.
127
128Exit criteria:
129- Events drive projection rows in DB; projection state advances transactionally.
130
131## 7. Move RPC reads to projections and diff blobs
132Work 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
140Deliverables:
141- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store.
142
143Breakage allowed:
144- Provider runtime persistence may still be partially legacy until step 8.
145
146Exit 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
150Work 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
157Deliverables:
158- Provider runtime state entirely backed by `provider_session_runtime`.
159
160Breakage allowed:
161- Some legacy interfaces may still exist but should be disconnected.
162
163Exit criteria:
164- Runtime restore no longer reads/writes legacy provider session persistence.
165
166## 9. Remove old cruft aggressively
167Work 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
174Deliverables:
175- Legacy persistence and translation layers removed from active codebase.
176
177Breakage allowed:
178- Temporary compile failures acceptable while deletion/refactor is in progress.
179
180Exit criteria:
181- No production code path references deleted legacy tables/services.
182
183## 10. Final verification gate (first point where green is required)
184Work 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
194Deliverables:
195- Green checks with canonical schema + persistence model in place.
196
197Breakage allowed:
198- None at end of step.
199
200Exit 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
3Status 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 @@
1import path from "path";
2import fs from "fs";
3
4const 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
11const 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
17const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i;
18const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i;
19
20function 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
52function 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
62for (const p of getTestPaths()) {
63 test(path.resolve(p));
64}
.vscode/settings.json+2-1
......@@ -4,5 +4,6 @@
44 "editor.codeActionsOnSave": {
55 "source.fixAll.oxc": "always"
66 },
7 "oxc.unusedDisableDirectives": "warn"
7 "oxc.unusedDisableDirectives": "warn",
8 "typescript.tsdk": "node_modules/typescript/lib"
89}
AGENTS.md+1-1
......@@ -29,7 +29,7 @@ How we use it in this codebase:
2929- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
3030- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
3131- 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).
3333
3434Docs:
3535
README.md+6-3
......@@ -68,6 +68,9 @@ npx t3
6868- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.
6969- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).
7070- `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`
7174- `bun run start` — Runs the production server (serves built web app as static files).
7275- `bun run build` — Builds contracts, web app, and server through Turbo.
7376- `bun run typecheck` — Strict TypeScript checks for all packages.
......@@ -88,7 +91,7 @@ npx t3
8891
8992Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together.
9093
91- Default ports: server `3773`, web `5173`
94- Default ports: server `3773`, web `5733`
9295- Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`)
9396- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop`
9497
......@@ -106,12 +109,12 @@ T3 Code has a global runtime mode switch in the chat toolbar:
106109The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
107110
108111- **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
110113
111114Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:
112115
113116- `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn`
114- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions`
117- `providers.respondToRequest`, `providers.stopSession`
115118- `shell.openInEditor`, `server.getConfig`
116119
117120Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI.
REMOTE.md created+65
......@@ -0,0 +1,65 @@
1# Remote Access Setup
2
3Use this when you want to open T3 Code from another device (phone, tablet, another laptop).
4
5## CLI ↔ Env option map
6
7The 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
29Remote access should use the built web app (not local Vite redirect mode).
30
31```bash
32bun run build
33TOKEN="$(openssl rand -hex 24)"
34bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
35```
36
37Then open on your phone:
38
39`http://<your-machine-ip>:3773`
40
41Example:
42
43`http://192.168.1.42:3773`
44
45Notes:
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
53If you use Tailscale, you can bind directly to your Tailnet address.
54
55```bash
56TAILNET_IP="$(tailscale ip -4)"
57TOKEN="$(openssl rand -hex 24)"
58bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
59```
60
61Open from any device in your tailnet:
62
63`http://<tailnet-ip>:3773`
64
65You 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
3Status: Required target architecture (not a description of current behavior).
4
5## 1. Identifier Schema (Branded)
6
7All identifiers are branded and never plain `string` in schema definitions.
8
9```ts
10const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"));
11const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
12const CommandId = Schema.String.pipe(Schema.brand("CommandId"));
13const EventId = Schema.String.pipe(Schema.brand("EventId"));
14const MessageId = Schema.String.pipe(Schema.brand("MessageId"));
15const TurnId = Schema.String.pipe(Schema.brand("TurnId"));
16
17const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId"));
18const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId"));
19const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId"));
20const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId"));
21const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId"));
22
23const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef"));
24```
25
26Rules:
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
36All commands are sent through one RPC: `orchestration.dispatchCommand`.
37
38Output for every command: `DispatchResultSchema = { sequence: number }`.
39
40### 2.1 Client-Dispatchable Commands
41
421. `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
532. `project.meta.update`
54
55- Input:
56 - `commandId: CommandId`
57 - `projectId: ProjectId`
58 - optional `{ title, workspaceRoot, defaultModel, scripts }`
59- Output: `DispatchResult`
60
613. `project.delete`
62
63- Input: `commandId`, `projectId`
64- Output: `DispatchResult`
65
664. `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
795. `thread.delete`
80
81- Input: `commandId`, `threadId`
82- Output: `DispatchResult`
83
846. `thread.meta.update`
85
86- Input: `commandId`, `threadId`, optional `{ title, model, branch, worktreePath }`
87- Output: `DispatchResult`
88
897. `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
1048. `thread.turn.interrupt`
105
106- Input: `commandId`, `threadId`, optional `turnId: TurnId`, `createdAt`
107- Output: `DispatchResult`
108
1099. `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
11910. `thread.checkpoint.revert`
120
121- Input: `commandId`, `threadId`, `turnCount: number`, `createdAt`
122- Output: `DispatchResult`
123
12411. `thread.session.stop`
125
126- Input: `commandId`, `threadId`, `createdAt`
127- Output: `DispatchResult`
128
129### 2.2 Internal-Only Commands (Not Client Dispatchable)
130
1311. `thread.session.set`
132
133- Server-owned projection update for session lifecycle.
134
1352. `thread.message.assistant.delta`
136
137- Server-owned incremental assistant content append.
138
1393. `thread.message.assistant.complete`
140
141- Server-owned assistant message completion marker.
142
1434. `thread.turn.diff.complete`
144
145- Server-owned checkpoint diff summary write.
146
1475. `thread.activity.append`
148
149- Server-owned activity feed append.
150
151## 3. Client RPC (Required)
152
153These are the required client-facing RPC methods.
154
1551. `orchestration.getSnapshot`
156
157- Input: `{}`
158- Output: `OrchestrationReadModel`
159
1602. `orchestration.dispatchCommand`
161
162- Input: `ClientOrchestrationCommand` (`Schema.Union` of all client-dispatchable commands)
163- Output: `DispatchResult`
164
1653. `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
1774. `orchestration.replayEvents` (optional operational/debug API)
178
179- Input: `{ fromSequenceExclusive: number }`
180- Output: `OrchestrationEvent[]`
181
182### 3.1 RPC To Remove
183
184Remove 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
195Rationale:
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
202Each command deterministically yields one or more domain events that are persisted to the event store.
203
2041. `project.create` -> `project.created`
2052. `project.meta.update` -> `project.meta-updated`
2063. `project.delete` -> `project.deleted`
2074. `thread.create` -> `thread.created`
2085. `thread.delete` -> `thread.deleted`
2096. `thread.meta.update` -> `thread.meta-updated`
2107. `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
2168. `thread.turn.interrupt` -> `thread.turn-interrupt-requested`
2179. `thread.approval.respond` -> `thread.approval-response-requested`
21810. `thread.checkpoint.revert` -> `thread.checkpoint-revert-requested` then `thread.reverted`
21911. `thread.session.stop` -> `thread.session-stop-requested` then `thread.session-set`
220
221Internal 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
231Required 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
2401. 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
2462. 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
2523. 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
2584. 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
2645. 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
2726. Raw approval request
273
274- Generic: `approval.requested`
275- Internal command: `thread.activity.append` (typed `requestId`, `requestKind`)
276- Domain event: `thread.activity-appended`
277
2787. Raw approval decision ack
279
280- Generic: `approval.resolved`
281- Internal command: `thread.activity.append`
282- Domain event: `thread.activity-appended`
283
2848. Raw tool start/complete
285
286- Generic: `tool.started` / `tool.completed`
287- Internal command: `thread.activity.append`
288- Domain event: `thread.activity-appended`
289
2909. Checkpoint capture (server-produced)
291
292- Generic: `checkpoint.captured`
293- Internal command: `thread.turn.diff.complete`
294- Domain event: `thread.turn-diff-completed`
295
29610. 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
308Expose 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
319Not 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
335This section defines required persisted tables, the canonical persisted event envelope, and required projected/read tables.
336
337### 7.1 Write-Side Persisted Tables
338
3391. `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
3552. `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
3653. `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
3744. `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
391type 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
416Rules:
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
4241. `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
4352. `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
4483. `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
4594. `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
4705. `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
4816. `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
4927. `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
5048. `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
5149. `projection_state`
515
516- `projector: string` (primary key; e.g. `threads`, `messages`, `sessions`)
517- `lastAppliedSequence: number`
518- `updatedAt: IsoDateTime`
519
520Projection 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
5271. `orchestration.getSnapshot` is fully served from projection tables and returns `snapshotSequence: number`.
5282. Snapshot must include `projects[]` from `projection_projects`.
5293. 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
5394. 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
5445. 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
5496. `snapshotSequence` is derived from `projection_state`:
550
551- if snapshot depends on multiple projectors, use the minimum `lastAppliedSequence` across those projectors.
552
5537. 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
5611. Current git/worktree state is not projected from orchestration events.
5622. If persisted, it belongs in a separate git cache/read model (for example `git_state_cache`) owned by a git service.
5633. Orchestration may embed git metadata only when captured as a domain fact (for example checkpoint metadata at turn completion).
5644. Any RPC for current git status should be outside orchestration RPC (for example `git.getThreadState`).
565
566### 7.6 Existing Repository Placement
567
5681. `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
5732. `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
5793. `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 @@
1515- [x] "Response" bar should ONLY appear above the FINAL message in a series
1616- [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)
1717- [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
1919- [x] Making new thread should auto-focus input box
2020- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank)
2121
2222## Bigger things
2323
2424- [ ] 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 @@
22 "name": "@t3tools/desktop",
33 "version": "0.0.0",
44 "private": true,
5 "productName": "T3 Code (Alpha)",
65 "main": "dist-electron/main.js",
76 "scripts": {
87 "dev": "bun run --parallel dev:bundle dev:electron",
......@@ -15,14 +14,16 @@
1514 "smoke-test": "node scripts/smoke-test.mjs"
1615 },
1716 "dependencies": {
18 "electron": "33.4.11"
17 "electron": "40.6.0"
1918 },
2019 "devDependencies": {
20 "@t3tools/contracts": "workspace:*",
2121 "@types/node": "^22.10.2",
2222 "electronmon": "^2.0.2",
2323 "tsdown": "^0.20.3",
2424 "typescript": "^5.7.3",
2525 "vitest": "^4.0.0",
2626 "wait-on": "^8.0.2"
27 }
27 },
28 "productName": "T3 Code (Alpha)"
2829}
apps/desktop/scripts/dev-electron.mjs+2-5
......@@ -5,7 +5,7 @@ import waitOn from "wait-on";
55
66import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
99const devServerUrl = `http://localhost:${port}`;
1010
1111await waitOn({
......@@ -46,10 +46,7 @@ async function shutdown(exitCode) {
4646 shuttingDown = true;
4747
4848 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))]);
5350 } catch {
5451 // Best effort only; fallback process kill below handles stubborn children.
5552 }
apps/desktop/scripts/electron-launcher.mjs+12-41
......@@ -25,34 +25,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
2525export const desktopDir = resolve(__dirname, "..");
2626
2727function 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 });
3531 if (replaceResult.status === 0) {
3632 return;
3733 }
3834
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 });
4638 if (insertResult.status === 0) {
4739 return;
4840 }
4941
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());
5644}
5745
5846function patchMainBundleInfoPlist(appBundlePath, iconPath) {
......@@ -81,28 +69,16 @@ function patchHelperBundleInfoPlists(appBundlePath) {
8169 continue;
8270 }
8371
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");
9073 if (!existsSync(helperPlistPath)) {
9174 continue;
9275 }
9376
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();
9878 const helperName = suffix
9979 ? `${APP_DISPLAY_NAME} Helper ${suffix}`
10080 : `${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, "-");
10682 const helperBundleId = helperIdSuffix
10783 ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}`
10884 : `${APP_BUNDLE_ID}.helper`;
......@@ -125,12 +101,7 @@ function buildMacLauncher(electronBinaryPath) {
125101 const sourceAppBundlePath = resolve(electronBinaryPath, "../../..");
126102 const runtimeDir = join(desktopDir, ".electron-runtime");
127103 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");
134105 const iconPath = join(desktopDir, "resources", "icon.icns");
135106 const metadataPath = join(runtimeDir, "metadata.json");
136107
apps/desktop/src/main.ts+9-13
......@@ -5,16 +5,8 @@ import net from "node:net";
55import os from "node:os";
66import path from "node:path";
77
8import {
9 app,
10 BrowserWindow,
11 dialog,
12 ipcMain,
13 Menu,
14 type MenuItemConstructorOptions,
15 protocol,
16 shell,
17} from "electron";
8import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron";
9import type { MenuItemConstructorOptions } from "electron";
1810
1911import { showDesktopConfirmDialog } from "./confirmDialog";
2012import { fixPath } from "./fixPath";
......@@ -26,7 +18,8 @@ const CONFIRM_CHANNEL = "desktop:confirm";
2618const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
2719const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
2820const MENU_ACTION_CHANNEL = "desktop:menu-action";
29const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
21const STATE_DIR =
22 process.env.T3CODE_STATE_DIR?.trim() || path.join(os.homedir(), ".t3", "userdata");
3023const DESKTOP_SCHEME = "t3";
3124const ROOT_DIR = path.resolve(__dirname, "../../..");
3225const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
......@@ -126,7 +119,9 @@ function registerDesktopProtocol(): void {
126119
127120 const staticRoot = resolveDesktopStaticDir();
128121 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 );
130125 }
131126
132127 const staticRootResolved = path.resolve(staticRoot);
......@@ -160,7 +155,8 @@ function registerDesktopProtocol(): void {
160155}
161156
162157function dispatchMenuAction(action: string): void {
163 const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
158 const existingWindow =
159 BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
164160 const targetWindow = existingWindow ?? createWindow();
165161 if (!existingWindow) {
166162 mainWindow = targetWindow;
apps/desktop/src/preload.ts+7-7
......@@ -1,4 +1,5 @@
11import { contextBridge, ipcRenderer } from "electron";
2import type { DesktopBridge } from "@t3tools/contracts";
23
34const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
45const CONFIRM_CHANNEL = "desktop:confirm";
......@@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
910
1011contextBridge.exposeInMainWorld("desktopBridge", {
1112 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) => {
1818 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
1919 if (typeof action !== "string") return;
2020 listener(action);
......@@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", {
2525 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
2626 };
2727 },
28});
28} satisfies DesktopBridge);
apps/server/integration/OrchestrationEngineHarness.integration.ts created+401
......@@ -0,0 +1,401 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { execFileSync } from "node:child_process";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import {
8 ApprovalRequestId,
9 type OrchestrationEvent,
10 type OrchestrationThread,
11} from "@t3tools/contracts";
12import {
13 Effect,
14 Exit,
15 Layer,
16 ManagedRuntime,
17 Option,
18 Schedule,
19 Schema,
20 Scope,
21 Stream,
22} from "effect";
23
24import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
25import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
26import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
27import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
28import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
29import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
30import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
31import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
32import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
33import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
34import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
35import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
36import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
37import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
38import { ProviderService } from "../src/provider/Services/ProviderService.ts";
39import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
40import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
41import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
42import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
43import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
44import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
45import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
46import {
47 OrchestrationEngineService,
48 type OrchestrationEngineShape,
49} from "../src/orchestration/Services/OrchestrationEngine.ts";
50import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
51import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
52
53import {
54 makeTestProviderAdapterHarness,
55 type TestProviderAdapterHarness,
56} from "./TestProviderAdapter.integration.ts";
57
58function runGit(cwd: string, args: ReadonlyArray<string>) {
59 return execFileSync("git", args, {
60 cwd,
61 stdio: ["ignore", "pipe", "pipe"],
62 encoding: "utf8",
63 });
64}
65
66function 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
75export 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
84export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
85 return runGit(cwd, ["show", `${ref}:${filePath}`]);
86}
87
88class WaitForTimeoutError extends Schema.TaggedErrorClass<WaitForTimeoutError>()(
89 "WaitForTimeoutError",
90 {
91 description: Schema.String,
92 },
93) {}
94
95function 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>;
101function 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>;
107function 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
132class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<OrchestrationHarnessRuntimeError>()(
133 "OrchestrationHarnessRuntimeError",
134 {
135 operation: Schema.String,
136 cause: Schema.optional(Schema.Defect),
137 },
138) {}
139
140const tryRuntimePromise = <A>(operation: string, run: () => Promise<A>) =>
141 Effect.tryPromise({
142 try: run,
143 catch: (cause) => new OrchestrationHarnessRuntimeError({ operation, cause }),
144 });
145
146export 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
185export 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 @@
1import { randomUUID } from "node:crypto";
2
3import {
4 ApprovalRequestId,
5 EventId,
6 ProviderApprovalDecision,
7 ProviderRuntimeEvent,
8 ProviderSession,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ProviderTurnStartResult,
13} from "@t3tools/contracts";
14import { Effect, Queue, Stream } from "effect";
15
16import {
17 ProviderAdapterSessionNotFoundError,
18 ProviderAdapterValidationError,
19 type ProviderAdapterError,
20} from "../src/provider/Errors.ts";
21import type {
22 ProviderAdapterShape,
23 ProviderThreadSnapshot,
24 ProviderThreadTurnSnapshot,
25} from "../src/provider/Services/ProviderAdapter.ts";
26
27export 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
35interface SessionState {
36 readonly session: ProviderSession;
37 snapshot: ProviderThreadSnapshot;
38 turnCount: number;
39 readonly queuedResponses: Array<TestTurnResponse>;
40 readonly rollbackCalls: Array<number>;
41}
42
43export 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
60const PROVIDER = "codex" as const;
61
62function nowIso(): string {
63 return new Date().toISOString();
64}
65
66function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError {
67 return new ProviderAdapterSessionNotFoundError({
68 provider: PROVIDER,
69 sessionId,
70 });
71}
72
73function missingSessionEffect(sessionId: string): Effect.Effect<never, ProviderAdapterError> {
74 return Effect.fail(sessionNotFound(sessionId));
75}
76
77export 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 @@
1import {
2 ApprovalRequestId,
3 EventId,
4 ProviderSessionId,
5 ProviderThreadId,
6 ProviderTurnId,
7 type ProviderRuntimeEvent,
8} from "@t3tools/contracts";
9
10const PROVIDER = "codex" as const;
11const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session");
12const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread");
13const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn");
14const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1");
15
16function 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
28export 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
58export 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
99export 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 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import {
5 ApprovalRequestId,
6 CommandId,
7 EventId,
8 MessageId,
9 ProjectId,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { assert, it } from "@effect/vitest";
16import { Effect, Option, Schema } from "effect";
17
18import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
19import {
20 gitRefExists,
21 gitShowFileAtRef,
22 makeOrchestrationIntegrationHarness,
23 type OrchestrationIntegrationHarness,
24} from "./OrchestrationEngineHarness.integration.ts";
25import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";
26
27const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
28const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
29const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
30const asProviderSessionId = (value: string): ProviderSessionId =>
31 ProviderSessionId.makeUnsafe(value);
32const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
33const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
34const asApprovalRequestId = (value: string): ApprovalRequestId =>
35 ApprovalRequestId.makeUnsafe(value);
36
37const PROJECT_ID = asProjectId("project-1");
38const THREAD_ID = ThreadId.makeUnsafe("thread-1");
39const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43
44function nowIso() {
45 return new Date().toISOString();
46}
47
48class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWaitTimeoutError>()(
49 "IntegrationWaitTimeoutError",
50 {
51 description: Schema.String,
52 },
53) {}
54
55const sleep = (ms: number) => Effect.sleep(ms);
56
57function 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
79function 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
88function 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
96const 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
123const 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
144it.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
214it.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
378it.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
460it.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
532it.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
715it.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 @@
1import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it, assert } from "@effect/vitest";
5import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
6
7import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
8import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
9import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
10import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
11import {
12 ProviderService,
13 type ProviderServiceShape,
14} from "../src/provider/Services/ProviderService.ts";
15import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
16import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
17
18import {
19 makeTestProviderAdapterHarness,
20 type TestProviderAdapterHarness,
21 type TestTurnResponse,
22} from "./TestProviderAdapter.integration.ts";
23import {
24 codexTurnApprovalFixture,
25 codexTurnToolFixture,
26 codexTurnTextFixture,
27} from "./fixtures/providerRuntime.ts";
28
29const 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
37interface IntegrationFixture {
38 readonly cwd: string;
39 readonly harness: TestProviderAdapterHarness;
40 readonly layer: Layer.Layer<ProviderService, unknown, never>;
41}
42
43const 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
73const 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
93const 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
114it.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
145it.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
182it.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
234it.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 @@
1010 "type": "module",
1111 "main": "./dist/index.mjs",
1212 "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",
1515 "start": "node dist/index.mjs",
16 "prepare": "effect-language-service patch",
1617 "typecheck": "tsc --noEmit",
1718 "test": "vitest run"
1819 },
1920 "dependencies": {
21 "@effect/platform-node": "catalog:",
22 "@effect/sql-sqlite-bun": "catalog:",
23 "@pierre/diffs": "^1.1.0-beta.16",
24 "effect": "catalog:",
2025 "node-pty": "^1.1.0",
2126 "open": "^10.1.0",
2227 "ws": "^8.18.0"
2328 },
2429 "devDependencies": {
30 "@effect/language-service": "0.75.1",
31 "@effect/vitest": "catalog:",
2532 "@t3tools/contracts": "workspace:*",
2633 "@t3tools/web": "workspace:*",
34 "@types/bun": "^1.3.9",
2735 "@types/node": "^22.10.2",
2836 "@types/ws": "^8.5.13",
2937 "tsdown": "^0.20.3",
30 "typescript": "^5.7.3"
38 "typescript": "^5.7.3",
39 "vitest": "^4.0.0"
3140 }
3241}
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 */
5import fs from "node:fs";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const webDist = path.resolve(__dirname, "../../web/dist");
11const target = path.resolve(__dirname, "../dist/client");
12
13if (!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
20fs.cpSync(webDist, target, { recursive: true });
21console.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 */
5import * as fs from "node:fs";
6import * as path from "node:path";
7
8const webDist = path.resolve(import.meta.dirname, "../../web/dist");
9const target = path.resolve(import.meta.dirname, "../dist/client");
10
11if (!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
18fs.cpSync(webDist, target, { recursive: true });
19console.log("✓ Bundled web app into dist/client");
apps/server/src/checkpointing/Diffs.test.ts created+68
......@@ -0,0 +1,68 @@
1import { describe, expect, it } from "vitest";
2
3import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts";
4
5describe("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 @@
1import { parsePatchFiles } from "@pierre/diffs";
2
3export interface TurnDiffFileSummary {
4 readonly path: string;
5 readonly additions: number;
6 readonly deletions: number;
7}
8
9export 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 @@
1import { Schema } from "effect";
2import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
3import { GitCommandError } from "../git/Errors.ts";
4
5/**
6 * CheckpointUnavailableError - Expected checkpoint does not exist.
7 */
8export 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 */
25export 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
38export type CheckpointStoreError =
39 | GitCommandError
40 | CheckpointInvariantError
41 | CheckpointUnavailableError;
42
43export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError;
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts created+193
......@@ -0,0 +1,193 @@
1import {
2 CheckpointRef,
3 ProjectId,
4 ThreadId,
5 TurnId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import { Effect, Layer } from "effect";
9import { describe, expect, it } from "vitest";
10
11import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
12import { checkpointRefForThreadTurn } from "../Utils.ts";
13import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts";
14import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
15import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts";
16
17function 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
78describe("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 @@
1import {
2 OrchestrationGetTurnDiffResult,
3 type OrchestrationGetFullThreadDiffInput,
4 type OrchestrationGetFullThreadDiffResult,
5 type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType,
6} from "@t3tools/contracts";
7import { Effect, Layer, Schema } from "effect";
8
9import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
10import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts";
11import { checkpointRefForThreadTurn, resolveThreadWorkspaceCwd } from "../Utils.ts";
12import { CheckpointStore } from "../Services/CheckpointStore.ts";
13import {
14 CheckpointDiffQuery,
15 type CheckpointDiffQueryShape,
16} from "../Services/CheckpointDiffQuery.ts";
17
18const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult);
19
20const 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
169export 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 */
12import { randomUUID } from "node:crypto";
13
14import { Effect, Layer, FileSystem, Path } from "effect";
15
16import { CheckpointInvariantError } from "../Errors.ts";
17import { GitCommandError } from "../../git/Errors.ts";
18import { GitServiceLive } from "../../git/Layers/GitService.ts";
19import { GitService } from "../../git/Services/GitService.ts";
20import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
21import { CheckpointRef } from "@t3tools/contracts";
22
23const 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
280export 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 */
9import type {
10 OrchestrationGetFullThreadDiffInput,
11 OrchestrationGetFullThreadDiffResult,
12 OrchestrationGetTurnDiffInput,
13 OrchestrationGetTurnDiffResult,
14} from "@t3tools/contracts";
15import { ServiceMap } from "effect";
16import type { Effect } from "effect";
17
18import type { CheckpointServiceError } from "../Errors.ts";
19
20/**
21 * CheckpointDiffQueryShape - Service API for checkpoint diff queries.
22 */
23export 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 */
46export 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 */
13import { ServiceMap } from "effect";
14import type { Effect } from "effect";
15
16import type { CheckpointStoreError } from "../Errors.ts";
17import { CheckpointRef } from "@t3tools/contracts";
18
19export interface CaptureCheckpointInput {
20 readonly cwd: string;
21 readonly checkpointRef: CheckpointRef;
22}
23
24export interface RestoreCheckpointInput {
25 readonly cwd: string;
26 readonly checkpointRef: CheckpointRef;
27 readonly fallbackToHead?: boolean;
28}
29
30export interface DiffCheckpointsInput {
31 readonly cwd: string;
32 readonly fromCheckpointRef: CheckpointRef;
33 readonly toCheckpointRef: CheckpointRef;
34 readonly fallbackFromToHead?: boolean;
35}
36
37export 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 */
45export 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 */
98export 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 @@
1import { Encoding } from "effect";
2import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts";
3
4export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
5
6export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
7 return CheckpointRef.makeUnsafe(
8 `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`,
9 );
10}
11
12export 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 @@
11import { describe, expect, it, vi } from "vitest";
2import { ProviderSessionId } from "@t3tools/contracts";
23
34import {
45 CodexAppServerManager,
......@@ -7,6 +8,8 @@ import {
78 normalizeCodexModelSlug,
89} from "./codexAppServerManager";
910
11const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
12
1013function createSendTurnHarness() {
1114 const manager = new CodexAppServerManager();
1215 const context = {
......@@ -183,7 +186,7 @@ describe("sendTurn", () => {
183186 createSendTurnHarness();
184187
185188 const result = await manager.sendTurn({
186 sessionId: "sess_1",
189 sessionId: asSessionId("sess_1"),
187190 input: "Inspect this image",
188191 attachments: [
189192 {
......@@ -201,6 +204,9 @@ describe("sendTurn", () => {
201204 expect(result).toEqual({
202205 threadId: "thread_1",
203206 turnId: "turn_1",
207 resumeCursor: {
208 threadId: "thread_1",
209 },
204210 });
205211 expect(requireSession).toHaveBeenCalledWith("sess_1");
206212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
......@@ -222,6 +228,9 @@ describe("sendTurn", () => {
222228 expect(updateSession).toHaveBeenCalledWith(context, {
223229 status: "running",
224230 activeTurnId: "turn_1",
231 resumeCursor: {
232 threadId: "thread_1",
233 },
225234 });
226235 });
227236
......@@ -229,7 +238,7 @@ describe("sendTurn", () => {
229238 const { manager, context, sendRequest } = createSendTurnHarness();
230239
231240 await manager.sendTurn({
232 sessionId: "sess_1",
241 sessionId: asSessionId("sess_1"),
233242 attachments: [
234243 {
235244 type: "image",
......@@ -257,7 +266,7 @@ describe("sendTurn", () => {
257266
258267 await expect(
259268 manager.sendTurn({
260 sessionId: "sess_1",
269 sessionId: asSessionId("sess_1"),
261270 }),
262271 ).rejects.toThrow("Turn input must include text or attachments.");
263272 });
......@@ -278,7 +287,7 @@ describe("thread checkpoint control", () => {
278287 },
279288 });
280289
281 const result = await manager.readThread("sess_1");
290 const result = await manager.readThread(asSessionId("sess_1"));
282291
283292 expect(requireSession).toHaveBeenCalledWith("sess_1");
284293 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
......@@ -308,7 +317,7 @@ describe("thread checkpoint control", () => {
308317 ],
309318 });
310319
311 const result = await manager.readThread("sess_1");
320 const result = await manager.readThread(asSessionId("sess_1"));
312321
313322 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
314323 threadId: "thread_1",
......@@ -334,7 +343,7 @@ describe("thread checkpoint control", () => {
334343 },
335344 });
336345
337 const result = await manager.rollbackThread("sess_1", 2);
346 const result = await manager.rollbackThread(asSessionId("sess_1"), 2);
338347
339348 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
340349 threadId: "thread_1",
apps/server/src/codexAppServerManager.ts+119-61
......@@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto";
33import { EventEmitter } from "node:events";
44import readline from "node:readline";
55
6import type {
7 ProviderApprovalDecision,
8 ProviderEvent,
6import {
7 ApprovalRequestId,
8 EventId,
9 ProviderItemId,
910 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,
1421} from "@t3tools/contracts";
15import { normalizeModelSlug } from "@t3tools/contracts";
1622
1723type PendingRequestKey = string;
1824
......@@ -24,13 +30,13 @@ interface PendingRequest {
2430}
2531
2632interface PendingApprovalRequest {
27 requestId: string;
33 requestId: ApprovalRequestId;
2834 jsonRpcId: string | number;
2935 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
3036 requestKind: ProviderRequestKind;
31 threadId?: string;
32 turnId?: string;
33 itemId?: string;
37 threadId?: ProviderThreadId;
38 turnId?: ProviderTurnId;
39 itemId?: ProviderItemId;
3440}
3541
3642interface CodexSessionContext {
......@@ -38,7 +44,7 @@ interface CodexSessionContext {
3844 child: ChildProcessWithoutNullStreams;
3945 output: readline.Interface;
4046 pending: Map<PendingRequestKey, PendingRequest>;
41 pendingApprovals: Map<string, PendingApprovalRequest>;
47 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
4248 nextRequestId: number;
4349 stopping: boolean;
4450}
......@@ -66,12 +72,12 @@ interface JsonRpcNotification {
6672}
6773
6874export interface CodexThreadTurnSnapshot {
69 id: string;
75 id: ProviderTurnId;
7076 items: unknown[];
7177}
7278
7379export interface CodexThreadSnapshot {
74 threadId: string;
80 threadId: ProviderThreadId;
7581 turns: CodexThreadTurnSnapshot[];
7682}
7783
......@@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents {
143149}
144150
145151export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {
146 private readonly sessions = new Map<string, CodexSessionContext>();
152 private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>();
147153
148154 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
149 const sessionId = randomUUID();
155 const sessionId = ProviderSessionId.makeUnsafe(randomUUID());
150156 const now = new Date().toISOString();
151157 let context: CodexSessionContext | undefined;
152158
......@@ -163,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
163169 updatedAt: now,
164170 };
165171
166 const codexBinaryPath = input.codexBinaryPath?.trim() || "codex";
167 const codexHomePath = input.codexHomePath?.trim();
172 const codexBinaryPath = input.codexBinaryPath ?? "codex";
173 const codexHomePath = input.codexHomePath;
168174 const child = spawn(codexBinaryPath, ["app-server"], {
169175 cwd: resolvedCwd,
170176 env: {
......@@ -215,15 +221,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
215221 ...sessionOverrides,
216222 experimentalRawEvents: false,
217223 };
224 const resumeThreadId = readResumeThreadId(input);
218225
219226 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
220227 let threadOpenResponse: unknown;
221 if (input.resumeThreadId) {
228 if (resumeThreadId) {
222229 try {
223230 threadOpenMethod = "thread/resume";
224231 threadOpenResponse = await this.sendRequest(context, "thread/resume", {
225232 ...sessionOverrides,
226 threadId: input.resumeThreadId,
233 threadId: resumeThreadId,
227234 });
228235 } catch (error) {
229236 if (!isRecoverableThreadResumeError(error)) {
......@@ -234,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
234241 this.emitLifecycleEvent(
235242 context,
236243 "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.`,
238245 );
239246 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
240247 }
......@@ -244,16 +251,18 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
244251 }
245252
246253 const threadOpenRecord = this.readObject(threadOpenResponse);
247 const threadId =
254 const threadIdRaw =
248255 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249256 this.readString(threadOpenRecord, "threadId");
250 if (!threadId) {
257 if (!threadIdRaw) {
251258 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
252259 }
260 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
253261
254262 this.updateSession(context, {
255263 status: "ready",
256264 threadId,
265 resumeCursor: { threadId },
257266 });
258267 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);
259268 return { ...context.session };
......@@ -268,7 +277,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
268277 this.stopSession(sessionId);
269278 } else {
270279 this.emitEvent({
271 id: randomUUID(),
280 id: EventId.makeUnsafe(randomUUID()),
272281 kind: "error",
273282 provider: "codex",
274283 sessionId,
......@@ -310,7 +319,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
310319 }
311320
312321 const turnStartParams: {
313 threadId: string;
322 threadId: ProviderThreadId;
314323 input: Array<
315324 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
316325 >;
......@@ -331,23 +340,26 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
331340 const response = await this.sendRequest(context, "turn/start", turnStartParams);
332341
333342 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) {
336345 throw new Error("turn/start response did not include a turn id.");
337346 }
347 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
338348
339349 this.updateSession(context, {
340350 status: "running",
341351 activeTurnId: turnId,
352 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
342353 });
343354
344355 return {
345356 threadId: context.session.threadId,
346357 turnId,
358 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
347359 };
348360 }
349361
350 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
362 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
351363 const context = this.requireSession(sessionId);
352364 const effectiveTurnId = turnId ?? context.session.activeTurnId;
353365
......@@ -361,7 +373,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
361373 });
362374 }
363375
364 async readThread(sessionId: string): Promise<CodexThreadSnapshot> {
376 async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> {
365377 const context = this.requireSession(sessionId);
366378 const threadId = context.session.threadId;
367379 if (!threadId) {
......@@ -375,7 +387,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
375387 return this.parseThreadSnapshot("thread/read", response);
376388 }
377389
378 async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> {
390 async rollbackThread(
391 sessionId: ProviderSessionId,
392 numTurns: number,
393 ): Promise<CodexThreadSnapshot> {
379394 const context = this.requireSession(sessionId);
380395 const threadId = context.session.threadId;
381396 if (!threadId) {
......@@ -397,8 +412,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
397412 }
398413
399414 async respondToRequest(
400 sessionId: string,
401 requestId: string,
415 sessionId: ProviderSessionId,
416 requestId: ApprovalRequestId,
402417 decision: ProviderApprovalDecision,
403418 ): Promise<void> {
404419 const context = this.requireSession(sessionId);
......@@ -416,7 +431,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
416431 });
417432
418433 this.emitEvent({
419 id: randomUUID(),
434 id: EventId.makeUnsafe(randomUUID()),
420435 kind: "notification",
421436 provider: "codex",
422437 sessionId: context.session.sessionId,
......@@ -435,7 +450,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
435450 });
436451 }
437452
438 stopSession(sessionId: string): void {
453 stopSession(sessionId: ProviderSessionId): void {
439454 const context = this.sessions.get(sessionId);
440455 if (!context) {
441456 return;
......@@ -470,7 +485,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
470485 }));
471486 }
472487
473 hasSession(sessionId: string): boolean {
488 hasSession(sessionId: ProviderSessionId): boolean {
474489 return this.sessions.has(sessionId);
475490 }
476491
......@@ -480,7 +495,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
480495 }
481496 }
482497
483 private requireSession(sessionId: string): CodexSessionContext {
498 private requireSession(sessionId: ProviderSessionId): CodexSessionContext {
484499 const context = this.sessions.get(sessionId);
485500 if (!context) {
486501 throw new Error(`Unknown session: ${sessionId}`);
......@@ -591,7 +606,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
591606 : undefined;
592607
593608 this.emitEvent({
594 id: randomUUID(),
609 id: EventId.makeUnsafe(randomUUID()),
595610 kind: "notification",
596611 provider: "codex",
597612 sessionId: context.session.sessionId,
......@@ -605,7 +620,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
605620 });
606621
607622 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 );
609626 if (threadId) {
610627 this.updateSession(context, { threadId });
611628 }
......@@ -613,7 +630,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
613630 }
614631
615632 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 );
617636 this.updateSession(context, {
618637 status: "running",
619638 activeTurnId: turnId,
......@@ -647,9 +666,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
647666 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {
648667 const route = this.readRouteFields(request.params);
649668 const requestKind = this.requestKindForMethod(request.method);
650 let requestId: string | undefined;
669 let requestId: ApprovalRequestId | undefined;
651670 if (requestKind) {
652 requestId = randomUUID();
671 requestId = ApprovalRequestId.makeUnsafe(randomUUID());
653672 const pendingRequest: PendingApprovalRequest = {
654673 requestId,
655674 jsonRpcId: request.id,
......@@ -666,7 +685,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
666685 }
667686
668687 this.emitEvent({
669 id: randomUUID(),
688 id: EventId.makeUnsafe(randomUUID()),
670689 kind: "request",
671690 provider: "codex",
672691 sessionId: context.session.sessionId,
......@@ -761,7 +780,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
761780
762781 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {
763782 this.emitEvent({
764 id: randomUUID(),
783 id: EventId.makeUnsafe(randomUUID()),
765784 kind: "session",
766785 provider: "codex",
767786 sessionId: context.session.sessionId,
......@@ -773,7 +792,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
773792
774793 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {
775794 this.emitEvent({
776 id: randomUUID(),
795 id: EventId.makeUnsafe(randomUUID()),
777796 kind: "error",
778797 provider: "codex",
779798 sessionId: context.session.sessionId,
......@@ -810,15 +829,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
810829 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {
811830 const responseRecord = this.readObject(response);
812831 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) {
815835 throw new Error(`${method} response did not include a thread id.`);
816836 }
837 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
817838
818 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
839 const turnsRaw =
840 this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
819841 const turns = turnsRaw.map((turnValue, index) => {
820842 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);
822845 const items = this.readArray(turn, "items") ?? [];
823846 return {
824847 id: turnId,
......@@ -865,23 +888,26 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
865888 }
866889
867890 private readRouteFields(params: unknown): {
868 threadId?: string;
869 turnId?: string;
870 itemId?: string;
891 threadId?: ProviderThreadId;
892 turnId?: ProviderTurnId;
893 itemId?: ProviderItemId;
871894 } {
872895 const route: {
873 threadId?: string;
874 turnId?: string;
875 itemId?: string;
896 threadId?: ProviderThreadId;
897 turnId?: ProviderTurnId;
898 itemId?: ProviderItemId;
876899 } = {};
877900
878 const threadId =
901 const threadId = toProviderThreadId(
879902 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 );
885911
886912 if (threadId) {
887913 route.threadId = threadId;
......@@ -941,3 +967,35 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
941967 return typeof candidate === "boolean" ? candidate : undefined;
942968 }
943969}
970
971function 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
979function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined {
980 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
981}
982
983function 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
995function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
996 return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe);
997}
998
999function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
1000 return brandIfNonEmpty(value, ProviderItemId.makeUnsafe);
1001}
apps/server/src/codexTextGenerator.ts deleted-252
......@@ -1,252 +0,0 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import type {
7 CommitMessageGenerationInput,
8 CommitMessageGenerationResult,
9 PrContentGenerationInput,
10 PrContentGenerationResult,
11 TextGenerationService,
12} from "./coreServices";
13import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
14
15type ProcessRunner = (
16 command: string,
17 args: readonly string[],
18 options?: ProcessRunOptions,
19) => Promise<ProcessRunResult>;
20
21const CODEX_MODEL = "gpt-5.3-codex";
22const CODEX_REASONING_EFFORT = "low";
23
24const 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
34const 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
44function 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
57function 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
70function 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
76async 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
82async function safeUnlink(filePath: string): Promise<void> {
83 try {
84 await fs.unlink(filePath);
85 } catch {
86 // Best-effort cleanup.
87 }
88}
89
90function 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
103function 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
111async 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
171interface CodexTextGeneratorDeps {
172 runProcess?: ProcessRunner;
173}
174
175export 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 */
9import * as Net from "node:net";
10import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
11
12export const DEFAULT_PORT = 3773;
13
14export type RuntimeMode = "web" | "desktop";
15
16/**
17 * ServerConfigShape - Process/runtime configuration required by the server.
18 */
19export 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 */
37export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
38 "t3/config/ServerConfig",
39) {}
40
41// Helpers
42
43export 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 */
51export 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 */
63export 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
103export 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 @@
1export interface CommitMessageGenerationInput {
2 cwd: string;
3 branch: string | null;
4 stagedSummary: string;
5 stagedPatch: string;
6}
7
8export interface CommitMessageGenerationResult {
9 subject: string;
10 body: string;
11}
12
13export interface PrContentGenerationInput {
14 cwd: string;
15 baseBranch: string;
16 headBranch: string;
17 commitSummary: string;
18 diffSummary: string;
19 diffPatch: string;
20}
21
22export interface PrContentGenerationResult {
23 title: string;
24 body: string;
25}
26
27export 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 @@
1import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2import fs from "node:fs";
3import os from "node:os";
4import path from "node:path";
5
6import { describe, expect, it } from "vitest";
7
8import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore";
9import { runProcess } from "./processRunner";
10
11async 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
22function exists(filePath: string): boolean {
23 return fs.existsSync(filePath);
24}
25
26async 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
38async 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
44describe("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 @@
1import { mkdtemp, rm } from "node:fs/promises";
2import os from "node:os";
3import path from "node:path";
4import { randomUUID } from "node:crypto";
5
6import { runProcess } from "./processRunner";
7
8const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
9const DEFAULT_TIMEOUT_MS = 30_000;
10const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
11
12function quoteGitCommand(args: readonly string[]): string {
13 return `git ${args.join(" ")}`;
14}
15
16function checkpointRefThreadSegment(threadId: string): string {
17 return Buffer.from(threadId, "utf8").toString("base64url");
18}
19
20export 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
27interface GitOptions {
28 env?: NodeJS.ProcessEnv;
29 stdin?: string;
30 allowNonZeroExit?: boolean;
31}
32
33export 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 @@
1import { execFileSync } from "node:child_process";
2
3export 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 @@
1import { existsSync } from "node:fs";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import path from "node:path";
5import { describe, expect, it, vi } from "vitest";
6
7import {
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). */
22async 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. */
35async 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. */
46async 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
55async 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
76describe("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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
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";
23import { type ProcessRunResult, runProcess } from "./processRunner";
24
25export interface TerminalCommandInput {
26 command: string;
27 cwd: string;
28 timeoutMs?: number;
29 maxOutputBytes?: number;
30}
31
32export type TerminalCommandResult = ProcessRunResult;
33
34export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
35 upstreamRef: string | null;
36}
37
38export interface GitPreparedCommitContext {
39 stagedSummary: string;
40 stagedPatch: string;
41}
42
43export interface GitPushResult {
44 status: "pushed" | "skipped_up_to_date";
45 branch: string;
46 upstreamBranch?: string | undefined;
47 setUpstream?: boolean | undefined;
48}
49
50export interface GitRangeContext {
51 commitSummary: string;
52 diffSummary: string;
53 diffPatch: string;
54}
55
56interface RunGitOptions {
57 timeoutMs?: number | undefined;
58 allowNonZeroExit?: boolean | undefined;
59}
60
61interface ExecuteGitOptions {
62 timeoutMs?: number | undefined;
63 allowNonZeroExit?: boolean | undefined;
64 fallbackErrorMessage?: string | undefined;
65}
66
67const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
68const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
69const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
70
71/** Spawn git directly with an argv array — no shell, no quoting needed. */
72function 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
86function trimStdout(value: string): string {
87 return value.trim();
88}
89
90function 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
99function 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
123function 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
145function commandLabel(args: readonly string[]): string {
146 return `git ${args.join(" ")}`;
147}
148
149function 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
162function 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
175async 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
196async 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
220export 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
679const defaultGitCoreService = new GitCoreService();
680
681export 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
711export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
712 return defaultGitCoreService.listBranches(input);
713}
714
715export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> {
716 const input = gitPullInputSchema.parse(raw);
717 return defaultGitCoreService.pullCurrentBranch(input.cwd);
718}
719
720export async function createGitWorktree(
721 input: GitCreateWorktreeInput,
722): Promise<GitCreateWorktreeResult> {
723 return defaultGitCoreService.createWorktree(input);
724}
725
726export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {
727 await defaultGitCoreService.removeWorktree(input);
728}
729
730export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {
731 await defaultGitCoreService.createBranch(input);
732}
733
734export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {
735 await defaultGitCoreService.checkoutBranch(input);
736}
737
738export 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 @@
1import { Schema } from "effect";
2
3/**
4 * GitCommandError - Git command execution failed.
5 */
6export 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 */
21export 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 */
34export 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 */
50export 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 */
63export type GitManagerServiceError =
64 | GitManagerError
65 | GitCommandError
66 | GitHubCliError
67 | TextGenerationError;
apps/server/src/git/Layers/CodexTextGeneration.test.ts created+205
......@@ -0,0 +1,205 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { expect } from "vitest";
5
6import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
7import { TextGenerationError } from "../Errors.ts";
8import { TextGeneration } from "../Services/TextGeneration.ts";
9
10function 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
46function 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
114const CodexTextGenerationTestLayer = Layer.provideMerge(
115 CodexTextGenerationLive,
116 NodeServices.layer,
117);
118
119it.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 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
5
6import { TextGenerationError } from "../Errors.ts";
7import {
8 type CommitMessageGenerationResult,
9 type PrContentGenerationResult,
10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";
13
14const CODEX_MODEL = "gpt-5.3-codex";
15const CODEX_REASONING_EFFORT = "low";
16const CODEX_TIMEOUT_MS = 180_000;
17
18const 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
28const 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
38function 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
74function 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
87function 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
100function 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
106function 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
119function 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
127const 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
393export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration);
apps/server/src/git/Layers/GitCore.test.ts created+1157
......@@ -0,0 +1,1157 @@
1import { existsSync } from "node:fs";
2import path from "node:path";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { describe, expect, vi } from "vitest";
8
9import { GitServiceLive } from "./GitService.ts";
10import { GitService, type GitServiceShape } from "../Services/GitService.ts";
11import { GitCoreLive } from "./GitCore.ts";
12import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
13import { GitCommandError } from "../Errors.ts";
14import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
15
16// ── Helpers ──
17
18const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
19const GitCoreTestLayer = GitCoreLive.pipe(
20 Layer.provide(GitServiceTestLayer),
21 Layer.provide(NodeServices.layer),
22);
23const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);
24
25function 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
34function 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). */
45function 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
63function 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
88const 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
115function 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
122function 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
129function 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
136function 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
143function 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
150function 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
157function 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. */
165function 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
182function 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
206it.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 @@
1import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect";
2
3import { GitCommandError } from "../Errors.ts";
4import { GitService } from "../Services/GitService.ts";
5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
6
7const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
8const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
9const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
10
11class StatusUpstreamRefreshCacheKey extends Data.Class<{
12 cwd: string;
13 upstreamRef: string;
14 remoteName: string;
15 upstreamBranch: string;
16}> {}
17
18interface ExecuteGitOptions {
19 timeoutMs?: number | undefined;
20 allowNonZeroExit?: boolean | undefined;
21 fallbackErrorMessage?: string | undefined;
22}
23
24function 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
33function 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
57function 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
79function commandLabel(args: readonly string[]): string {
80 return `git ${args.join(" ")}`;
81}
82
83function 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
99const 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
759export const GitCoreLive = Layer.effect(GitCore, makeGitCore);
apps/server/src/git/Layers/GitHubCli.ts created+178
......@@ -0,0 +1,178 @@
1import { Effect, Layer } from "effect";
2
3import { runProcess } from "../../processRunner";
4import { GitHubCliError } from "../Errors.ts";
5import { GitHubCli, type GitHubCliShape } from "../Services/GitHubCli.ts";
6
7const DEFAULT_TIMEOUT_MS = 30_000;
8
9function 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
47function 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
102const 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
178export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli);
apps/server/src/git/Layers/GitManager.test.ts created+680
......@@ -0,0 +1,680 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { expect } from "vitest";
8
9import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
10import { type GitManagerShape } from "../Services/GitManager.ts";
11import {
12 type GitHubCliShape,
13 type GitHubPullRequestSummary,
14 GitHubCli,
15} from "../Services/GitHubCli.ts";
16import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
17import { GitServiceLive } from "./GitService.ts";
18import { GitService } from "../Services/GitService.ts";
19import { GitCoreLive } from "./GitCore.ts";
20import { makeGitManager } from "./GitManager.ts";
21
22interface FakeGhScenario {
23 prListSequence?: string[];
24 createdPrUrl?: string;
25 defaultBranch?: string;
26 failWith?: GitHubCliError;
27}
28
29interface 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
46function 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
55function 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
75function 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
89function 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
101function 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
142function 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
261function 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
272function 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
297const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);
298
299it.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 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, FileSystem, Layer, Path } from "effect";
4
5import { GitManagerError } from "../Errors.ts";
6import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
7import { GitCore } from "../Services/GitCore.ts";
8import { GitHubCli } from "../Services/GitHubCli.ts";
9import { TextGeneration } from "../Services/TextGeneration.ts";
10
11interface OpenPrInfo {
12 number: number;
13 title: string;
14 url: string;
15 baseRefName: string;
16 headRefName: string;
17}
18
19interface PullRequestInfo extends OpenPrInfo {
20 state: "open" | "closed" | "merged";
21 updatedAt: string | null;
22}
23
24function 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
75function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
76 return new GitManagerError({
77 operation,
78 detail,
79 ...(cause !== undefined ? { cause } : {}),
80 });
81}
82
83function limitContext(value: string, maxChars: number): string {
84 if (value.length <= maxChars) return value;
85 return `${value.slice(0, maxChars)}\n\n[truncated]`;
86}
87
88function 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
101function 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
119function 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
138function 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
156export 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
420export const GitManagerLive = Layer.effect(GitManager, makeGitManager);
apps/server/src/git/Layers/GitService.test.ts created+59
......@@ -0,0 +1,59 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, assert } from "@effect/vitest";
3import { Effect, Layer, Schema } from "effect";
4
5import { GitCommandError } from "../Errors.ts";
6import { GitServiceLive } from "./GitService.ts";
7import { GitService } from "../Services/GitService.ts";
8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
10
11layer("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 */
9import { Effect, Layer, Option, Schema, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "../Errors.ts";
12import {
13 ExecuteGitInput,
14 ExecuteGitResult,
15 GitService,
16 GitServiceShape,
17} from "../Services/GitService.ts";
18
19const DEFAULT_TIMEOUT_MS = 30_000;
20const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
21
22function quoteGitCommand(args: ReadonlyArray<string>): string {
23 return `git ${args.join(" ")}`;
24}
25
26function 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
42const 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
70const 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
144export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11import 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
25import type { GitCommandError } from "../Errors.ts";
26
27export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
28 upstreamRef: string | null;
29}
30
31export interface GitPreparedCommitContext {
32 stagedSummary: string;
33 stagedPatch: string;
34}
35
36export interface GitPushResult {
37 status: "pushed" | "skipped_up_to_date";
38 branch: string;
39 upstreamBranch?: string | undefined;
40 setUpstream?: boolean | undefined;
41}
42
43export 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 */
52export 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 */
146export 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 */
8import { ServiceMap } from "effect";
9import type { Effect } from "effect";
10
11import type { ProcessRunResult } from "../../processRunner";
12import type { GitHubCliError } from "../Errors.ts";
13
14export 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 */
25export 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 */
66export 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 */
9import {
10 GitRunStackedActionInput,
11 GitRunStackedActionResult,
12 GitStatusInput,
13 GitStatusResult,
14} from "@t3tools/contracts";
15import { ServiceMap } from "effect";
16import type { Effect } from "effect";
17import type { GitManagerServiceError } from "../Errors.ts";
18
19/**
20 * GitManagerShape - Service API for high-level Git workflow actions.
21 */
22export 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 */
41export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { GitCommandError } from "../Errors.ts";
13
14export 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
24export 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 */
33export 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 */
43export 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 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { TextGenerationError } from "../Errors.ts";
13
14export interface CommitMessageGenerationInput {
15 cwd: string;
16 branch: string | null;
17 stagedSummary: string;
18 stagedPatch: string;
19}
20
21export interface CommitMessageGenerationResult {
22 subject: string;
23 body: string;
24}
25
26export interface PrContentGenerationInput {
27 cwd: string;
28 baseBranch: string;
29 headBranch: string;
30 commitSummary: string;
31 diffSummary: string;
32 diffPatch: string;
33}
34
35export interface PrContentGenerationResult {
36 title: string;
37 body: string;
38}
39
40export 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 */
50export 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 */
69export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { GitManager } from "./gitManager";
8import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
9
10interface FakeGhScenario {
11 prListSequence?: string[];
12 createdPrUrl?: string;
13 defaultBranch?: string;
14 failWith?: Error;
15}
16
17interface 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
34const tempDirs: string[] = [];
35
36function makeTempDir(prefix: string): string {
37 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
38 tempDirs.push(dir);
39 return dir;
40}
41
42async function runGit(
43 cwd: string,
44 args: readonly string[],
45 allowNonZeroExit = false,
46): Promise<ProcessRunResult> {
47 return runProcess("git", args, { cwd, allowNonZeroExit });
48}
49
50async function runGitStdout(cwd: string, args: readonly string[]): Promise<string> {
51 const result = await runGit(cwd, args);
52 return result.stdout.trim();
53}
54
55async 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
64async function createBareRemote(): Promise<string> {
65 const remoteDir = makeTempDir("t3code-git-remote-");
66 await runProcess("git", ["init", "--bare"], { cwd: remoteDir });
67 return remoteDir;
68}
69
70function 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
84function 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
157afterEach(() => {
158 for (const dir of tempDirs.splice(0, tempDirs.length)) {
159 fs.rmSync(dir, { recursive: true, force: true });
160 }
161});
162
163describe("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 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5
6import {
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
17import type {
18 CommitMessageGenerationResult,
19 PrContentGenerationResult,
20 TextGenerationService,
21} from "./coreServices";
22import { CodexTextGenerator } from "./codexTextGenerator";
23import { GitCoreService } from "./git";
24import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner";
25
26const GH_CLI_TIMEOUT_MS = 30_000;
27
28interface GitManagerDeps {
29 runProcess?: (
30 command: string,
31 args: readonly string[],
32 options?: ProcessRunOptions,
33 ) => Promise<ProcessRunResult>;
34 textGenerator?: TextGenerationService;
35 gitCore?: GitCoreService;
36}
37
38interface 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
48function 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
102function trimStdout(value: string): string {
103 return value.trim();
104}
105
106function limitContext(value: string, maxChars: number): string {
107 if (value.length <= maxChars) return value;
108 return `${value.slice(0, maxChars)}\n\n[truncated]`;
109}
110
111function 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
123function 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
141function 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
160function 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
172function 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
186function 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
204export 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 @@
1import fs from "node:fs";
2import net from "node:net";
3import os from "node:os";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
6
7import { fixPath } from "./fixPath";
8import { createLogger } from "./logger";
9import { ProjectRegistry } from "./projectRegistry";
10import { createServer } from "./wsServer";
11
12fixPath();
13
14const DEFAULT_PORT = 3773;
15const cwd = process.cwd();
16const logger = createLogger("server");
17
18type RuntimeMode = "web" | "desktop";
19
20function 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
28function 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
37function 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
45function 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
52async 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
74function 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
99async 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
173main().catch((err) => {
174 logger.error("failed to start T3 Code", { error: err });
175 process.exit(1);
176});
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { CliConfig, t3Cli } from "./main";
7import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";
11import { NetService } from "./config";
12
13const 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
21Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/keybindings.test.ts+456-191
......@@ -1,225 +1,490 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { assert, afterEach, describe, expect, it, vi } from "vitest";
1import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6import { ServerConfig, type ServerConfigShape } from "./config";
57
68import {
9 DEFAULT_KEYBINDINGS,
10 Keybindings,
11 KeybindingsConfigError,
12 KeybindingsLive,
13 ResolvedKeybindingFromConfig,
714 compileResolvedKeybindingRule,
15 compileResolvedKeybindingsConfig,
816 parseKeybindingShortcut,
9 upsertKeybindingRule,
1017} from "./keybindings";
1118
12describe("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 });
19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
20const 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 );
3135
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 });
36const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) =>
37 effect.pipe(
38 Effect.mapError((error) => error.detail),
39 Effect.result,
40 );
41
42const 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);
4947 });
5048
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 });
49const 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 });
5755
58 assert.deepEqual(compiled, {
59 command: "terminal.split",
60 shortcut: {
61 key: "d",
56it.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",
6261 metaKey: false,
6362 ctrlKey: false,
6463 shiftKey: false,
6564 altKey: false,
6665 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 );
8677
87 assert.isNull(
88 compileResolvedKeybindingRule({
78 it.effect("compiles valid rule with parsed when AST", () =>
79 Effect.sync(() => {
80 const compiled = compileResolvedKeybindingRule({
8981 key: "mod+d",
9082 command: "terminal.split",
91 when: "terminalFocus && (",
92 }),
93 );
83 when: "terminalOpen && !terminalFocus",
84 });
9485
95 assert.isNull(
96 compileResolvedKeybindingRule({
97 key: "mod+d",
86 assert.deepEqual(compiled, {
9887 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 );
103107
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 });
115121
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 );
131126
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 );
143135
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 );
148143
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 );
155153
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));
173159
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 );
198194
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));
208266 });
209267
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 ]);
216273
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 ),
219295 );
220 const tempFiles = fs
221 .readdirSync(configDir)
222 .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp"));
223 expect(tempFiles).toEqual([]);
224296 });
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 );
225490});
apps/server/src/keybindings.ts+551-155
......@@ -1,19 +1,55 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import 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 */
9import {
610 KeybindingRule,
711 KeybindingsConfig,
812 KeybindingShortcut,
913 KeybindingWhenNode,
14 MAX_KEYBINDINGS_COUNT,
15 MAX_WHEN_EXPRESSION_DEPTH,
1016 ResolvedKeybindingRule,
1117 ResolvedKeybindingsConfig,
18 type ServerConfigIssue,
1219} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
14
15interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;
20import { Mutable } from "effect/Types";
21import {
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";
39import * as Semaphore from "effect/Semaphore";
40import { ServerConfig } from "./config";
41
42export 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 }
1753}
1854
1955type WhenToken =
......@@ -24,13 +60,12 @@ type WhenToken =
2460 | { type: "lparen" }
2561 | { type: "rparen" };
2662
27const MAX_WHEN_EXPRESSION_DEPTH = 256;
28
2963export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
3064 { key: "mod+j", command: "terminal.toggle" },
3165 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
3266 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
3367 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
68 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
3469 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
3570 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
3671 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
......@@ -43,6 +78,7 @@ function normalizeKeyToken(token: string): string {
4378 return token;
4479}
4580
81/** @internal - Exported for testing */
4682export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
4783 const rawTokens = value
4884 .toLowerCase()
......@@ -156,7 +192,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null {
156192 return tokens;
157193}
158194
159export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
195function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
160196 const tokens = tokenizeWhenExpression(expression);
161197 if (!tokens || tokens.length === 0) return null;
162198 let index = 0;
......@@ -241,15 +277,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
241277 return ast;
242278}
243279
280/** @internal - Exported for testing */
244281export 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);
248283 if (!shortcut) return null;
249284
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);
253287 if (!whenAst) return null;
254288 return {
255289 command: rule.command,
......@@ -267,114 +301,148 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey
267301export function compileResolvedKeybindingsConfig(
268302 config: KeybindingsConfig,
269303): ResolvedKeybindingsConfig {
270 const compiled: ResolvedKeybindingsConfig = [];
304 const compiled: Mutable<ResolvedKeybindingsConfig> = [];
271305 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 }
275310 }
276311 return compiled;
277312}
278313
279function 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;
314export 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
352export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check(
353 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
354);
355
356function 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
364function 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
372function 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;
289377}
290378
291const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
292const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");
293const MAX_KEYBINDINGS = 256;
379function 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}
294391
295class KeybindingsConfigParseError extends Error {
296 constructor(configPath: string, detail: string, options?: { cause?: unknown }) {
297 super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options);
392function 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)})`;
298402 }
299403}
300404
301function resolveKeybindingsConfigPath(): string {
302 return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);
405const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
406
407const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown));
408const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
409const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
410 SchemaGetter.stringifyJson({ space: 2 }),
411);
412const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(
413 Schema.encode({
414 decode: PrettyJsonString,
415 encode: PrettyJsonString,
416 }),
417);
418
419export interface KeybindingsConfigState {
420 readonly keybindings: ResolvedKeybindingsConfig;
421 readonly issues: readonly ServerConfigIssue[];
303422}
304423
305function 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 }
424export interface KeybindingsChangeEvent {
425 readonly issues: readonly ServerConfigIssue[];
426}
324427
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 }
428function trimIssueMessage(message: string): string {
429 const trimmed = message.trim();
430 return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration.";
367431}
368432
369function 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 }
433function malformedConfigIssue(detail: string): ServerConfigIssue {
434 return {
435 kind: "keybindings.malformed-config",
436 message: trimIssueMessage(detail),
437 };
438}
439
440function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
441 return {
442 kind: "keybindings.invalid-entry",
443 index,
444 message: trimIssueMessage(detail),
445 };
378446}
379447
380448function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
......@@ -388,60 +456,388 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve
388456 );
389457 const merged = [...retainedDefaults, ...custom];
390458
391 if (merged.length <= MAX_KEYBINDINGS) {
459 if (merged.length <= MAX_KEYBINDINGS_COUNT) {
392460 return merged;
393461 }
394462
395463 // 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);
397465}
398466
399export 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 */
470export 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>;
416496}
417497
418export 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 */
501export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()(
502 "t3/keybindings",
503) {}
504
505const 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 }
426545
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)));
430581 });
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 }
441582
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 }
444593
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
843export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings);
apps/server/src/main.test.ts created+242
......@@ -0,0 +1,242 @@
1import * as Http from "node:http";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it, vi } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Command from "effect/unstable/cli/Command";
8import { beforeEach } from "vitest";
9
10import { CliConfig, t3Cli, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";
12import { Open, type OpenShape } from "./open";
13import { Server, type ServerShape } from "./wsServer";
14
15const start = vi.fn(() => undefined);
16const stop = vi.fn(() => undefined);
17let resolvedConfig: ServerConfigShape | null = null;
18const 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);
26const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
27
28// Shared service layer used by this CLI test suite.
29const 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
49const 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
57beforeEach(() => {
58 vi.clearAllMocks();
59 resolvedConfig = null;
60 start.mockImplementation(() => undefined);
61 stop.mockImplementation(() => undefined);
62 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
63});
64
65it.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 */
9import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
10import { Command, Flag } from "effect/unstable/cli";
11
12import {
13 DEFAULT_PORT,
14 NetService,
15 resolveStaticDir,
16 ServerConfig,
17 type RuntimeMode,
18 type ServerConfigShape,
19} from "./config";
20import { fixPath, resolveStateDir } from "./os-jank";
21import { Open } from "./open";
22import * as SqlitePersistence from "./persistence/Layers/Sqlite";
23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
24import { Server } from "./wsServer";
25
26export class StartupError extends Data.TaggedError("StartupError")<{
27 readonly message: string;
28 readonly cause?: unknown;
29}> {}
30
31interface 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 */
46export 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 */
66export 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
86const 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
121const 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
196const 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
204const isWildcardHost = (host: string | undefined): boolean =>
205 host === "0.0.0.0" || host === "::" || host === "[::]";
206
207const formatHostForUrl = (host: string): string =>
208 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
209
210const 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
263const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
264 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
265 Flag.optional,
266);
267const 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);
272const 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);
276const stateDirFlag = Flag.string("state-dir").pipe(
277 Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."),
278 Flag.optional,
279);
280const 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);
285const noBrowserFlag = Flag.boolean("no-browser").pipe(
286 Flag.withDescription("Disable automatic browser opening."),
287 Flag.optional,
288);
289const authTokenFlag = Flag.string("auth-token").pipe(
290 Flag.withDescription("Auth token required for WebSocket connections."),
291 Flag.withAlias("token"),
292 Flag.optional,
293);
294const 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);
300const 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
308export 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 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { launchDetached, resolveEditorLaunch } from "./open";
4import { Effect } from "effect";
5import { assertSuccess } from "@effect/vitest/utils";
6
7describe("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
75describe("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 */
9import { spawn } from "node:child_process";
10
11import { EDITORS, type EditorId } from "@t3tools/contracts";
12import { ServiceMap, Schema, Effect, Layer } from "effect";
13
14// ==============================
15// Definitions
16// ==============================
17
18export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
19 message: Schema.String,
20 cause: Schema.optional(Schema.Defect),
21}) {}
22
23export interface OpenInEditorInput {
24 readonly cwd: string;
25 readonly editor: EditorId;
26}
27
28interface EditorLaunch {
29 readonly command: string;
30 readonly args: ReadonlyArray<string>;
31}
32
33const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
34
35function 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 */
42export 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 */
59export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
60
61// ==============================
62// Implementations
63// ==============================
64
65export 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
94export 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
119const 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
135export const OpenLive = Layer.effect(Open, make);
apps/server/src/orchestration/Errors.ts created+123
......@@ -0,0 +1,123 @@
1import { SchemaIssue, Schema } from "effect";
2
3import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
4
5export 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
17export 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
29export 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
42export 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
55export 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
68export 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
81export type OrchestrationDispatchError =
82 | ProjectionRepositoryError
83 | OrchestrationCommandInvariantError
84 | OrchestrationCommandPreviouslyRejectedError
85 | OrchestrationProjectorDecodeError
86 | OrchestrationListenerCallbackError;
87
88export type OrchestrationEngineError =
89 | OrchestrationDispatchError
90 | OrchestrationCommandJsonParseError
91 | OrchestrationCommandDecodeError;
92
93export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) {
94 return new OrchestrationCommandDecodeError({
95 issue: SchemaIssue.makeFormatterDefault()(error.issue),
96 cause: error,
97 });
98}
99
100export 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
109export function toOrchestrationJsonParseError(cause: unknown) {
110 return new OrchestrationCommandJsonParseError({
111 detail: `Failed to parse orchestration command JSON`,
112 cause,
113 });
114}
115
116export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { execFileSync } from "node:child_process";
5
6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
7import {
8 CommandId,
9 EventId,
10 MessageId,
11 ProjectId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,
16 TurnId,
17} from "@t3tools/contracts";
18import * as NodeServices from "@effect/platform-node/NodeServices";
19import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
20import { afterEach, describe, expect, it, vi } from "vitest";
21
22import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
23import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
24import { CheckpointReactorLive } from "./CheckpointReactor.ts";
25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
27import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
28import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
29import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
30import {
31 OrchestrationEngineService,
32 type OrchestrationEngineShape,
33} from "../Services/OrchestrationEngine.ts";
34import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
35import {
36 ProviderService,
37 type ProviderServiceShape,
38} from "../../provider/Services/ProviderService.ts";
39import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
40
41const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
42const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
43const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
44const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
45const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
46
47function 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
93async 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
122async 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
144function runGit(cwd: string, args: ReadonlyArray<string>) {
145 return execFileSync("git", args, {
146 cwd,
147 stdio: ["ignore", "pipe", "pipe"],
148 encoding: "utf8",
149 });
150}
151
152function 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
163function 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
172function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
173 return runGit(cwd, ["show", `${ref}:${filePath}`]);
174}
175
176async 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
191describe("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 @@
1import {
2 CommandId,
3 EventId,
4 MessageId,
5 ProviderSessionId,
6 ProviderThreadId,
7 ThreadId,
8 TurnId,
9 type OrchestrationEvent,
10 type ProviderRuntimeEvent,
11} from "@t3tools/contracts";
12import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
13
14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import {
16 checkpointRefForThreadTurn,
17 resolveThreadWorkspaceCwd,
18} from "../../checkpointing/Utils.ts";
19import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
20import { ProviderService } from "../../provider/Services/ProviderService.ts";
21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
24import { OrchestrationDispatchError } from "../Errors.ts";
25
26type ReactorInput =
27 | {
28 readonly source: "runtime";
29 readonly event: ProviderRuntimeEvent;
30 }
31 | {
32 readonly source: "domain";
33 readonly event: OrchestrationEvent;
34 };
35
36function toTurnId(value: string | undefined): TurnId | null {
37 return value === undefined ? null : TurnId.makeUnsafe(value);
38}
39
40function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
41 return value === undefined ? null : ProviderThreadId.makeUnsafe(value);
42}
43
44function 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
51function 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
64const serverCommandId = (tag: string): CommandId =>
65 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
66
67const 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
737export const CheckpointReactorLive = Layer.effect(CheckpointReactor, make);
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts created+652
......@@ -0,0 +1,652 @@
1import {
2 CheckpointRef,
3 CommandId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8 type OrchestrationEvent,
9} from "@t3tools/contracts";
10import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
11import { describe, expect, it } from "vitest";
12
13import { PersistenceSqlError } from "../../persistence/Errors.ts";
14import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
15import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import {
18 OrchestrationEventStore,
19 type OrchestrationEventStoreShape,
20} from "../../persistence/Services/OrchestrationEventStore.ts";
21import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
22import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
23import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
24import {
25 OrchestrationProjectionPipeline,
26 type OrchestrationProjectionPipelineShape,
27} from "../Services/ProjectionPipeline.ts";
28
29const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
30const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
31const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
32const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
33
34async 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
50function now() {
51 return new Date().toISOString();
52}
53
54describe("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 @@
1import type {
2 OrchestrationEvent,
3 OrchestrationReadModel,
4 ProjectId,
5 ThreadId,
6} from "@t3tools/contracts";
7import { OrchestrationCommand } from "@t3tools/contracts";
8import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect";
9import * as SqlClient from "effect/unstable/sql/SqlClient";
10
11import { toPersistenceSqlError } from "../../persistence/Errors.ts";
12import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
13import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
14import {
15 OrchestrationCommandInvariantError,
16 OrchestrationCommandPreviouslyRejectedError,
17 type OrchestrationDispatchError,
18} from "../Errors.ts";
19import { decideOrchestrationCommand } from "../decider.ts";
20import { createEmptyReadModel, projectEvent } from "../projector.ts";
21import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
22import {
23 OrchestrationEngineService,
24 type OrchestrationEngineShape,
25} from "../Services/OrchestrationEngine.ts";
26
27interface CommandEnvelope {
28 command: OrchestrationCommand;
29 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
30}
31
32function 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
52function formatDispatchError(error: OrchestrationDispatchError): string {
53 if (error instanceof Error && error.message.length > 0) {
54 return error.message;
55 }
56 return String(error);
57}
58
59const 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
243export const OrchestrationEngineLive = Layer.effect(
244 OrchestrationEngineService,
245 makeOrchestrationEngine,
246);
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts created+61
......@@ -0,0 +1,61 @@
1import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
2import { afterEach, describe, expect, it } from "vitest";
3
4import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
5import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
6import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
7import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
8import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
9
10describe("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 @@
1import { Effect, Layer } from "effect";
2
3import {
4 OrchestrationReactor,
5 type OrchestrationReactorShape,
6} from "../Services/OrchestrationReactor.ts";
7import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
8import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
9import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
10
11export 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
27export const OrchestrationReactorLive = Layer.effect(
28 OrchestrationReactor,
29 makeOrchestrationReactor,
30);
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts created+974
......@@ -0,0 +1,974 @@
1import {
2 CheckpointRef,
3 CommandId,
4 CorrelationId,
5 EventId,
6 MessageId,
7 ProviderSessionId,
8 ProviderThreadId,
9 ProjectId,
10 ThreadId,
11 TurnId,
12} from "@t3tools/contracts";
13import * as NodeServices from "@effect/platform-node/NodeServices";
14import { assert, it } from "@effect/vitest";
15import { Effect, Layer } from "effect";
16import fs from "node:fs";
17import os from "node:os";
18import path from "node:path";
19import * as SqlClient from "effect/unstable/sql/SqlClient";
20
21import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
22import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
23import {
24 makeSqlitePersistenceLive,
25 SqlitePersistenceMemory,
26} from "../../persistence/Layers/Sqlite.ts";
27import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
28import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
29import {
30 ORCHESTRATION_PROJECTOR_NAMES,
31 OrchestrationProjectionPipelineLive,
32} from "./ProjectionPipeline.ts";
33import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
34import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
35
36const projectionLayer = it.layer(
37 OrchestrationProjectionPipelineLive.pipe(
38 Layer.provideMerge(OrchestrationEventStoreLive),
39 Layer.provideMerge(SqlitePersistenceMemory),
40 ),
41);
42
43projectionLayer("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
760it.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
878const 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
887engineLayer("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 @@
1import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts";
2import { Effect, Layer, Option, Stream } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
6import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
7import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts";
8import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
9import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
10import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
11import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
12import {
13 type ProjectionThreadMessage,
14 ProjectionThreadMessageRepository,
15} from "../../persistence/Services/ProjectionThreadMessages.ts";
16import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
17import {
18 type ProjectionTurn,
19 ProjectionTurnRepository,
20} from "../../persistence/Services/ProjectionTurns.ts";
21import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
22import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
23import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
24import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
25import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";
26import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts";
27import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
28import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
29import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
30import {
31 OrchestrationProjectionPipeline,
32 type OrchestrationProjectionPipelineShape,
33} from "../Services/ProjectionPipeline.ts";
34
35export 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
46type ProjectorName =
47 (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];
48
49interface ProjectorDefinition {
50 readonly name: ProjectorName;
51 readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>;
52}
53
54function 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
62function 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
146function 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
166const 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
900export 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 @@
1import {
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ThreadId,
9 TurnId,
10} from "@t3tools/contracts";
11import { assert, it } from "@effect/vitest";
12import { Effect, Layer } from "effect";
13import * as SqlClient from "effect/unstable/sql/SqlClient";
14
15import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
16import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
17import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
18import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
19
20const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
21const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
22const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
23const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
24const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
25const asProviderSessionId = (value: string): ProviderSessionId =>
26 ProviderSessionId.makeUnsafe(value);
27const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
28
29const projectionSnapshotLayer = it.layer(
30 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
31);
32
33projectionSnapshotLayer("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 @@
1import {
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";
16import { Effect, Layer, Schema, Struct } from "effect";
17import * as SqlClient from "effect/unstable/sql/SqlClient";
18import * as SqlSchema from "effect/unstable/sql/SqlSchema";
19
20import {
21 isPersistenceError,
22 toPersistenceDecodeError,
23 toPersistenceSqlError,
24 type ProjectionRepositoryError,
25} from "../../persistence/Errors.ts";
26import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
27import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
28import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
29import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
30import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
31import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
32import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
33import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
34import {
35 ProjectionSnapshotQuery,
36 type ProjectionSnapshotQueryShape,
37} from "../Services/ProjectionSnapshotQuery.ts";
38
39const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
40const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
41 Struct.assign({
42 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
43 }),
44);
45const ProjectionThreadDbRowSchema = ProjectionThread;
46const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
47 Struct.assign({
48 isStreaming: Schema.Number,
49 }),
50);
51const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
52 Struct.assign({
53 payload: Schema.fromJsonString(Schema.Unknown),
54 }),
55);
56const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
57const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
58 Struct.assign({
59 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
60 }),
61);
62const 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});
71const ProjectionStateDbRowSchema = ProjectionState;
72
73const 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
82function maxIso(left: string | null, right: string): string {
83 if (left === null) {
84 return right;
85 }
86 return left > right ? left : right;
87}
88
89function 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
113function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
114 return (cause: unknown): ProjectionRepositoryError =>
115 Schema.isSchemaError(cause)
116 ? toPersistenceDecodeError(decodeOperation)(cause)
117 : toPersistenceSqlError(sqlOperation)(cause);
118}
119
120const 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
517export const OrchestrationProjectionSnapshotQueryLive = Layer.effect(
518 ProjectionSnapshotQuery,
519 makeProjectionSnapshotQuery,
520);
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts created+493
......@@ -0,0 +1,493 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {
3 ApprovalRequestId,
4 CommandId,
5 MessageId,
6 ProjectId,
7 ProviderSessionId,
8 ProviderThreadId,
9 ProviderTurnId,
10 ThreadId,
11 TurnId,
12} from "@t3tools/contracts";
13import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
14import { afterEach, describe, expect, it, vi } from "vitest";
15
16import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
17import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
18import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
19import {
20 ProviderService,
21 type ProviderServiceShape,
22} from "../../provider/Services/ProviderService.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
26import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
27import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
28
29const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
31const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
32const asApprovalRequestId = (value: string): ApprovalRequestId =>
33 ApprovalRequestId.makeUnsafe(value);
34const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
35const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
36
37async 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
53describe("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 @@
1import {
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";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
16
17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import { ProviderService } from "../../provider/Services/ProviderService.ts";
19import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
20import {
21 ProviderCommandReactor,
22 type ProviderCommandReactorShape,
23} from "../Services/ProviderCommandReactor.ts";
24
25type 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
36function toNonEmptyProviderInput(value: string | undefined): string | undefined {
37 const normalized = value?.trim();
38 return normalized && normalized.length > 0 ? normalized : undefined;
39}
40
41function 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
59const turnStartKeyForEvent = (event: ProviderIntentEvent): string =>
60 event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`;
61
62const serverCommandId = (tag: string): CommandId =>
63 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
64
65const HANDLED_TURN_START_KEY_MAX = 10_000;
66const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
67const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
68const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
69
70const 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
430export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make);
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts created+751
......@@ -0,0 +1,751 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {
3 CommandId,
4 EventId,
5 MessageId,
6 ProjectId,
7 ProviderItemId,
8 ProviderSessionId,
9 ProviderThreadId,
10 ProviderTurnId,
11 ThreadId,
12} from "@t3tools/contracts";
13import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
14import { afterEach, describe, expect, it } from "vitest";
15
16import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
17import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
18import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
19import {
20 ProviderService,
21 type ProviderServiceShape,
22} from "../../provider/Services/ProviderService.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
26import {
27 OrchestrationEngineService,
28 type OrchestrationEngineShape,
29} from "../Services/OrchestrationEngine.ts";
30import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
31
32const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
33const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
34const asProviderThreadId = (value: string): ProviderThreadId =>
35 ProviderThreadId.makeUnsafe(value);
36const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
37const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
38const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
39const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
40
41function 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
67async 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
96describe("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 @@
1import {
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";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";
16
17import { ProviderService } from "../../provider/Services/ProviderService.ts";
18import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
19import {
20 ProviderRuntimeIngestionService,
21 type ProviderRuntimeIngestionShape,
22} from "../Services/ProviderRuntimeIngestion.ts";
23
24const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;
25const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
26 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
27
28const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered";
29const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "on-request";
30const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
31const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
32const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
33const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
34const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120);
35const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
36const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0";
37
38type TurnStartRequestedDomainEvent = Extract<
39 OrchestrationEvent,
40 { type: "thread.turn-start-requested" }
41>;
42
43type RuntimeIngestionInput =
44 | {
45 source: "runtime";
46 event: ProviderRuntimeEvent;
47 }
48 | {
49 source: "domain";
50 event: TurnStartRequestedDomainEvent;
51 };
52
53function toTurnId(value: string | undefined): TurnId | undefined {
54 return value === undefined ? undefined : TurnId.makeUnsafe(value);
55}
56
57function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
58 return value === undefined ? null : ProviderThreadId.makeUnsafe(value);
59}
60
61function 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
68function truncateDetail(value: string, limit = 180): string {
69 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
70}
71
72function 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
170const 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
639export const ProviderRuntimeIngestionLive = Layer.effect(ProviderRuntimeIngestionService, make);
apps/server/src/orchestration/Schemas.ts created+42
......@@ -0,0 +1,42 @@
1import {
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.
21export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema;
22export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema;
23export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
24
25export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
26export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
27export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
28
29export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
30export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema;
31export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
32export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema;
33export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema;
34
35export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema;
36export const ThreadTurnInterruptRequestedPayload =
37 ContractsThreadTurnInterruptRequestedPayloadSchema;
38export const ThreadApprovalResponseRequestedPayload =
39 ContractsThreadApprovalResponseRequestedPayloadSchema;
40export const ThreadCheckpointRevertRequestedPayload =
41 ContractsThreadCheckpointRevertRequestedPayloadSchema;
42export 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 */
9import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";
11
12/**
13 * CheckpointReactorShape - Service API for checkpoint reactor lifecycle.
14 */
15export 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 */
31export 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 */
13import type {
14 OrchestrationCommand,
15 OrchestrationEvent,
16 OrchestrationReadModel,
17} from "@t3tools/contracts";
18import { ServiceMap } from "effect";
19import type { Effect, Stream } from "effect";
20
21import type { OrchestrationDispatchError } from "../Errors.ts";
22import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
23
24/**
25 * OrchestrationEngineShape - Service API for orchestration command and event flow.
26 */
27export 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 */
77export 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 */
9import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";
11
12/**
13 * OrchestrationReactorShape - Service API for orchestration reactor lifecycle.
14 */
15export 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 */
28export 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 */
9import type { OrchestrationEvent } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProjectionRepositoryError } from "../../persistence/Errors.ts";
14
15/**
16 * OrchestrationProjectionPipelineShape - Service API for projection execution.
17 */
18export 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 */
39export 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 */
9import type { OrchestrationReadModel } from "@t3tools/contracts";
10import { ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProjectionRepositoryError } from "../../persistence/Errors.ts";
14
15/**
16 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
17 */
18export 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 */
31export 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 */
9import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";
11
12/**
13 * ProviderCommandReactorShape - Service API for provider command reactors.
14 */
15export 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 */
31export 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 */
9import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";
11
12/**
13 * ProviderRuntimeIngestionShape - Service API for runtime ingestion lifecycle.
14 */
15export 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 */
31export 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 @@
1import { describe, expect, it } from "vitest";
2import {
3 MessageId,
4 CommandId,
5 ProjectId,
6 ThreadId,
7 type OrchestrationCommand,
8 type OrchestrationReadModel,
9} from "@t3tools/contracts";
10import { Effect } from "effect";
11
12import {
13 findThreadById,
14 listThreadsByProjectId,
15 requireNonNegativeInteger,
16 requireThread,
17 requireThreadAbsent,
18} from "./commandInvariants.ts";
19
20const now = new Date().toISOString();
21
22const 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
83const 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
98describe("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 @@
1import type {
2 OrchestrationCommand,
3 OrchestrationProject,
4 OrchestrationReadModel,
5 OrchestrationThread,
6 ProjectId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { Effect } from "effect";
10
11import { OrchestrationCommandInvariantError } from "./Errors.ts";
12
13function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError {
14 return new OrchestrationCommandInvariantError({
15 commandType,
16 detail,
17 });
18}
19
20export function findThreadById(
21 readModel: OrchestrationReadModel,
22 threadId: ThreadId,
23): OrchestrationThread | undefined {
24 return readModel.threads.find((thread) => thread.id === threadId);
25}
26
27export function findProjectById(
28 readModel: OrchestrationReadModel,
29 projectId: ProjectId,
30): OrchestrationProject | undefined {
31 return readModel.projects.find((project) => project.id === projectId);
32}
33
34export function listThreadsByProjectId(
35 readModel: OrchestrationReadModel,
36 projectId: ProjectId,
37): ReadonlyArray<OrchestrationThread> {
38 return readModel.threads.filter((thread) => thread.projectId === projectId);
39}
40
41export 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
58export 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
74export 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
91export 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
107export 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 @@
1import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3import { Effect } from "effect";
4
5import { decideOrchestrationCommand } from "./decider.ts";
6import { createEmptyReadModel, projectEvent } from "./projector.ts";
7
8const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
9const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
10const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
11
12describe("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 @@
1import type {
2 OrchestrationCommand,
3 OrchestrationEvent,
4 OrchestrationReadModel,
5} from "@t3tools/contracts";
6import { Effect } from "effect";
7
8import { OrchestrationCommandInvariantError } from "./Errors.ts";
9import {
10 requireProject,
11 requireProjectAbsent,
12 requireThread,
13 requireThreadAbsent,
14} from "./commandInvariants.ts";
15
16const nowIso = () => new Date().toISOString();
17const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const;
18
19const 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
30function 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
50export 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 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ThreadId,
6 type OrchestrationEvent,
7} from "@t3tools/contracts";
8import { Effect } from "effect";
9import { describe, expect, it } from "vitest";
10
11import { createEmptyReadModel, projectEvent } from "./projector.ts";
12
13function 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
40describe("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 @@
1import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts";
2import {
3 OrchestrationCheckpointSummary,
4 OrchestrationMessage,
5 OrchestrationSession,
6 OrchestrationThread,
7} from "@t3tools/contracts";
8import { Effect, Schema } from "effect";
9
10import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";
11import {
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
25type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
26const MAX_THREAD_MESSAGES = 2_000;
27const MAX_THREAD_CHECKPOINTS = 500;
28
29function 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
35function 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
43function 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
55function 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
118function 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
127export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
128 return {
129 snapshotSequence: 0,
130 projects: [],
131 threads: [],
132 updatedAt: nowIso,
133 };
134}
135
136export 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 @@
1import * as OS from "node:os";
2import { Effect, Path } from "effect";
3import { execFileSync } from "node:child_process";
4
5export 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
22export 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
33export 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 @@
1import { Schema, SchemaIssue } from "effect";
2
3// ===============================
4// Core Persistence Errors
5// ===============================
6
7export 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
20export 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
33export 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
42export 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
51export 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
60export const isPersistenceError = (u: unknown) =>
61 Schema.is(PersistenceSqlError)(u) || Schema.is(PersistenceDecodeError)(u);
62
63// ===============================
64// Provider Session Repository Errors
65// ===============================
66
67export 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
80export 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
93export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError;
94
95export type ProviderSessionRepositoryError =
96 | ProviderSessionRepositoryValidationError
97 | ProviderSessionRepositoryPersistenceError;
98
99export type OrchestrationCommandReceiptRepositoryError =
100 | PersistenceSqlError
101 | PersistenceDecodeError;
102
103export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError;
104
105export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts created+89
......@@ -0,0 +1,89 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 GetByCommandIdInput,
9 OrchestrationCommandReceipt,
10 OrchestrationCommandReceiptRepository,
11 type OrchestrationCommandReceiptRepositoryShape,
12} from "../Services/OrchestrationCommandReceipts.ts";
13
14const 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
86export const OrchestrationCommandReceiptRepositoryLive = Layer.effect(
87 OrchestrationCommandReceiptRepository,
88 makeOrchestrationCommandReceiptRepository,
89);
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts created+119
......@@ -0,0 +1,119 @@
1import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import { Effect, Layer, Schema, Stream } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { PersistenceDecodeError } from "../Errors.ts";
7import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts";
8import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts";
9import { SqlitePersistenceMemory } from "./Sqlite.ts";
10
11const layer = it.layer(
12 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
13);
14
15layer("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 @@
1import {
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";
14import * as SqlClient from "effect/unstable/sql/SqlClient";
15import * as SqlSchema from "effect/unstable/sql/SqlSchema";
16import { Effect, Layer, Schema, Stream } from "effect";
17
18import {
19 toPersistenceDecodeError,
20 toPersistenceSqlError,
21 type OrchestrationEventStoreError,
22} from "../Errors.ts";
23import {
24 OrchestrationEventStore,
25 type OrchestrationEventStoreShape,
26} from "../Services/OrchestrationEventStore.ts";
27
28const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
29const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
30const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata);
31
32const 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
46const 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
60const ReadFromSequenceRequestSchema = Schema.Struct({
61 sequenceExclusive: NonNegativeInt,
62 limit: Schema.Number,
63});
64const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;
65const READ_PAGE_SIZE = 500;
66
67function 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
91function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
92 return (cause: unknown): OrchestrationEventStoreError =>
93 Schema.isSchemaError(cause)
94 ? toPersistenceDecodeError(decodeOperation)(cause)
95 : toPersistenceSqlError(sqlOperation)(cause);
96}
97
98const 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
269export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore);
apps/server/src/persistence/Layers/ProjectionCheckpoints.ts created+213
......@@ -0,0 +1,213 @@
1import { OrchestrationCheckpointFile } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";
5
6import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
7import {
8 DeleteByThreadIdInput,
9 GetByThreadAndTurnCountInput,
10 ListByThreadIdInput,
11 ProjectionCheckpoint,
12 ProjectionCheckpointRepository,
13 type ProjectionCheckpointRepositoryShape,
14} from "../Services/ProjectionCheckpoints.ts";
15
16const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
17 Struct.assign({
18 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
19 }),
20);
21
22function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
23 return (cause: unknown) =>
24 Schema.isSchemaError(cause)
25 ? toPersistenceDecodeError(decodeOperation)(cause)
26 : toPersistenceSqlError(sqlOperation)(cause);
27}
28
29const 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
210export const ProjectionCheckpointRepositoryLive = Layer.effect(
211 ProjectionCheckpointRepository,
212 makeProjectionCheckpointRepository,
213);
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts created+136
......@@ -0,0 +1,136 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 GetProjectionPendingApprovalInput,
8 DeleteProjectionPendingApprovalInput,
9 ListProjectionPendingApprovalsInput,
10 ProjectionPendingApproval,
11 ProjectionPendingApprovalRepository,
12 type ProjectionPendingApprovalRepositoryShape,
13} from "../Services/ProjectionPendingApprovals.ts";
14
15const 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
133export const ProjectionPendingApprovalRepositoryLive = Layer.effect(
134 ProjectionPendingApprovalRepository,
135 makeProjectionPendingApprovalRepository,
136);
apps/server/src/persistence/Layers/ProjectionProjects.ts created+168
......@@ -0,0 +1,168 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Option, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 DeleteProjectionProjectInput,
9 GetProjectionProjectInput,
10 ProjectionProject,
11 ProjectionProjectRepository,
12 type ProjectionProjectRepositoryShape,
13} from "../Services/ProjectionProjects.ts";
14import { ProjectScript } from "@t3tools/contracts";
15
16// Makes sure that the scripts are parsed from the JSON string the DB returns
17const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
18 Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }),
19);
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
27
28const 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
165export const ProjectionProjectRepositoryLive = Layer.effect(
166 ProjectionProjectRepository,
167 makeProjectionProjectRepository,
168);
apps/server/src/persistence/Layers/ProjectionState.ts created+116
......@@ -0,0 +1,116 @@
1import { NonNegativeInt } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Schema } from "effect";
5
6import { toPersistenceSqlError } from "../Errors.ts";
7
8import {
9 ProjectionStateRepository,
10 type ProjectionStateRepositoryShape,
11 GetProjectionStateInput,
12 ProjectionState,
13} from "../Services/ProjectionState.ts";
14
15const MinLastAppliedSequenceRowSchema = Schema.Struct({
16 minLastAppliedSequence: Schema.NullOr(NonNegativeInt),
17});
18
19const 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
113export const ProjectionStateRepositoryLive = Layer.effect(
114 ProjectionStateRepository,
115 makeProjectionStateRepository,
116);
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts created+136
......@@ -0,0 +1,136 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 DeleteProjectionThreadActivitiesInput,
9 ListProjectionThreadActivitiesInput,
10 ProjectionThreadActivity,
11 ProjectionThreadActivityRepository,
12 type ProjectionThreadActivityRepositoryShape,
13} from "../Services/ProjectionThreadActivities.ts";
14
15const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
16 Struct.assign({
17 payload: Schema.fromJsonString(Schema.Unknown),
18 }),
19);
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
27
28const 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
133export const ProjectionThreadActivityRepositoryLive = Layer.effect(
134 ProjectionThreadActivityRepository,
135 makeProjectionThreadActivityRepository,
136);
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts created+123
......@@ -0,0 +1,123 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 ProjectionThreadMessageRepository,
8 type ProjectionThreadMessageRepositoryShape,
9 DeleteProjectionThreadMessagesInput,
10 ListProjectionThreadMessagesInput,
11 ProjectionThreadMessage,
12} from "../Services/ProjectionThreadMessages.ts";
13
14const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
15 Struct.assign({
16 isStreaming: Schema.Number,
17 }),
18);
19
20const 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
120export const ProjectionThreadMessageRepositoryLive = Layer.effect(
121 ProjectionThreadMessageRepository,
122 makeProjectionThreadMessageRepository,
123);
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts created+119
......@@ -0,0 +1,119 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 ProjectionThreadSession,
9 ProjectionThreadSessionRepository,
10 type ProjectionThreadSessionRepositoryShape,
11 DeleteProjectionThreadSessionInput,
12 GetProjectionThreadSessionInput,
13} from "../Services/ProjectionThreadSessions.ts";
14
15const 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
116export const ProjectionThreadSessionRepositoryLive = Layer.effect(
117 ProjectionThreadSessionRepository,
118 makeProjectionThreadSessionRepository,
119);
apps/server/src/persistence/Layers/ProjectionThreads.ts created+143
......@@ -0,0 +1,143 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 DeleteProjectionThreadInput,
8 GetProjectionThreadInput,
9 ListProjectionThreadsByProjectInput,
10 ProjectionThread,
11 ProjectionThreadRepository,
12 type ProjectionThreadRepositoryShape,
13} from "../Services/ProjectionThreads.ts";
14
15const 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
140export const ProjectionThreadRepositoryLive = Layer.effect(
141 ProjectionThreadRepository,
142 makeProjectionThreadRepository,
143);
apps/server/src/persistence/Layers/ProjectionTurns.ts created+335
......@@ -0,0 +1,335 @@
1import { OrchestrationCheckpointFile } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";
5
6import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
7import {
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
20const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields(
21 Struct.assign({
22 checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
23 }),
24);
25
26const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields(
27 Struct.assign({
28 checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
29 }),
30);
31
32function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
33 return (cause: unknown) =>
34 Schema.isSchemaError(cause)
35 ? toPersistenceDecodeError(decodeOperation)(cause)
36 : toPersistenceSqlError(sqlOperation)(cause);
37}
38
39const 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
332export const ProjectionTurnRepositoryLive = Layer.effect(
333 ProjectionTurnRepository,
334 makeProjectionTurnRepository,
335);
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts created+204
......@@ -0,0 +1,204 @@
1import { ProviderSessionId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";
5
6import {
7 toPersistenceDecodeError,
8 toPersistenceSqlError,
9 type ProviderSessionRuntimeRepositoryError,
10} from "../Errors.ts";
11import {
12 ProviderSessionRuntime,
13 ProviderSessionRuntimeRepository,
14 type ProviderSessionRuntimeRepositoryShape,
15} from "../Services/ProviderSessionRuntime.ts";
16
17const 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
24const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);
25
26const GetRuntimeRequestSchema = Schema.Struct({
27 providerSessionId: ProviderSessionId,
28});
29
30const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
31
32function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
33 return (cause: unknown): ProviderSessionRuntimeRepositoryError =>
34 Schema.isSchemaError(cause)
35 ? toPersistenceDecodeError(decodeOperation)(cause)
36 : toPersistenceSqlError(sqlOperation)(cause);
37}
38
39const 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
201export const ProviderSessionRuntimeRepositoryLive = Layer.effect(
202 ProviderSessionRuntimeRepository,
203 makeProviderSessionRuntimeRepository,
204);
apps/server/src/persistence/Layers/ProviderSessions.ts created+225
......@@ -0,0 +1,225 @@
1import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema } from "effect";
5
6import {
7 ProviderSessionRepositoryPersistenceError,
8 ProviderSessionRepositoryValidationError,
9} from "../Errors.ts";
10import {
11 ProviderSessionRepository,
12 type ProviderSessionEntry,
13 type ProviderSessionRepositoryShape,
14} from "../Services/ProviderSessions.ts";
15
16const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
17
18const ProviderSessionRowSchema = Schema.Struct({
19 sessionId: ProviderSessionId,
20 provider: ProviderKind,
21 threadId: Schema.NullOr(ThreadId),
22 createdAt: Schema.String,
23 updatedAt: Schema.String,
24});
25
26const SessionIdRequestSchema = Schema.Struct({
27 sessionId: ProviderSessionId,
28});
29
30const UpsertSessionRequestSchema = Schema.Struct({
31 sessionId: ProviderSessionId,
32 provider: ProviderKind,
33 threadId: Schema.NullOr(ThreadId),
34});
35
36function 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
43function 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
54function 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
60function 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
71function 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
81const 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
222export const ProviderSessionRepositoryLive = Layer.effect(
223 ProviderSessionRepository,
224 makeProviderSessionRepository,
225);
apps/server/src/persistence/Layers/Sqlite.ts created+57
......@@ -0,0 +1,57 @@
1import { Effect, Layer, FileSystem, Path } from "effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4import { runMigrations } from "../Migrations.ts";
5import { ServerConfig } from "../../config.ts";
6
7type RuntimeSqliteLayerConfig = {
8 readonly filename: string;
9};
10
11type Loader = {
12 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>;
13};
14const defaultSqliteClientLoaders = {
15 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
16 node: () => import("../NodeSqliteClient.ts"),
17} satisfies Record<string, () => Promise<Loader>>;
18
19const 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
29const 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
38export 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
47export const SqlitePersistenceMemory = Layer.provideMerge(
48 setup,
49 makeRuntimeSqliteLayer({ filename: ":memory:" }),
50);
51
52export 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
11import * as Migrator from "effect/unstable/sql/Migrator";
12import * as Layer from "effect/Layer";
13
14// Import all migrations statically
15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
16import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts";
17import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";
20import 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 */
32const 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 */
45const 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 */
57export 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 */
76export const MigrationsLive = Layer.effectDiscard(runMigrations);
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts created+44
......@@ -0,0 +1,44 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export 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 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4const DEFAULT_APPROVAL_POLICY = "never";
5const DEFAULT_SANDBOX_MODE = "workspace-write";
6
7export 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 @@
1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import * as SqliteClient from "./NodeSqliteClient.ts";
6
7const layer = it.layer(SqliteClient.layerMemory());
8
9layer("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 */
7import { DatabaseSync, type StatementSync } from "node:sqlite";
8
9import * as Cache from "effect/Cache";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import { identity } from "effect/Function";
15import * as Layer from "effect/Layer";
16import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";
18import * as ServiceMap from "effect/ServiceMap";
19import * as Stream from "effect/Stream";
20import * as Reactivity from "effect/unstable/reactivity/Reactivity";
21import * as Client from "effect/unstable/sql/SqlClient";
22import type { Connection } from "effect/unstable/sql/SqlConnection";
23import { SqlError } from "effect/unstable/sql/SqlError";
24import * as Statement from "effect/unstable/sql/Statement";
25
26const ATTR_DB_SYSTEM_NAME = "db.system.name";
27
28export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
29
30export type TypeId = "~local/sqlite-node/SqliteClient";
31
32/**
33 * SqliteClient - Effect service tag for the sqlite SQL client.
34 */
35export const SqliteClient = ServiceMap.Service<Client.SqlClient>("t3/persistence/NodeSqliteClient");
36
37export 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
48export interface SqliteMemoryClientConfig extends Omit<
49 SqliteClientConfig,
50 "filename" | "readonly"
51> {}
52
53const 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
186const 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
198const 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
215export 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
229export 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
236export 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 */
9import {
10 CommandId,
11 IsoDateTime,
12 NonNegativeInt,
13 OrchestrationAggregateKind,
14 OrchestrationCommandReceiptStatus,
15 ProjectId,
16 ThreadId,
17} from "@t3tools/contracts";
18import { Option, Schema, ServiceMap } from "effect";
19import type { Effect } from "effect";
20
21import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts";
22
23export 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});
32export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type;
33
34export const GetByCommandIdInput = Schema.Struct({
35 commandId: CommandId,
36});
37export type GetByCommandIdInput = typeof GetByCommandIdInput.Type;
38
39/**
40 * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts.
41 */
42export 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 */
66export 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 */
12import { OrchestrationEvent } from "@t3tools/contracts";
13import { ServiceMap } from "effect";
14import type { Effect, Stream } from "effect";
15
16import type { OrchestrationEventStoreError } from "../Errors.ts";
17
18/**
19 * OrchestrationEventStoreShape - Service API for orchestration event persistence.
20 */
21export 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 */
67export 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 */
9import {
10 CheckpointRef,
11 IsoDateTime,
12 MessageId,
13 NonNegativeInt,
14 OrchestrationCheckpointFile,
15 OrchestrationCheckpointStatus,
16 ThreadId,
17 TurnId,
18} from "@t3tools/contracts";
19import { Option, ServiceMap, Schema } from "effect";
20import type { Effect } from "effect";
21
22import type { ProjectionRepositoryError } from "../Errors.ts";
23
24export 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});
34export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type;
35
36export const ListByThreadIdInput = Schema.Struct({
37 threadId: ThreadId,
38});
39export type ListByThreadIdInput = typeof ListByThreadIdInput.Type;
40
41export const GetByThreadAndTurnCountInput = Schema.Struct({
42 threadId: ThreadId,
43 checkpointTurnCount: NonNegativeInt,
44});
45export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type;
46
47export const DeleteByThreadIdInput = Schema.Struct({
48 threadId: ThreadId,
49});
50export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type;
51
52/**
53 * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints.
54 */
55export 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 */
90export 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 */
9import {
10 ApprovalRequestId,
11 IsoDateTime,
12 ProjectionPendingApprovalDecision,
13 ProjectionPendingApprovalStatus,
14 ThreadId,
15 TurnId,
16} from "@t3tools/contracts";
17import { Option, Schema, ServiceMap } from "effect";
18import type { Effect } from "effect";
19
20import type { ProjectionRepositoryError } from "../Errors.ts";
21
22export 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});
31export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type;
32
33export const ListProjectionPendingApprovalsInput = Schema.Struct({
34 threadId: ThreadId,
35});
36export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type;
37
38export const GetProjectionPendingApprovalInput = Schema.Struct({
39 requestId: ApprovalRequestId,
40});
41export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;
42
43export const DeleteProjectionPendingApprovalInput = Schema.Struct({
44 requestId: ApprovalRequestId,
45});
46export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type;
47
48/**
49 * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals.
50 */
51export 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 */
88export 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 */
9import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProjectionRepositoryError } from "../Errors.ts";
14
15export 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});
25export type ProjectionProject = typeof ProjectionProject.Type;
26
27export const GetProjectionProjectInput = Schema.Struct({
28 projectId: ProjectId,
29});
30export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;
31
32export const DeleteProjectionProjectInput = Schema.Struct({
33 projectId: ProjectId,
34});
35export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type;
36
37/**
38 * ProjectionProjectRepositoryShape - Service API for projected project records.
39 */
40export 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 */
76export 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 */
9import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProjectionRepositoryError } from "../Errors.ts";
14
15export const ProjectionState = Schema.Struct({
16 projector: Schema.String,
17 lastAppliedSequence: NonNegativeInt,
18 updatedAt: IsoDateTime,
19});
20export type ProjectionState = typeof ProjectionState.Type;
21
22export const GetProjectionStateInput = Schema.Struct({
23 projector: Schema.String,
24});
25export type GetProjectionStateInput = typeof GetProjectionStateInput.Type;
26
27/**
28 * ProjectionStateRepositoryShape - Service API for projector state records.
29 */
30export 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 */
61export 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 */
9import {
10 EventId,
11 IsoDateTime,
12 OrchestrationThreadActivityTone,
13 ThreadId,
14 TurnId,
15} from "@t3tools/contracts";
16import { Schema, ServiceMap } from "effect";
17import type { Effect } from "effect";
18
19import type { ProjectionRepositoryError } from "../Errors.ts";
20
21export 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});
31export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;
32
33export const ListProjectionThreadActivitiesInput = Schema.Struct({
34 threadId: ThreadId,
35});
36export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type;
37
38export const DeleteProjectionThreadActivitiesInput = Schema.Struct({
39 threadId: ThreadId,
40});
41export type DeleteProjectionThreadActivitiesInput =
42 typeof DeleteProjectionThreadActivitiesInput.Type;
43
44/**
45 * ProjectionThreadActivityRepositoryShape - Service API for projected thread activity.
46 */
47export 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 */
77export 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 */
9import {
10 OrchestrationMessageRole,
11 MessageId,
12 ThreadId,
13 TurnId,
14 IsoDateTime,
15} from "@t3tools/contracts";
16import { Schema, ServiceMap } from "effect";
17import type { Effect } from "effect";
18
19import type { ProjectionRepositoryError } from "../Errors.ts";
20
21export 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});
31export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type;
32
33export const ListProjectionThreadMessagesInput = Schema.Struct({
34 threadId: ThreadId,
35});
36export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type;
37
38export const DeleteProjectionThreadMessagesInput = Schema.Struct({
39 threadId: ThreadId,
40});
41export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type;
42
43/**
44 * ProjectionThreadMessageRepositoryShape - Service API for projected thread messages.
45 */
46export 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 */
76export 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 */
9import {
10 IsoDateTime,
11 OrchestrationSessionStatus,
12 ProviderApprovalPolicy,
13 ProviderSandboxMode,
14 ProviderSessionId,
15 ProviderThreadId,
16 ThreadId,
17 TurnId,
18} from "@t3tools/contracts";
19import { Option, Schema, ServiceMap } from "effect";
20import type { Effect } from "effect";
21
22import type { ProjectionRepositoryError } from "../Errors.ts";
23
24export 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});
36export type ProjectionThreadSession = typeof ProjectionThreadSession.Type;
37
38export const GetProjectionThreadSessionInput = Schema.Struct({
39 threadId: ThreadId,
40});
41export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type;
42
43export const DeleteProjectionThreadSessionInput = Schema.Struct({
44 threadId: ThreadId,
45});
46export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type;
47
48/**
49 * ProjectionThreadSessionRepositoryShape - Service API for projected thread sessions.
50 */
51export 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 */
77export 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 */
9import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProjectionRepositoryError } from "../Errors.ts";
14
15export 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});
27export type ProjectionThread = typeof ProjectionThread.Type;
28
29export const GetProjectionThreadInput = Schema.Struct({
30 threadId: ThreadId,
31});
32export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type;
33
34export const DeleteProjectionThreadInput = Schema.Struct({
35 threadId: ThreadId,
36});
37export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type;
38
39export const ListProjectionThreadsByProjectInput = Schema.Struct({
40 projectId: ProjectId,
41});
42export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type;
43
44/**
45 * ProjectionThreadRepositoryShape - Service API for projected thread records.
46 */
47export 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 */
82export 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 */
9import {
10 CheckpointRef,
11 IsoDateTime,
12 MessageId,
13 NonNegativeInt,
14 OrchestrationCheckpointFile,
15 OrchestrationCheckpointStatus,
16 ThreadId,
17 TurnId,
18} from "@t3tools/contracts";
19import { Option, Schema, ServiceMap } from "effect";
20import type { Effect } from "effect";
21
22import type { ProjectionRepositoryError } from "../Errors.ts";
23
24export const ProjectionTurnState = Schema.Literals([
25 "pending",
26 "running",
27 "interrupted",
28 "completed",
29 "error",
30]);
31export type ProjectionTurnState = typeof ProjectionTurnState.Type;
32
33export 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});
47export type ProjectionTurn = typeof ProjectionTurn.Type;
48
49export 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});
63export type ProjectionTurnById = typeof ProjectionTurnById.Type;
64
65export const ProjectionPendingTurnStart = Schema.Struct({
66 threadId: ThreadId,
67 messageId: MessageId,
68 requestedAt: IsoDateTime,
69});
70export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type;
71
72export const ListProjectionTurnsByThreadInput = Schema.Struct({
73 threadId: ThreadId,
74});
75export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type;
76
77export const GetProjectionTurnByTurnIdInput = Schema.Struct({
78 threadId: ThreadId,
79 turnId: TurnId,
80});
81export type GetProjectionTurnByTurnIdInput = typeof GetProjectionTurnByTurnIdInput.Type;
82
83export const GetProjectionPendingTurnStartInput = Schema.Struct({
84 threadId: ThreadId,
85});
86export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type;
87
88export const DeleteProjectionTurnsByThreadInput = Schema.Struct({
89 threadId: ThreadId,
90});
91export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type;
92
93export const ClearCheckpointTurnConflictInput = Schema.Struct({
94 threadId: ThreadId,
95 turnId: TurnId,
96 checkpointTurnCount: NonNegativeInt,
97});
98export type ClearCheckpointTurnConflictInput = typeof ClearCheckpointTurnConflictInput.Type;
99
100export 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
158export 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 */
8import {
9 IsoDateTime,
10 ProviderSessionId,
11 ProviderSessionRuntimeStatus,
12 ProviderThreadId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { Option, Schema, ServiceMap } from "effect";
16import type { Effect } from "effect";
17
18import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";
19
20export 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});
31export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type;
32
33export const GetProviderSessionRuntimeInput = Schema.Struct({
34 providerSessionId: ProviderSessionId,
35});
36export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type;
37
38export const DeleteProviderSessionRuntimeInput = Schema.Struct({
39 providerSessionId: ProviderSessionId,
40});
41export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
42
43/**
44 * ProviderSessionRuntimeRepositoryShape - Service API for provider runtime records.
45 */
46export 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 */
84export 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 */
9import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts";
10import { Option, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { ProviderSessionRepositoryError } from "../Errors.ts";
14
15export interface ProviderSessionEntry {
16 readonly sessionId: ProviderSessionId;
17 readonly provider: ProviderKind;
18 readonly threadId?: ThreadId;
19 readonly createdAt: string;
20 readonly updatedAt: string;
21}
22
23export interface UpsertProviderSessionInput {
24 readonly sessionId: ProviderSessionId;
25 readonly provider: ProviderKind;
26 readonly threadId?: ThreadId;
27}
28
29export interface DeleteProviderSessionInput {
30 readonly sessionId: ProviderSessionId;
31}
32
33export interface GetProviderSessionInput {
34 readonly sessionId: ProviderSessionId;
35}
36
37/**
38 * ProviderSessionRepositoryShape - Service API for provider-session records.
39 */
40export 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 */
78export 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 @@
1import fs from "node:fs";
2import http from "node:http";
3import os from "node:os";
4import path from "node:path";
5
6import { afterEach, describe, expect, it } from "vitest";
7import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
8
9interface HttpResponse {
10 statusCode: number;
11 contentType: string | null;
12 body: string;
13}
14
15const tempDirs: string[] = [];
16
17function makeTempDir(prefix: string): string {
18 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
19 tempDirs.push(dir);
20 return dir;
21}
22
23async 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
66async 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
75describe("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 @@
1import fs from "node:fs";
2import http from "node:http";
3import path from "node:path";
4
5const 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.
13const 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.
37const 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.
48const LINK_ICON_HTML_RE =
49 /<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i;
50const LINK_ICON_OBJ_RE =
51 /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i;
52
53function 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
61function resolveIconHref(projectCwd: string, href: string): string[] {
62 const clean = href.replace(/^\//, "");
63 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];
64}
65
66function 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
71function 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
88export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { ProjectRegistry } from "./projectRegistry";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("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 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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
22function 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
36function 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
45function isDirectory(cwd: string): boolean {
46 try {
47 return fs.statSync(cwd).isDirectory();
48 } catch {
49 return false;
50 }
51}
52
53function inferProjectName(cwd: string): string {
54 const name = path.basename(cwd);
55 return name.length > 0 ? name : "project";
56}
57
58export 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 @@
1import { Schema } from "effect";
2
3import type { CheckpointServiceError } from "../checkpointing/Errors.ts";
4
5/**
6 * ProviderAdapterValidationError - Invalid adapter API input.
7 */
8export 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 */
25export 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 */
41export 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 */
57export 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 */
74export 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 */
91export 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 */
107export 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 */
122export 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 */
137export 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
150export type ProviderAdapterError =
151 | ProviderAdapterValidationError
152 | ProviderAdapterSessionNotFoundError
153 | ProviderAdapterSessionClosedError
154 | ProviderAdapterRequestError
155 | ProviderAdapterProcessError;
156
157export 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 @@
1import {
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";
15import { afterAll, assert, it, vi } from "@effect/vitest";
16import { assertFailure } from "@effect/vitest/utils";
17
18import { Effect, Fiber, Stream } from "effect";
19
20import { CodexAppServerManager } from "../../codexAppServerManager.ts";
21import { ProviderAdapterValidationError } from "../Errors.ts";
22import { CodexAdapter } from "../Services/CodexAdapter.ts";
23import { makeCodexAdapterLive } from "./CodexAdapter.ts";
24
25const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
26const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
27const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
28const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
29
30class 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
120const validationManager = new FakeCodexManager();
121const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager }));
122
123validationLayer("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
146const sessionErrorManager = new FakeCodexManager();
147sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
148 throw new Error("Unknown session: sess-missing");
149});
150const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager }));
151
152sessionErrorLayer("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
180const lifecycleManager = new FakeCodexManager();
181const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager }));
182
183lifecycleLayer("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
255afterAll(() => {
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 */
9import {
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";
20import { Effect, Layer, Queue, Schema, Stream } from "effect";
21
22import {
23 ProviderAdapterProcessError,
24 ProviderAdapterRequestError,
25 ProviderAdapterSessionClosedError,
26 ProviderAdapterSessionNotFoundError,
27 ProviderAdapterValidationError,
28 type ProviderAdapterError,
29} from "../Errors.ts";
30import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
31import { CodexAppServerManager } from "../../codexAppServerManager.ts";
32import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
33
34const PROVIDER = "codex" as const;
35
36export interface CodexAdapterLiveOptions {
37 readonly manager?: CodexAppServerManager;
38 readonly makeManager?: () => CodexAppServerManager;
39 readonly nativeEventLogPath?: string;
40}
41
42function 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
49function 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
71function 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
88function 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
95function asString(value: unknown): string | undefined {
96 return typeof value === "string" ? value : undefined;
97}
98
99function 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
111function 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
122function 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
129function 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
147function 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
173function 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
206function 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
250function 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
427const 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
577export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter());
578
579export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { describe, expect, it } from "vitest";
6
7import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
8
9describe("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 */
7import fs from "node:fs";
8import path from "node:path";
9
10import { createLogger } from "../../logger.ts";
11
12const logger = createLogger("provider-observability");
13
14export interface EventNdjsonLogger {
15 readonly filePath: string;
16 write: (record: unknown) => void;
17 close: () => void;
18}
19
20function 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
29export 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 @@
1import type { ProviderKind } from "@t3tools/contracts";
2import { it, assert, vi } from "@effect/vitest";
3import { assertFailure } from "@effect/vitest/utils";
4
5import { Effect, Layer, Stream } from "effect";
6
7import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
8import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
9import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
10import { ProviderUnsupportedError } from "../Errors.ts";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12
13const 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
28const layer = it.layer(
29 Layer.mergeAll(
30 Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)),
31 NodeServices.layer,
32 ),
33);
34
35layer("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 */
10import { Effect, Layer } from "effect";
11
12import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
13import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
14import {
15 ProviderAdapterRegistry,
16 type ProviderAdapterRegistryShape,
17} from "../Services/ProviderAdapterRegistry.ts";
18import { CodexAdapter } from "../Services/CodexAdapter.ts";
19
20export interface ProviderAdapterRegistryLiveOptions {
21 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
22}
23
24const 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
46export const ProviderAdapterRegistryLive = Layer.effect(
47 ProviderAdapterRegistry,
48 makeProviderAdapterRegistry(),
49);
apps/server/src/provider/Layers/ProviderService.test.ts created+718
......@@ -0,0 +1,718 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type {
6 ProviderApprovalDecision,
7 ProviderRuntimeEvent,
8 ProviderSendTurnInput,
9 ProviderSession,
10 ProviderTurnStartResult,
11} from "@t3tools/contracts";
12import {
13 ApprovalRequestId,
14 EventId,
15 ProviderSessionStartInput,
16 ProviderSessionId,
17 ProviderThreadId,
18 ProviderTurnId,
19 ThreadId,
20} from "@t3tools/contracts";
21import { it, assert, vi } from "@effect/vitest";
22import { assertFailure } from "@effect/vitest/utils";
23
24import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect";
25import * as SqlClient from "effect/unstable/sql/SqlClient";
26
27import {
28 ProviderAdapterSessionNotFoundError,
29 ProviderSessionNotFoundError,
30 ProviderValidationError,
31 ProviderUnsupportedError,
32 type ProviderAdapterError,
33} from "../Errors.ts";
34import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
35import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
36import { ProviderService } from "../Services/ProviderService.ts";
37import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
38import { makeProviderServiceLive } from "./ProviderService.ts";
39import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
40import * as NodeServices from "@effect/platform-node/NodeServices";
41import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
42import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
43import {
44 makeSqlitePersistenceLive,
45 SqlitePersistenceMemory,
46} from "../../persistence/Layers/Sqlite.ts";
47
48const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
49const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
50const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
51const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
52const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
53const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
54
55function 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
195const sleep = (ms: number) =>
196 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
197
198function 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
232const routing = makeProviderServiceLayer();
233it.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
297it.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
394routing.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
547const fanout = makeProviderServiceLayer();
548fanout.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
659const validation = makeProviderServiceLayer();
660validation.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 */
12import {
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";
24import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, SchemaIssue, Stream } from "effect";
25
26import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
27import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
28import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
29import {
30 ProviderSessionDirectory,
31 type ProviderSessionBinding,
32} from "../Services/ProviderSessionDirectory.ts";
33import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
34
35export interface ProviderServiceLiveOptions {
36 readonly canonicalEventLogPath?: string;
37}
38
39const ProviderRollbackConversationInput = Schema.Struct({
40 sessionId: ProviderSessionId,
41 numTurns: NonNegativeInt,
42});
43
44function 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
56const 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
72function 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
87function 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
96const 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
575export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService());
576
577export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts";
7import { it, assert } from "@effect/vitest";
8import { assertFailure, assertSome } from "@effect/vitest/utils";
9import { Effect, Layer, Option } from "effect";
10import * as SqlClient from "effect/unstable/sql/SqlClient";
11
12import {
13 makeSqlitePersistenceLive,
14 SqlitePersistenceMemory,
15} from "../../persistence/Layers/Sqlite.ts";
16import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
17import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
18import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
19import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
20import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
21
22function 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
32it.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 @@
1import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";
3
4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import {
6 ProviderSessionDirectoryPersistenceError,
7 ProviderSessionNotFoundError,
8 ProviderValidationError,
9} from "../Errors.ts";
10import {
11 ProviderSessionDirectory,
12 type ProviderSessionBinding,
13 type ProviderSessionDirectoryShape,
14} from "../Services/ProviderSessionDirectory.ts";
15
16function toPersistenceError(operation: string) {
17 return (cause: unknown) =>
18 new ProviderSessionDirectoryPersistenceError({
19 operation,
20 detail: `Failed to execute ${operation}.`,
21 cause,
22 });
23}
24
25function 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
40function isRecord(value: unknown): value is Record<string, unknown> {
41 return value !== null && typeof value === "object" && !Array.isArray(value);
42}
43
44function 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
60const 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
189export const ProviderSessionDirectoryLive = Layer.effect(
190 ProviderSessionDirectory,
191 makeProviderSessionDirectory,
192);
193
194export 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 */
13import { ServiceMap } from "effect";
14
15import type { ProviderAdapterError } from "../Errors.ts";
16import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
17
18/**
19 * CodexAdapterShape - Service API for the Codex provider adapter.
20 */
21export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
22 readonly provider: "codex";
23}
24
25/**
26 * CodexAdapter - Service tag for Codex provider adapter operations.
27 */
28export 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 */
10import 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";
23import type { Effect } from "effect";
24import type { Stream } from "effect";
25
26export interface ProviderThreadTurnSnapshot {
27 readonly id: ProviderTurnId;
28 readonly items: ReadonlyArray<unknown>;
29}
30
31export interface ProviderThreadSnapshot {
32 readonly threadId: ProviderThreadId;
33 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
34}
35
36export 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 */
10import type { ProviderKind } from "@t3tools/contracts";
11import { ServiceMap } from "effect";
12import type { Effect } from "effect";
13
14import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts";
15import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
16
17/**
18 * ProviderAdapterRegistryShape - Service API for adapter lookup by provider kind.
19 */
20export 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 */
37export 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 */
14import 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";
26import { ServiceMap } from "effect";
27import type { Effect, Stream } from "effect";
28
29import type { ProviderServiceError } from "../Errors.ts";
30
31/**
32 * ProviderServiceShape - Service API for provider session and turn orchestration.
33 */
34export 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 */
107export 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 */
10import type {
11 ProviderKind,
12 ProviderSessionId,
13 ProviderSessionRuntimeStatus,
14 ProviderThreadId,
15 ThreadId,
16} from "@t3tools/contracts";
17import { Option, ServiceMap } from "effect";
18import type { Effect } from "effect";
19
20import type {
21 ProviderSessionDirectoryPersistenceError,
22 ProviderSessionNotFoundError,
23 ProviderValidationError,
24} from "../Errors.ts";
25
26export 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
37export type ProviderSessionDirectoryReadError =
38 | ProviderSessionNotFoundError
39 | ProviderSessionDirectoryPersistenceError;
40
41export type ProviderSessionDirectoryWriteError =
42 | ProviderValidationError
43 | ProviderSessionDirectoryPersistenceError;
44
45/**
46 * ProviderSessionDirectoryShape - Service API for provider session ownership metadata.
47 */
48export 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 */
99export 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 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { ProviderManager } from "./providerManager";
4
5describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4import { randomUUID } from "node:crypto";
5
6import {
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";
31import type { CodexThreadTurnSnapshot } from "./codexAppServerManager";
32import { CodexAppServerManager } from "./codexAppServerManager";
33import { FilesystemCheckpointStore } from "./filesystemCheckpointStore";
34
35export interface ProviderManagerEvents {
36 event: [event: ProviderEvent];
37}
38
39function 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
46function asString(value: unknown): string | undefined {
47 return typeof value === "string" ? value : undefined;
48}
49
50function asArray(value: unknown): unknown[] {
51 return Array.isArray(value) ? value : [];
52}
53
54function 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
62function 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
95function 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
133function 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
163export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter";
8
9function fileMode(filePath: string): number {
10 return fs.statSync(filePath).mode & 0o777;
11}
12
13describe("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 @@
1import fs from "node:fs";
2import { createRequire } from "node:module";
3import path from "node:path";
4
5import * as nodePty from "node-pty";
6
7export interface PtyExitEvent {
8 exitCode: number;
9 signal: number | null;
10}
11
12export 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
21export interface PtySpawnInput {
22 shell: string;
23 args?: string[];
24 cwd: string;
25 cols: number;
26 rows: number;
27 env: NodeJS.ProcessEnv;
28}
29
30export interface PtyAdapter {
31 spawn(input: PtySpawnInput): PtyProcess;
32}
33
34const requireForNodePty = createRequire(import.meta.url);
35let didEnsureSpawnHelperExecutable = false;
36
37function 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
59export 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
80class 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
119export 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 @@
1import path from "node:path";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { Effect, Layer } from "effect";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6
7import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
8import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
9import { ServerConfig } from "./config";
10import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
11import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
12import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
13import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
14import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
15import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
16import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
20import { ProviderUnsupportedError } from "./provider/Errors";
21import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
22import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
23import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
24import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
25import { ProviderService } from "./provider/Services/ProviderService";
26
27import { TerminalManagerLive } from "./terminal/Layers/Manager";
28import { KeybindingsLive } from "./keybindings";
29import { GitManagerLive } from "./git/Layers/GitManager";
30import { GitCoreLive } from "./git/Layers/GitCore";
31import { GitHubCliLive } from "./git/Layers/GitHubCli";
32import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration";
33import { GitServiceLive } from "./git/Layers/GitService";
34import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY";
35import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";
36
37export 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
58export 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 @@
1import { Effect, Layer } from "effect";
2import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
3
4class 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
89export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalEvent,
8 type TerminalOpenInput,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import {
13 PtySpawnError,
14 type PtyAdapterShape,
15 type PtyExitEvent,
16 type PtyProcess,
17 type PtySpawnInput,
18} from "../Services/PTY";
19import { TerminalManagerRuntime } from "./Manager";
20import { Effect, Encoding } from "effect";
21
22class 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
72class 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
109function 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
127function 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
137function historyLogName(threadId: string): string {
138 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
139}
140
141function 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
149function historyLogPath(logsDir: string, threadId = "thread-1"): string {
150 return path.join(logsDir, historyLogName(threadId));
151}
152
153function multiTerminalHistoryLogPath(
154 logsDir: string,
155 threadId = "thread-1",
156 terminalId = "default",
157): string {
158 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
159}
160
161describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalWriteInput,
12 type TerminalEvent,
13 type TerminalSessionSnapshot,
14} from "@t3tools/contracts";
15import { Effect, Encoding, Layer, Path, Schema } from "effect";
16
17import { createLogger } from "../../logger";
18import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
19import { runProcess } from "../../processRunner";
20import { ServerConfig } from "../../config";
21import {
22 ShellCandidate,
23 TerminalError,
24 TerminalManager,
25 TerminalManagerShape,
26 TerminalSessionState,
27 TerminalStartInput,
28} from "../Services/Manager";
29
30const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
31const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
32const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
33const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;
34const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;
35const DEFAULT_OPEN_COLS = 120;
36const DEFAULT_OPEN_ROWS = 30;
37const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
38
39const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);
40const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
41const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
42const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
43const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
44
45type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
46
47function defaultShellResolver(): string {
48 if (process.platform === "win32") {
49 return process.env.ComSpec ?? "cmd.exe";
50 }
51 return process.env.SHELL ?? "bash";
52}
53
54function 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
68function 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
77function formatShellCandidate(candidate: ShellCandidate): string {
78 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
79 return `${candidate.shell} ${candidate.args.join(" ")}`;
80}
81
82function 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
95function 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
119function 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
166async 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
189async 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
233async 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
243function 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
255function legacySafeThreadId(threadId: string): string {
256 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
257}
258
259function toSafeThreadId(threadId: string): string {
260 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
261}
262
263function toSafeTerminalId(terminalId: string): string {
264 return Encoding.encodeBase64Url(terminalId);
265}
266
267function toSessionKey(threadId: string, terminalId: string): string {
268 return `${threadId}\u0000${terminalId}`;
269}
270
271function 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
282function 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
300function 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
309interface TerminalManagerEvents {
310 event: [event: TerminalEvent];
311}
312
313interface 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
324export 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
1170export 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 @@
1import { FileSystem, Path, Effect } from "effect";
2import { assert, it } from "@effect/vitest";
3
4import { ensureNodePtySpawnHelperExecutable } from "./NodePTY";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6
7it.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 @@
1import { createRequire } from "node:module";
2
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY";
5
6let didEnsureSpawnHelperExecutable = false;
7
8const 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
29export 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
48class 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
87export 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 */
9import {
10 TerminalClearInput,
11 TerminalCloseInput,
12 TerminalEvent,
13 TerminalOpenInput,
14 TerminalResizeInput,
15 TerminalSessionSnapshot,
16 TerminalSessionStatus,
17 TerminalWriteInput,
18} from "@t3tools/contracts";
19import { PtyProcess } from "./PTY";
20import { Effect, Schema, ServiceMap } from "effect";
21
22export class TerminalError extends Schema.TaggedErrorClass<TerminalError>()("TerminalError", {
23 message: Schema.String,
24 cause: Schema.optional(Schema.Defect),
25}) {}
26
27export 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
46export interface ShellCandidate {
47 shell: string;
48 args?: string[];
49}
50
51export interface TerminalStartInput extends TerminalOpenInput {
52 cols: number;
53 rows: number;
54}
55
56/**
57 * TerminalManagerShape - Service API for terminal session lifecycle operations.
58 */
59export 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 */
115export 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 */
9import { Effect, Schema, ServiceMap } from "effect";
10
11/**
12 * PtyError - Error type for PTY adapter operations.
13 */
14export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", {
15 adapter: Schema.String,
16 message: Schema.String,
17 cause: Schema.optional(Schema.Defect),
18}) {}
19
20export interface PtyExitEvent {
21 exitCode: number;
22 signal: number | null;
23}
24
25export 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
34export 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 */
46export 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 */
56export 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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import {
6 DEFAULT_TERMINAL_ID,
7 type TerminalEvent,
8 type TerminalOpenInput,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManager } from "./terminalManager";
14
15class 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
63class 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
81function 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
99function 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
109function historyLogName(threadId: string): string {
110 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
111}
112
113function 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
121function historyLogPath(logsDir: string, threadId = "thread-1"): string {
122 return path.join(logsDir, historyLogName(threadId));
123}
124
125function multiTerminalHistoryLogPath(
126 logsDir: string,
127 threadId = "thread-1",
128 terminalId = "default",
129): string {
130 return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId));
131}
132
133describe("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 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
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
22import { createLogger } from "./logger";
23import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
24import { runProcess } from "./processRunner";
25
26const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
27const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
28const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
29const DEFAULT_OPEN_COLS = 120;
30const DEFAULT_OPEN_ROWS = 30;
31const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
32
33type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
34
35export interface TerminalManagerEvents {
36 event: [event: TerminalEvent];
37}
38
39export interface TerminalManagerOptions {
40 logsDir?: string;
41 historyLineLimit?: number;
42 ptyAdapter?: PtyAdapter;
43 shellResolver?: () => string;
44 subprocessChecker?: TerminalSubprocessChecker;
45 subprocessPollIntervalMs?: number;
46}
47
48interface 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
67interface ShellCandidate {
68 shell: string;
69 args?: string[];
70}
71
72type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number };
73
74function defaultShellResolver(): string {
75 if (process.platform === "win32") {
76 return process.env.ComSpec ?? "cmd.exe";
77 }
78 return process.env.SHELL ?? "bash";
79}
80
81function 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
95function 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
104function formatShellCandidate(candidate: ShellCandidate): string {
105 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
106 return `${candidate.shell} ${candidate.args.join(" ")}`;
107}
108
109function 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
122function 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
146function 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
158async 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
181async 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
225async 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
235function 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
247function legacySafeThreadId(threadId: string): string {
248 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
249}
250
251function toSafeThreadId(threadId: string): string {
252 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
253}
254
255function toSafeTerminalId(terminalId: string): string {
256 return Buffer.from(terminalId, "utf8").toString("base64url");
257}
258
259function toSessionKey(threadId: string, terminalId: string): string {
260 return `${threadId}\u0000${terminalId}`;
261}
262
263function 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
274function 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
292function 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
301export 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";
22import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
33
44const { 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 >(),
1213}));
1314
1415vi.mock("./processRunner", () => ({
apps/server/src/workspaceEntries.test.ts+24-24
......@@ -124,15 +124,15 @@ describe("searchWorkspaceEntries", () => {
124124
125125 let rootReadCount = 0;
126126 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);
136136
137137 await Promise.all([
138138 searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
......@@ -152,22 +152,22 @@ describe("searchWorkspaceEntries", () => {
152152 let activeReads = 0;
153153 let peakReads = 0;
154154 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;
167167 }
168 return originalReaddir(...args);
169 }) as typeof fsPromises.readdir,
170 );
168 }
169 return originalReaddir(...args);
170 }) as typeof fsPromises.readdir);
171171
172172 await searchWorkspaceEntries({ cwd, query: "", limit: 200 });
173173
apps/server/src/workspaceEntries.ts+7-9
......@@ -4,10 +4,9 @@ import path from "node:path";
44import { runProcess } from "./processRunner";
55
66import {
7 projectSearchEntriesInputSchema,
8 type ProjectEntry,
9 type ProjectSearchEntriesInput,
10 type ProjectSearchEntriesResult,
7 ProjectEntry,
8 ProjectSearchEntriesInput,
9 ProjectSearchEntriesResult,
1110} from "@t3tools/contracts";
1211
1312const WORKSPACE_CACHE_TTL_MS = 15_000;
......@@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
413412export async function searchWorkspaceEntries(
414413 input: ProjectSearchEntriesInput,
415414): 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);
419417 const candidates = normalizedQuery
420418 ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
421419 : index.entries;
......@@ -427,7 +425,7 @@ export async function searchWorkspaceEntries(
427425 });
428426
429427 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,
432430 };
433431}
apps/server/src/wsServer.test.ts+927-535
......@@ -1,22 +1,33 @@
1import * as Http from "node:http";
12import fs from "node:fs";
23import os from "node:os";
34import path from "node:path";
4import { EventEmitter } from "node:events";
55
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
68import { describe, expect, it, afterEach, vi } from "vitest";
79import { createServer } from "./wsServer";
810import WebSocket from "ws";
11import { ServerConfig, type ServerConfigShape } from "./config";
12import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
913
1014import {
1115 DEFAULT_TERMINAL_ID,
16 EventId,
17 ORCHESTRATION_WS_CHANNELS,
18 ORCHESTRATION_WS_METHODS,
19 ProviderItemId,
20 ProviderSessionId,
21 ProviderThreadId,
22 ProviderTurnId,
1223 WS_CHANNELS,
1324 WS_METHODS,
25 type WebSocketResponse,
26 type ProviderRuntimeEvent,
1427 type KeybindingsConfig,
1528 type ResolvedKeybindingsConfig,
1629 type WsPush,
17 type WsResponse,
1830} from "@t3tools/contracts";
19import { ProjectRegistry } from "./projectRegistry";
2031import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
2132import type {
2233 TerminalClearInput,
......@@ -27,7 +38,15 @@ import type {
2738 TerminalSessionSnapshot,
2839 TerminalWriteInput,
2940} from "@t3tools/contracts";
30import type { TerminalManager } from "./terminalManager";
41import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager";
42import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
43import { SqlClient } from "effect/unstable/sql";
44import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
45import { Open, type OpenShape } from "./open";
46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
47import type { GitCoreShape } from "./git/Services/GitCore.ts";
48import { GitCore } from "./git/Services/GitCore.ts";
49import { GitCommandError, GitManagerError } from "./git/Errors.ts";
3150
3251interface PendingMessages {
3352 queue: unknown[];
......@@ -36,111 +55,147 @@ interface PendingMessages {
3655
3756const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
3857
39class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
58const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
59const asProviderSessionId = (value: string): ProviderSessionId =>
60 ProviderSessionId.makeUnsafe(value);
61const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
62const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
63const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
64
65const defaultOpenService: OpenShape = {
66 openBrowser: () => Effect.void,
67 openInEditor: () => Effect.void,
68};
69
70class MockTerminalManager implements TerminalManagerShape {
4071 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
72 private readonly listeners = new Set<(event: TerminalEvent) => void>();
4173
4274 private key(threadId: string, terminalId: string): string {
4375 return `${threadId}\u0000${terminalId}`;
4476 }
4577
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 }
7182 }
7283
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 = {
8293 threadId: input.threadId,
8394 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 });
86112 });
113 return snapshot;
87114 });
88 }
89115
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 });
91133
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 });
100146 });
101147 });
102 }
103148
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 = {
122154 threadId: input.threadId,
123155 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 });
126173 });
174 return snapshot;
127175 });
128 return snapshot;
129 }
130176
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;
139182 }
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 });
142189
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;
144199}
145200
146201function connectWs(port: number, token?: string): Promise<WebSocket> {
......@@ -181,22 +236,68 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
181236 });
182237}
183238
184async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> {
239function 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
247async function sendRequest(
248 ws: WebSocket,
249 method: string,
250 params?: unknown,
251): Promise<WebSocketResponse> {
185252 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 });
187260 ws.send(message);
188261
189262 // Wait for response with matching id
190263 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 }
192268 if (parsed.id === id) {
193 return parsed as WsResponse;
269 return parsed;
270 }
271 if (parsed.id === "unknown") {
272 return parsed;
194273 }
195274 }
196275}
197276
277async 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
198299function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
199 const resolved: ResolvedKeybindingsConfig = [];
300 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
200301 for (const binding of bindings) {
201302 const compiled = compileResolvedKeybindingRule(binding);
202303 if (!compiled) {
......@@ -209,20 +310,9 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
209310
210311const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
211312
212function 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
224313describe("WebSocket Server", () => {
225 let server: ReturnType<typeof createServer> | null = null;
314 let server: Http.Server | null = null;
315 let serverScope: Scope.Closeable | null = null;
226316 const connections: WebSocket[] = [];
227317 const tempDirs: string[] = [];
228318
......@@ -232,29 +322,88 @@ describe("WebSocket Server", () => {
232322 return dir;
233323 }
234324
235 function createTestServer(
325 async function createTestServer(
236326 options: {
327 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>;
237328 cwd?: string;
329 autoBootstrapProjectFromCwd?: boolean;
330 logWebSocketEvents?: boolean;
238331 devUrl?: string;
239332 authToken?: string;
240333 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;
246339 } = {},
247 ): ReturnType<typeof createServer> {
340 ): Promise<Http.Server> {
341 if (serverScope) {
342 throw new Error("Test server is already running");
343 }
344
248345 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",
250352 port: 0,
353 host: undefined,
251354 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));
258407 }
259408
260409 afterEach(async () => {
......@@ -262,9 +411,7 @@ describe("WebSocket Server", () => {
262411 ws.close();
263412 }
264413 connections.length = 0;
265 if (server) {
266 await server.stop();
267 }
414 await closeTestServer();
268415 server = null;
269416 for (const dir of tempDirs.splice(0, tempDirs.length)) {
270417 fs.rmSync(dir, { recursive: true, force: true });
......@@ -273,10 +420,9 @@ describe("WebSocket Server", () => {
273420 });
274421
275422 it("sends welcome message on connect", async () => {
276 server = createTestServer({ cwd: "/test/project" });
423 server = await createTestServer({ cwd: "/test/project" });
277424 // Get the actual port after listen
278 await server.start();
279 const addr = server.httpServer.address();
425 const addr = server.address();
280426 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
281427 expect(port).toBeGreaterThan(0);
282428
......@@ -292,17 +438,140 @@ describe("WebSocket Server", () => {
292438 });
293439 });
294440
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
295565 it("logs outbound websocket push events in dev mode", async () => {
296566 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
297567 // Keep test output clean while verifying websocket logs.
298568 });
299569
300 server = createTestServer({
570 server = await createTestServer({
301571 cwd: "/test/project",
302572 devUrl: "http://localhost:5173",
303573 });
304 await server.start();
305 const addr = server.httpServer.address();
574 const addr = server.address();
306575 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
307576 expect(port).toBeGreaterThan(0);
308577
......@@ -323,11 +592,12 @@ describe("WebSocket Server", () => {
323592 });
324593
325594 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();
331601 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
332602
333603 const ws = await connectWs(port);
......@@ -340,174 +610,226 @@ describe("WebSocket Server", () => {
340610 expect(response.error).toBeUndefined();
341611 expect(response.result).toEqual({
342612 cwd: "/my/workspace",
613 keybindingsConfigPath: keybindingsPath,
343614 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
615 issues: [],
344616 });
345617 });
346618
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);
361623
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();
365626 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
366627
367628 const ws = await connectWs(port);
368629 connections.push(ws);
369
370630 await waitForMessage(ws);
371631
372632 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
373633 expect(response.error).toBeUndefined();
374634 expect(response.result).toEqual({
375635 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: [],
381639 });
640
641 const persistedConfig = JSON.parse(
642 fs.readFileSync(keybindingsPath, "utf8"),
643 ) as KeybindingsConfig;
644 expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS);
382645 });
383646
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");
398651
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();
402654 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
403655
404656 const ws = await connectWs(port);
405657 connections.push(ws);
406
407658 await waitForMessage(ws);
408659
409660 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
410661 expect(response.error).toBeUndefined();
411662 expect(response.result).toEqual({
412663 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 ],
414672 });
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");
420674 });
421675
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");
427679 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 ]),
430686 "utf8",
431687 );
432 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
433688
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();
437691 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
438692
439693 const ws = await connectWs(port);
440694 connections.push(ws);
441
442695 await waitForMessage(ws);
443696
444697 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
445698 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);
455722 });
456723
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",
466746 );
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 });
468761
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();
472774 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
473775
474776 const ws = await connectWs(port);
475777 connections.push(ws);
476778 await waitForMessage(ws);
477779
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, {
481781 cwd: "/my/workspace",
482 keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]),
782 editor: "cursor",
483783 });
784 expect(response.error).toBeUndefined();
785 expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
786 });
484787
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");
485791 fs.writeFileSync(
486792 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 ]),
488798 "utf8",
489799 );
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);
490806
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 });
494820 });
495821
496822 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");
501825 fs.writeFileSync(
502826 keybindingsPath,
503827 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
504828 "utf8",
505829 );
506 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
507830
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();
511833 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
512834
513835 const ws = await connectWs(port);
......@@ -519,174 +841,266 @@ describe("WebSocket Server", () => {
519841 command: "script.run-tests.run",
520842 });
521843 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);
522852 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: [],
527855 });
528856
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
538857 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
539858 expect(configResponse.error).toBeUndefined();
540859 expect(configResponse.result).toEqual({
541860 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: [],
546864 });
547865 });
548866
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();
564870 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
565871
566872 const ws = await connectWs(port);
567873 connections.push(ws);
568874
875 // Consume welcome push
569876 await waitForMessage(ws);
570877
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");
582881 });
583882
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();
595886 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
596887
597888 const ws = await connectWs(port);
598889 connections.push(ws);
599
600890 await waitForMessage(ws);
601891
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,
607896 });
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.");
613899 });
614900
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();
619904 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
620905
621906 const ws = await connectWs(port);
622907 connections.push(ws);
623
624 // Consume welcome
625908 await waitForMessage(ws);
626909
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 );
630919 });
631920
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();
636924 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
637925
638926 const ws = await connectWs(port);
639927 connections.push(ws);
640
641 // Consume welcome
642928 await waitForMessage(ws);
643929
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.");
647936 });
648937
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();
653941 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
654942
655943 const ws = await connectWs(port);
656944 connections.push(ws);
657945 await waitForMessage(ws);
658946
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,
661956 });
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,
668968 });
669 expect(revertResponse.result).toBeUndefined();
670 expect(revertResponse.error?.message).toContain("Unknown provider session");
969 expect(createThreadResponse.error).toBeUndefined();
671970
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",
674973 fromTurnCount: 0,
675974 toTurnCount: 1,
676975 });
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");
6791094 });
6801095
6811096 it("routes terminal RPC methods and broadcasts terminal events", async () => {
6821097 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
6831098 const terminalManager = new MockTerminalManager();
684 server = createTestServer({
1099 server = await createTestServer({
6851100 cwd: "/test",
686 terminalManager: terminalManager as unknown as TerminalManager,
1101 terminalManager,
6871102 });
688 await server.start();
689 const addr = server.httpServer.address();
1103 const addr = server.address();
6901104 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
6911105
6921106 const ws = await connectWs(port);
......@@ -742,7 +1156,7 @@ describe("WebSocket Server", () => {
7421156 createdAt: new Date().toISOString(),
7431157 data: "manual test output\n",
7441158 };
745 terminalManager.emit("event", manualEvent);
1159 terminalManager.emitEvent(manualEvent);
7461160
7471161 const push = (await waitForMessage(ws)) as WsPush;
7481162 expect(push.type).toBe("push");
......@@ -752,24 +1166,22 @@ describe("WebSocket Server", () => {
7521166
7531167 it("detaches terminal event listener on stop for injected manager", async () => {
7541168 const terminalManager = new MockTerminalManager();
755 server = createTestServer({
1169 server = await createTestServer({
7561170 cwd: "/test",
757 terminalManager: terminalManager as unknown as TerminalManager,
1171 terminalManager,
7581172 });
759 await server.start();
7601173
761 expect(terminalManager.listenerCount("event")).toBe(1);
1174 expect(terminalManager.subscriptionCount()).toBe(1);
7621175
763 await server.stop();
1176 await closeTestServer();
7641177 server = null;
7651178
766 expect(terminalManager.listenerCount("event")).toBe(0);
1179 expect(terminalManager.subscriptionCount()).toBe(0);
7671180 });
7681181
7691182 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();
7731185 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
7741186
7751187 const ws = await connectWs(port);
......@@ -786,9 +1198,8 @@ describe("WebSocket Server", () => {
7861198 });
7871199
7881200 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();
7921203 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
7931204
7941205 const ws = await connectWs(port);
......@@ -800,106 +1211,125 @@ describe("WebSocket Server", () => {
8001211 // Send garbage
8011212 ws.send("not json at all");
8021213
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");
8061232 });
8071233
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);
8111240
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();
8151296 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
8161297
8171298 const ws = await connectWs(port);
8181299 connections.push(ws);
8191300 await waitForMessage(ws);
8201301
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");
8451305
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",
8571308 });
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");
8811311
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",
8841314 });
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");
8901317 });
8911318
8921319 it("supports projects.searchEntries", async () => {
8931320 const workspace = makeTempDir("t3code-ws-workspace-entries-");
8941321 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 );
8961327 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
8971328 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
8981329 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
8991330
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();
9031333 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
9041334
9051335 const ws = await connectWs(port);
......@@ -921,69 +1351,57 @@ describe("WebSocket Server", () => {
9211351 });
9221352 });
9231353
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 );
9261372
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();
9301382 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
9311383
9321384 const ws = await connectWs(port);
9331385 connections.push(ws);
9341386 await waitForMessage(ws);
9351387
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" });
9391392
940 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd });
1393 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
9411394 expect(initResponse.error).toBeUndefined();
1395 expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" });
9421396
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" });
9501398 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");
9531401 });
9541402
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 = {
9871405 branch: "feature/test",
9881406 hasWorkingTreeChanges: true,
9891407 workingTree: {
......@@ -995,91 +1413,65 @@ describe("WebSocket Server", () => {
9951413 aheadCount: 0,
9961414 behindCount: 0,
9971415 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.")),
10061416 };
10071417
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();
10111424 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10121425
10131426 const ws = await connectWs(port);
10141427 connections.push(ws);
10151428 await waitForMessage(ws);
10161429
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, {
10241431 cwd: "/test",
1025 action: "commit_push",
10261432 });
1433 expect(response.error).toBeUndefined();
1434 expect(response.result).toEqual(statusResult);
1435 expect(status).toHaveBeenCalledWith({ cwd: "/test" });
10271436 });
10281437
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 }),
10601445 ),
10611446 );
1447 const gitManager: GitManagerShape = {
1448 status: vi.fn(() => Effect.void as any),
1449 runStackedAction,
1450 };
10621451
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();
10661454 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10671455
10681456 const ws = await connectWs(port);
10691457 connections.push(ws);
10701458 await waitForMessage(ws);
10711459
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 });
10771470 });
10781471
10791472 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();
10831475 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10841476
10851477 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
apps/server/src/wsServer.ts+517-329
......@@ -1,38 +1,48 @@
1import { 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 */
29import fs from "node:fs";
310import http from "node:http";
4import os from "node:os";
511import path from "node:path";
612import type { Duplex } from "node:stream";
713
814import {
9 EDITORS,
15 CommandId,
16 ORCHESTRATION_WS_CHANNELS,
17 ORCHESTRATION_WS_METHODS,
18 ProjectId,
19 ThreadId,
20 TerminalEvent,
1021 WS_CHANNELS,
1122 WS_METHODS,
12 type TerminalEvent,
13 type WsPush,
14 type WsRequest,
15 type WsResponse,
16 wsRequestSchema,
23 WebSocketRequest,
24 WsPush,
25 WsResponse,
1726} from "@t3tools/contracts";
27import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
28import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect";
1829import { WebSocketServer, type WebSocket } from "ws";
1930
2031import { createLogger } from "./logger";
21import { ProjectRegistry } from "./projectRegistry";
22import { ProviderManager } from "./providerManager";
23import { GitManager } from "./gitManager";
24import {
25 checkoutGitBranch,
26 createGitBranch,
27 createGitWorktree,
28 initGitRepo,
29 listGitBranches,
30 pullGitBranch,
31 removeGitWorktree,
32} from "./git";
33import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
32import { GitManager } from "./git/Services/GitManager.ts";
33import { TerminalManager } from "./terminal/Services/Manager.ts";
34import { Keybindings } from "./keybindings";
3535import { searchWorkspaceEntries } from "./workspaceEntries";
36import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
37import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
38import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
39import { ProviderService } from "./provider/Services/ProviderService";
40import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
41import { clamp } from "effect/Number";
42import { Open } from "./open";
43import { ServerConfig } from "./config";
44import { GitCore } from "./git/Services/GitCore.ts";
45import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
3646
3747const MIME_TYPES: Record<string, string> = {
3848 ".html": "text/html; charset=utf-8",
......@@ -49,50 +59,147 @@ const MIME_TYPES: Record<string, string> = {
4959 ".map": "application/json",
5060};
5161
52export 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 */
65export 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 */
84export class Server extends ServiceMap.Service<Server, ServerShape>()("t3/wsServer/Server") {}
85
86const 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
94function 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
105function 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;
63135}
64136
65function 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;
137function stripRequestTag<T extends { _tag: string }>(body: T) {
138 return Struct.omit(body, ["_tag"]);
71139}
72140
73export function createServer(options: ServerOptions) {
141export type ServerCoreRuntimeServices =
142 | OrchestrationEngineService
143 | ProjectionSnapshotQuery
144 | CheckpointDiffQuery
145 | OrchestrationReactor
146 | ProviderService;
147
148export type ServerRuntimeServices =
149 | ServerCoreRuntimeServices
150 | GitManager
151 | GitCore
152 | TerminalManager
153 | Keybindings
154 | Open;
155
156export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
157 "ServerLifecycleError",
158 {
159 operation: Schema.String,
160 cause: Schema.optional(Schema.Defect),
161 },
162) {}
163
164class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {
165 message: Schema.String,
166}) {}
167
168export const createServer = Effect.fn(function* (): Effect.fn.Return<
169 http.Server,
170 ServerLifecycleError,
171 Scope.Scope | ServerRuntimeServices | ServerConfig
172> {
173 const serverConfig = yield* ServerConfig;
74174 const {
75175 port,
76 host,
77176 cwd,
177 keybindingsConfigPath,
78178 staticDir,
79179 devUrl,
80 logWebSocketEvents: explicitLogWsEvents,
81 projectRegistry: providedRegistry,
82 gitManager: providedGitManager,
83 terminalManager: providedTerminalManager,
84180 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>());
92202 const logger = createLogger("ws");
93 const logWebSocketEvents =
94 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
95 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
96203
97204 function logOutgoingPush(push: WsPush, recipients: number) {
98205 if (!logWebSocketEvents) return;
......@@ -103,16 +210,11 @@ export function createServer(options: ServerOptions) {
103210 });
104211 }
105212
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);
114216 let recipients = 0;
115 for (const client of clients) {
217 for (const client of yield* Ref.get(clients)) {
116218 if (client.readyState === client.OPEN) {
117219 client.send(message);
118220 recipients += 1;
......@@ -121,29 +223,25 @@ export function createServer(options: ServerOptions) {
121223 logOutgoingPush(push, recipients);
122224 });
123225
124 const onTerminalEvent = (event: TerminalEvent) => {
125 const push: WsPush = {
226 const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) {
227 yield* broadcastPush({
126228 type: "push",
127229 channel: WS_CHANNELS.terminalEvent,
128230 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 });
141233
142234 // HTTP server — serves static files or redirects to Vite dev server
143235 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
144242 // In dev mode, redirect to Vite dev server
145243 if (devUrl) {
146 res.writeHead(302, { Location: devUrl });
244 res.writeHead(302, { Location: devUrl.href });
147245 res.end();
148246 return;
149247 }
......@@ -155,7 +253,6 @@ export function createServer(options: ServerOptions) {
155253 return;
156254 }
157255
158 const url = new URL(req.url ?? "/", `http://localhost:${port}`);
159256 let filePath = path.join(staticDir, url.pathname);
160257
161258 // SPA fallback: if no file extension and not found, serve index.html
......@@ -198,312 +295,403 @@ export function createServer(options: ServerOptions) {
198295 // WebSocket server — upgrades from the HTTP server
199296 const wss = new WebSocketServer({ noServer: true });
200297
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);
222308 }
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);
232309 });
233310 });
234311
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 );
237316
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 };
241318
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({
243331 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));
249336
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));
253344
254 ws.on("close", () => {
255 clients.delete(ws);
256 });
345 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
257346
258 ws.on("error", () => {
259 clients.delete(ws);
260 });
261 });
347 let welcomeBootstrapProjectId: ProjectId | undefined;
348 let welcomeBootstrapThreadId: ThreadId | undefined;
262349
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 }
276377
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 );
289406 }
290407
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();
295412
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 }
298417
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 }
301422
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 }
304427
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)));
308438 }
309439
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 }
312450
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 }
315455
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 }
318460
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 }
321465
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 }
324470
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 }
327475
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 }
331480
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 }
336485
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 }
369490
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);
379494 }
380495
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 }
383500
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 }
386505
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 }
391510
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 }
394515
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 }
397520
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 }
400525
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 }
403530
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 };
406539
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 }
409545
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 });
413554
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));
417557
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 }
421567
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 }
424577
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 }
428587
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 });
434594
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
440597
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 }
445607
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");
459610 return;
460611 }
461 httpServer.listen(port, onListening);
612 }
613
614 wss.handleUpgrade(request, socket, head, (ws) => {
615 wss.emit("connection", ws, request);
462616 });
463 }
617 });
464618
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)));
470621
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 },
483634 };
635 logOutgoingPush(welcome, 1);
636 ws.send(JSON.stringify(welcome));
484637
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 );
493644 });
494645
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 );
503653 });
504654
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 });
507664
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
694export 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 @@
22 "extends": "../../tsconfig.base.json",
33 "compilerOptions": {
44 "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 ]
723 },
8 "include": ["src", "tsdown.config.ts"]
24 "include": ["src", "tsdown.config.ts", "scripts", "integration"]
925}
apps/server/tsdown.config.ts+1-1
......@@ -6,7 +6,7 @@ export default defineConfig({
66 outDir: "dist",
77 sourcemap: true,
88 clean: true,
9 noExternal: ["@t3tools/contracts", "zod"],
9 noExternal: ["@t3tools/contracts"],
1010 inlineOnly: false,
1111 banner: {
1212 js: "#!/usr/bin/env node\n",
apps/web/package.json+3-2
......@@ -21,6 +21,7 @@
2121 "@xterm/addon-fit": "^0.11.0",
2222 "@xterm/xterm": "^6.0.0",
2323 "class-variance-authority": "^0.7.1",
24 "effect": "catalog:",
2425 "highlight.js": "^11.11.1",
2526 "lucide-react": "^0.564.0",
2627 "react": "^19.0.0",
......@@ -28,10 +29,10 @@
2829 "react-markdown": "^10.1.0",
2930 "rehype-highlight": "^7.0.2",
3031 "remark-gfm": "^4.0.1",
31 "tailwind-merge": "^3.4.0",
32 "zod": "^3.24.1"
32 "tailwind-merge": "^3.4.0"
3333 },
3434 "devDependencies": {
35 "@effect/language-service": "catalog:",
3536 "@tailwindcss/vite": "^4.0.0",
3637 "@tanstack/router-plugin": "^1.161.0",
3738 "@types/react": "^19.0.0",
apps/web/src/appSettings.ts+21-12
......@@ -1,17 +1,23 @@
11import { useCallback, useSyncExternalStore } from "react";
2import { z } from "zod";
2import { Option, Schema } from "effect";
33
44const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
55
6const 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),
6const 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 ),
1017});
18export type AppSettings = typeof AppSettingsSchema.Type;
1119
12export type AppSettings = z.infer<typeof appSettingsSchema>;
13
14const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({});
20const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
1521
1622let listeners: Array<() => void> = [];
1723let cachedRawSettings: string | null | undefined;
......@@ -29,8 +35,7 @@ function parsePersistedSettings(value: string | null): AppSettings {
2935 }
3036
3137 try {
32 const parsed = JSON.parse(value) as unknown;
33 return appSettingsSchema.parse(parsed);
38 return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value);
3439 } catch {
3540 return DEFAULT_APP_SETTINGS;
3641 }
......@@ -84,10 +89,14 @@ function subscribe(listener: () => void): () => void {
8489}
8590
8691export function useAppSettings() {
87 const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS);
92 const settings = useSyncExternalStore(
93 subscribe,
94 getAppSettingsSnapshot,
95 () => DEFAULT_APP_SETTINGS,
96 );
8897
8998 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
90 const next = appSettingsSchema.parse({
99 const next = Schema.decodeSync(AppSettingsSchema)({
91100 ...getAppSettingsSnapshot(),
92101 ...patch,
93102 });
apps/web/src/components/BranchToolbar.logic.test.ts+7-4
......@@ -1,4 +1,4 @@
1import type { GitBranch } from "@t3tools/contracts";
1import { ThreadId, type GitBranch } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
44
......@@ -19,8 +19,9 @@ const branches: GitBranch[] = [
1919
2020describe("deriveSyncedLocalBranch", () => {
2121 it("syncs to git current branch in local mode", () => {
22 const threadId = ThreadId.makeUnsafe("thread-1");
2223 const result = deriveSyncedLocalBranch({
23 activeThreadId: "thread-1",
24 activeThreadId: threadId,
2425 activeWorktreePath: null,
2526 envMode: "local",
2627 activeThreadBranch: "feature/demo",
......@@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => {
3132 });
3233
3334 it("does not sync when creating a new worktree", () => {
35 const threadId = ThreadId.makeUnsafe("thread-1");
3436 const result = deriveSyncedLocalBranch({
35 activeThreadId: "thread-1",
37 activeThreadId: threadId,
3638 activeWorktreePath: null,
3739 envMode: "worktree",
3840 activeThreadBranch: "feature/demo",
......@@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => {
4345 });
4446
4547 it("does not sync when thread already targets a worktree path", () => {
48 const threadId = ThreadId.makeUnsafe("thread-1");
4649 const result = deriveSyncedLocalBranch({
47 activeThreadId: "thread-1",
50 activeThreadId: threadId,
4851 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",
4952 envMode: "local",
5053 activeThreadBranch: "feature/demo",
apps/web/src/components/BranchToolbar.logic.ts+3-3
......@@ -1,11 +1,11 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
22
33interface DeriveSyncedLocalBranchInput {
4 activeThreadId: string | undefined;
4 activeThreadId: ThreadId | undefined;
55 activeWorktreePath: string | null;
66 envMode: "local" | "worktree";
77 activeThreadBranch: string | null;
8 queryBranches: GitBranch[] | undefined;
8 queryBranches: ReadonlyArray<GitBranch> | undefined;
99}
1010
1111export function deriveSyncedLocalBranch({
apps/web/src/components/BranchToolbar.tsx+38-11
......@@ -1,13 +1,14 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useEffect, useState } from "react";
44
5import { useNativeApi } from "../hooks/useNativeApi";
5import { newCommandId } from "../lib/utils";
66import {
77 gitBranchesQueryOptions,
88 gitCheckoutMutationOptions,
99 gitCreateBranchAndCheckoutMutationOptions,
1010} from "../lib/gitReactQuery";
11import { readNativeApi } from "../nativeApi";
1112import { useStore } from "../store";
1213import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
1314import { Button } from "./ui/button";
......@@ -23,7 +24,7 @@ import {
2324import { ChevronDownIcon } from "lucide-react";
2425
2526interface BranchToolbarProps {
26 threadId: string;
27 threadId: ThreadId;
2728 envMode: "local" | "worktree";
2829 onEnvModeChange: (mode: "local" | "worktree") => void;
2930 envLocked: boolean;
......@@ -38,7 +39,6 @@ export default function BranchToolbar({
3839 onComposerFocusRequest,
3940}: BranchToolbarProps) {
4041 const { state, dispatch } = useStore();
41 const api = useNativeApi();
4242 const queryClient = useQueryClient();
4343
4444 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
......@@ -53,7 +53,7 @@ export default function BranchToolbar({
5353
5454 // ── Queries ───────────────────────────────────────────────────────────
5555
56 const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd));
56 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
5757
5858 const branches = branchesQuery.data?.branches ?? [];
5959 const branchNames = branches.map((branch) => branch.name);
......@@ -70,12 +70,10 @@ export default function BranchToolbar({
7070 : branchNames;
7171 // ── Mutations ─────────────────────────────────────────────────────────
7272
73 const checkoutMutation = useMutation(
74 gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
75 );
73 const checkoutMutation = useMutation(gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }));
7674
7775 const createBranchMutation = useMutation(
78 gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
76 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
7977 );
8078
8179 // ── Effects ───────────────────────────────────────────────────────────
......@@ -91,7 +89,17 @@ export default function BranchToolbar({
9189 queryBranches,
9290 });
9391 if (!activeThreadId || !syncedBranch) return;
92 const api = readNativeApi();
9493
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 }
95103 dispatch({
96104 type: "SET_THREAD_BRANCH",
97105 threadId: activeThreadId,
......@@ -114,16 +122,34 @@ export default function BranchToolbar({
114122
115123 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
116124 if (!activeThreadId) return;
125 const api = readNativeApi();
117126 // If the effective cwd is about to change, stop the running session so the
118127 // next message creates a new one with the correct cwd.
119128 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 });
122147 }
123148 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
124149 };
125150
126151 const selectBranch = (branch: GitBranch) => {
152 const api = readNativeApi();
127153 if (!api || !activeThreadId || !branchCwd) return;
128154
129155 // For new worktree mode, selecting a branch picks the base branch.
......@@ -164,6 +190,7 @@ export default function BranchToolbar({
164190
165191 const createBranch = (rawName: string) => {
166192 const name = rawName.trim();
193 const api = readNativeApi();
167194 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
168195 createBranchMutation.mutate(name, {
169196 onSuccess: () => {
apps/web/src/components/ChatMarkdown.tsx+35-8
......@@ -1,22 +1,49 @@
1import { memo } from "react";
1import { memo, useMemo } from "react";
22import type { Components } from "react-markdown";
33import ReactMarkdown from "react-markdown";
44import rehypeHighlight from "rehype-highlight";
55import remarkGfm from "remark-gfm";
6import { readNativeApi } from "../nativeApi";
7import { resolveMarkdownFileLinkTarget } from "../markdown-links";
8import { preferredTerminalEditor } from "../terminal-links";
69
710interface ChatMarkdownProps {
811 text: string;
12 cwd: string | undefined;
913}
1014
11const markdownComponents: Components = {
12 a({ node: _node, ...props }) {
13 return <a {...props} target="_blank" rel="noreferrer" />;
14 },
15};
15function 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 );
1644
17function ChatMarkdown({ text }: ChatMarkdownProps) {
1845 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">
2047 <ReactMarkdown
2148 remarkPlugins={[remarkGfm]}
2249 rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
apps/web/src/components/ChatView.tsx+584-794
......@@ -1,66 +1,50 @@
11import {
2 type ApprovalRequestId,
3 DEFAULT_MODEL,
4 DEFAULT_REASONING,
25 EDITORS,
36 type EditorId,
4 type NativeApi,
7 type KeybindingCommand,
8 type MessageId,
9 MODEL_OPTIONS,
10 type ProjectId,
511 type ProjectEntry,
612 type ProjectScript,
713 ModelSlug,
8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
914 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1015 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
16 REASONING_OPTIONS,
17 type ReasoningEffort,
1118 type ResolvedKeybindingsConfig,
1219 type ProviderApprovalDecision,
13 type ProviderSendTurnAttachmentInput,
20 type ThreadId,
21 type TurnId,
22 resolveModelSlug,
1423} from "@t3tools/contracts";
15import {
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";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
24import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
25import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3026import { useDebouncedValue } from "@tanstack/react-pacer";
3127import { useNavigate, useSearch } from "@tanstack/react-router";
3228import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3329import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
3530import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3631import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3732
3833import { isElectron } from "../env";
3934import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
40import { useAppSettings } from "../appSettings";
41import { buildBootstrapInput } from "../historyBootstrap";
4235import {
4336 type ComposerTriggerKind,
4437 detectComposerTrigger,
4538 replaceTextRange,
4639} from "../composer-logic";
47import {
48 DEFAULT_MODEL,
49 DEFAULT_REASONING,
50 MODEL_OPTIONS,
51 REASONING_OPTIONS,
52 ReasoningEffort,
53 resolveModelSlug,
54} from "../model-logic";
5540import {
5641 derivePendingApprovals,
5742 derivePhase,
58 deriveTurnDiffFilesFromUnifiedDiff,
5943 deriveTimelineEntries,
60 type TurnDiffSummary,
6144 type PendingApproval,
6245 deriveWorkLogEntries,
63 formatDuration,
46 hasToolActivityForTurn,
47 isLatestTurnSettled,
6448 formatElapsed,
6549 formatTimestamp,
6650} from "../session-logic";
......@@ -70,8 +54,9 @@ import { truncateTitle } from "../truncateTitle";
7054import {
7155 DEFAULT_THREAD_TERMINAL_ID,
7256 MAX_THREAD_TERMINAL_COUNT,
57 type ChatMessage,
7358 type ChatImageAttachment,
74 type TurnDiffFileChange,
59 type TurnDiffSummary,
7560} from "../types";
7661import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
7762import { useTheme } from "../hooks/useTheme";
......@@ -85,7 +70,6 @@ import {
8570} from "../keybindings";
8671import ChatMarkdown from "./ChatMarkdown";
8772import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
88import { useNativeApi } from "../hooks/useNativeApi";
8973import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
9074import {
9175 BotIcon,
......@@ -100,6 +84,8 @@ import {
10084 LockOpenIcon,
10185 Undo2Icon,
10286 XIcon,
87 CopyIcon,
88 CheckIcon,
10389} from "lucide-react";
10490import { Button } from "./ui/button";
10591import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
......@@ -109,7 +95,9 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
10995import { CursorIcon, Icon } from "./Icons";
11096import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
11197import { Badge } from "./ui/badge";
98import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
11299import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
100import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
113101import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
114102import {
115103 commandForProjectScript,
......@@ -119,6 +107,10 @@ import {
119107 setupProjectScript,
120108} from "~/projectScripts";
121109import { Toggle } from "./ui/toggle";
110import { SidebarTrigger } from "./ui/sidebar";
111import { newCommandId, newMessageId } from "~/lib/utils";
112import { readNativeApi } from "~/nativeApi";
113import { useAppSettings } from "../appSettings";
122114
123115function formatMessageMeta(createdAt: string, duration: string | null): string {
124116 if (!duration) return formatTimestamp(createdAt);
......@@ -168,14 +160,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
168160 return "text-muted-foreground/40";
169161}
170162
171type SessionContinuityState = "resumed" | "new" | "fallback_new";
172
173interface EnsuredSessionInfo {
174 sessionId: string;
175 resolvedThreadId: string | null;
176 continuityState: SessionContinuityState;
177}
178
179163interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
180164 previewUrl: string;
181165 file: File;
......@@ -186,33 +170,6 @@ interface ExpandedImagePreview {
186170 name: string;
187171}
188172
189type 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
206function 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
216173type ComposerCommandItem =
217174 | {
218175 id: string;
......@@ -236,6 +193,8 @@ type ComposerCommandItem =
236193 description: string;
237194 };
238195
196type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
197
239198function readFileAsDataUrl(file: File): Promise<string> {
240199 return new Promise((resolve, reject) => {
241200 const reader = new FileReader();
......@@ -332,7 +291,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
332291 triggerKind: ComposerTriggerKind | null;
333292 onHighlightedItemChange: (itemId: string | null) => void;
334293 onSelect: (item: ComposerCommandItem) => void;
335 commandInputRef: RefObject<HTMLInputElement | null>;
294 commandInputRef: React.RefObject<HTMLInputElement | null>;
336295}) {
337296 return (
338297 <Command
......@@ -372,32 +331,33 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
372331});
373332
374333interface ChatViewProps {
375 threadId: string;
334 threadId: ThreadId;
376335}
377336
378337export default function ChatView({ threadId }: ChatViewProps) {
379338 const { state, dispatch } = useStore();
339 const { settings } = useAppSettings();
380340 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 });
383345 const { resolvedTheme } = useTheme();
384 const { settings: appSettings } = useAppSettings();
385346 const queryClient = useQueryClient();
386 const createWorktreeMutation = useMutation(
387 gitCreateWorktreeMutationOptions({ api, queryClient }),
388 );
347 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
389348 const [prompt, setPrompt] = useState("");
390349 const promptRef = useRef(prompt);
391350 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
392351 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
393352 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);
396356 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
397357 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
398358 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
399359 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
400 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
360 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
401361 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
402362 const [nowTick, setNowTick] = useState(() => Date.now());
403363 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
......@@ -411,10 +371,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
411371 const textareaRef = useRef<HTMLTextAreaElement>(null);
412372 const composerCommandInputRef = useRef<HTMLInputElement>(null);
413373 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
374 const sendInFlightRef = useRef(false);
414375 const dragDepthRef = useRef(0);
415376 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
416 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
417 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
418377
419378 const activeThread = state.threads.find((t) => t.id === threadId);
420379 const diffSearch = useMemo(
......@@ -423,117 +382,79 @@ export default function ChatView({ threadId }: ChatViewProps) {
423382 );
424383 const diffOpen = diffSearch.diff === "1";
425384 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);
429387 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
430410 const selectedModel = resolveModelSlug(
431411 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
432412 );
433413 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;
435417 const nowIso = new Date(nowTick).toISOString();
418 const threadActivities = activeThread?.activities ?? [];
436419 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],
443422 );
423 const latestTurnHasToolActivity = useMemo(() => {
424 return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId);
425 }, [activeLatestTurn?.turnId, threadActivities]);
444426 const pendingApprovals = useMemo(
445 () => derivePendingApprovals(activeThread?.events ?? []),
446 [activeThread?.events],
427 () => derivePendingApprovals(threadActivities),
428 [threadActivities],
447429 );
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;
455434 }
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]);
458442 const timelineEntries = useMemo(
459 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
460 [activeThread?.messages, workLogEntries],
443 () => deriveTimelineEntries(timelineMessages, workLogEntries),
444 [timelineMessages, workLogEntries],
461445 );
462446 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
463447 useTurnDiffSummaries(activeThread);
464448 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
465 const byMessageId = new Map<string, TurnDiffSummary>();
449 const byMessageId = new Map<MessageId, TurnDiffSummary>();
466450 for (const summary of turnDiffSummaries) {
467451 if (!summary.assistantMessageId) continue;
468452 byMessageId.set(summary.assistantMessageId, summary);
469453 }
470454 return byMessageId;
471455 }, [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]);
535456 const revertTurnCountByUserMessageId = useMemo(() => {
536 const byUserMessageId = new Map<string, number>();
457 const byUserMessageId = new Map<MessageId, number>();
537458 for (let index = 0; index < timelineEntries.length; index += 1) {
538459 const entry = timelineEntries[index];
539460 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
......@@ -565,69 +486,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
565486 return byUserMessageId;
566487 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
567488
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
598489 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;
612494
613 const elapsed = formatElapsed(
614 activeThread.latestTurnStartedAt,
615 activeThread.latestTurnCompletedAt,
616 );
495 const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt);
617496 return elapsed ? `Worked for ${elapsed}` : null;
618497 }, [
619 activeThread?.latestTurnStartedAt,
620 activeThread?.latestTurnCompletedAt,
621 activeThread?.latestTurnDurationMs,
622 latestTurnWorkEntries,
498 activeLatestTurn?.completedAt,
499 activeLatestTurn?.startedAt,
500 latestTurnHasToolActivity,
501 latestTurnSettled,
623502 ]);
624503 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;
627507 if (!completionSummary) return null;
628508
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);
631511 if (Number.isNaN(turnStartedAt)) return null;
632512 if (Number.isNaN(turnCompletedAt)) return null;
633513
......@@ -645,16 +525,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
645525 }
646526 return inRangeMatch ?? fallbackMatch;
647527 }, [
648 activeThread?.latestTurnCompletedAt,
649 activeThread?.latestTurnStartedAt,
528 activeLatestTurn?.completedAt,
529 activeLatestTurn?.startedAt,
650530 completionSummary,
531 latestTurnSettled,
651532 timelineEntries,
652533 ]);
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();
658534 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
659535 const composerTrigger = useMemo(
660536 () => detectComposerTrigger(prompt, composerCursor),
......@@ -669,14 +545,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
669545 (debouncerState) => ({ isPending: debouncerState.isPending }),
670546 );
671547 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
672 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
548 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
673549 const keybindingsQuery = useQuery({
674 ...serverConfigQueryOptions(api),
550 ...serverConfigQueryOptions(),
675551 select: (config) => config.keybindings,
676552 });
677553 const workspaceEntriesQuery = useQuery(
678554 projectSearchEntriesQueryOptions({
679 api,
680555 cwd: gitCwd,
681556 query: effectivePathQuery,
682557 enabled: isPathTrigger,
......@@ -755,6 +630,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
755630 () => shortcutLabelForCommand(keybindings, "terminal.close"),
756631 [keybindings],
757632 );
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]);
758648
759649 const envLocked = Boolean(
760650 activeThread &&
......@@ -822,6 +712,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
822712 );
823713 const closeTerminal = useCallback(
824714 (terminalId: string) => {
715 const api = readNativeApi();
825716 if (!activeThreadId || !api) return;
826717 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;
827718 const fallbackExitWrite = () =>
......@@ -847,7 +738,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
847738 });
848739 setTerminalFocusRequestId((value) => value + 1);
849740 },
850 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],
741 [activeThread?.terminalIds.length, activeThreadId, dispatch],
851742 );
852743 const runProjectScript = useCallback(
853744 async (
......@@ -860,6 +751,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
860751 rememberAsLastInvoked?: boolean;
861752 },
862753 ) => {
754 const api = readNativeApi();
863755 if (!api || !activeThreadId || !activeProject || !activeThread) return;
864756 if (options?.rememberAsLastInvoked !== false) {
865757 setLastInvokedScriptByProjectId((current) => {
......@@ -932,73 +824,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
932824 });
933825 }
934826 },
935 [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd],
827 [activeProject, activeThread, activeThreadId, dispatch, gitCwd],
936828 );
937829 const persistProjectScripts = useCallback(
938830 async (input: {
939 projectId: string;
831 projectId: ProjectId;
940832 projectCwd: string;
941833 previousScripts: ProjectScript[];
942834 nextScripts: ProjectScript[];
943835 keybinding?: string | null;
944 keybindingCommand: string;
836 keybindingCommand: KeybindingCommand;
945837 }) => {
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(),
948844 projectId: input.projectId,
949845 scripts: input.nextScripts,
950846 });
951847
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 });
978852
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 });
999856 }
1000857 },
1001 [api, dispatch, queryClient],
858 [queryClient],
1002859 );
1003860 const saveProjectScript = useCallback(
1004861 async (input: NewProjectScriptInput) => {
......@@ -1073,19 +930,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
1073930 if (mode === state.runtimeMode) return;
1074931 dispatch({ type: "SET_RUNTIME_MODE", mode });
1075932 scheduleComposerFocus();
933 const api = readNativeApi();
1076934 if (!api) return;
1077935
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);
1082939
1083 if (sessionIds.length === 0) return;
940 if (runningThreadIds.length === 0) return;
1084941
1085942 setIsSwitchingRuntimeMode(true);
1086943 try {
1087944 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 ),
1089955 );
1090956 } finally {
1091957 setIsSwitchingRuntimeMode(false);
......@@ -1108,7 +974,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1108974 }, [lastInvokedScriptByProjectId]);
1109975
1110976 // Auto-scroll on new messages
1111 const messageCount = activeThread?.messages.length ?? 0;
977 const messageCount = timelineMessages.length;
1112978 const workLogCount = workLogEntries.length;
1113979 const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
1114980 const scrollContainer = messagesScrollRef.current;
......@@ -1167,14 +1033,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
11671033 composerImagesRef.current = composerImages;
11681034 }, [composerImages]);
11691035
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
11701051 useEffect(() => {
11711052 setComposerImages((existing) => {
11721053 revokePreviewUrls(existing);
11731054 return [];
11741055 });
1056 setOptimisticUserMessages([]);
11751057 setPrompt("");
11761058 promptRef.current = "";
1177 setIsSending(false);
1059 setSendPhase("idle");
11781060 setComposerCursor(0);
11791061 setComposerHighlightedItemId(null);
11801062 dragDepthRef.current = 0;
......@@ -1311,6 +1193,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
13111193 return;
13121194 }
13131195
1196 if (command === "diff.toggle") {
1197 event.preventDefault();
1198 event.stopPropagation();
1199 onToggleDiff();
1200 return;
1201 }
1202
13141203 const scriptId = projectScriptIdFromCommand(command);
13151204 if (!scriptId || !activeProject) return;
13161205 const script = activeProject.scripts.find((entry) => entry.id === scriptId);
......@@ -1332,11 +1221,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
13321221 runProjectScript,
13331222 splitTerminal,
13341223 keybindings,
1224 onToggleDiff,
13351225 toggleTerminalVisibility,
13361226 ]);
13371227
13381228 const setThreadError = useCallback(
1339 (threadId: string | null, error: string | null) => {
1229 (threadId: ThreadId | null, error: string | null) => {
13401230 if (!threadId) return;
13411231 dispatch({
13421232 type: "SET_ERROR",
......@@ -1392,7 +1282,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13921282 });
13931283 };
13941284
1395 const onComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
1285 const onComposerPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
13961286 const files = Array.from(event.clipboardData.files);
13971287 if (files.length === 0) {
13981288 return;
......@@ -1405,7 +1295,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14051295 addComposerImages(imageFiles);
14061296 };
14071297
1408 const onComposerDragEnter = (event: DragEvent<HTMLDivElement>) => {
1298 const onComposerDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
14091299 if (!event.dataTransfer.types.includes("Files")) {
14101300 return;
14111301 }
......@@ -1414,7 +1304,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14141304 setIsDragOverComposer(true);
14151305 };
14161306
1417 const onComposerDragOver = (event: DragEvent<HTMLDivElement>) => {
1307 const onComposerDragOver = (event: React.DragEvent<HTMLDivElement>) => {
14181308 if (!event.dataTransfer.types.includes("Files")) {
14191309 return;
14201310 }
......@@ -1423,7 +1313,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14231313 setIsDragOverComposer(true);
14241314 };
14251315
1426 const onComposerDragLeave = (event: DragEvent<HTMLDivElement>) => {
1316 const onComposerDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
14271317 if (!event.dataTransfer.types.includes("Files")) {
14281318 return;
14291319 }
......@@ -1438,7 +1328,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14381328 }
14391329 };
14401330
1441 const onComposerDrop = (event: DragEvent<HTMLDivElement>) => {
1331 const onComposerDrop = (event: React.DragEvent<HTMLDivElement>) => {
14421332 if (!event.dataTransfer.types.includes("Files")) {
14431333 return;
14441334 }
......@@ -1450,177 +1340,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
14501340 focusComposer();
14511341 };
14521342
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
16181343 const onRevertToTurnCount = useCallback(
16191344 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) {
16241349 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
16251350 return;
16261351 }
......@@ -1638,30 +1363,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
16381363 setIsRevertingCheckpoint(true);
16391364 setThreadError(activeThread.id, null);
16401365 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(),
16591369 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(),
16651372 });
16661373 } catch (err) {
16671374 setThreadError(
......@@ -1672,113 +1379,133 @@ export default function ChatView({ threadId }: ChatViewProps) {
16721379 setIsRevertingCheckpoint(false);
16731380 }
16741381 },
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],
16861383 );
16871384
1688 const onSend = async (e: FormEvent) => {
1385 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
16891386 e.preventDefault();
1690 if (!api || !activeThread || isSending || isConnecting) return;
1387 const api = readNativeApi();
1388 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;
16911389 const trimmed = prompt.trim();
16921390 if (!trimmed && composerImages.length === 0) return;
16931391 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
16941415 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 ]);
16951437
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");
17051450 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;
17061451 const result = await createWorktreeMutation.mutateAsync({
17071452 cwd: activeProject.cwd,
1708 branch: activeThread.branch,
1453 branch: baseBranchForWorktree,
17091454 newBranch,
17101455 });
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.
17121465 dispatch({
17131466 type: "SET_THREAD_BRANCH",
1714 threadId: activeThread.id,
1467 threadId: threadIdForSend,
17151468 branch: result.worktree.branch,
17161469 worktreePath: result.worktree.path,
17171470 });
17181471 const setupScript = setupProjectScript(activeProject.scripts);
17191472 if (setupScript) {
1720 void runProjectScript(setupScript, {
1473 await runProjectScript(setupScript, {
17211474 cwd: result.worktree.path,
17221475 worktreePath: result.worktree.path,
17231476 rememberAsLastInvoked: false,
17241477 });
17251478 }
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;
17331479 }
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);
17731480
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 }
17761496
1777 setIsSending(true);
1778 try {
1497 setSendPhase("sending-turn");
17791498 const turnAttachments = await Promise.all(
17801499 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 }> => ({
17821509 type: "image",
17831510 name: image.name,
17841511 mimeType: image.mimeType,
......@@ -1787,54 +1514,78 @@ export default function ChatView({ threadId }: ChatViewProps) {
17871514 }),
17881515 ),
17891516 );
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 },
18051531 model: selectedModel || undefined,
18061532 effort: selectedEffort || undefined,
1533 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
1534 approvalPolicy,
1535 sandboxMode,
1536 createdAt: messageCreatedAt,
18071537 });
18081538 } 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 }
18091552 setThreadError(
1810 activeThread.id,
1553 threadIdForSend,
18111554 err instanceof Error ? err.message : "Failed to send message.",
18121555 );
18131556 } finally {
1814 setIsSending(false);
1557 sendInFlightRef.current = false;
1558 setSendPhase("idle");
18151559 }
18161560 };
18171561
18181562 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(),
18231570 });
18241571 };
18251572
18261573 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;
18291577
18301578 setRespondingRequestIds((existing) =>
18311579 existing.includes(requestId) ? existing : [...existing, requestId],
18321580 );
18331581 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,
18361586 requestId,
18371587 decision,
1588 createdAt: new Date().toISOString(),
18381589 });
18391590 } catch (err) {
18401591 dispatch({
......@@ -1846,20 +1597,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
18461597 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
18471598 }
18481599 },
1849 [activeSessionId, activeThreadId, api, dispatch],
1600 [activeThreadId, dispatch],
18501601 );
18511602
18521603 const onModelSelect = useCallback(
18531604 (model: ModelSlug) => {
1605 const api = readNativeApi();
18541606 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 }
18601615 scheduleComposerFocus();
18611616 },
1862 [activeThread, dispatch, scheduleComposerFocus],
1617 [activeThread, scheduleComposerFocus],
18631618 );
18641619 const onEffortSelect = useCallback(
18651620 (effort: ReasoningEffort) => {
......@@ -1934,7 +1689,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19341689 setComposerCursor(nextCursor);
19351690 }, []);
19361691
1937 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
1692 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
19381693 if (composerMenuOpen && composerMenuItems.length > 0) {
19391694 if (e.key === "ArrowDown") {
19401695 e.preventDefault();
......@@ -1958,7 +1713,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19581713
19591714 if (e.key === "Enter" && !e.shiftKey) {
19601715 e.preventDefault();
1961 void onSend(e as unknown as FormEvent);
1716 void onSend(e);
19621717 }
19631718 };
19641719 const onToggleWorkGroup = useCallback((groupId: string) => {
......@@ -1970,18 +1725,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
19701725 const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => {
19711726 setExpandedImage(image);
19721727 }, []);
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]);
19831728 const onOpenTurnDiff = useCallback(
1984 (turnId: string, filePath?: string) => {
1729 (turnId: TurnId, filePath?: string) => {
19851730 void navigate({
19861731 to: "/$threadId",
19871732 params: { threadId },
......@@ -1996,7 +1741,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19961741 [navigate, threadId],
19971742 );
19981743 const onRevertUserMessage = useCallback(
1999 (messageId: string) => {
1744 (messageId: MessageId) => {
20001745 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
20011746 if (typeof targetTurnCount !== "number") {
20021747 return;
......@@ -2010,6 +1755,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
20101755 if (!activeThread) {
20111756 return (
20121757 <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 )}
20131766 {isElectron && (
20141767 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
20151768 <span className="text-xs text-muted-foreground/50">No active thread</span>
......@@ -2025,10 +1778,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
20251778 }
20261779
20271780 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">
20291782 {/* Top bar */}
20301783 <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 )}
20321788 >
20331789 <ChatHeader
20341790 activeThreadId={activeThread.id}
......@@ -2039,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20391795 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
20401796 }
20411797 keybindings={keybindings}
2042 api={api}
1798 diffToggleShortcutLabel={diffPanelShortcutLabel}
20431799 gitCwd={gitCwd}
20441800 diffOpen={diffOpen}
20451801 onRunProjectScript={(script) => {
......@@ -2062,19 +1818,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
20621818 {/* Messages */}
20631819 <div
20641820 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"
20661822 onScroll={onMessagesScroll}
20671823 >
20681824 <MessagesTimeline
2069 hasMessages={activeThread.messages.length > 0}
1825 hasMessages={timelineMessages.length > 0}
20701826 isWorking={isWorking}
20711827 scrollContainerRef={messagesScrollRef}
20721828 timelineEntries={timelineEntries}
20731829 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
20741830 completionSummary={completionSummary}
2075 assistantCompletionByItemId={assistantCompletionByItemId}
20761831 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
2077 turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId}
20781832 nowIso={nowIso}
20791833 expandedWorkGroups={expandedWorkGroups}
20801834 onToggleWorkGroup={onToggleWorkGroup}
......@@ -2083,12 +1837,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
20831837 onRevertUserMessage={onRevertUserMessage}
20841838 isRevertingCheckpoint={isRevertingCheckpoint}
20851839 onImageExpand={onExpandTimelineImage}
1840 markdownCwd={gitCwd ?? undefined}
20861841 />
20871842 </div>
20881843
20891844 {/* 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 >
20921851 <div
20931852 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
20941853 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
......@@ -2099,7 +1858,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20991858 onDrop={onComposerDrop}
21001859 >
21011860 {/* 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">
21031862 {composerMenuOpen && (
21041863 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
21051864 <ComposerCommandMenu
......@@ -2181,29 +1940,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
21811940 ? "Ask for follow-up changes or attach images"
21821941 : "Ask anything, @tag files/folders, or use /model"
21831942 }
2184 disabled={isSending || isConnecting}
1943 disabled={isConnecting}
21851944 />
21861945 </div>
21871946
21881947 {/* 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">
21911950 {/* Model picker */}
21921951 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
21931952
21941953 {/* Divider */}
2195 <Separator orientation="vertical" className="mx-0.5 h-4" />
1954 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
21961955
21971956 {/* Reasoning effort */}
21981957 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} />
21991958
22001959 {/* Divider */}
2201 <Separator orientation="vertical" className="mx-0.5 h-4" />
1960 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
22021961
22031962 {/* Runtime mode toggle */}
22041963 <Button
22051964 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"
22071966 size="sm"
22081967 type="button"
22091968 disabled={isSwitchingRuntimeMode}
......@@ -2219,16 +1978,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
22191978 }
22201979 >
22211980 {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>
22231984 </Button>
22241985 </div>
22251986
22261987 {/* 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}
22281992 {phase === "running" ? (
22291993 <button
22301994 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"
22321996 onClick={() => void onInterrupt()}
22331997 aria-label="Stop generation"
22341998 >
......@@ -2245,15 +2009,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
22452009 ) : (
22462010 <button
22472011 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"
22492013 disabled={
2250 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
2014 isSendBusy || isConnecting || (!prompt.trim() && composerImages.length === 0)
22512015 }
22522016 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"
22542024 }
22552025 >
2256 {isConnecting || isSending ? (
2026 {isConnecting || isSendBusy ? (
22572027 <svg
22582028 width="14"
22592029 height="14"
......@@ -2307,35 +2077,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
23072077 />
23082078 )}
23092079
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 })()}
23392113
23402114 {expandedImage && (
23412115 <div
......@@ -2376,13 +2150,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
23762150}
23772151
23782152interface ChatHeaderProps {
2379 activeThreadId: string;
2153 activeThreadId: ThreadId;
23802154 activeThreadTitle: string;
23812155 activeProjectName: string | undefined;
23822156 activeProjectScripts: ProjectScript[] | undefined;
23832157 preferredScriptId: string | null;
23842158 keybindings: ResolvedKeybindingsConfig;
2385 api: NativeApi | undefined;
2159 diffToggleShortcutLabel: string | null;
23862160 gitCwd: string | null;
23872161 diffOpen: boolean;
23882162 onRunProjectScript: (script: ProjectScript) => void;
......@@ -2398,7 +2172,7 @@ const ChatHeader = memo(function ChatHeader({
23982172 activeProjectScripts,
23992173 preferredScriptId,
24002174 keybindings,
2401 api,
2175 diffToggleShortcutLabel,
24022176 gitCwd,
24032177 diffOpen,
24042178 onRunProjectScript,
......@@ -2407,14 +2181,22 @@ const ChatHeader = memo(function ChatHeader({
24072181 onToggleDiff,
24082182}: ChatHeaderProps) {
24092183 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 >
24132191 {activeThreadTitle}
24142192 </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 )}
24162198 </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">
24182200 {activeProjectScripts && (
24192201 <ProjectScriptsControl
24202202 scripts={activeProjectScripts}
......@@ -2428,20 +2210,30 @@ const ChatHeader = memo(function ChatHeader({
24282210 {activeProjectName && (
24292211 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
24302212 )}
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>
24432235 </div>
2444 </>
2236 </div>
24452237 );
24462238});
24472239
......@@ -2461,8 +2253,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st
24612253
24622254interface PendingApprovalsPanelProps {
24632255 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>;
24662261}
24672262
24682263const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
......@@ -2531,24 +2326,45 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
25312326 );
25322327});
25332328
2329const 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
25342351interface MessagesTimelineProps {
25352352 hasMessages: boolean;
25362353 isWorking: boolean;
2537 scrollContainerRef: RefObject<HTMLDivElement | null>;
2354 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
25382355 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
25392356 completionDividerBeforeEntryId: string | null;
25402357 completionSummary: string | null;
2541 assistantCompletionByItemId: Map<string, string>;
2542 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2543 turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>;
2358 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
25442359 nowIso: string;
25452360 expandedWorkGroups: Record<string, boolean>;
25462361 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;
25502365 isRevertingCheckpoint: boolean;
25512366 onImageExpand: (image: ExpandedImagePreview) => void;
2367 markdownCwd: string | undefined;
25522368}
25532369
25542370type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
......@@ -2575,9 +2391,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
25752391 timelineEntries,
25762392 completionDividerBeforeEntryId,
25772393 completionSummary,
2578 assistantCompletionByItemId,
25792394 turnDiffSummaryByAssistantMessageId,
2580 turnCheckpointFilesByTurnId,
25812395 nowIso,
25822396 expandedWorkGroups,
25832397 onToggleWorkGroup,
......@@ -2586,6 +2400,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
25862400 onRevertUserMessage,
25872401 isRevertingCheckpoint,
25882402 onImageExpand,
2403 markdownCwd,
25892404}: MessagesTimelineProps) {
25902405 const rows = useMemo<TimelineRow[]>(() => {
25912406 const nextRows: TimelineRow[] = [];
......@@ -2659,7 +2474,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
26592474
26602475 return (
26612476 <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"
26632478 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
26642479 >
26652480 {virtualRows.map((virtualRow: VirtualItem) => {
......@@ -2749,7 +2564,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
27492564 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
27502565 return (
27512566 <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">
27532568 {userImages.length > 0 && (
27542569 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
27552570 {userImages.map(
......@@ -2783,17 +2598,21 @@ const MessagesTimeline = memo(function MessagesTimeline({
27832598 </pre>
27842599 )}
27852600 <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>
27972616 <p className="text-right text-[10px] text-muted-foreground/30">
27982617 {formatTimestamp(row.message.createdAt)}
27992618 </p>
......@@ -2819,17 +2638,15 @@ const MessagesTimeline = memo(function MessagesTimeline({
28192638 <span className="h-px flex-1 bg-border" />
28202639 </div>
28212640 )}
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} />
28242643 {(() => {
28252644 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
28262645 row.message.id,
28272646 );
28282647 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;
28332650 const summaryStat = checkpointFiles.reduce(
28342651 (acc, file) => {
28352652 if (
......@@ -2845,10 +2662,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
28452662 },
28462663 { additions: 0, deletions: 0 },
28472664 );
2848 const changedFileCountLabel =
2849 checkpointFilesState?.status === "ready"
2850 ? String(checkpointFiles.length)
2851 : "...";
2665 const changedFileCountLabel = String(checkpointFiles.length);
28522666 return (
28532667 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
28542668 <div className="mb-1.5 flex items-center justify-between gap-2">
......@@ -2876,66 +2690,42 @@ const MessagesTimeline = memo(function MessagesTimeline({
28762690 View diff
28772691 </Button>
28782692 </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>
29392729 </div>
29402730 );
29412731 })()}
......@@ -2944,10 +2734,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
29442734 row.message.createdAt,
29452735 row.message.streaming
29462736 ? 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),
29512738 )}
29522739 </p>
29532740 </div>
......@@ -3028,7 +2815,7 @@ const OpenInPicker = memo(function OpenInPicker({
30282815 activeThreadId,
30292816}: {
30302817 keybindings: ResolvedKeybindingsConfig;
3031 activeThreadId: string | null;
2818 activeThreadId: ThreadId | null;
30322819}) {
30332820 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
30342821 const stored = localStorage.getItem(LAST_EDITOR_KEY);
......@@ -3053,13 +2840,13 @@ const OpenInPicker = memo(function OpenInPicker({
30532840 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
30542841 const primaryOption = options.find(({ value }) => value === lastEditor);
30552842
3056 const api = useNativeApi();
30572843 const { state } = useStore();
30582844 const activeThread = state.threads.find((t) => t.id === activeThreadId);
30592845 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
30602846
30612847 const openInEditor = useCallback(
30622848 (editorId: EditorId | null) => {
2849 const api = readNativeApi();
30632850 if (!api || !activeProject) return;
30642851 const editor = editorId ?? lastEditor;
30652852 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
......@@ -3067,7 +2854,7 @@ const OpenInPicker = memo(function OpenInPicker({
30672854 localStorage.setItem(LAST_EDITOR_KEY, editor);
30682855 setLastEditor(editor);
30692856 },
3070 [api, activeProject, activeThread, lastEditor, setLastEditor],
2857 [activeProject, activeThread, lastEditor, setLastEditor],
30712858 );
30722859
30732860 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -3077,6 +2864,7 @@ const OpenInPicker = memo(function OpenInPicker({
30772864
30782865 useEffect(() => {
30792866 const handler = (e: globalThis.KeyboardEvent) => {
2867 const api = readNativeApi();
30802868 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
30812869 if (!api || !activeProject) return;
30822870
......@@ -3086,15 +2874,17 @@ const OpenInPicker = memo(function OpenInPicker({
30862874 };
30872875 window.addEventListener("keydown", handler);
30882876 return () => window.removeEventListener("keydown", handler);
3089 }, [api, activeProject, activeThread, keybindings, lastEditor]);
2877 }, [activeProject, activeThread, keybindings, lastEditor]);
30902878
30912879 return (
30922880 <Group aria-label="Subscription actions">
30932881 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
30942882 {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>
30962886 </Button>
3097 <GroupSeparator />
2887 <GroupSeparator className="hidden @sm/header-actions:block" />
30982888 <Menu>
30992889 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
31002890 <ChevronDownIcon aria-hidden="true" className="size-4" />
apps/web/src/components/DiffPanel.tsx+169-135
......@@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs";
22import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";
56import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
67import { useCallback, useEffect, useMemo, useRef, useState } from "react";
78import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
89import { cn } from "~/lib/utils";
910import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
1011import { isElectron } from "../env";
11import { useNativeApi } from "../hooks/useNativeApi";
1212import { useTheme } from "../hooks/useTheme";
1313import { buildPatchCacheKey } from "../lib/diffRendering";
1414import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -16,7 +16,11 @@ import { useStore } from "../store";
1616import { ToggleGroup, Toggle } from "./ui/toggle-group";
1717
1818type DiffRenderMode = "stacked" | "split";
19type DiffThemeType = "light" | "dark" | "system";
19type DiffThemeType = "light" | "dark";
20
21function resolveDiffThemeName(theme: "light" | "dark") {
22 return theme === "dark" ? "pierre-dark" : "pierre-light";
23}
2024
2125type RenderablePatch =
2226 | {
......@@ -29,13 +33,19 @@ type RenderablePatch =
2933 reason: string;
3034 };
3135
32function getRenderablePatch(patch: string | undefined): RenderablePatch | null {
36function getRenderablePatch(
37 patch: string | undefined,
38 cacheScope = "diff-panel",
39): RenderablePatch | null {
3340 if (!patch) return null;
3441 const normalizedPatch = patch.trim();
3542 if (normalizedPatch.length === 0) return null;
3643
3744 try {
38 const parsedPatches = parsePatchFiles(normalizedPatch, buildPatchCacheKey(normalizedPatch));
45 const parsedPatches = parsePatchFiles(
46 normalizedPatch,
47 buildPatchCacheKey(normalizedPatch, cacheScope),
48 );
3949 const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
4050 if (files.length > 0) {
4151 return { kind: "files", files };
......@@ -75,13 +85,12 @@ function formatTurnChipTimestamp(isoDate: string): string {
7585}
7686
7787interface DiffPanelProps {
78 mode?: "inline" | "sheet";
88 mode?: "inline" | "sheet" | "sidebar";
7989}
8090
8191export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
8292
8393export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
84 const api = useNativeApi();
8594 const navigate = useNavigate();
8695 const { resolvedTheme } = useTheme();
8796 const { state } = useStore();
......@@ -90,28 +99,37 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
9099 const turnStripRef = useRef<HTMLDivElement>(null);
91100 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
92101 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) });
100107 const activeThreadId = routeThreadId;
101108 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;
105109 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
106110 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 );
107125
108126 const selectedTurnId = diffSearch.diffTurnId ?? null;
109127 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;
110128 const selectedTurn =
111129 selectedTurnId === null
112130 ? undefined
113 : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
114 turnDiffSummaries[0]);
131 : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
132 orderedTurnDiffSummaries[0]);
115133 const selectedCheckpointTurnCount =
116134 selectedTurn &&
117135 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
......@@ -126,7 +144,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
126144 [selectedCheckpointTurnCount],
127145 );
128146 const conversationCheckpointTurnCount = useMemo(() => {
129 const turnCounts = turnDiffSummaries
147 const turnCounts = orderedTurnDiffSummaries
130148 .map(
131149 (summary) =>
132150 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],
......@@ -137,7 +155,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
137155 }
138156 const latest = Math.max(...turnCounts);
139157 return latest > 0 ? latest : undefined;
140 }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
158 }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]);
141159 const conversationCheckpointRange = useMemo(
142160 () =>
143161 !selectedTurn && typeof conversationCheckpointTurnCount === "number"
......@@ -152,15 +170,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
152170 ? selectedCheckpointRange
153171 : conversationCheckpointRange;
154172 const conversationCacheScope = useMemo(() => {
155 if (selectedTurn || turnDiffSummaries.length === 0) {
173 if (selectedTurn || orderedTurnDiffSummaries.length === 0) {
156174 return null;
157175 }
158 return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
159 }, [selectedTurn, turnDiffSummaries]);
176 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
177 }, [orderedTurnDiffSummaries, selectedTurn]);
160178 const activeCheckpointDiffQuery = useQuery(
161 checkpointDiffQueryOptions(api, {
162 sessionId: activeSessionId,
163 threadRuntimeId: activeThreadRuntimeId,
179 checkpointDiffQueryOptions({
180 threadId: activeThreadId,
164181 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
165182 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
166183 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
......@@ -181,7 +198,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
181198 : null;
182199
183200 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 );
185207 const renderableFiles = useMemo(() => {
186208 if (!renderablePatch || renderablePatch.kind !== "files") {
187209 return [];
......@@ -204,7 +226,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
204226 target?.scrollIntoView({ block: "nearest" });
205227 }, [selectedFilePath, renderableFiles]);
206228
207 const selectTurn = (turnId: string) => {
229 const selectTurn = (turnId: TurnId) => {
208230 if (!activeThread) return;
209231 void navigate({
210232 to: "/$threadId",
......@@ -259,7 +281,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
259281
260282 updateTurnStripScrollState();
261283 const onScroll = () => updateTurnStripScrollState();
262
284
263285 element.addEventListener("scroll", onScroll, { passive: true });
264286 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
265287
......@@ -275,7 +297,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
275297
276298 useEffect(() => {
277299 updateTurnStripScrollState();
278 }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
300 }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
279301
280302 useEffect(() => {
281303 const element = turnStripRef.current;
......@@ -285,135 +307,143 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
285307 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
286308 }, [selectedTurn?.turnId, selectedTurnId]);
287309
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" />
303316 )}
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",
308327 )}
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",
311341 )}
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">
312349 <button
313350 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}
337354 >
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>
339365 </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) => (
344367 <button
368 key={summary.turnId}
345369 type="button"
346370 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}
349374 >
350375 <div
351376 className={cn(
352377 "rounded-md border px-2 py-1 text-left transition-colors",
353 selectedTurnId === null
378 summary.turnId === selectedTurn?.turnId
354379 ? "border-border bg-accent text-accent-foreground"
355380 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
356381 )}
357382 >
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>
359394 </div>
360395 </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 ))}
390397 </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>
410398 </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 )}
411441
412442 {!activeThread ? (
413443 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
414444 Select a thread to inspect turn diffs.
415445 </div>
416 ) : turnDiffSummaries.length === 0 ? (
446 ) : orderedTurnDiffSummaries.length === 0 ? (
417447 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
418448 No completed turns yet.
419449 </div>
......@@ -430,7 +460,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
430460 <p>
431461 {isLoadingCheckpointDiff
432462 ? "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."}
434466 </p>
435467 </div>
436468 ) : renderablePatch.kind === "files" ? (
......@@ -444,13 +476,15 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
444476 {renderableFiles.map((fileDiff) => {
445477 const filePath = resolveFileDiffPath(fileDiff);
446478 const fileKey = buildFileDiffRenderKey(fileDiff);
479 const themedFileKey = `${fileKey}:${resolvedTheme}`;
447480 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">
449482 <FileDiff
450483 fileDiff={fileDiff}
451484 options={{
452485 diffStyle: diffRenderMode === "split" ? "split" : "unified",
453486 lineDiffType: "none",
487 theme: resolveDiffThemeName(resolvedTheme),
454488 themeType: resolvedTheme as DiffThemeType,
455489 }}
456490 />
......@@ -471,6 +505,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
471505 </div>
472506 </>
473507 )}
474 </aside>
508 </div>
475509 );
476510}
apps/web/src/components/DiffWorkerPoolProvider.tsx+39-6
......@@ -1,26 +1,59 @@
1import { WorkerPoolContextProvider } from "@pierre/diffs/react";
2import { useMemo, type ReactNode } from "react";
1import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
2import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
3import { useEffect, useMemo, type ReactNode } from "react";
4import { useTheme } from "../hooks/useTheme";
5
6function resolveDiffThemeName(theme: "light" | "dark") {
7 return theme === "dark" ? "pierre-dark" : "pierre-light";
8}
9
10function 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}
333
434export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
35 const { resolvedTheme } = useTheme();
36 const diffThemeName = resolveDiffThemeName(resolvedTheme);
537 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);
740 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
841 }, []);
942
1043 return (
1144 <WorkerPoolContextProvider
1245 poolOptions={{
13 workerFactory: () =>
14 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),
46 workerFactory: () => new DiffsWorker(),
1547 poolSize: workerPoolSize,
1648 totalASTLRUCacheSize: 240,
1749 }}
1850 highlighterOptions={{
51 theme: diffThemeName,
1952 tokenizeMaxLineLength: 1_000,
2053 }}
2154 >
55 <DiffWorkerThemeSync themeName={diffThemeName} />
2256 {children}
2357 </WorkerPoolContextProvider>
2458 );
2559}
26
apps/web/src/components/GitActionsControl.tsx+32-26
......@@ -1,5 +1,5 @@
1import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";
2import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useCallback, useEffect, useMemo, useState } from "react";
44import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
55import { GitHubIcon } from "./Icons";
......@@ -33,17 +33,18 @@ import { toastManager } from "~/components/ui/toast";
3333import {
3434 gitBranchesQueryOptions,
3535 gitInitMutationOptions,
36 gitMutationKeys,
3637 gitPullMutationOptions,
3738 gitRunStackedActionMutationOptions,
3839 gitStatusQueryOptions,
3940 invalidateGitQueries,
4041} from "~/lib/gitReactQuery";
4142import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
43import { readNativeApi } from "~/nativeApi";
4244
4345interface GitActionsControlProps {
44 api: NativeApi | undefined;
4546 gitCwd: string | null;
46 activeThreadId: string | null;
47 activeThreadId: ThreadId | null;
4748}
4849
4950function getMenuActionDisabledReason(
......@@ -136,11 +137,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136137 return <InfoIcon className={iconClassName} />;
137138}
138139
139export default function GitActionsControl({
140 api,
141 gitCwd,
142 activeThreadId,
143}: GitActionsControlProps) {
140export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
144141 const threadToastData = useMemo(
145142 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
146143 [activeThreadId],
......@@ -149,11 +146,9 @@ export default function GitActionsControl({
149146 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
150147 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
151148
152 const { data: gitStatus = null, error: gitStatusError } = useQuery(
153 gitStatusQueryOptions(api, gitCwd),
154 );
149 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
155150
156 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
151 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
157152 // Default to true while loading so we don't flash init controls.
158153 const isRepo = branchList?.isRepo ?? true;
159154 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
......@@ -167,14 +162,17 @@ export default function GitActionsControl({
167162
168163 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
169164
170 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
165 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
171166
172167 const runImmediateGitActionMutation = useMutation(
173 gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }),
168 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
174169 );
175 const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient }));
170 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
176171
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;
178176 const isDefaultBranch = useMemo(() => {
179177 const branchName = gitStatusForActions?.branch;
180178 if (!branchName) return false;
......@@ -196,6 +194,7 @@ export default function GitActionsControl({
196194
197195 const maybeConfirmPushToDefaultBranch = useCallback(
198196 async (action: GitStackedAction): Promise<boolean> => {
197 const api = readNativeApi();
199198 if (!api) return false;
200199 if (
201200 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
......@@ -207,10 +206,11 @@ export default function GitActionsControl({
207206 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,
208207 );
209208 },
210 [api, gitStatusForActions?.branch, isDefaultBranch],
209 [gitStatusForActions?.branch, isDefaultBranch],
211210 );
212211
213212 const openExistingPr = useCallback(async () => {
213 const api = readNativeApi();
214214 if (!api) {
215215 toastManager.add({
216216 type: "error",
......@@ -236,8 +236,7 @@ export default function GitActionsControl({
236236 data: threadToastData,
237237 });
238238 });
239 }, [api, gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
240
239 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
241240 const runGitActionWithToast = useCallback(
242241 async ({
243242 action,
......@@ -335,8 +334,10 @@ export default function GitActionsControl({
335334 actionProps: {
336335 children: "Open PR",
337336 onClick: () => {
337 const api = readNativeApi();
338 if (!api) return;
338339 closeResultToast();
339 void api?.shell.openExternal(prUrl);
340 void api.shell.openExternal(prUrl);
340341 },
341342 },
342343 }
......@@ -362,8 +363,8 @@ export default function GitActionsControl({
362363 });
363364 }
364365 },
366
365367 [
366 api,
367368 gitStatusForActions?.branch,
368369 gitStatusForActions?.hasWorkingTreeChanges,
369370 gitStatusForActions?.pr,
......@@ -453,6 +454,7 @@ export default function GitActionsControl({
453454
454455 const openChangedFileInEditor = useCallback(
455456 (filePath: string) => {
457 const api = readNativeApi();
456458 if (!api || !gitCwd) {
457459 toastManager.add({
458460 type: "error",
......@@ -471,7 +473,7 @@ export default function GitActionsControl({
471473 });
472474 });
473475 },
474 [api, gitCwd, threadToastData],
476 [gitCwd, threadToastData],
475477 );
476478
477479 if (!gitCwd) return null;
......@@ -503,7 +505,9 @@ export default function GitActionsControl({
503505 }
504506 >
505507 <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>
507511 </PopoverTrigger>
508512 <PopoverPopup tooltipStyle side="bottom" align="start">
509513 {quickActionDisabledReason}
......@@ -517,10 +521,12 @@ export default function GitActionsControl({
517521 onClick={runQuickAction}
518522 >
519523 <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>
521527 </Button>
522528 )}
523 <GroupSeparator />
529 <GroupSeparator className="hidden @sm/header-actions:block" />
524530 <Menu
525531 onOpenChange={(open) => {
526532 if (open) void invalidateGitQueries(queryClient);
apps/web/src/components/ProjectScriptsControl.tsx+28-32
......@@ -16,7 +16,15 @@ import {
1616} from "lucide-react";
1717import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
1818
19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
19import {
20 keybindingValueForCommand,
21 decodeProjectScriptKeybindingRule,
22} from "~/lib/projectScriptKeybindings";
23import {
24 commandForProjectScript,
25 nextProjectScriptId,
26 primaryProjectScript,
27} from "~/projectScripts";
2028import { shortcutLabelForCommand } from "~/keybindings";
2129import { isMacPlatform } from "~/lib/utils";
2230import { Button } from "./ui/button";
......@@ -129,32 +137,6 @@ function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | n
129137 return parts.join("+");
130138}
131139
132function 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
158140export default function ProjectScriptsControl({
159141 scripts,
160142 keybindings,
......@@ -212,12 +194,22 @@ export default function ProjectScriptsControl({
212194
213195 setValidationError(null);
214196 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 });
215207 const payload = {
216208 name: trimmedName,
217209 command: trimmedCommand,
218210 icon,
219211 runOnWorktreeCreate,
220 keybinding: keybinding.trim() || null,
212 keybinding: keybindingRule?.key ?? null,
221213 } satisfies NewProjectScriptInput;
222214 if (editingScriptId) {
223215 await onUpdateScript(editingScriptId, payload);
......@@ -266,9 +258,11 @@ export default function ProjectScriptsControl({
266258 title={`Run ${primaryScript.name}`}
267259 >
268260 <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>
270264 </Button>
271 <GroupSeparator />
265 <GroupSeparator className="hidden @sm/header-actions:block" />
272266 <Menu highlightItemOnHover={false}>
273267 <MenuTrigger
274268 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
......@@ -327,9 +321,11 @@ export default function ProjectScriptsControl({
327321 </Menu>
328322 </Group>
329323 ) : (
330 <Button size="xs" variant="outline" onClick={openAddDialog}>
324 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
331325 <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>
333329 </Button>
334330 )}
335331
apps/web/src/components/Sidebar.tsx+484-370
......@@ -1,29 +1,43 @@
1import {
2 GitPullRequestIcon,
3 MonitorIcon,
4 MoonIcon,
5 SunIcon,
6 TerminalIcon,
7} from "lucide-react";
1import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
82import { useCallback, useEffect, useMemo, useState } from "react";
9import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import {
4 DEFAULT_MODEL,
5 ProjectId,
6 ThreadId,
7 type GitStatusResult,
8 type ResolvedKeybindingsConfig,
9} from "@t3tools/contracts";
1010import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
11import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
1211import { useNavigate, useParams } from "@tanstack/react-router";
1312import { useAppSettings } from "../appSettings";
1413import { isElectron } from "../env";
1514import { APP_STAGE_LABEL } from "../branding";
16import { DEFAULT_MODEL } from "../model-logic";
17import { derivePendingApprovals } from "../session-logic";
15import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
1816import { useStore } from "../store";
1917import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
20import { type Project, type Thread } from "../types";
21import { useNativeApi } from "../hooks/useNativeApi";
18import { type Thread } from "../types";
19import { derivePendingApprovals } from "../session-logic";
2220import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
2321import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";
2423import { toastManager } from "./ui/toast";
24import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
25import {
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";
2539import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
26import { createThread } from "../threadFactory";
40import { isNonEmpty as isNonEmptyString } from "effect/String";
2741
2842const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2943
......@@ -37,15 +51,8 @@ function formatRelativeTime(iso: string): string {
3751 return `${Math.floor(hours / 24)}d ago`;
3852}
3953
40function 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
4754interface ThreadStatusPill {
48 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
55 label: "Working" | "Connecting" | "Completed" | "Pending Approval";
4956 colorClass: string;
5057 dotClass: string;
5158 pulse: boolean;
......@@ -65,8 +72,8 @@ interface PrStatusIndicator {
6572type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
6673
6774function 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);
7077 if (Number.isNaN(completedAt)) return false;
7178 if (!thread.lastVisitedAt) return true;
7279
......@@ -75,13 +82,10 @@ function hasUnseenCompletion(thread: Thread): boolean {
7582 return completedAt > lastVisitedAt;
7683}
7784
78function threadStatusPill(
79 thread: Thread,
80 hasPendingApprovals: boolean,
81): ThreadStatusPill | null {
85function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {
8286 if (hasPendingApprovals) {
8387 return {
84 label: "Awaiting response",
88 label: "Pending Approval",
8589 colorClass: "text-amber-600 dark:text-amber-300/90",
8690 dotClass: "bg-amber-500 dark:bg-amber-300/90",
8791 pulse: false,
......@@ -118,6 +122,17 @@ function threadStatusPill(
118122 return null;
119123}
120124
125function 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
121136function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
122137 if (prState === "open") {
123138 return {
......@@ -140,40 +155,88 @@ function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
140155 return null;
141156}
142157
143function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {
144 if (thread.runningTerminalIds.length === 0) {
145 return null;
158function 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 */
178function 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;
146193 }
147 return {
148 label: "Terminal process running",
149 colorClass: "text-teal-600 dark:text-teal-300/90",
150 pulse: true,
151 };
194}
195
196const serverHttpOrigin = getServerHttpOrigin();
197
198function 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 );
152216}
153217
154218export default function Sidebar() {
155219 const { state, dispatch } = useStore();
156 const api = useNativeApi();
157220 const navigate = useNavigate();
158221 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 });
161226 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
162 ...serverConfigQueryOptions(api),
227 ...serverConfigQueryOptions(),
163228 select: (config) => config.keybindings,
164229 });
165230 const queryClient = useQueryClient();
166 const removeWorktreeMutation = useMutation(
167 gitRemoveWorktreeMutationOptions({ api, queryClient }),
168 );
231 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
169232 const [addingProject, setAddingProject] = useState(false);
170233 const [newCwd, setNewCwd] = useState("");
171234 const [isPickingFolder, setIsPickingFolder] = useState(false);
172235 const [isAddingProject, setIsAddingProject] = useState(false);
173236 const pendingApprovalByThreadId = useMemo(() => {
174 const map = new Map<string, boolean>();
237 const map = new Map<ThreadId, boolean>();
175238 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);
177240 }
178241 return map;
179242 }, [state.threads]);
......@@ -203,28 +266,24 @@ export default function Sidebar() {
203266 [threadGitTargets],
204267 );
205268 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 })),
215274 });
216275 const prStateByThreadId = useMemo(() => {
217276 const statusByCwd = new Map<string, GitStatusResult>();
218277 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
219278 const cwd = threadGitStatusCwds[index];
220279 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);
224283 }
225284 }
226285
227 const map = new Map<string, ThreadPrState>();
286 const map = new Map<ThreadId, ThreadPrState>();
228287 for (const target of threadGitTargets) {
229288 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
230289 const branchMatches =
......@@ -236,31 +295,50 @@ export default function Sidebar() {
236295
237296 const handleNewThread = useCallback(
238297 (
239 projectId: string,
298 projectId: ProjectId,
240299 options?: {
241300 branch?: string | null;
242301 worktreePath?: string | null;
243302 },
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 })();
258336 },
259337 [dispatch, navigate, state.projects],
260338 );
261339
262340 const focusMostRecentThreadForProject = useCallback(
263 (projectId: string) => {
341 (projectId: ProjectId) => {
264342 const latestThread = state.threads
265343 .filter((thread) => thread.projectId === projectId)
266344 .toSorted((a, b) => {
......@@ -282,64 +360,37 @@ export default function Sidebar() {
282360 async (rawCwd: string) => {
283361 const cwd = rawCwd.trim();
284362 if (!cwd || isAddingProject) return;
363 const api = readNativeApi();
285364
286365 setIsAddingProject(true);
287366 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;
328372 }
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);
329387 } finally {
330388 setIsAddingProject(false);
331389 setNewCwd("");
332390 setAddingProject(false);
333391 }
334392 },
335 [
336 api,
337 dispatch,
338 focusMostRecentThreadForProject,
339 handleNewThread,
340 isAddingProject,
341 state.projects,
342 ],
393 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects],
343394 );
344395
345396 const handleAddProject = () => {
......@@ -347,6 +398,7 @@ export default function Sidebar() {
347398 };
348399
349400 const handlePickFolder = async () => {
401 const api = readNativeApi();
350402 if (!api || isPickingFolder) return;
351403 setIsPickingFolder(true);
352404 try {
......@@ -359,9 +411,20 @@ export default function Sidebar() {
359411 };
360412
361413 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();
363416 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 }
365428 if (clicked !== "delete") return;
366429
367430 const thread = state.threads.find((t) => t.id === threadId);
......@@ -369,7 +432,7 @@ export default function Sidebar() {
369432 if (appSettings.confirmThreadDelete) {
370433 const confirmed = await api.dialogs.confirm(
371434 [
372 `Delete thread \"${thread.title}\"?`,
435 `Delete thread "${thread.title}"?`,
373436 "This permanently clears conversation history for this thread.",
374437 ].join("\n"),
375438 );
......@@ -394,15 +457,15 @@ export default function Sidebar() {
394457 ].join("\n"),
395458 ));
396459
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);
406469 }
407470
408471 try {
......@@ -416,7 +479,11 @@ export default function Sidebar() {
416479
417480 const shouldNavigateToFallback = routeThreadId === threadId;
418481 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 });
420487 if (shouldNavigateToFallback) {
421488 if (fallbackThreadId) {
422489 void navigate({
......@@ -455,7 +522,6 @@ export default function Sidebar() {
455522 }
456523 },
457524 [
458 api,
459525 appSettings.confirmThreadDelete,
460526 dispatch,
461527 navigate,
......@@ -467,7 +533,8 @@ export default function Sidebar() {
467533 );
468534
469535 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();
471538 if (!api) return;
472539 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
473540 if (clicked !== "delete") return;
......@@ -490,25 +557,23 @@ export default function Sidebar() {
490557 );
491558 if (!confirmed) return;
492559
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 });
507574 }
508
509 dispatch({ type: "DELETE_PROJECT", projectId });
510575 },
511 [api, dispatch, state.projects, state.threads],
576 [state.projects, state.threads],
512577 );
513578
514579 useEffect(() => {
......@@ -520,7 +585,7 @@ export default function Sidebar() {
520585 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
521586 if (!projectId) return;
522587 event.preventDefault();
523 handleNewThread(projectId);
588 void handleNewThread(projectId);
524589 return;
525590 }
526591
......@@ -528,7 +593,7 @@ export default function Sidebar() {
528593 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
529594 if (!projectId) return;
530595 event.preventDefault();
531 handleNewThread(projectId, {
596 void handleNewThread(projectId, {
532597 branch: activeThread?.branch ?? null,
533598 worktreePath: activeThread?.worktreePath ?? null,
534599 });
......@@ -540,224 +605,273 @@ export default function Sidebar() {
540605 };
541606 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
542607
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 };
558616
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>
576628 </div>
629 </div>
630 );
577631
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 );
611642
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" : ""
631700 }`}
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"
654730 }`}
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.
702824 </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>
740825 )}
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 ) : (
761875 <button
762876 type="button"
763877 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() {
765879 >
766880 + Add project
767881 </button>
768 </div>
769 )}
770 </aside>
882 )}
883 </SidebarFooter>
884 </>
771885 );
772886}
apps/web/src/components/ThreadTerminalDrawer.tsx+41-22
......@@ -1,6 +1,6 @@
11import { FitAddon } from "@xterm/addon-fit";
22import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import { type NativeApi } from "@t3tools/contracts";
3import { type ThreadId } from "@t3tools/contracts";
44import { Terminal, type ITheme } from "@xterm/xterm";
55import {
66 type PointerEvent as ReactPointerEvent,
......@@ -25,6 +25,7 @@ import {
2525 MAX_THREAD_TERMINAL_COUNT,
2626 type ThreadTerminalGroup,
2727} from "../types";
28import { readNativeApi } from "~/nativeApi";
2829
2930const MIN_DRAWER_HEIGHT = 180;
3031const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -107,11 +108,11 @@ function terminalThemeFromApp(): ITheme {
107108}
108109
109110interface TerminalViewportProps {
110 api: NativeApi;
111 threadId: string;
111 threadId: ThreadId;
112112 terminalId: string;
113113 cwd: string;
114114 runtimeEnv?: Record<string, string>;
115 onSessionExited: () => void;
115116 focusRequestId: number;
116117 autoFocus: boolean;
117118 resizeEpoch: number;
......@@ -119,11 +120,11 @@ interface TerminalViewportProps {
119120}
120121
121122function TerminalViewport({
122 api,
123123 threadId,
124124 terminalId,
125125 cwd,
126126 runtimeEnv,
127 onSessionExited,
127128 focusRequestId,
128129 autoFocus,
129130 resizeEpoch,
......@@ -132,6 +133,12 @@ function TerminalViewport({
132133 const containerRef = useRef<HTMLDivElement>(null);
133134 const terminalRef = useRef<Terminal | null>(null);
134135 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]);
135142
136143 useEffect(() => {
137144 const mount = containerRef.current;
......@@ -155,6 +162,9 @@ function TerminalViewport({
155162 terminalRef.current = terminal;
156163 fitAddonRef.current = fitAddon;
157164
165 const api = readNativeApi();
166 if (!api) return;
167
158168 const sendTerminalInput = async (data: string, fallbackError: string) => {
159169 const activeTerminal = terminalRef.current;
160170 if (!activeTerminal) return;
......@@ -210,14 +220,10 @@ function TerminalViewport({
210220 end: { x: match.end, y: bufferLineNumber },
211221 },
212222 activate: (event: MouseEvent) => {
213 if (!isTerminalLinkActivation(event)) {
214 return;
215 }
223 if (!isTerminalLinkActivation(event)) return;
216224
217225 const latestTerminal = terminalRef.current;
218 if (!latestTerminal) {
219 return;
220 }
226 if (!latestTerminal) return;
221227
222228 if (match.kind === "url") {
223229 void api.shell.openExternal(match.text).catch((error) => {
......@@ -297,7 +303,7 @@ function TerminalViewport({
297303 }
298304 };
299305
300 const unsubscribe = api.terminal.onEvent((event) => {
306 const unsubscribe = api?.terminal.onEvent((event) => {
301307 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
302308 const activeTerminal = terminalRef.current;
303309 if (!activeTerminal) return;
......@@ -308,6 +314,7 @@ function TerminalViewport({
308314 }
309315
310316 if (event.type === "started" || event.type === "restarted") {
317 hasHandledExitRef.current = false;
311318 activeTerminal.write("\u001bc");
312319 if (event.snapshot.history.length > 0) {
313320 activeTerminal.write(event.snapshot.history);
......@@ -337,6 +344,16 @@ function TerminalViewport({
337344 activeTerminal,
338345 details.length > 0 ? `Process exited (${details})` : "Process exited",
339346 );
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);
340357 }
341358 });
342359
......@@ -372,7 +389,7 @@ function TerminalViewport({
372389 fitAddonRef.current = null;
373390 terminal.dispose();
374391 };
375 }, [api, cwd, runtimeEnv, terminalId, threadId]);
392 }, [cwd, runtimeEnv, terminalId, threadId]);
376393
377394 useEffect(() => {
378395 if (!autoFocus) return;
......@@ -387,9 +404,10 @@ function TerminalViewport({
387404 }, [autoFocus, focusRequestId]);
388405
389406 useEffect(() => {
407 const api = readNativeApi();
390408 const terminal = terminalRef.current;
391409 const fitAddon = fitAddonRef.current;
392 if (!terminal || !fitAddon) return;
410 if (!api || !terminal || !fitAddon) return;
393411 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
394412 const frame = window.requestAnimationFrame(() => {
395413 fitAddon.fit();
......@@ -408,14 +426,12 @@ function TerminalViewport({
408426 return () => {
409427 window.cancelAnimationFrame(frame);
410428 };
411 }, [api, drawerHeight, resizeEpoch, terminalId, threadId]);
412
429 }, [drawerHeight, resizeEpoch, terminalId, threadId]);
413430 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
414431}
415432
416433interface ThreadTerminalDrawerProps {
417 api: NativeApi;
418 threadId: string;
434 threadId: ThreadId;
419435 cwd: string;
420436 runtimeEnv?: Record<string, string>;
421437 height: number;
......@@ -464,7 +480,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
464480}
465481
466482export default function ThreadTerminalDrawer({
467 api,
468483 threadId,
469484 cwd,
470485 runtimeEnv,
......@@ -597,10 +612,14 @@ export default function ThreadTerminalDrawer({
597612 );
598613 const splitTerminalActionLabel = hasReachedTerminalLimit
599614 ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
600 : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal");
615 : splitShortcutLabel
616 ? `Split Terminal (${splitShortcutLabel})`
617 : "Split Terminal";
601618 const newTerminalActionLabel = hasReachedTerminalLimit
602619 ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
603 : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal");
620 : newShortcutLabel
621 ? `New Terminal (${newShortcutLabel})`
622 : "New Terminal";
604623 const closeTerminalActionLabel = closeShortcutLabel
605624 ? `Close Terminal (${closeShortcutLabel})`
606625 : "Close Terminal";
......@@ -779,11 +798,11 @@ export default function ThreadTerminalDrawer({
779798 >
780799 <div className="h-full p-1">
781800 <TerminalViewport
782 api={api}
783801 threadId={threadId}
784802 terminalId={terminalId}
785803 cwd={cwd}
786804 {...(runtimeEnv ? { runtimeEnv } : {})}
805 onSessionExited={() => onCloseTerminal(terminalId)}
787806 focusRequestId={focusRequestId}
788807 autoFocus={terminalId === resolvedActiveTerminalId}
789808 resizeEpoch={resizeEpoch}
......@@ -797,11 +816,11 @@ export default function ThreadTerminalDrawer({
797816 <div className="h-full p-1">
798817 <TerminalViewport
799818 key={resolvedActiveTerminalId}
800 api={api}
801819 threadId={threadId}
802820 terminalId={resolvedActiveTerminalId}
803821 cwd={cwd}
804822 {...(runtimeEnv ? { runtimeEnv } : {})}
823 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
805824 focusRequestId={focusRequestId}
806825 autoFocus
807826 resizeEpoch={resizeEpoch}
apps/web/src/components/ui/collapsible.tsx created+39
......@@ -0,0 +1,39 @@
1"use client";
2
3import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
4
5import { cn } from "~/lib/utils";
6
7function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
8 return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
9}
10
11function 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
21function 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
34export {
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>(
2121) {
2222 const chipsRef = React.useRef<Element | null>(null);
2323 return (
24 <ComboboxContext.Provider value={{ chipsRef, multiple: !!props.multiple }}>
24 <ComboboxContext.Provider
25 value={React.useMemo(() => ({ chipsRef, multiple: !!props.multiple }), [props.multiple])}
26 >
2527 <ComboboxPrimitive.Root {...props} />
2628 </ComboboxContext.Provider>
2729 );
apps/web/src/components/ui/command.tsx+4-1
......@@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc
133133function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
134134 return (
135135 <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 )}
137140 {...props}
138141 />
139142 );
apps/web/src/components/ui/sheet.tsx+1
......@@ -49,6 +49,7 @@ function SheetViewport({
4949 side === "left" && "flex justify-start",
5050 side === "right" && "flex justify-end",
5151 variant === "inset" && "sm:p-4",
52 className,
5253 )}
5354 data-slot="sheet-viewport"
5455 {...props}
apps/web/src/components/ui/sidebar.tsx created+991
......@@ -0,0 +1,991 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { cva, type VariantProps } from "class-variance-authority";
4import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
5import * as React from "react";
6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";
8import { Input } from "~/components/ui/input";
9import { ScrollArea } from "~/components/ui/scroll-area";
10import { Separator } from "~/components/ui/separator";
11import {
12 Sheet,
13 SheetDescription,
14 SheetHeader,
15 SheetPopup,
16 SheetTitle,
17} from "~/components/ui/sheet";
18import { Skeleton } from "~/components/ui/skeleton";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useMediaQuery } from "~/hooks/useMediaQuery";
21
22const SIDEBAR_COOKIE_NAME = "sidebar_state";
23const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
24const SIDEBAR_WIDTH = "16rem";
25const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
26const SIDEBAR_WIDTH_ICON = "3rem";
27const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16;
28
29type 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
39type 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
54type 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
69type SidebarInstanceContextProps = {
70 resizable: SidebarResolvedResizableOptions | null;
71 side: "left" | "right";
72};
73
74const SidebarContext = React.createContext<SidebarContextProps | null>(null);
75const SidebarInstanceContext = React.createContext<SidebarInstanceContextProps | null>(null);
76
77function 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
86function 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
171function 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
303function 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
325function clampSidebarWidth(width: number, options: SidebarResolvedResizableOptions): number {
326 return Math.max(options.minWidth, Math.min(width, options.maxWidth));
327}
328
329function 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
593function 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
607function 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
618function 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
629function 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
640function 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
651function 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
667function 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
678function 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
696function 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
716function 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
727function 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
738function 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
749const 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
771function 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
824function 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
856function 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
875function 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
908function 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
923function 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
934function 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
966export {
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 @@
33import { Toast } from "@base-ui/react/toast";
44import { useEffect } from "react";
55import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";
67import {
78 CircleAlertIcon,
89 CircleCheckIcon,
......@@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button";
1617import { shouldHideCollapsedToastContent } from "./toast.logic";
1718
1819type ThreadToastData = {
19 threadId?: string | null;
20 threadId?: ThreadId | null;
2021 tooltipStyle?: boolean;
2122 dismissAfterVisibleMs?: number;
2223};
......@@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props {
4849
4950function shouldRenderForActiveThread(
5051 data: ThreadToastData | undefined,
51 activeThreadId: string | null,
52 activeThreadId: ThreadId | null,
5253): boolean {
5354 const toastThreadId = data?.threadId;
5455 if (!toastThreadId) return true;
5556 return toastThreadId === activeThreadId;
5657}
5758
58function useActiveThreadIdFromRoute(): string | null {
59function useActiveThreadIdFromRoute(): ThreadId | null {
5960 return useParams({
6061 strict: false,
61 select: (params) => params.threadId ?? null,
62 select: (params) =>
63 typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null,
6264 });
6365}
6466
apps/web/src/components/ui/toggle-group.tsx+3-1
......@@ -42,7 +42,9 @@ function ToggleGroup({
4242 orientation={orientation}
4343 {...props}
4444 >
45 <ToggleGroupContext.Provider value={{ size, variant }}>
45 <ToggleGroupContext.Provider
46 value={React.useMemo(() => ({ size, variant }), [size, variant])}
47 >
4648 {children}
4749 </ToggleGroupContext.Provider>
4850 </ToggleGroupPrimitive>
apps/web/src/components/ui/tooltip.tsx created+59
......@@ -0,0 +1,59 @@
1import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2
3import { cn } from "~/lib/utils";
4
5const TooltipCreateHandle = TooltipPrimitive.createHandle;
6
7const TooltipProvider = TooltipPrimitive.Provider;
8
9const Tooltip = TooltipPrimitive.Root;
10
11function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
12 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
13}
14
15function 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
59export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
apps/web/src/contextMenuFallback.ts+4-2
......@@ -41,8 +41,10 @@ export function showContextMenuFallback<T extends string>(
4141 const btn = document.createElement("button");
4242 btn.type = "button";
4343 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";
4648 btn.addEventListener("click", () => cleanup(item.id));
4749 menu.appendChild(btn);
4850 }
apps/web/src/diffRouteSearch.test.ts+22-1
......@@ -17,6 +17,28 @@ describe("parseDiffRouteSearch", () => {
1717 });
1818 });
1919
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
2042 it("drops turn and file values when diff is closed", () => {
2143 const parsed = parseDiffRouteSearch({
2244 diff: "0",
......@@ -50,4 +72,3 @@ describe("parseDiffRouteSearch", () => {
5072 });
5173 });
5274});
53
apps/web/src/diffRouteSearch.ts+10-3
......@@ -1,9 +1,15 @@
1import { TurnId } from "@t3tools/contracts";
2
13export interface DiffRouteSearch {
24 diff?: "1";
3 diffTurnId?: string;
5 diffTurnId?: TurnId;
46 diffFilePath?: string;
57}
68
9function isDiffOpenValue(value: unknown): boolean {
10 return value === "1" || value === 1 || value === true;
11}
12
713function normalizeSearchString(value: unknown): string | undefined {
814 if (typeof value !== "string") {
915 return undefined;
......@@ -20,8 +26,9 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>(
2026}
2127
2228export 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;
2532 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
2633
2734 return {
apps/web/src/historyBootstrap.test.ts+10-7
......@@ -1,20 +1,23 @@
1import { MessageId } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23
34import { buildBootstrapInput } from "./historyBootstrap";
45
6const messageId = (value: string) => MessageId.makeUnsafe(value);
7
58describe("buildBootstrapInput", () => {
69 it("includes full transcript when under budget", () => {
710 const result = buildBootstrapInput(
811 [
912 {
10 id: "u-1",
13 id: messageId("u-1"),
1114 role: "user",
1215 text: "hello",
1316 createdAt: "2026-02-09T00:00:00.000Z",
1417 streaming: false,
1518 },
1619 {
17 id: "a-1",
20 id: messageId("a-1"),
1821 role: "assistant",
1922 text: "world",
2023 createdAt: "2026-02-09T00:00:01.000Z",
......@@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => {
3841 const result = buildBootstrapInput(
3942 [
4043 {
41 id: "u-1",
44 id: messageId("u-1"),
4245 role: "user",
4346 text: "first question with details",
4447 createdAt: "2026-02-09T00:00:00.000Z",
4548 streaming: false,
4649 },
4750 {
48 id: "a-1",
51 id: messageId("a-1"),
4952 role: "assistant",
5053 text: "first answer with details",
5154 createdAt: "2026-02-09T00:00:01.000Z",
5255 streaming: false,
5356 },
5457 {
55 id: "u-2",
58 id: messageId("u-2"),
5659 role: "user",
5760 text: "second question with details",
5861 createdAt: "2026-02-09T00:00:02.000Z",
......@@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => {
7578 const result = buildBootstrapInput(
7679 [
7780 {
78 id: "u-1",
81 id: messageId("u-1"),
7982 role: "user",
8083 text: "old context",
8184 createdAt: "2026-02-09T00:00:00.000Z",
......@@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => {
9699 const result = buildBootstrapInput(
97100 [
98101 {
99 id: "u-image",
102 id: messageId("u-image"),
100103 role: "user",
101104 text: "",
102105 attachments: [
apps/web/src/hooks/useNativeApi.ts deleted-6
......@@ -1,6 +0,0 @@
1import { useMemo } from "react";
2import { readNativeApi } from "../session-logic";
3
4export function useNativeApi() {
5 return useMemo(() => readNativeApi(), []);
6}
apps/web/src/hooks/useTheme.ts+18-4
......@@ -1,11 +1,16 @@
11import { useCallback, useEffect, useSyncExternalStore } from "react";
22
33type Theme = "light" | "dark" | "system";
4type ThemeSnapshot = {
5 theme: Theme;
6 systemDark: boolean;
7};
48
59const STORAGE_KEY = "t3code:theme";
610const MEDIA_QUERY = "(prefers-color-scheme: dark)";
711
812let listeners: Array<() => void> = [];
13let lastSnapshot: ThemeSnapshot | null = null;
914function emitChange() {
1015 for (const listener of listeners) listener();
1116}
......@@ -39,8 +44,16 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
3944// Apply immediately on module load to prevent flash
4045applyTheme(getStored());
4146
42function getSnapshot(): Theme {
43 return getStored();
47function 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;
4457}
4558
4659function subscribe(listener: () => void): () => void {
......@@ -71,10 +84,11 @@ function subscribe(listener: () => void): () => void {
7184}
7285
7386export function useTheme() {
74 const theme = useSyncExternalStore(subscribe, getSnapshot);
87 const snapshot = useSyncExternalStore(subscribe, getSnapshot);
88 const theme = snapshot.theme;
7589
7690 const resolvedTheme: "light" | "dark" =
77 theme === "system" ? (getSystemDark() ? "dark" : "light") : theme;
91 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
7892
7993 const setTheme = useCallback((next: Theme) => {
8094 localStorage.setItem(STORAGE_KEY, next);
apps/web/src/hooks/useTurnDiffSummaries.ts+3-19
......@@ -1,29 +1,13 @@
1import type { ProviderEvent } from "@t3tools/contracts";
21import { useMemo } from "react";
3import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic";
4import type { Thread, TurnDiffSummary } from "../types";
5
6const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>();
7
8function 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}
2import { inferCheckpointTurnCountByTurnId } from "../session-logic";
3import type { Thread } from "../types";
174
185export function useTurnDiffSummaries(activeThread: Thread | undefined) {
196 const turnDiffSummaries = useMemo(() => {
207 if (!activeThread) {
218 return [];
229 }
23 if (activeThread.turnDiffSummaries.length > 0) {
24 return activeThread.turnDiffSummaries;
25 }
26 return deriveTurnDiffSummariesCached(activeThread.events);
10 return activeThread.turnDiffSummaries;
2711 }, [activeThread]);
2812
2913 const inferredCheckpointTurnCountByTurnId = useMemo(
apps/web/src/index.css+17
......@@ -133,6 +133,18 @@ body {
133133 min-height: 100vh;
134134}
135135
136html,
137body,
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
136148body::after {
137149 content: "";
138150 position: fixed;
......@@ -229,7 +241,9 @@ label:has(> select#reasoning-effort) select {
229241
230242/* Chat markdown rendering */
231243.chat-markdown {
244 min-width: 0;
232245 overflow-wrap: anywhere;
246 word-break: break-word;
233247}
234248
235249.chat-markdown > :first-child {
......@@ -280,9 +294,11 @@ label:has(> select#reasoning-effort) select {
280294 background: var(--muted);
281295 padding: 0.1rem 0.35rem;
282296 color: var(--foreground);
297 font-size: 0.75rem;
283298}
284299
285300.chat-markdown pre {
301 max-width: 100%;
286302 overflow-x: auto;
287303 border: 1px solid var(--border);
288304 border-radius: 0.75rem;
......@@ -295,6 +311,7 @@ label:has(> select#reasoning-effort) select {
295311 background: transparent;
296312 padding: 0;
297313 line-height: 1.5;
314 font-size: 0.875rem;
298315}
299316
300317.chat-markdown table {
apps/web/src/keybindings.test.ts+23-3
......@@ -10,6 +10,7 @@ import {
1010 formatShortcutLabel,
1111 isChatNewShortcut,
1212 isChatNewLocalShortcut,
13 isDiffToggleShortcut,
1314 isOpenFavoriteEditorShortcut,
1415 isTerminalClearShortcut,
1516 isTerminalCloseShortcut,
......@@ -91,6 +92,11 @@ const DEFAULT_BINDINGS = compile([
9192 command: "terminal.close",
9293 whenAst: whenIdentifier("terminalFocus"),
9394 },
95 {
96 shortcut: modShortcut("d"),
97 command: "diff.toggle",
98 whenAst: whenNot(whenIdentifier("terminalFocus")),
99 },
94100 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
95101 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
96102 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
......@@ -230,6 +236,7 @@ describe("shortcutLabelForCommand", () => {
230236
231237 it("returns labels for non-terminal commands", () => {
232238 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
239 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
233240 assert.strictEqual(
234241 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
235242 "Ctrl+O",
......@@ -276,6 +283,21 @@ describe("chat/editor shortcuts", () => {
276283 }),
277284 );
278285 });
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 });
279301});
280302
281303describe("cross-command precedence", () => {
......@@ -342,9 +364,7 @@ describe("cross-command precedence", () => {
342364
343365describe("resolveShortcutCommand", () => {
344366 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" }]);
348368
349369 assert.strictEqual(
350370 resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, {
apps/web/src/keybindings.ts+8
......@@ -198,6 +198,14 @@ export function isTerminalCloseShortcut(
198198 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
199199}
200200
201export function isDiffToggleShortcut(
202 event: ShortcutEventLike,
203 keybindings: ResolvedKeybindingsConfig,
204 options?: ShortcutMatchOptions,
205): boolean {
206 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
207}
208
201209export function isChatNewShortcut(
202210 event: ShortcutEventLike,
203211 keybindings: ResolvedKeybindingsConfig,
apps/web/src/lib/diffRendering.test.ts+8
......@@ -20,4 +20,12 @@ describe("buildPatchCacheKey", () => {
2020
2121 expect(buildPatchCacheKey(before)).not.toBe(buildPatchCacheKey(after));
2222 });
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 });
2331});
apps/web/src/lib/gitReactQuery.test.ts created+33
......@@ -0,0 +1,33 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it } from "vitest";
3import {
4 gitMutationKeys,
5 gitPullMutationOptions,
6 gitRunStackedActionMutationOptions,
7} from "./gitReactQuery";
8
9describe("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
21describe("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 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
1import type { GitStackedAction } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "../nativeApi";
34
45const GIT_STATUS_STALE_TIME_MS = 5_000;
56const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
......@@ -12,20 +13,28 @@ export const gitQueryKeys = {
1213 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
1314};
1415
16export 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
1525export function invalidateGitQueries(queryClient: QueryClient) {
1626 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
1727}
1828
19export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) {
29export function gitStatusQueryOptions(cwd: string | null) {
2030 return queryOptions({
2131 queryKey: gitQueryKeys.status(cwd),
2232 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.");
2635 return api.git.status({ cwd });
2736 },
28 enabled: !!api && !!cwd,
37 enabled: cwd !== null,
2938 staleTime: GIT_STATUS_STALE_TIME_MS,
3039 refetchOnWindowFocus: "always",
3140 refetchOnReconnect: "always",
......@@ -33,16 +42,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
3342 });
3443}
3544
36export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) {
45export function gitBranchesQueryOptions(cwd: string | null) {
3746 return queryOptions({
3847 queryKey: gitQueryKeys.branches(cwd),
3948 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.");
4351 return api.git.listBranches({ cwd });
4452 },
45 enabled: !!api && !!cwd,
53 enabled: cwd !== null,
4654 staleTime: GIT_BRANCHES_STALE_TIME_MS,
4755 refetchOnWindowFocus: true,
4856 refetchOnReconnect: true,
......@@ -50,15 +58,13 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
5058 });
5159}
5260
53export function gitInitMutationOptions(input: {
54 api: NativeApi | undefined;
55 cwd: string | null;
56 queryClient: QueryClient;
57}) {
61export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
5862 return mutationOptions({
63 mutationKey: gitMutationKeys.init(input.cwd),
5964 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 });
6268 },
6369 onSuccess: async () => {
6470 await invalidateGitQueries(input.queryClient);
......@@ -67,14 +73,15 @@ export function gitInitMutationOptions(input: {
6773}
6874
6975export function gitCheckoutMutationOptions(input: {
70 api: NativeApi | undefined;
7176 cwd: string | null;
7277 queryClient: QueryClient;
7378}) {
7479 return mutationOptions({
80 mutationKey: gitMutationKeys.checkout(input.cwd),
7581 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 });
7885 },
7986 onSuccess: async () => {
8087 await invalidateGitQueries(input.queryClient);
......@@ -83,15 +90,16 @@ export function gitCheckoutMutationOptions(input: {
8390}
8491
8592export function gitCreateBranchAndCheckoutMutationOptions(input: {
86 api: NativeApi | undefined;
8793 cwd: string | null;
8894 queryClient: QueryClient;
8995}) {
9096 return mutationOptions({
97 mutationKey: gitMutationKeys.createBranchAndCheckout(input.cwd),
9198 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 });
95103 },
96104 onSuccess: async () => {
97105 await invalidateGitQueries(input.queryClient);
......@@ -100,11 +108,11 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {
100108}
101109
102110export function gitRunStackedActionMutationOptions(input: {
103 api: NativeApi | undefined;
104111 cwd: string | null;
105112 queryClient: QueryClient;
106113}) {
107114 return mutationOptions({
115 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
108116 mutationFn: async ({
109117 action,
110118 commitMessage,
......@@ -112,10 +120,9 @@ export function gitRunStackedActionMutationOptions(input: {
112120 action: GitStackedAction;
113121 commitMessage?: string;
114122 }) => {
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({
119126 cwd: input.cwd,
120127 action,
121128 ...(commitMessage ? { commitMessage } : {}),
......@@ -127,15 +134,13 @@ export function gitRunStackedActionMutationOptions(input: {
127134 });
128135}
129136
130export function gitPullMutationOptions(input: {
131 api: NativeApi | undefined;
132 cwd: string | null;
133 queryClient: QueryClient;
134}) {
137export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
135138 return mutationOptions({
139 mutationKey: gitMutationKeys.pull(input.cwd),
136140 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 });
139144 },
140145 onSettled: async () => {
141146 await invalidateGitQueries(input.queryClient);
......@@ -143,42 +148,38 @@ export function gitPullMutationOptions(input: {
143148 });
144149}
145150
146export function gitCreateWorktreeMutationOptions(input: {
147 api: NativeApi | undefined;
148 queryClient: QueryClient;
149}) {
151export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
150152 return mutationOptions({
151153 mutationFn: async ({
152154 cwd,
153155 branch,
154156 newBranch,
157 path,
155158 }: {
156159 cwd: string;
157160 branch: string;
158161 newBranch: string;
162 path?: string | null;
159163 }) => {
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 });
164167 },
168 mutationKey: ["git", "mutation", "create-worktree"] as const,
165169 onSettled: async () => {
166170 await invalidateGitQueries(input.queryClient);
167171 },
168172 });
169173}
170174
171export function gitRemoveWorktreeMutationOptions(input: {
172 api: NativeApi | undefined;
173 queryClient: QueryClient;
174}) {
175export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
175176 return mutationOptions({
176177 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 });
181181 },
182 mutationKey: ["git", "mutation", "remove-worktree"] as const,
182183 onSettled: async () => {
183184 await invalidateGitQueries(input.queryClient);
184185 },
apps/web/src/lib/projectReactQuery.ts+6-5
......@@ -1,5 +1,6 @@
1import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts";
1import type { ProjectSearchEntriesResult } from "@t3tools/contracts";
22import { queryOptions } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
34
45export const projectQueryKeys = {
56 all: ["projects"] as const,
......@@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
1516};
1617
1718export function projectSearchEntriesQueryOptions(input: {
18 api: NativeApi | undefined;
1919 cwd: string | null;
2020 query: string;
2121 enabled?: boolean;
......@@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: {
2626 return queryOptions({
2727 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
2828 queryFn: async () => {
29 if (!input.api || !input.cwd) {
29 const api = ensureNativeApi();
30 if (!input.cwd) {
3031 throw new Error("Workspace entry search is unavailable.");
3132 }
32 return input.api.projects.searchEntries({
33 return api.projects.searchEntries({
3334 cwd: input.cwd,
3435 query: input.query,
3536 limit,
3637 });
3738 },
38 enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd),
39 enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0,
3940 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
4041 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
4142 });
apps/web/src/lib/projectScriptKeybindings.test.ts created+83
......@@ -0,0 +1,83 @@
1import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { commandForProjectScript } from "../projectScripts";
5import {
6 decodeProjectScriptKeybindingRule,
7 keybindingValueForCommand,
8 PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE,
9} from "./projectScriptKeybindings";
10
11describe("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 @@
1import {
2 KeybindingRule as KeybindingRuleSchema,
3 type KeybindingCommand,
4 type KeybindingRule,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
7import { Schema } from "effect";
8
9export const PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE = "Invalid keybinding.";
10
11function normalizeProjectScriptKeybindingInput(
12 keybinding: string | null | undefined,
13): string | null {
14 const trimmed = keybinding?.trim() ?? "";
15 return trimmed.length > 0 ? trimmed : null;
16}
17
18export 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
35export 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 @@
1import type { NativeApi } from "@t3tools/contracts";
1import { ThreadId, type NativeApi } from "@t3tools/contracts";
22import { QueryClient } from "@tanstack/react-query";
3import { describe, expect, it, vi } from "vitest";
3import { afterEach, describe, expect, it, vi } from "vitest";
44import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5import * as nativeApi from "../nativeApi";
6
7const threadId = ThreadId.makeUnsafe("thread-id");
8
9function 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
21afterEach(() => {
22 vi.restoreAllMocks();
23});
524
625describe("providerQueryKeys.checkpointDiff", () => {
726 it("includes cacheScope so reused turn counts do not collide", () => {
827 const baseInput = {
9 sessionId: "session-id",
10 threadRuntimeId: "thread-id",
28 threadId,
1129 fromTurnCount: 1,
1230 toTurnCount: 2,
1331 } as const;
......@@ -28,16 +46,12 @@ describe("providerQueryKeys.checkpointDiff", () => {
2846
2947describe("checkpointDiffQueryOptions", () => {
3048 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,
4155 fromTurnCount: 3,
4256 toTurnCount: 4,
4357 cacheScope: "turn:abc",
......@@ -46,17 +60,60 @@ describe("checkpointDiffQueryOptions", () => {
4660 const queryClient = new QueryClient();
4761 await queryClient.fetchQuery(options);
4862
49 expect(getCheckpointDiff).toHaveBeenCalledWith({
50 sessionId: "session-id",
63 expect(getTurnDiff).toHaveBeenCalledWith({
64 threadId,
5165 fromTurnCount: 3,
5266 toTurnCount: 4,
5367 });
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();
54112 });
55113
56114 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,
60117 fromTurnCount: 1,
61118 toTurnCount: 2,
62119 cacheScope: "turn:abc",
......@@ -69,25 +126,18 @@ describe("checkpointDiffQueryOptions", () => {
69126
70127 expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true);
71128 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.")),
76130 ).toBe(true);
77131 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.")),
82133 ).toBe(false);
83134 expect(retry(2, new Error("Something else failed."))).toBe(true);
84135 expect(retry(3, new Error("Something else failed."))).toBe(false);
85136 });
86137
87138 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,
91141 fromTurnCount: 1,
92142 toTurnCount: 2,
93143 cacheScope: "turn:abc",
apps/web/src/lib/providerReactQuery.ts+33-28
......@@ -1,9 +1,14 @@
1import type { NativeApi } from "@t3tools/contracts";
1import {
2 OrchestrationGetFullThreadDiffInput,
3 OrchestrationGetTurnDiffInput,
4 ThreadId,
5} from "@t3tools/contracts";
26import { queryOptions } from "@tanstack/react-query";
7import { Option, Schema } from "effect";
8import { ensureNativeApi } from "../nativeApi";
39
4export interface CheckpointDiffQueryInput {
5 sessionId: string | null;
6 threadRuntimeId: string | null;
10interface CheckpointDiffQueryInput {
11 threadId: ThreadId | null;
712 fromTurnCount: number | null;
813 toTurnCount: number | null;
914 cacheScope?: string | null;
......@@ -15,14 +20,28 @@ export const providerQueryKeys = {
1520 [
1621 "providers",
1722 "checkpointDiff",
18 input.sessionId,
19 input.threadRuntimeId,
23 input.threadId,
2024 input.fromTurnCount,
2125 input.toTurnCount,
2226 input.cacheScope ?? null,
2327 ] as const,
2428};
2529
30function 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
2645function asCheckpointErrorMessage(error: unknown): string {
2746 if (error instanceof Error) {
2847 return error.message;
......@@ -42,36 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
4261 );
4362}
4463
45export 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;
64export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
65 const decodedRequest = decodeCheckpointDiffRequest(input);
5766
5867 return queryOptions({
5968 queryKey: providerQueryKeys.checkpointDiff(input),
6069 queryFn: async () => {
61 if (!api || !input.sessionId || !hasValidRange) {
70 const api = ensureNativeApi();
71 if (!input.threadId || decodedRequest._tag === "None") {
6272 throw new Error("Checkpoint diff is unavailable.");
6373 }
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);
6776 }
68 return api.providers.getCheckpointDiff({
69 sessionId: input.sessionId,
70 fromTurnCount,
71 toTurnCount,
72 });
77 return api.orchestration.getTurnDiff(decodedRequest.value.input);
7378 },
74 enabled: !!api && !!input.sessionId && hasValidRange,
79 enabled: !!input.threadId && decodedRequest._tag === "Some",
7580 staleTime: Infinity,
7681 retry: (failureCount, error) => {
7782 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/serverReactQuery.ts+3-6
......@@ -1,21 +1,18 @@
1import type { NativeApi } from "@t3tools/contracts";
21import { queryOptions } from "@tanstack/react-query";
2import { ensureNativeApi } from "~/nativeApi";
33
44export const serverQueryKeys = {
55 all: ["server"] as const,
66 config: () => ["server", "config"] as const,
77};
88
9export function serverConfigQueryOptions(api: NativeApi | undefined) {
9export function serverConfigQueryOptions() {
1010 return queryOptions({
1111 queryKey: serverQueryKeys.config(),
1212 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();
1614 return api.server.getConfig();
1715 },
18 enabled: !!api && !!api.server && typeof api.server.getConfig === "function",
1916 staleTime: Infinity,
2017 });
2118}
apps/web/src/lib/utils.ts+9
......@@ -1,3 +1,4 @@
1import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
12import { type CxOptions, cx } from "class-variance-authority";
23import { twMerge } from "tailwind-merge";
34
......@@ -12,3 +13,11 @@ export function isMacPlatform(platform: string): boolean {
1213export function isWindowsPlatform(platform: string): boolean {
1314 return /^win(dows)?/i.test(platform);
1415}
16
17export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID());
18
19export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID());
20
21export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID());
22
23export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID());
apps/web/src/markdown-links.test.ts created+49
......@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveMarkdownFileLinkTarget } from "./markdown-links";
4
5describe("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 @@
1import { resolvePathLinkTarget } from "./terminal-links";
2
3const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
4const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
5const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
6const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
7const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
8const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
9const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
10const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
11const 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
24function safeDecode(value: string): string {
25 try {
26 return decodeURIComponent(value);
27 } catch {
28 return value;
29 }
30}
31
32function 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
41function 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
60function 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
68function 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
77function 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
84function 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
93function 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
101export 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 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
3
4describe("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
23describe("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 @@
1export {
2 DEFAULT_MODEL,
3 MODEL_OPTIONS,
4 normalizeModelSlug,
5 resolveModelSlug,
6} from "@t3tools/contracts";
7
8export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
9export type ReasoningEffort = (typeof REASONING_OPTIONS)[number];
10export const DEFAULT_REASONING: ReasoningEffort = "high";
apps/web/src/nativeApi.ts created+26
......@@ -0,0 +1,26 @@
1import type { NativeApi } from "@t3tools/contracts";
2
3import { createWsNativeApi } from "./wsNativeApi";
4
5let cachedApi: NativeApi | undefined;
6
7export 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
20export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
6import type { Thread } from "./types";
7
8describe("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
367describe("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 @@
1import { z } from "zod";
2import { projectScriptSchema } from "@t3tools/contracts";
3
4import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
5import {
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
15const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
16
17const 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
26const 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
45const persistedTerminalGroupSchema = z.object({
46 id: z.string().trim().min(1),
47 terminalIds: z.array(z.string().trim().min(1)),
48});
49
50const 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
57const 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
66const 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
89const persistedStateBodySchema = z.object({
90 projects: z.array(persistedProjectSchema),
91 threads: z.array(persistedThreadSchema),
92 activeThreadId: z.string().min(1).nullable().optional(),
93});
94
95const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
96
97export const persistedStateV1Schema = persistedStateBodySchema.extend({
98 version: z.literal(1).optional(),
99});
100
101export const persistedStateV2Schema = persistedStateBodySchema.extend({
102 version: z.literal(2).optional(),
103});
104
105export const persistedStateV3Schema = persistedStateBodySchema.extend({
106 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
107 version: z.literal(3).optional(),
108});
109
110export const persistedStateV4Schema = persistedStateBodySchema.extend({
111 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
112 version: z.literal(4).optional(),
113});
114
115export const persistedStateV6Schema = persistedStateBodySchema.extend({
116 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
117 version: z.literal(6).optional(),
118});
119
120export const persistedStateV7Schema = persistedStateBodySchema.extend({
121 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
122 version: z.literal(7).optional(),
123});
124
125export const persistedStateV5Schema = persistedStateBodySchema.extend({
126 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
127 version: z.literal(5).optional(),
128});
129
130const persistedStateSchema = z.union([
131 persistedStateV7Schema,
132 persistedStateV6Schema,
133 persistedStateV5Schema,
134 persistedStateV4Schema,
135 persistedStateV3Schema,
136 persistedStateV2Schema,
137 persistedStateV1Schema,
138]);
139
140export interface PersistedStoreSnapshot {
141 projects: Project[];
142 threads: Thread[];
143 runtimeMode: RuntimeMode;
144}
145
146function 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
154function 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
165function 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
310export 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
341export 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 @@
1import type { ProjectScript } from "@t3tools/contracts";
2
3const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/;
4const MAX_SCRIPT_ID_LENGTH = 64;
1import {
2 MAX_SCRIPT_ID_LENGTH,
3 SCRIPT_RUN_COMMAND_PATTERN,
4 type KeybindingCommand,
5 type ProjectScript,
6} from "@t3tools/contracts";
7import { Schema } from "effect";
58
69function normalizeScriptId(value: string): string {
710 const cleaned = value
......@@ -18,13 +21,16 @@ function normalizeScriptId(value: string): string {
1821 return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script";
1922}
2023
21export function commandForProjectScript(scriptId: string): string {
22 return `script.${scriptId}.run`;
23}
24export const commandForProjectScript = (scriptId: string): KeybindingCommand =>
25 SCRIPT_RUN_COMMAND_PATTERN.makeUnsafe(`script.${scriptId}.run`);
2426
2527export 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);
2834}
2935
3036export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string {
......@@ -57,7 +63,9 @@ interface ProjectScriptRuntimeEnvInput {
5763 extraEnv?: Record<string, string>;
5864}
5965
60export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> {
66export function projectScriptRuntimeEnv(
67 input: ProjectScriptRuntimeEnvInput,
68): Record<string, string> {
6169 const env: Record<string, string> = {
6270 T3CODE_PROJECT_ROOT: input.project.cwd,
6371 };
apps/web/src/routes/__root.tsx+218-135
......@@ -1,26 +1,34 @@
1import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router";
1import { ThreadId } from "@t3tools/contracts";
2import {
3 Outlet,
4 createRootRouteWithContext,
5 type ErrorComponentProps,
6 useNavigate,
7 useRouterState,
8} from "@tanstack/react-router";
29import { useEffect, useRef } from "react";
310import { QueryClient, useQueryClient } from "@tanstack/react-query";
411
5import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
6import { isElectron } from "../env";
7import { useNativeApi } from "../hooks/useNativeApi";
8import { invalidateGitQueries } from "../lib/gitReactQuery";
9import { DEFAULT_MODEL } from "../model-logic";
10import { useStore } from "../store";
11import { onServerWelcome } from "../wsNativeApi";
1212import { APP_DISPLAY_NAME } from "../branding";
13import { Button } from "../components/ui/button";
14import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
15import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
16import { readNativeApi } from "../nativeApi";
17import { useStore } from "../store";
18import { preferredTerminalEditor } from "../terminal-links";
19import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
20import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
21import { providerQueryKeys } from "../lib/providerReactQuery";
1322
1423export const Route = createRootRouteWithContext<{
1524 queryClient: QueryClient;
1625}>()({
1726 component: RootRouteView,
27 errorComponent: RootRouteErrorView,
1828});
1929
2030function RootRouteView() {
21 const api = useNativeApi();
22
23 if (!api) {
31 if (!readNativeApi()) {
2432 return (
2533 <div className="flex h-screen flex-col bg-background text-foreground">
2634 <div className="flex flex-1 items-center justify-center">
......@@ -36,7 +44,6 @@ function RootRouteView() {
3644 <ToastProvider>
3745 <AnchoredToastProvider>
3846 <EventRouter />
39 <AutoProjectBootstrap />
4047 <DesktopProjectBootstrap />
4148 <Outlet />
4249 </AnchoredToastProvider>
......@@ -44,155 +51,231 @@ function RootRouteView() {
4451 );
4552}
4653
47function 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 });
54function RootRouteErrorView({ error, reset }: ErrorComponentProps) {
55 const message = errorMessage(error);
56 const details = errorDetails(error);
5657
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>
8364
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>
9273
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>
10282
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 );
10495}
10596
106function AutoProjectBootstrap() {
107 const { state, dispatch } = useStore();
108 const bootstrappedRef = useRef(false);
97function errorMessage(error: unknown): string {
98 if (error instanceof Error && error.message.trim().length > 0) {
99 return error.message;
100 }
109101
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}
125108
126 bootstrappedRef.current = true;
109function errorDetails(error: unknown): string {
110 if (error instanceof Error) {
111 return error.stack ?? error.message;
112 }
127113
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 }
144117
145 return null;
118 try {
119 return JSON.stringify(error, null, 2);
120 } catch {
121 return "No additional error details are available.";
122 }
146123}
147124
148function DesktopProjectBootstrap() {
149 const api = useNativeApi();
125function EventRouter() {
150126 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;
152134
153135 useEffect(() => {
154 if (!isElectron || !api || bootstrappedRef.current) return;
155
136 const api = readNativeApi();
137 if (!api) return;
156138 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;
159142
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;
161160 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();
177163 } 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;
184167 }
185168 };
186169
187 void attemptBootstrap();
170 void syncSnapshot().catch(() => undefined);
188171
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 });
189266 return () => {
190267 disposed = true;
191 if (retryTimer) {
192 clearTimeout(retryTimer);
193 }
268 unsubDomainEvent();
269 unsubTerminalEvent();
270 unsubWelcome();
271 unsubServerConfigUpdated();
194272 };
195 }, [api, dispatch]);
273 }, [dispatch, navigate, queryClient]);
196274
197275 return null;
198276}
277
278function 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 @@
1import { ThreadId } from "@t3tools/contracts";
12import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
34
45import ChatView from "../components/ChatView";
56import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
67import { useMediaQuery } from "../hooks/useMediaQuery";
78import { useStore } from "../store";
89import { Sheet, SheetPopup } from "../components/ui/sheet";
10import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
911
1012const DiffPanel = lazy(() => import("../components/DiffPanel"));
1113const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
14const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
15const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
16const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
1217
13const DiffPanelWrapper = (props: {
18const DiffPanelSheet = (props: {
1419 children: ReactNode;
15 sheet: boolean;
1620 diffOpen: boolean;
1721 onCloseDiff: () => void;
1822}) => {
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"
2837 >
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
44const 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>
3850 );
3951 }
4052
4153 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...
4456 </aside>
4557 );
4658};
4759
60const 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
48134function ChatThreadRouteView() {
49135 const { state } = useStore();
50136 const navigate = useNavigate();
51 const { threadId } = Route.useParams();
137 const threadId = Route.useParams({
138 select: (params) => ThreadId.makeUnsafe(params.threadId),
139 });
52140 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);
54142 const diffOpen = search.diff === "1";
55143 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
56144 const closeDiff = useCallback(() => {
......@@ -62,13 +150,18 @@ function ChatThreadRouteView() {
62150 },
63151 });
64152 }, [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]);
65163
66164 useEffect(() => {
67 if (!threadId) {
68 void navigate({ to: "/", replace: true });
69 return;
70 }
71
72165 if (!state.threadsHydrated) {
73166 return;
74167 }
......@@ -79,31 +172,31 @@ function ChatThreadRouteView() {
79172 }
80173 }, [navigate, state.threadsHydrated, threadExists, threadId]);
81174
82 if (!threadId || !state.threadsHydrated || !threadExists) {
175 if (!state.threadsHydrated || !threadExists) {
83176 return null;
84177 }
85178
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 </>
95187 );
188 }
96189
97190 return (
98191 <>
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>
107200 </>
108201 );
109202}
apps/web/src/routes/_chat.index.tsx+10
......@@ -1,10 +1,20 @@
11import { createFileRoute } from "@tanstack/react-router";
22
33import { isElectron } from "../env";
4import { SidebarTrigger } from "../components/ui/sidebar";
45
56function ChatIndexRouteView() {
67 return (
78 <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
818 {isElectron && (
919 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
1020 <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 @@
11import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";
24
35import { useAppSettings } from "../appSettings";
46import { isElectron } from "../env";
57import { useTheme } from "../hooks/useTheme";
8import { serverConfigQueryOptions } from "../lib/serverReactQuery";
9import { ensureNativeApi } from "../nativeApi";
610import { useStore } from "../store";
11import { preferredTerminalEditor } from "../terminal-links";
712import { Button } from "../components/ui/button";
813import { Input } from "../components/ui/input";
914import { Switch } from "../components/ui/switch";
15import { SidebarInset } from "~/components/ui/sidebar";
1016
1117const THEME_OPTIONS = [
1218 {
......@@ -43,214 +49,319 @@ function SettingsRouteView() {
4349 const { theme, setTheme, resolvedTheme } = useTheme();
4450 const { state, dispatch } = useStore();
4551 const { settings, defaults, updateSettings } = useAppSettings();
52 const serverConfigQuery = useQuery(serverConfigQueryOptions());
53 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
54 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
4655
4756 const codexBinaryPath = settings.codexBinaryPath;
4857 const codexHomePath = settings.codexHomePath;
58 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
4959
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]);
5776
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 )}
6687
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.
7294 </p>
73 </div>
95 </header>
7496
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>
104104
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>
109134
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>
115137 </p>
116 </div>
138 </section>
117139
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>
131147
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>
144203
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.
148245 </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) =>
153258 updateSettings({
154 codexBinaryPath: defaults.codexBinaryPath,
155 codexHomePath: defaults.codexHomePath,
259 enableAssistantStreaming: Boolean(checked),
156260 })
157261 }
158 >
159 Reset codex overrides
160 </Button>
262 aria-label="Stream assistant messages"
263 />
161264 </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>
172265
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 }
193276 >
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>
208282
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>
216309
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>
220310 <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.
222324 </p>
223325 </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>
234326
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) =>
241337 updateSettings({
242 confirmThreadDelete: defaults.confirmThreadDelete,
338 confirmThreadDelete: Boolean(checked),
243339 })
244340 }
245 >
246 Restore default
247 </Button>
341 aria-label="Confirm thread deletion"
342 />
248343 </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>
251362 </div>
252363 </div>
253 </div>
364 </SidebarInset>
254365 );
255366}
256367
apps/web/src/routes/_chat.tsx+11-5
......@@ -2,7 +2,8 @@ import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
22import { useEffect } from "react";
33
44import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import Sidebar from "../components/Sidebar";
5import ThreadSidebar from "../components/Sidebar";
6import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
67
78function ChatRouteLayout() {
89 const navigate = useNavigate();
......@@ -24,16 +25,21 @@ function ChatRouteLayout() {
2425 }, [navigate]);
2526
2627 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>
2936 <DiffWorkerPoolProvider>
3037 <Outlet />
3138 </DiffWorkerPoolProvider>
32 </div>
39 </SidebarProvider>
3340 );
3441}
3542
3643export const Route = createFileRoute("/_chat")({
3744 component: ChatRouteLayout,
3845});
39
apps/web/src/session-logic.test.ts+147-880
......@@ -1,935 +1,202 @@
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23
3import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
44import {
5 type WorkLogEntry,
6 applyEventToMessages,
75 derivePendingApprovals,
8 deriveTurnDiffFilesFromUnifiedDiff,
9 deriveTurnDiffSummaries,
10 deriveTimelineEntries,
116 deriveWorkLogEntries,
12 evolveSession,
7 hasToolActivityForTurn,
8 isLatestTurnSettled,
139} from "./session-logic";
14import type { ChatMessage } from "./types";
1510
16function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent {
11function 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 ?? {};
1721 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,
2529 };
2630}
2731
28function 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
39describe("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
107describe("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
398describe("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",
32describe("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",
55241 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",
55945 },
56046 }),
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" },
57262 }),
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
582describe("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
631describe("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 ];
64464
645 expect(approvals).toEqual([
65 expect(derivePendingApprovals(activities)).toEqual([
64666 {
64767 requestId: "req-1",
64868 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",
65171 },
65272 ]);
65373 });
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 });
69674});
69775
698describe("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" } },
76describe("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",
70790 }),
708 );
91 ];
70992
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"]);
71295 });
71396
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",
722105 }),
723 );
106 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
107 ];
724108
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"]);
728111 });
729112
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",
742120 }),
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",
763127 }),
764 );
128 ];
765129
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"]);
769132 });
133});
770134
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 );
135describe("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 ];
782140
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);
785143 });
786144
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 ];
800150
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);
804153 });
805154});
806155
807describe("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 );
156describe("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;
825162
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"),
844168 }),
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);
868170 });
869171
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"),
879177 }),
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);
905179 });
906180
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,
929186 }),
930 { current: null },
931 );
187 ).toBe(true);
188 });
932189
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);
934201 });
935202});
apps/web/src/session-logic.ts+129-872
......@@ -1,6 +1,12 @@
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";
1import {
2 ApprovalRequestId,
3 type OrchestrationLatestTurn,
4 type OrchestrationThreadActivity,
5 type ProviderKind,
6 type TurnId,
7} from "@t3tools/contracts";
8
9import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
410
511export const PROVIDER_OPTIONS: Array<{
612 value: ProviderKind;
......@@ -11,39 +17,34 @@ export const PROVIDER_OPTIONS: Array<{
1117 { value: "claudeCode", label: "Claude Code (soon)", available: false },
1218];
1319
14let cachedApi: NativeApi | undefined;
15
16export 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
31export 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
36function asArray(value: unknown): unknown[] | undefined {
37 return Array.isArray(value) ? value : undefined;
20export interface WorkLogEntry {
21 id: string;
22 createdAt: string;
23 label: string;
24 detail?: string;
25 tone: "thinking" | "tool" | "info" | "error";
3826}
3927
40export function asString(value: unknown): string | undefined {
41 return typeof value === "string" ? value : undefined;
28export interface PendingApproval {
29 requestId: ApprovalRequestId;
30 requestKind: "command" | "file-change";
31 createdAt: string;
32 detail?: string;
4233}
4334
44function asNumber(value: unknown): number | undefined {
45 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46}
35export 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 };
4748
4849export function formatTimestamp(isoDate: string): string {
4950 return new Intl.DateTimeFormat(undefined, {
......@@ -58,7 +59,6 @@ export function formatDuration(durationMs: number): string {
5859 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
5960 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
6061 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
61
6262 const minutes = Math.floor(durationMs / 60_000);
6363 const seconds = Math.round((durationMs % 60_000) / 1_000);
6464 if (seconds === 0) return `${minutes}m`;
......@@ -68,883 +68,140 @@ export function formatDuration(durationMs: number): string {
6868
6969export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
7070 if (!endIso) return null;
71
7271 const startedAt = Date.parse(startIso);
7372 const endedAt = Date.parse(endIso);
7473 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
7574 return null;
7675 }
77
7876 return formatDuration(endedAt - startedAt);
7977}
8078
81export interface WorkLogEntry {
82 id: string;
83 createdAt: string;
84 label: string;
85 detail?: string;
86 tone: "thinking" | "tool" | "info" | "error";
87}
88
89export interface PendingApproval {
90 requestId: string;
91 requestKind: "command" | "file-change";
92 createdAt: string;
93 detail?: string;
94}
95
96export 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
110export type { TurnDiffFileChange, TurnDiffSummary } from "./types";
111
112function 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
118function 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
125export 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, {
79export 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
90export 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, {
148115 requestId,
149 requestKind: event.requestKind,
150 createdAt: event.createdAt,
116 requestKind,
117 createdAt: activity.createdAt,
151118 ...(detail ? { detail } : {}),
152119 });
153120 continue;
154121 }
155122
156 if (event.method === "item/requestApproval/decision") {
157 pending.delete(requestId);
158 }
159 }
160
161 return Array.from(pending.values());
162}
163
164function 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
174function 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
182function shouldShowItemLifecycle(type: string): boolean {
183 return type.includes("tool") || type.includes("command") || type.includes("file change");
184}
185
186function shouldDropMethod(method: string): boolean {
187 return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
188}
189
190function 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
206interface 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
216function 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
240function 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
271function 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
310function 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);
325125 }
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 };
337126 }
338127
339 if (event.method.startsWith("item/")) return null;
340
341 return null;
342}
343
344function 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
358function 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 );
362131}
363132
364133export function deriveWorkLogEntries(
365 events: ProviderEvent[],
366 turnId: string | undefined,
134 activities: ReadonlyArray<OrchestrationThreadActivity>,
135 latestTurnId: TurnId | undefined,
367136): 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
451interface 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
460function 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
496function 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
517function 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
530export 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
557export 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,
566154 };
567 })
568 .toSorted((a, b) => a.path.localeCompare(b.path));
569}
570
571export 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
602export 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
623export 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;
662157 }
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;
702159 });
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;
715160}
716161
717function toTimestamp(isoDate: string): number {
718 const parsed = Date.parse(isoDate);
719 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
162export 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");
720168}
721169
722170export function deriveTimelineEntries(
723171 messages: ChatMessage[],
724172 workEntries: WorkLogEntry[],
725173): 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}
773188
774 timeline.push({
775 id: `message:${message.id}`,
776 kind: "message",
777 createdAt: message.createdAt,
778 message,
779 });
780 messageIndex += 1;
189export 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;
781198 }
782
783 return timeline;
199 return result;
784200}
785201
786export function derivePhase(session: ProviderSession | null): SessionPhase {
202export function derivePhase(session: ThreadSession | null): SessionPhase {
787203 if (!session || session.status === "closed") return "disconnected";
788204 if (session.status === "connecting") return "connecting";
789205 if (session.status === "running") return "running";
790206 return "ready";
791207}
792
793export 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
850export 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 @@
1import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { type AppState, reducer } from "./store";
5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 MAX_THREAD_TERMINAL_COUNT,
9} from "./types";
10import type { Thread } from "./types";
11
12type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
13type TerminalActivityEvent = Extract<TerminalEvent, { type: "activity" }>;
14
15function 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
26function 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}
4import { reducer, type AppState } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
376
387function makeThread(overrides: Partial<Thread> = {}): Thread {
398 return {
40 id: "thread-local-1",
9 id: ThreadId.makeUnsafe("thread-1"),
4110 codexThreadId: null,
42 projectId: "project-1",
11 projectId: ProjectId.makeUnsafe("project-1"),
4312 title: "Thread",
44 model: "gpt-5.3-codex",
13 model: "gpt-5-codex",
4514 terminalOpen: false,
4615 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
4716 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
......@@ -54,12 +23,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
5423 },
5524 ],
5625 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
57 session: makeSession(),
26 session: null,
5827 messages: [],
59 events: [],
6028 turnDiffSummaries: [],
29 activities: [],
6130 error: null,
62 createdAt: "2026-02-09T00:00:00.000Z",
31 createdAt: "2026-02-13T00:00:00.000Z",
32 latestTurn: null,
6333 branch: null,
6434 worktreePath: null,
6535 ...overrides,
......@@ -70,10 +40,10 @@ function makeState(thread: Thread): AppState {
7040 return {
7141 projects: [
7242 {
73 id: "project-1",
43 id: ProjectId.makeUnsafe("project-1"),
7444 name: "Project",
7545 cwd: "/tmp/project",
76 model: "gpt-5.3-codex",
46 model: "gpt-5-codex",
7747 expanded: true,
7848 scripts: [],
7949 },
......@@ -84,900 +54,108 @@ function makeState(thread: Thread): AppState {
8454 };
8555}
8656
87function 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
110function 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
123describe("store reducer thread continuity", () => {
124 it("stores codexThreadId from UPDATE_SESSION", () => {
125 const state = makeState(
57describe("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(
12661 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",
12871 }),
12972 );
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 });
16773
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"),
17477 });
17578
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),
22184 );
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}`);
23085 });
23186
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(
23489 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",
24492 }),
24593 );
246 const next = reducer(state, {
247 type: "SPLIT_THREAD_TERMINAL",
248 threadId: "thread-local-1",
249 terminalId: "term-3",
250 });
25194
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"),
28198 });
28299
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);
291101 });
102});
292103
293 it("caps new terminals at four per thread", () => {
104describe("store terminal activity reducer", () => {
105 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {
294106 const state = makeState(
295107 makeThread({
296 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
297 activeTerminalId: "term-4",
108 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
298109 terminalGroups: [
299110 {
300111 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
301 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
112 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
302113 },
303 { id: "group-term-2", terminalIds: ["term-2"] },
304 { id: "group-term-3", terminalIds: ["term-3"] },
305 { id: "group-term-4", terminalIds: ["term-4"] },
306114 ],
307 activeTerminalGroupId: "group-term-4",
308115 }),
309116 );
310117 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,
314122 });
315123
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"]);
325125 });
326126
327 it("closes a terminal and keeps grouped layout coherent", () => {
127 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {
328128 const state = makeState(
329129 makeThread({
330 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
331 activeTerminalId: "term-2",
130 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
332131 terminalGroups: [
333132 {
334133 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
335 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
134 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
336135 },
337 { id: "group-term-3", terminalIds: ["term-3"] },
338136 ],
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"],
364138 }),
365139 );
366140 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,
370145 });
371146
372 expect(next.threads[0]?.terminalOpen).toBe(false);
373 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
374147 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 ]);
382148 });
383149
384 it("tracks running terminals from subprocess activity events", () => {
150 it("ignores activity events for unknown terminal ids", () => {
385151 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
434152 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,
496157 });
497158
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([]);
982160 });
983161});
apps/web/src/store.ts+244-506
......@@ -8,23 +8,20 @@ import {
88 useReducer,
99} from "react";
1010
11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1411import {
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";
2219import {
23 type ChatAttachment,
20 DEFAULT_THREAD_TERMINAL_HEIGHT,
2421 DEFAULT_THREAD_TERMINAL_ID,
2522 DEFAULT_RUNTIME_MODE,
2623 MAX_THREAD_TERMINAL_COUNT,
27 type ProjectScript,
24 type ChatMessage,
2825 type Project,
2926 type RuntimeMode,
3027 type Thread,
......@@ -34,60 +31,32 @@ import {
3431// ── Actions ──────────────────────────────────────────────────────────
3532
3633type 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"] }
5138 | {
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;
8143 }
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 }
8253 | {
8354 type: "SET_THREAD_BRANCH";
84 threadId: string;
55 threadId: ThreadId;
8556 branch: string | null;
8657 worktreePath: string | null;
8758 }
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 };
9160
9261// ── State ────────────────────────────────────────────────────────────
9362
......@@ -116,6 +85,7 @@ const initialState: AppState = {
11685 threadsHydrated: false,
11786 runtimeMode: DEFAULT_RUNTIME_MODE,
11887};
88const persistedExpandedProjectCwds = new Set<string>();
11989
12090// ── Helpers ──────────────────────────────────────────────────────────
12191
......@@ -123,21 +93,24 @@ function readPersistedState(): AppState {
12393 if (typeof window === "undefined") return initialState;
12494
12595 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);
13097 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 }
137108 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,
141114 };
142115 } catch {
143116 return initialState;
......@@ -148,7 +121,15 @@ function persistState(state: AppState): void {
148121 if (typeof window === "undefined") return;
149122
150123 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 );
152133 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
153134 window.localStorage.removeItem(legacyKey);
154135 }
......@@ -159,68 +140,56 @@ function persistState(state: AppState): void {
159140
160141function updateThread(
161142 threads: Thread[],
162 threadId: string,
143 threadId: ThreadId,
163144 updater: (t: Thread) => Thread,
164145): Thread[] {
165146 return threads.map((t) => (t.id === threadId ? updater(t) : t));
166147}
167148
168function 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
149function 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);
184157 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 })),
197168 };
198169 });
170}
199171
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 }
172function 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";
205188 }
189}
206190
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 );
191function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
192 return providerName === "claudeCode" ? "claudeCode" : "codex";
224193}
225194
226195function normalizeTerminalIds(terminalIds: string[]): string[] {
......@@ -243,7 +212,6 @@ function normalizeRunningTerminalIds(
243212 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
244213}
245214
246
247215function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
248216 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
249217}
......@@ -409,165 +377,166 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
409377 });
410378}
411379
412function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
413 return threads.find((t) => t.session?.sessionId === sessionId);
414}
415
416function 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
423function 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
437function 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
456function 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;
380function 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;
461388 }
462
463 return end - start;
464}
465
466function 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);
474394 }
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 });
493399}
494400
495401// ── Reducer ──────────────────────────────────────────────────────────
496402
497403export function reducer(state: AppState, action: Action): AppState {
498404 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);
514417
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 });
516490 return {
517491 ...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,
523495 };
496 }
524497
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);
529501 return {
530502 ...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 {
561513 ...thread,
562 projectId: mappedProjectId,
563 });
564 })
565 .filter((thread): thread is Thread => thread !== null);
514 lastVisitedAt: visitedAt,
515 };
516 }),
517 };
518 }
566519
520 case "MARK_THREAD_UNREAD": {
567521 return {
568522 ...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 }),
571540 };
572541 }
573542
......@@ -579,33 +548,21 @@ export function reducer(state: AppState, action: Action): AppState {
579548 ),
580549 };
581550
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":
590552 return {
591553 ...state,
592 projects,
593 threads,
554 threads: updateThread(state.threads, action.threadId, (thread) =>
555 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),
556 ),
594557 };
595 }
596558
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":
604560 return {
605561 ...state,
606 threads: [...state.threads, nextThread],
562 projects: state.projects.map((p) =>
563 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,
564 ),
607565 };
608 }
609566
610567 case "TOGGLE_THREAD_TERMINAL":
611568 return {
......@@ -640,10 +597,7 @@ export function reducer(state: AppState, action: Action): AppState {
640597 threads: updateThread(state.threads, action.threadId, (thread) => {
641598 const normalizedThread = normalizeThreadTerminals(thread);
642599 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) {
647601 return normalizedThread;
648602 }
649603 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
......@@ -713,10 +667,7 @@ export function reducer(state: AppState, action: Action): AppState {
713667 threads: updateThread(state.threads, action.threadId, (thread) => {
714668 const normalizedThread = normalizeThreadTerminals(thread);
715669 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) {
720671 return normalizedThread;
721672 }
722673 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
......@@ -774,118 +725,6 @@ export function reducer(state: AppState, action: Action): AppState {
774725 ),
775726 };
776727
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
889728 case "SET_ERROR":
890729 return {
891730 ...state,
......@@ -895,90 +734,6 @@ export function reducer(state: AppState, action: Action): AppState {
895734 })),
896735 };
897736
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
982737 case "SET_THREAD_BRANCH": {
983738 return {
984739 ...state,
......@@ -997,29 +752,12 @@ export function reducer(state: AppState, action: Action): AppState {
997752 };
998753 }
999754
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
1011755 case "SET_RUNTIME_MODE":
1012756 return {
1013757 ...state,
1014758 runtimeMode: action.mode,
1015759 };
1016760
1017 case "DELETE_THREAD":
1018 return {
1019 ...state,
1020 threads: state.threads.filter((t) => t.id !== action.threadId),
1021 };
1022
1023761 default:
1024762 return state;
1025763 }
apps/web/src/terminalActivity.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "vitest";
2
3import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts";
4import { terminalRunningSubprocessFromEvent } from "./terminalActivity";
5
6const 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
18function eventBase() {
19 return {
20 threadId: "thread-1",
21 terminalId: "default",
22 createdAt: "2026-01-01T00:00:00.000Z",
23 };
24}
25
26describe("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 @@
1import type { TerminalEvent } from "@t3tools/contracts";
2
3export 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 @@
1import { DEFAULT_MODEL } from "./model-logic";
2import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3
4interface CreateThreadOptions {
5 branch?: string | null;
6 createdAt?: string;
7 model?: string;
8 title?: string;
9 worktreePath?: string | null;
10}
11
12export 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 @@
11import type {
2 OrchestrationLatestTurn,
3 OrchestrationSessionStatus,
4 OrchestrationThreadActivity,
25 ProjectScript as ContractProjectScript,
3 ProviderEvent,
4 ProviderSession,
6 ThreadId,
7 ProjectId,
8 TurnId,
9 MessageId,
10 CheckpointRef,
11 ProviderThreadId,
12 ProviderSessionId,
13 ProviderKind,
514} from "@t3tools/contracts";
615
716export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
......@@ -29,11 +38,12 @@ export interface ChatImageAttachment {
2938export type ChatAttachment = ChatImageAttachment;
3039
3140export interface ChatMessage {
32 id: string;
33 role: "user" | "assistant";
41 id: MessageId;
42 role: "user" | "assistant" | "system";
3443 text: string;
3544 attachments?: ChatAttachment[];
3645 createdAt: string;
46 completedAt?: string | undefined;
3747 streaming: boolean;
3848}
3949
......@@ -45,16 +55,17 @@ export interface TurnDiffFileChange {
4555}
4656
4757export interface TurnDiffSummary {
48 turnId: string;
58 turnId: TurnId;
4959 completedAt: string;
5060 status?: string | undefined;
5161 files: TurnDiffFileChange[];
52 assistantMessageId?: string | undefined;
62 checkpointRef?: CheckpointRef | undefined;
63 assistantMessageId?: MessageId | undefined;
5364 checkpointTurnCount?: number | undefined;
5465}
5566
5667export interface Project {
57 id: string;
68 id: ProjectId;
5869 name: string;
5970 cwd: string;
6071 model: string;
......@@ -63,9 +74,9 @@ export interface Project {
6374}
6475
6576export interface Thread {
66 id: string;
67 codexThreadId: string | null;
68 projectId: string;
77 id: ThreadId;
78 codexThreadId: ProviderThreadId | null;
79 projectId: ProjectId;
6980 title: string;
7081 model: string;
7182 terminalOpen: boolean;
......@@ -75,17 +86,26 @@ export interface Thread {
7586 activeTerminalId: string;
7687 terminalGroups: ThreadTerminalGroup[];
7788 activeTerminalGroupId: string;
78 session: ProviderSession | null;
89 session: ThreadSession | null;
7990 messages: ChatMessage[];
80 events: ProviderEvent[];
8191 error: string | null;
8292 createdAt: string;
83 latestTurnId?: string | undefined;
84 latestTurnStartedAt?: string | undefined;
85 latestTurnCompletedAt?: string | undefined;
86 latestTurnDurationMs?: number | undefined;
93 latestTurn: OrchestrationLatestTurn | null;
8794 lastVisitedAt?: string | undefined;
8895 branch: string | null;
8996 worktreePath: string | null;
9097 turnDiffSummaries: TurnDiffSummary[];
98 activities: OrchestrationThreadActivity[];
99}
100
101export 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;
91111}
apps/web/src/vite-env.d.ts+1-10
......@@ -1,15 +1,6 @@
11/// <reference types="vite/client" />
22
3import type { NativeApi } from "@t3tools/contracts";
4
5interface 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}
3import type { NativeApi, DesktopBridge } from "@t3tools/contracts";
134
145declare global {
156 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 @@
1import "@pierre/diffs/worker/worker.js";
apps/web/src/worktreeCleanup.test.ts+26-12
......@@ -1,3 +1,4 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23
34import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
......@@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
56
67function makeThread(overrides: Partial<Thread> = {}): Thread {
78 return {
8 id: "thread-1",
9 id: ThreadId.makeUnsafe("thread-1"),
910 codexThreadId: null,
10 projectId: "project-1",
11 projectId: ProjectId.makeUnsafe("project-1"),
1112 title: "Thread",
1213 model: "gpt-5.3-codex",
1314 terminalOpen: false,
......@@ -24,10 +25,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2425 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
2526 session: null,
2627 messages: [],
27 events: [],
2828 turnDiffSummaries: [],
29 activities: [],
2930 error: null,
3031 createdAt: "2026-02-13T00:00:00.000Z",
32 latestTurn: null,
3133 branch: null,
3234 worktreePath: null,
3335 ...overrides,
......@@ -36,37 +38,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3638
3739describe("getOrphanedWorktreePathForThread", () => {
3840 it("returns null when the target thread does not exist", () => {
39 const result = getOrphanedWorktreePathForThread([], "missing-thread");
41 const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread"));
4042 expect(result).toBeNull();
4143 });
4244
4345 it("returns null when the target thread has no worktree", () => {
4446 const threads = [makeThread()];
45 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
47 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
4648 expect(result).toBeNull();
4749 });
4850
4951 it("returns the path when no other thread links to that worktree", () => {
5052 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"));
5254 expect(result).toBe("/tmp/repo/worktrees/feature-a");
5355 });
5456
5557 it("returns null when another thread links to the same worktree", () => {
5658 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 }),
5967 ];
60 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
68 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
6169 expect(result).toBeNull();
6270 });
6371
6472 it("ignores threads linked to different worktrees", () => {
6573 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 }),
6882 ];
69 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
83 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
7084 expect(result).toBe("/tmp/repo/worktrees/feature-a");
7185 });
7286});
apps/web/src/worktreeCleanup.ts+1-1
......@@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null {
1010
1111export function getOrphanedWorktreePathForThread(
1212 threads: readonly Thread[],
13 threadId: string,
13 threadId: Thread["id"],
1414): string | null {
1515 const targetThread = threads.find((thread) => thread.id === threadId);
1616 if (!targetThread) {
apps/web/src/wsNativeApi.test.ts created+310
......@@ -0,0 +1,310 @@
1import {
2 CommandId,
3 ORCHESTRATION_WS_CHANNELS,
4 ORCHESTRATION_WS_METHODS,
5 ProjectId,
6 ThreadId,
7 WS_CHANNELS,
8} from "@t3tools/contracts";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
11const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
12const channelListeners = new Map<string, Set<(data: unknown) => void>>();
13const 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
27vi.mock("./wsTransport", () => {
28 return {
29 WsTransport: class MockWsTransport {
30 request = requestMock;
31 subscribe = subscribeMock;
32 },
33 };
34});
35
36function 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
44beforeEach(() => {
45 vi.resetModules();
46 requestMock.mockReset();
47 subscribeMock.mockClear();
48 channelListeners.clear();
49});
50
51afterEach(() => {
52 vi.restoreAllMocks();
53});
54
55describe("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 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
1import {
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";
12import { Cause, Schema } from "effect";
213
314import { showContextMenuFallback } from "./contextMenuFallback";
415import { WsTransport } from "./wsTransport";
516
617let instance: { api: NativeApi; transport: WsTransport } | null = null;
718const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
19const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
820let lastWelcome: WsWelcomePayload | null = null;
21let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null;
22
23const 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};
938
1039/**
1140 * Subscribe to the server welcome message. If a welcome was already received
......@@ -29,6 +58,28 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void):
2958 };
3059}
3160
61/**
62 * Subscribe to server config update events. Replays the latest update for
63 * late subscribers to avoid missing config validation feedback.
64 */
65export 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
3283export function createWsNativeApi(): NativeApi {
3384 if (instance) return instance.api;
3485
......@@ -37,7 +88,8 @@ export function createWsNativeApi(): NativeApi {
3788 // Listen for server welcome and forward to registered listeners.
3889 // Also cache it so late subscribers (React effects) get it immediately.
3990 transport.subscribe(WS_CHANNELS.serverWelcome, (data) => {
40 const payload = data as WsWelcomePayload;
91 const payload = decodeAndWarnOnFailure(WsWelcomePayload, data);
92 if (!payload) return;
4193 lastWelcome = payload;
4294 for (const listener of welcomeListeners) {
4395 try {
......@@ -47,14 +99,20 @@ export function createWsNativeApi(): NativeApi {
4799 }
48100 }
49101 });
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 });
50114
51115 const api: NativeApi = {
52 todos: {
53 list: async () => [],
54 add: async () => [],
55 toggle: async () => [],
56 remove: async () => [],
57 },
58116 dialogs: {
59117 pickFolder: async () => {
60118 if (!window.desktopBridge) return null;
......@@ -75,35 +133,13 @@ export function createWsNativeApi(): NativeApi {
75133 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
76134 close: (input) => transport.request(WS_METHODS.terminalClose, input),
77135 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 }),
100140 },
101141 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),
105142 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
106 updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input),
107143 },
108144 shell: {
109145 openInEditor: (cwd, editor) =>
......@@ -149,6 +185,21 @@ export function createWsNativeApi(): NativeApi {
149185 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
150186 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
151187 },
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 },
152203 };
153204
154205 instance = { api, transport };
apps/web/src/wsTransport.test.ts created+175
......@@ -0,0 +1,175 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import { WsTransport } from "./wsTransport";
4
5type WsEventType = "open" | "message" | "close" | "error";
6type WsListener = (event?: { data?: unknown }) => void;
7
8const sockets: MockWebSocket[] = [];
9
10class 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
57const originalWebSocket = globalThis.WebSocket;
58
59function 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
67beforeEach(() => {
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
81afterEach(() => {
82 globalThis.WebSocket = originalWebSocket;
83 vi.restoreAllMocks();
84});
85
86describe("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 @@
1import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
1import { WebSocketResponse, WsPush, WsResponse } from "@t3tools/contracts";
2import { Cause, Schema } from "effect";
23
34type PushListener = (data: unknown) => void;
45
......@@ -10,6 +11,17 @@ interface PendingRequest {
1011
1112const REQUEST_TIMEOUT_MS = 60_000;
1213const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
14const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));
15const isWsPushEnvelope = Schema.is(WsPush);
16const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
17
18interface WsRequestEnvelope {
19 id: string;
20 body: {
21 _tag: string;
22 [key: string]: unknown;
23 };
24}
1325
1426export class WsTransport {
1527 private ws: WebSocket | null = null;
......@@ -41,7 +53,8 @@ export class WsTransport {
4153 throw new Error("Request method is required");
4254 }
4355 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 };
4558
4659 return new Promise<T>((resolve, reject) => {
4760 const timeout = setTimeout(() => {
......@@ -115,25 +128,24 @@ export class WsTransport {
115128 }
116129
117130 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 });
122138 return;
123139 }
124
125 if (!parsed || typeof parsed !== "object") return;
126
127 const message = parsed as Record<string, unknown>;
140 const message = exit.value;
128141
129142 // 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);
133145 if (channelListeners) {
134146 for (const listener of channelListeners) {
135147 try {
136 listener(push.data);
148 listener(message.data);
137149 } catch {
138150 // Swallow listener errors
139151 }
......@@ -143,23 +155,24 @@ export class WsTransport {
143155 }
144156
145157 // 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 }
150161
151 clearTimeout(pending.timeout);
152 this.pending.delete(response.id);
162 const pending = this.pending.get(message.id);
163 if (!pending) return;
153164
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);
159172 }
160173 }
161174
162 private send(message: WsRequest) {
175 private send(message: WsRequestEnvelope) {
163176 if (this.ws?.readyState === WebSocket.OPEN) {
164177 this.ws.send(JSON.stringify(message));
165178 return;
apps/web/tsconfig.json+13-1
......@@ -7,7 +7,19 @@
77 "types": ["vite/client"],
88 "paths": {
99 "~/*": ["./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 ]
1123 },
1224 "include": ["src", "vite.config.ts"]
1325}
apps/web/vite.config.ts+1-1
......@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
33import { tanstackRouter } from "@tanstack/router-plugin/vite";
44import { defineConfig } from "vite";
55
6const port = Number(process.env.PORT ?? 5173);
6const port = Number(process.env.PORT ?? 5733);
77
88export default defineConfig({
99 plugins: [
bun.lock+157-36
......@@ -5,8 +5,8 @@
55 "": {
66 "name": "@t3tools/monorepo",
77 "devDependencies": {
8 "oxfmt": "^0.28.0",
9 "oxlint": "^1.43.0",
8 "oxfmt": "^0.35.0",
9 "oxlint": "^1.50.0",
1010 "turbo": "^2.3.3",
1111 "typescript": "^5.7.3",
1212 "vitest": "^4.0.0",
......@@ -16,9 +16,10 @@
1616 "name": "@t3tools/desktop",
1717 "version": "0.0.0",
1818 "dependencies": {
19 "electron": "33.4.11",
19 "electron": "40.6.0",
2020 },
2121 "devDependencies": {
22 "@t3tools/contracts": "workspace:*",
2223 "@types/node": "^22.10.2",
2324 "electronmon": "^2.0.2",
2425 "tsdown": "^0.20.3",
......@@ -34,17 +35,25 @@
3435 "t3": "./dist/index.mjs",
3536 },
3637 "dependencies": {
38 "@effect/platform-node": "catalog:",
39 "@effect/sql-sqlite-bun": "catalog:",
40 "@pierre/diffs": "^1.1.0-beta.16",
41 "effect": "catalog:",
3742 "node-pty": "^1.1.0",
3843 "open": "^10.1.0",
3944 "ws": "^8.18.0",
4045 },
4146 "devDependencies": {
47 "@effect/language-service": "0.75.1",
48 "@effect/vitest": "catalog:",
4249 "@t3tools/contracts": "workspace:*",
4350 "@t3tools/web": "workspace:*",
51 "@types/bun": "^1.3.9",
4452 "@types/node": "^22.10.2",
4553 "@types/ws": "^8.5.13",
4654 "tsdown": "^0.20.3",
4755 "typescript": "^5.7.3",
56 "vitest": "^4.0.0",
4857 },
4958 },
5059 "apps/web": {
......@@ -61,6 +70,7 @@
6170 "@xterm/addon-fit": "^0.11.0",
6271 "@xterm/xterm": "^6.0.0",
6372 "class-variance-authority": "^0.7.1",
73 "effect": "catalog:",
6474 "highlight.js": "^11.11.1",
6575 "lucide-react": "^0.564.0",
6676 "react": "^19.0.0",
......@@ -69,9 +79,9 @@
6979 "rehype-highlight": "^7.0.2",
7080 "remark-gfm": "^4.0.1",
7181 "tailwind-merge": "^3.4.0",
72 "zod": "^3.24.1",
7382 },
7483 "devDependencies": {
84 "@effect/language-service": "catalog:",
7585 "@tailwindcss/vite": "^4.0.0",
7686 "@tanstack/router-plugin": "^1.161.0",
7787 "@types/react": "^19.0.0",
......@@ -87,14 +97,23 @@
8797 "name": "@t3tools/contracts",
8898 "version": "0.0.0",
8999 "dependencies": {
90 "zod": "^3.24.1",
100 "effect": "catalog:",
91101 },
92102 "devDependencies": {
103 "@effect/language-service": "0.75.1",
104 "@effect/vitest": "catalog:",
93105 "tsdown": "^0.20.3",
94106 "typescript": "^5.7.3",
95107 },
96108 },
97109 },
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 },
98117 "packages": {
99118 "@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=="],
100119
......@@ -144,6 +163,16 @@
144163
145164 "@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=="],
146165
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
147176 "@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=="],
148177
149178 "@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 @@
234263
235264 "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
236265
266 "@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
267
237268 "@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=="],
238269
239270 "@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 @@
244275
245276 "@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=="],
246277
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
247290 "@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=="],
248291
249292 "@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 @@
258301
259302 "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="],
260303
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=="],
262315
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=="],
264317
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=="],
266319
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=="],
268321
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=="],
270323
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=="],
272325
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=="],
274327
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=="],
276329
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=="],
278331
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=="],
280333
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=="],
282335
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=="],
284337
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=="],
286339
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=="],
288341
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=="],
290343
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=="],
292345
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=="],
294347
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=="],
296349
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=="],
298351
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=="],
300353
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=="],
302355
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=="],
304357
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=="],
306359
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=="],
308361
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=="],
310363
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=="],
312365
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=="],
314379
315380 "@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=="],
316381
......@@ -494,6 +559,8 @@
494559
495560 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
496561
562 "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
563
497564 "@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=="],
498565
499566 "@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 @@
596663
597664 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
598665
666 "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
667
599668 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
600669
601670 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
......@@ -634,6 +703,8 @@
634703
635704 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
636705
706 "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
707
637708 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
638709
639710 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
......@@ -672,6 +743,8 @@
672743
673744 "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
674745
746 "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
747
675748 "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
676749
677750 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
......@@ -686,7 +759,9 @@
686759
687760 "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=="],
688761
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=="],
690765
691766 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
692767
......@@ -732,12 +807,16 @@
732807
733808 "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=="],
734809
810 "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="],
811
735812 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
736813
737814 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
738815
739816 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
740817
818 "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
819
741820 "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
742821
743822 "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 @@
812891
813892 "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="],
814893
894 "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
895
815896 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
816897
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
817900 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
818901
819902 "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 @@
862945
863946 "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
864947
948 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
949
865950 "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=="],
866951
867952 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
......@@ -888,8 +973,12 @@
888973
889974 "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
890975
976 "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
977
891978 "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
892979
980 "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
981
893982 "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
894983
895984 "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
......@@ -998,6 +1087,8 @@
9981087
9991088 "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
10001089
1090 "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
1091
10011092 "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
10021093
10031094 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
......@@ -1008,14 +1099,22 @@
10081099
10091100 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
10101101
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
10111106 "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=="],
10121107
1108 "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
1109
10131110 "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
10141111
10151112 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
10161113
10171114 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
10181115
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
10191118 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
10201119
10211120 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
......@@ -1038,9 +1137,9 @@
10381137
10391138 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
10401139
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=="],
10421141
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=="],
10441143
10451144 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
10461145
......@@ -1070,6 +1169,8 @@
10701169
10711170 "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
10721171
1172 "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="],
1173
10731174 "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
10741175
10751176 "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
......@@ -1086,6 +1187,10 @@
10861187
10871188 "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=="],
10881189
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
10891194 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
10901195
10911196 "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
......@@ -1160,6 +1265,8 @@
11601265
11611266 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
11621267
1268 "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
1269
11631270 "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
11641271
11651272 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
......@@ -1212,6 +1319,8 @@
12121319
12131320 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
12141321
1322 "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],
1323
12151324 "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
12161325
12171326 "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
......@@ -1246,6 +1355,8 @@
12461355
12471356 "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="],
12481357
1358 "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
1359
12491360 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
12501361
12511362 "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 @@
12761387
12771388 "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=="],
12781389
1390 "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
1391
12791392 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
12801393
12811394 "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 @@
13041417
13051418 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
13061419
1420 "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
1421
13071422 "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=="],
13081423
13091424 "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
......@@ -1372,11 +1487,13 @@
13721487
13731488 "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=="],
13741489
1490 "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
1491
13751492 "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=="],
13761493
13771494 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
13781495
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=="],
13801497
13811498 "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
13821499
......@@ -1422,6 +1539,10 @@
14221539
14231540 "@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=="],
14241541
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
14251546 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
14261547 }
14271548}
docs/adr/0001-provider-runtime-lifecycle-ownership.md created+40
......@@ -0,0 +1,40 @@
1# ADR 0001: Provider Runtime Lifecycle Ownership
2
3## Status
4Accepted
5
6## Context
7Provider runtime streams can include auxiliary work (for example collab/child-agent turns) under the same provider session as a user-visible primary turn.
8
9The 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
16Define 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
21Current 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
381. Add explicit runtime lane metadata (`primary` vs `auxiliary`) to canonical provider runtime events.
392. Persist orchestration-to-provider turn binding (`TurnId` <-> `ProviderTurnId`) for stronger reconciliation and replay safety.
403. Promote lifecycle ownership checks from heuristic guards to schema-level invariants once lane metadata is available.
package.json+17-7
......@@ -1,10 +1,19 @@
11{
22 "name": "@t3tools/monorepo",
33 "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 },
817 "scripts": {
918 "dev": "bun run scripts/dev-runner.mjs dev",
1019 "dev:server": "bun run scripts/dev-runner.mjs dev:server",
......@@ -27,14 +36,15 @@
2736 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
2837 },
2938 "devDependencies": {
30 "oxfmt": "^0.28.0",
31 "oxlint": "^1.43.0",
39 "oxfmt": "^0.35.0",
40 "oxlint": "^1.50.0",
3241 "turbo": "^2.3.3",
3342 "typescript": "^5.7.3",
3443 "vitest": "^4.0.0"
3544 },
3645 "engines": {
37 "bun": ">=1.3.9"
46 "bun": "^1.3.9",
47 "node": "^24.13.1"
3848 },
3949 "packageManager": "bun@1.3.9"
4050}
packages/contracts/package.json+4-1
......@@ -19,13 +19,16 @@
1919 "scripts": {
2020 "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean",
2121 "build": "tsdown src/index.ts --format esm,cjs --dts --clean",
22 "prepare": "effect-language-service patch",
2223 "typecheck": "tsc --noEmit",
2324 "test": "vitest run"
2425 },
2526 "dependencies": {
26 "zod": "^3.24.1"
27 "effect": "catalog:"
2728 },
2829 "devDependencies": {
30 "@effect/language-service": "0.75.1",
31 "@effect/vitest": "catalog:",
2932 "tsdown": "^0.20.3",
3033 "typescript": "^5.7.3"
3134 }
packages/contracts/src/agent.ts deleted-27
......@@ -1,27 +0,0 @@
1import { z } from "zod";
2
3export const agentSessionIdSchema = z.string().min(1);
4
5export 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
13export const outputChunkSchema = z.object({
14 sessionId: z.string(),
15 stream: z.enum(["stdout", "stderr"]),
16 data: z.string(),
17});
18
19export const agentExitSchema = z.object({
20 sessionId: z.string(),
21 code: z.number().nullable(),
22 signal: z.string().nullable(),
23});
24
25export type AgentConfig = z.infer<typeof agentConfigSchema>;
26export type OutputChunk = z.infer<typeof outputChunkSchema>;
27export type AgentExit = z.infer<typeof agentExitSchema>;
packages/contracts/src/baseSchemas.ts created+42
......@@ -0,0 +1,42 @@
1import { Schema } from "effect";
2
3export const TrimmedString = Schema.Trim;
4export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());
5
6export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
7export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
8
9export const IsoDateTime = Schema.String;
10export type IsoDateTime = typeof IsoDateTime.Type;
11
12/**
13 * Construct a branded identifier. Enforces non-empty trimmed strings
14 */
15const makeEntityId = <Brand extends string>(brand: Brand) =>
16 TrimmedNonEmptyString.pipe(Schema.brand(brand));
17
18export const ThreadId = makeEntityId("ThreadId");
19export type ThreadId = typeof ThreadId.Type;
20export const ProjectId = makeEntityId("ProjectId");
21export type ProjectId = typeof ProjectId.Type;
22export const CommandId = makeEntityId("CommandId");
23export type CommandId = typeof CommandId.Type;
24export const EventId = makeEntityId("EventId");
25export type EventId = typeof EventId.Type;
26export const MessageId = makeEntityId("MessageId");
27export type MessageId = typeof MessageId.Type;
28export const TurnId = makeEntityId("TurnId");
29export type TurnId = typeof TurnId.Type;
30
31export const ProviderSessionId = makeEntityId("ProviderSessionId");
32export type ProviderSessionId = typeof ProviderSessionId.Type;
33export const ProviderThreadId = makeEntityId("ProviderThreadId");
34export type ProviderThreadId = typeof ProviderThreadId.Type;
35export const ProviderTurnId = makeEntityId("ProviderTurnId");
36export type ProviderTurnId = typeof ProviderTurnId.Type;
37export const ProviderItemId = makeEntityId("ProviderItemId");
38export type ProviderItemId = typeof ProviderItemId.Type;
39export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
40export type ApprovalRequestId = typeof ApprovalRequestId.Type;
41export const CheckpointRef = makeEntityId("CheckpointRef");
42export type CheckpointRef = typeof CheckpointRef.Type;
packages/contracts/src/editor.ts created+16
......@@ -0,0 +1,16 @@
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
3
4export const EDITORS = [
5 { id: "cursor", label: "Cursor", command: "cursor" },
6 { id: "file-manager", label: "File Manager", command: null },
7] as const;
8
9export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
10export type EditorId = typeof EditorId.Type;
11
12export const OpenInEditorInput = Schema.Struct({
13 cwd: TrimmedNonEmptyString,
14 editor: EditorId,
15});
16export type OpenInEditorInput = typeof OpenInEditorInput.Type;
packages/contracts/src/git.test.ts deleted-107
......@@ -1,107 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 gitPullInputSchema,
5 gitPullResultSchema,
6 gitRunStackedActionInputSchema,
7 gitRunStackedActionResultSchema,
8 gitStackedActionSchema,
9 gitStatusInputSchema,
10 gitStatusResultSchema,
11} from "./git";
12
13describe("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 @@
1import { z } from "zod";
2
3// ── Existing branch/worktree Git API ─────────────────────────────────
4
5export const gitListBranchesInputSchema = z.object({
6 cwd: z.string().min(1),
1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
5
6// Domain Types
7
8export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
9export type GitStackedAction = typeof GitStackedAction.Type;
10export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
11export const GitPushStepStatus = Schema.Literals([
12 "pushed",
13 "skipped_not_requested",
14 "skipped_up_to_date",
15]);
16export const GitPrStepStatus = Schema.Literals([
17 "created",
18 "opened_existing",
19 "skipped_not_requested",
20]);
21export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
22export type GitStatusPrState = typeof GitStatusPrState.Type;
23
24export const GitBranch = Schema.Struct({
25 name: TrimmedNonEmptyStringSchema,
26 current: Schema.Boolean,
27 isDefault: Schema.Boolean,
28 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
729});
30export type GitBranch = typeof GitBranch.Type;
831
9export 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(),
32export const GitWorktree = Schema.Struct({
33 path: TrimmedNonEmptyStringSchema,
34 branch: TrimmedNonEmptyStringSchema,
1435});
36export type GitWorktree = typeof GitWorktree.Type;
1537
16export 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
2139
22export const gitCreateBranchInputSchema = z.object({
23 cwd: z.string().min(1),
24 branch: z.string().min(1),
40export const GitStatusInput = Schema.Struct({
41 cwd: TrimmedNonEmptyStringSchema,
2542});
43export type GitStatusInput = typeof GitStatusInput.Type;
2644
27export const gitCheckoutInputSchema = z.object({
28 cwd: z.string().min(1),
29 branch: z.string().min(1),
45export const GitPullInput = Schema.Struct({
46 cwd: TrimmedNonEmptyStringSchema,
3047});
48export type GitPullInput = typeof GitPullInput.Type;
3149
32export const gitInitInputSchema = z.object({
33 cwd: z.string().min(1),
50export const GitRunStackedActionInput = Schema.Struct({
51 cwd: TrimmedNonEmptyStringSchema,
52 action: GitStackedAction,
53 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
3454});
55export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
3556
36export 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(),
57export const GitListBranchesInput = Schema.Struct({
58 cwd: TrimmedNonEmptyStringSchema,
4159});
60export type GitListBranchesInput = typeof GitListBranchesInput.Type;
4261
43export const gitWorktreeSchema = z.object({
44 path: z.string().min(1),
45 branch: z.string().min(1),
62export const GitCreateWorktreeInput = Schema.Struct({
63 cwd: TrimmedNonEmptyStringSchema,
64 branch: TrimmedNonEmptyStringSchema,
65 newBranch: TrimmedNonEmptyStringSchema,
66 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
4667});
68export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
4769
48export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;
49export type GitBranch = z.infer<typeof gitBranchSchema>;
50export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;
51export type GitWorktree = z.infer<typeof gitWorktreeSchema>;
52export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>;
53export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;
54export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;
55export type GitInitInput = z.infer<typeof gitInitInputSchema>;
56
57export interface GitListBranchesResult {
58 branches: GitBranch[];
59 isRepo: boolean;
60}
61
62export interface GitCreateWorktreeResult {
63 worktree: GitWorktree;
64}
70export const GitRemoveWorktreeInput = Schema.Struct({
71 cwd: TrimmedNonEmptyStringSchema,
72 path: TrimmedNonEmptyStringSchema,
73 force: Schema.optional(Schema.Boolean),
74});
75export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
6576
66// ── Stacked action Git API ───────────────────────────────────────────
77export const GitCreateBranchInput = Schema.Struct({
78 cwd: TrimmedNonEmptyStringSchema,
79 branch: TrimmedNonEmptyStringSchema,
80});
81export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
6782
68export const gitStatusInputSchema = z.object({
69 cwd: z.string().trim().min(1),
83export const GitCheckoutInput = Schema.Struct({
84 cwd: TrimmedNonEmptyStringSchema,
85 branch: TrimmedNonEmptyStringSchema,
7086});
87export type GitCheckoutInput = typeof GitCheckoutInput.Type;
7188
72export const gitPullInputSchema = z.object({
73 cwd: z.string().trim().min(1),
89export const GitInitInput = Schema.Struct({
90 cwd: TrimmedNonEmptyStringSchema,
7491});
92export type GitInitInput = typeof GitInitInput.Type;
7593
76export const gitStatusPrStateSchema = z.enum(["open", "closed", "merged"]);
94// RPC Results
7795
78export 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,
96export const GitStatusPr = Schema.Struct({
97 number: PositiveInt,
98 title: TrimmedNonEmptyStringSchema,
99 url: Schema.String,
100 baseBranch: TrimmedNonEmptyStringSchema,
101 headBranch: TrimmedNonEmptyStringSchema,
102 state: GitStatusPrState,
85103});
86
87export 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(),
104export type GitStatusPr = typeof GitStatusPr.Type;
105
106export 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,
96115 }),
97116 ),
98 insertions: z.number().int().nonnegative(),
99 deletions: z.number().int().nonnegative(),
117 insertions: NonNegativeInt,
118 deletions: NonNegativeInt,
100119 }),
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),
105124});
125export type GitStatusResult = typeof GitStatusResult.Type;
106126
107export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);
108
109const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]);
110const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]);
111const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]);
112
113export 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(),
127export const GitListBranchesResult = Schema.Struct({
128 branches: Schema.Array(GitBranch),
129 isRepo: Schema.Boolean,
117130});
131export type GitListBranchesResult = typeof GitListBranchesResult.Type;
118132
119export 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(),
133export const GitCreateWorktreeResult = Schema.Struct({
134 worktree: GitWorktree,
135});
136export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
137
138export const GitRunStackedActionResult = Schema.Struct({
139 action: GitStackedAction,
140 commit: Schema.Struct({
141 status: GitCommitStepStatus,
142 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),
143 subject: Schema.optional(TrimmedNonEmptyStringSchema),
125144 }),
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),
131150 }),
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),
139158 }),
140159});
160export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
141161
142export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
143export type GitPullInput = z.input<typeof gitPullInputSchema>;
144export type GitStatusPrState = z.infer<typeof gitStatusPrStateSchema>;
145export type GitStatusPr = z.infer<typeof gitStatusPrSchema>;
146export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
147export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
148export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
149export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
150
151export 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(),
162export const GitPullResult = Schema.Struct({
163 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
164 branch: TrimmedNonEmptyStringSchema,
165 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
155166});
156
157export type GitPullResult = z.infer<typeof gitPullResultSchema>;
167export type GitPullResult = typeof GitPullResult.Type;
packages/contracts/src/index.ts+5-3
......@@ -1,11 +1,13 @@
1export * from "./todo";
1export * from "./baseSchemas";
22export * from "./ipc";
3export * from "./agent";
43export * from "./terminal";
54export * from "./provider";
5export * from "./providerRuntime";
66export * from "./model";
77export * from "./ws";
88export * from "./keybindings";
99export * from "./server";
10export * from "./project";
1110export * from "./git";
11export * from "./orchestration";
12export * from "./editor";
13export * from "./project";
packages/contracts/src/ipc.ts+32-69
......@@ -1,4 +1,3 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
21import type {
32 GitCheckoutInput,
43 GitCreateBranchInput,
......@@ -15,32 +14,7 @@ import type {
1514 GitStatusInput,
1615 GitStatusResult,
1716} from "./git";
18import 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";
34import type {
35 ProjectAddInput,
36 ProjectAddResult,
37 ProjectListResult,
38 ProjectSearchEntriesInput,
39 ProjectSearchEntriesResult,
40 ProjectRemoveInput,
41 ProjectUpdateScriptsInput,
42 ProjectUpdateScriptsResult,
43} from "./project";
17import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project";
4418import type { ServerConfig } from "./server";
4519import type {
4620 TerminalClearInput,
......@@ -51,23 +25,31 @@ import type {
5125 TerminalSessionSnapshot,
5226 TerminalWriteInput,
5327} from "./terminal";
54import type { NewTodoInput, Todo } from "./todo";
5528import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
29import type {
30 ClientOrchestrationCommand,
31 OrchestrationGetFullThreadDiffInput,
32 OrchestrationGetFullThreadDiffResult,
33 OrchestrationGetTurnDiffInput,
34 OrchestrationGetTurnDiffResult,
35 OrchestrationEvent,
36 OrchestrationReadModel,
37} from "./orchestration";
38import { EditorId } from "./editor";
5639
57export const EDITORS = [
58 { id: "cursor", label: "Cursor", command: "cursor" },
59 { id: "file-manager", label: "File Manager", command: null },
60] as const;
61
62export type EditorId = (typeof EDITORS)[number]["id"];
40export 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}
6351
6452export 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 };
7153 dialogs: {
7254 pickFolder: () => Promise<string | null>;
7355 confirm: (message: string) => Promise<boolean>;
......@@ -81,37 +63,8 @@ export interface NativeApi {
8163 close: (input: TerminalCloseInput) => Promise<void>;
8264 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
8365 };
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 };
10966 projects: {
110 list: () => Promise<ProjectListResult>;
111 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
112 remove: (input: ProjectRemoveInput) => Promise<void>;
11367 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
114 updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
11568 };
11669 shell: {
11770 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
......@@ -140,4 +93,14 @@ export interface NativeApi {
14093 getConfig: () => Promise<ServerConfig>;
14194 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
14295 };
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 };
143106}
packages/contracts/src/keybindings.test.ts+79-44
......@@ -1,60 +1,89 @@
1import { assert, describe, it } from "vitest";
1import { Schema } from "effect";
2import { assert, it } from "@effect/vitest";
3import { Effect } from "effect";
24
35import {
4 keybindingRuleSchema,
5 keybindingsConfigSchema,
6 resolvedKeybindingRuleSchema,
7 resolvedKeybindingsConfigSchema,
6 KeybindingsConfig,
7 KeybindingRule,
8 ResolvedKeybindingRule,
9 ResolvedKeybindingsConfig,
810} from "./keybindings";
911
10describe("keybindings contracts", () => {
11 it("parses keybinding rules", () => {
12 const parsed = keybindingRuleSchema.parse({
12const 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
22const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never);
23
24it.effect("parses keybinding rules", () =>
25 Effect.gen(function* () {
26 const parsed = yield* decode(KeybindingRule, {
1327 key: "mod+j",
1428 command: "terminal.toggle",
1529 });
1630 assert.strictEqual(parsed.command, "terminal.toggle");
1731
18 const parsedClose = keybindingRuleSchema.parse({
32 const parsedClose = yield* decode(KeybindingRule, {
1933 key: "mod+w",
2034 command: "terminal.close",
2135 });
2236 assert.strictEqual(parsedClose.command, "terminal.close");
2337
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, {
2545 key: "mod+shift+n",
2646 command: "chat.newLocal",
2747 });
2848 assert.strictEqual(parsedLocal.command, "chat.newLocal");
29 });
49 }),
50);
3051
31 it("rejects invalid command values", () => {
32 assert.throws(() =>
33 keybindingRuleSchema.parse({
52it.effect("rejects invalid command values", () =>
53 Effect.gen(function* () {
54 const result = yield* Effect.exit(
55 decode(KeybindingRule, {
3456 key: "mod+j",
3557 command: "script.Test.run",
3658 }),
3759 );
38 });
60 assert.strictEqual(result._tag, "Failure");
61 }),
62);
3963
40 it("accepts dynamic script run commands", () => {
41 const parsed = keybindingRuleSchema.parse({
64it.effect("accepts dynamic script run commands", () =>
65 Effect.gen(function* () {
66 const parsed = yield* decode(KeybindingRule, {
4267 key: "mod+r",
4368 command: "script.setup.run",
4469 });
4570 assert.strictEqual(parsed.command, "script.setup.run");
46 });
71 }),
72);
4773
48 it("parses keybindings array payload", () => {
49 const parsed = keybindingsConfigSchema.parse([
74it.effect("parses keybindings array payload", () =>
75 Effect.gen(function* () {
76 const parsed = yield* decode(KeybindingsConfig, [
5077 { key: "mod+j", command: "terminal.toggle" },
5178 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
5279 ]);
5380 assert.lengthOf(parsed, 2);
54 });
81 }),
82);
5583
56 it("parses resolved keybinding rules", () => {
57 const parsed = resolvedKeybindingRuleSchema.parse({
84it.effect("parses resolved keybinding rules", () =>
85 Effect.gen(function* () {
86 const parsed = yield* decode(ResolvedKeybindingRule, {
5887 command: "terminal.split",
5988 shortcut: {
6089 key: "d",
......@@ -74,10 +103,12 @@ describe("keybindings contracts", () => {
74103 },
75104 });
76105 assert.strictEqual(parsed.shortcut.key, "d");
77 });
106 }),
107);
78108
79 it("parses resolved keybindings arrays", () => {
80 const parsed = resolvedKeybindingsConfigSchema.parse([
109it.effect("parses resolved keybindings arrays", () =>
110 Effect.gen(function* () {
111 const parsed = yield* decode(ResolvedKeybindingsConfig, [
81112 {
82113 command: "terminal.toggle",
83114 shortcut: {
......@@ -91,22 +122,26 @@ describe("keybindings contracts", () => {
91122 },
92123 ]);
93124 assert.lengthOf(parsed, 1);
94 });
125 }),
126);
95127
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});
128it.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 @@
1import { z } from "zod";
1import { Schema } from "effect";
2import { TrimmedString } from "./baseSchemas";
3
4export const MAX_KEYBINDING_COMMAND_LENGTH = 96;
5export const MAX_KEYBINDING_VALUE_LENGTH = 64;
6export const MAX_KEYBINDING_WHEN_LENGTH = 256;
7export const MAX_WHEN_EXPRESSION_DEPTH = 64;
8export const MAX_SCRIPT_ID_LENGTH = 24;
9export const MAX_SCRIPT_RUN_COMMAND_LENGTH = 96;
10export const MAX_KEYBINDINGS_COUNT = 256;
211
312export const STATIC_KEYBINDING_COMMANDS = [
413 "terminal.toggle",
514 "terminal.split",
615 "terminal.new",
716 "terminal.close",
17 "diff.toggle",
818 "chat.new",
919 "chat.newLocal",
1020 "editor.openFavorite",
1121] as const;
1222
13export 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$/),
23export 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
32export const KeybindingCommand = Schema.Union([
33 Schema.Literals(STATIC_KEYBINDING_COMMANDS),
34 SCRIPT_RUN_COMMAND_PATTERN,
2135]);
36export type KeybindingCommand = typeof KeybindingCommand.Type;
2237
23const keybindingValueSchema = z.string().trim().min(1).max(64);
24const keybindingWhenSchema = z.string().trim().min(1).max(256);
38export const KeybindingValue = TrimmedString.check(
39 Schema.isMinLength(1),
40 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
41);
42export type KeybindingValue = typeof KeybindingValue.Type;
2543
26export const keybindingRuleSchema = z.object({
27 key: keybindingValueSchema,
28 command: keybindingCommandSchema,
29 when: keybindingWhenSchema.optional(),
44export const KeybindingWhen = TrimmedString.check(
45 Schema.isMinLength(1),
46 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
47);
48export type KeybindingWhen = typeof KeybindingWhen.Type;
49export const KeybindingRule = Schema.Struct({
50 key: KeybindingValue,
51 command: KeybindingCommand,
52 when: Schema.optional(KeybindingWhen),
3053});
54export type KeybindingRule = typeof KeybindingRule.Type;
3155
32export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
56export const KeybindingsConfig = Schema.Array(KeybindingRule).check(
57 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
58);
59export type KeybindingsConfig = typeof KeybindingsConfig.Type;
3360
34export 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();
61export 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});
69export type KeybindingShortcut = typeof KeybindingShortcut.Type;
4470
71export 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]);
4591export type KeybindingWhenNode =
4692 | { type: "identifier"; name: string }
4793 | { type: "not"; node: KeybindingWhenNode }
4894 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
4995 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
5096
51export 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
74export const resolvedKeybindingRuleSchema = z
75 .object({
76 command: keybindingCommandSchema,
77 shortcut: keybindingShortcutSchema,
78 whenAst: keybindingWhenNodeSchema.optional(),
79 })
80 .strict();
81
82export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
97export const ResolvedKeybindingRule = Schema.Struct({
98 command: KeybindingCommand,
99 shortcut: KeybindingShortcut,
100 whenAst: Schema.optional(KeybindingWhenNode),
101}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
102export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
83103
84export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
85export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
86export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
87export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>;
88export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>;
89export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>;
104export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
105 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
106);
107export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
packages/contracts/src/model.ts+4
......@@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug {
3737
3838 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
3939}
40
41export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
42export type ReasoningEffort = (typeof REASONING_OPTIONS)[number];
43export const DEFAULT_REASONING: ReasoningEffort = "high";
packages/contracts/src/orchestration.test.ts created+86
......@@ -0,0 +1,86 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3
4import {
5 OrchestrationGetTurnDiffInput,
6 ProjectCreateCommand,
7 ThreadTurnDiff,
8} from "./orchestration";
9
10const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
11const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
12const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
13
14it.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
26it.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
39it.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
53it.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
72it.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 @@
1import { Option, Schema, SchemaIssue, Struct } from "effect";
2import {
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
20export 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
28export const ORCHESTRATION_WS_CHANNELS = {
29 domainEvent: "orchestration.domainEvent",
30} as const;
31
32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
33export type ProviderKind = typeof ProviderKind.Type;
34export const ProviderApprovalPolicy = Schema.Literals([
35 "untrusted",
36 "on-failure",
37 "on-request",
38 "never",
39]);
40export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
41export const ProviderSandboxMode = Schema.Literals([
42 "read-only",
43 "workspace-write",
44 "danger-full-access",
45]);
46export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
47export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);
48export type ProviderRequestKind = typeof ProviderRequestKind.Type;
49export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
50export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type;
51export const ProviderApprovalDecision = Schema.Literals([
52 "accept",
53 "acceptForSession",
54 "decline",
55 "cancel",
56]);
57export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
58
59export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
60export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
61export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
62export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
63
64// Correlation id is command id by design in this model.
65export const CorrelationId = CommandId;
66export type CorrelationId = typeof CorrelationId.Type;
67
68export 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});
77export type ChatImageAttachment = typeof ChatImageAttachment.Type;
78
79export const ChatAttachment = Schema.Union([ChatImageAttachment]);
80export type ChatAttachment = typeof ChatAttachment.Type;
81
82export const ProjectScriptIcon = Schema.Literals([
83 "play",
84 "test",
85 "lint",
86 "configure",
87 "build",
88 "debug",
89]);
90export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
91
92export const ProjectScript = Schema.Struct({
93 id: TrimmedNonEmptyString,
94 name: TrimmedNonEmptyString,
95 command: TrimmedNonEmptyString,
96 icon: ProjectScriptIcon,
97 runOnWorktreeCreate: Schema.Boolean,
98});
99export type ProjectScript = typeof ProjectScript.Type;
100
101export 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});
111export type OrchestrationProject = typeof OrchestrationProject.Type;
112
113export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
114export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
115
116export 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});
126export type OrchestrationMessage = typeof OrchestrationMessage.Type;
127
128export const OrchestrationSessionStatus = Schema.Literals([
129 "idle",
130 "starting",
131 "running",
132 "ready",
133 "interrupted",
134 "stopped",
135 "error",
136]);
137export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type;
138
139export 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});
151export type OrchestrationSession = typeof OrchestrationSession.Type;
152
153export const OrchestrationCheckpointFile = Schema.Struct({
154 path: TrimmedNonEmptyString,
155 kind: TrimmedNonEmptyString,
156 additions: NonNegativeInt,
157 deletions: NonNegativeInt,
158});
159export type OrchestrationCheckpointFile = typeof OrchestrationCheckpointFile.Type;
160
161export const OrchestrationCheckpointStatus = Schema.Literals(["ready", "missing", "error"]);
162export type OrchestrationCheckpointStatus = typeof OrchestrationCheckpointStatus.Type;
163
164export 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});
173export type OrchestrationCheckpointSummary = typeof OrchestrationCheckpointSummary.Type;
174
175export const OrchestrationThreadActivityTone = Schema.Literals([
176 "info",
177 "tool",
178 "approval",
179 "error",
180]);
181export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type;
182
183export 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});
192export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
193
194export const OrchestrationLatestTurnState = Schema.Literals([
195 "running",
196 "interrupted",
197 "completed",
198 "error",
199]);
200export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type;
201
202export 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});
210export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
211
212export 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});
228export type OrchestrationThread = typeof OrchestrationThread.Type;
229
230export const OrchestrationReadModel = Schema.Struct({
231 snapshotSequence: NonNegativeInt,
232 projects: Schema.Array(OrchestrationProject),
233 threads: Schema.Array(OrchestrationThread),
234 updatedAt: IsoDateTime,
235});
236export type OrchestrationReadModel = typeof OrchestrationReadModel.Type;
237
238export 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
248export 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
258export const ProjectDeleteCommand = Schema.Struct({
259 type: Schema.Literal("project.delete"),
260 commandId: CommandId,
261 projectId: ProjectId,
262});
263
264export 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
276export const ThreadDeleteCommand = Schema.Struct({
277 type: Schema.Literal("thread.delete"),
278 commandId: CommandId,
279 threadId: ThreadId,
280});
281
282export 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
292export 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
310export 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
318export 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
327export 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
335export const ThreadSessionStopCommand = Schema.Struct({
336 type: Schema.Literal("thread.session.stop"),
337 commandId: CommandId,
338 threadId: ThreadId,
339 createdAt: IsoDateTime,
340});
341
342export 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]);
355export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
356
357export 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
365export 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
375export 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
384export 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
398export 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
406export 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
414export const InternalOrchestrationCommand = Schema.Union([
415 ThreadSessionSetCommand,
416 ThreadMessageAssistantDeltaCommand,
417 ThreadMessageAssistantCompleteCommand,
418 ThreadTurnDiffCompleteCommand,
419 ThreadActivityAppendCommand,
420 ThreadRevertCompleteCommand,
421]);
422export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
423
424export const OrchestrationCommand = Schema.Union([
425 ClientOrchestrationCommand,
426 InternalOrchestrationCommand,
427]);
428export type OrchestrationCommand = typeof OrchestrationCommand.Type;
429
430export 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]);
448export type OrchestrationEventType = typeof OrchestrationEventType.Type;
449
450export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]);
451export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type;
452export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]);
453
454export 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
464export 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
473export const ProjectDeletedPayload = Schema.Struct({
474 projectId: ProjectId,
475 deletedAt: IsoDateTime,
476});
477
478export 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
489export const ThreadDeletedPayload = Schema.Struct({
490 threadId: ThreadId,
491 deletedAt: IsoDateTime,
492});
493
494export 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
503export 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
515export 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
526export const ThreadTurnInterruptRequestedPayload = Schema.Struct({
527 threadId: ThreadId,
528 turnId: Schema.optional(TurnId),
529 createdAt: IsoDateTime,
530});
531
532export const ThreadApprovalResponseRequestedPayload = Schema.Struct({
533 threadId: ThreadId,
534 requestId: ApprovalRequestId,
535 decision: ProviderApprovalDecision,
536 createdAt: IsoDateTime,
537});
538
539export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
540 threadId: ThreadId,
541 turnCount: NonNegativeInt,
542 createdAt: IsoDateTime,
543});
544
545export const ThreadRevertedPayload = Schema.Struct({
546 threadId: ThreadId,
547 turnCount: NonNegativeInt,
548});
549
550export const ThreadSessionStopRequestedPayload = Schema.Struct({
551 threadId: ThreadId,
552 createdAt: IsoDateTime,
553});
554
555export const ThreadSessionSetPayload = Schema.Struct({
556 threadId: ThreadId,
557 session: OrchestrationSession,
558});
559
560export 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
571export const ThreadActivityAppendedPayload = Schema.Struct({
572 threadId: ThreadId,
573 activity: OrchestrationThreadActivity,
574});
575
576export 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});
585export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
586
587const 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
599const 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
613export 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]);
695export type OrchestrationEvent = typeof OrchestrationEvent.Type;
696
697export 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]);
779export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type;
780
781export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);
782export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;
783
784export 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
798export const ThreadTurnDiff = TurnCountRange.mapFields(
799 Struct.assign({
800 threadId: ThreadId,
801 diff: Schema.String,
802 }),
803 { unsafePreserveChecks: true },
804);
805
806export const ProviderSessionRuntimeStatus = Schema.Literals([
807 "starting",
808 "running",
809 "stopped",
810 "error",
811]);
812export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type;
813
814export const ProjectionThreadTurnStatus = Schema.Literals([
815 "running",
816 "completed",
817 "interrupted",
818 "error",
819]);
820export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type;
821
822export 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});
832export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type;
833
834export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]);
835export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type;
836
837export const ProjectionPendingApprovalDecision = Schema.NullOr(ProviderApprovalDecision);
838export type ProjectionPendingApprovalDecision = typeof ProjectionPendingApprovalDecision.Type;
839
840export const DispatchResult = Schema.Struct({
841 sequence: NonNegativeInt,
842});
843export type DispatchResult = typeof DispatchResult.Type;
844
845export const OrchestrationGetSnapshotInput = Schema.Struct({});
846export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type;
847export const OrchestrationGetSnapshotResult = OrchestrationReadModel;
848export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;
849
850export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
851 Struct.assign({ threadId: ThreadId }),
852 { unsafePreserveChecks: true },
853);
854export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
855
856export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
857export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type;
858
859export const OrchestrationGetFullThreadDiffInput = Schema.Struct({
860 threadId: ThreadId,
861 toTurnCount: NonNegativeInt,
862});
863export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;
864
865export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;
866export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type;
867
868export const OrchestrationReplayEventsInput = Schema.Struct({
869 fromSequenceExclusive: NonNegativeInt,
870});
871export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type;
872
873export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent);
874export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type;
875
876export 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 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 projectAddInputSchema,
5 projectAddResultSchema,
6 projectSearchEntriesInputSchema,
7 projectSearchEntriesResultSchema,
8 projectListResultSchema,
9 projectRemoveInputSchema,
10 projectUpdateScriptsInputSchema,
11 projectUpdateScriptsResultSchema,
12} from "./project";
13
14describe("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 @@
1import { z } from "zod";
2
3export const PROJECT_SCRIPT_ICON_VALUES = [
4 "play",
5 "test",
6 "lint",
7 "configure",
8 "build",
9 "debug",
10] as const;
11
12export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
13
14export 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
29export const projectScriptsSchema = z.array(projectScriptSchema).max(64);
30
31export 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
40export const projectListResultSchema = z.array(projectRecordSchema);
41
42export const projectAddInputSchema = z.object({
43 cwd: z.string().trim().min(1),
44});
45
46export const projectAddResultSchema = z.object({
47 project: projectRecordSchema,
48 created: z.boolean(),
49});
50
51export const projectRemoveInputSchema = z.object({
52 id: z.string().min(1),
53});
1import { Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
543
554export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
565
57export 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
63export const projectEntryKindSchema = z.enum(["file", "directory"]);
64
65export const projectEntrySchema = z.object({
66 path: z.string().min(1),
67 kind: projectEntryKindSchema,
68 parentPath: z.string().optional(),
6export 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)),
6910});
11export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
7012
71export const projectSearchEntriesResultSchema = z.object({
72 entries: z.array(projectEntrySchema),
73 truncated: z.boolean(),
74});
13export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
7514
76export const projectUpdateScriptsInputSchema = z.object({
77 id: z.string().min(1),
78 scripts: projectScriptsSchema,
15export const ProjectEntry = Schema.Struct({
16 path: TrimmedNonEmptyString,
17 kind: ProjectEntryKind,
18 parentPath: Schema.optional(TrimmedNonEmptyString),
7919});
20export type ProjectEntry = typeof ProjectEntry.Type;
8021
81export const projectUpdateScriptsResultSchema = z.object({
82 project: projectRecordSchema,
22export const ProjectSearchEntriesResult = Schema.Struct({
23 entries: Schema.Array(ProjectEntry),
24 truncated: Schema.Boolean,
8325});
84
85export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
86export 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 */
93export 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}
124export type ProjectRecord = z.infer<typeof projectRecordSchema>;
125export type ProjectListResult = z.infer<typeof projectListResultSchema>;
126export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
127export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
128export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
129export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>;
130export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
131export type ProjectEntry = z.infer<typeof projectEntrySchema>;
132export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
133export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>;
134export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>;
26export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
packages/contracts/src/provider.test.ts deleted-283
......@@ -1,283 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
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
18describe("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
64describe("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
136describe("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
169describe("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
190describe("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 @@
1import { z } from "zod";
2
3export const providerKindSchema = z.enum(["codex", "claudeCode"]);
4
5export const providerApprovalPolicySchema = z.enum([
6 "untrusted",
7 "on-failure",
8 "on-request",
9 "never",
10]);
11
12export const providerSandboxModeSchema = z.enum([
13 "read-only",
14 "workspace-write",
15 "danger-full-access",
16]);
17
18export const providerRequestKindSchema = z.enum(["command", "file-change"]);
19
20export const providerApprovalDecisionSchema = z.enum([
21 "accept",
22 "acceptForSession",
23 "decline",
24 "cancel",
25]);
26
27export const providerSessionStatusSchema = z.enum([
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
3import {
4 ApprovalRequestId,
5 EventId,
6 IsoDateTime,
7 NonNegativeInt,
8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ThreadId,
13} from "./baseSchemas";
14import {
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
26const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
27
28export const ProviderSessionStatus = Schema.Literals([
2829 "connecting",
2930 "ready",
3031 "running",
3132 "error",
3233 "closed",
3334]);
34
35export 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(),
35export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;
36
37export 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),
4649});
47
48export 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"),
50export type ProviderSession = typeof ProviderSession.Type;
51
52export 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),
5762});
58
59export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
60export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
61export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
62export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
63
64export 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),
63export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
64
65export 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),
7575});
76export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
7677
77export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [
78 providerSendTurnImageAttachmentSchema,
79]);
80
81export 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
102export const providerTurnStartResultSchema = z.object({
103 threadId: z.string().min(1),
104 turnId: z.string().min(1),
78export const ProviderTurnStartResult = Schema.Struct({
79 threadId: ProviderThreadId,
80 turnId: ProviderTurnId,
81 resumeCursor: Schema.optional(Schema.Unknown),
10582});
83export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
10684
107export const providerInterruptTurnInputSchema = z.object({
108 sessionId: z.string().min(1),
109 turnId: z.string().min(1).optional(),
85export const ProviderInterruptTurnInput = Schema.Struct({
86 sessionId: ProviderSessionId,
87 turnId: Schema.optional(ProviderTurnId),
11088});
89export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;
11190
112export const providerStopSessionInputSchema = z.object({
113 sessionId: z.string().min(1),
91export const ProviderStopSessionInput = Schema.Struct({
92 sessionId: ProviderSessionId,
11493});
94export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;
11595
116export const providerListCheckpointsInputSchema = z.object({
117 sessionId: z.string().min(1),
96export const ProviderListCheckpointsInput = Schema.Struct({
97 sessionId: ProviderSessionId,
11898});
119
120export 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(),
99export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;
100
101export 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,
127108});
109export type ProviderCheckpoint = typeof ProviderCheckpoint.Type;
128110
129export const providerListCheckpointsResultSchema = z.object({
130 threadId: z.string().min(1),
131 checkpoints: z.array(providerCheckpointSchema),
111export const ProviderListCheckpointsResult = Schema.Struct({
112 threadId: ThreadId,
113 checkpoints: Schema.Array(ProviderCheckpoint),
132114});
115export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;
133116
134export const providerRevertToCheckpointInputSchema = z.object({
135 sessionId: z.string().min(1),
136 turnCount: z.number().int().min(0),
117export const ProviderRevertToCheckpointInput = Schema.Struct({
118 sessionId: ProviderSessionId,
119 turnCount: NonNegativeInt,
137120});
138
139export 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),
121export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;
122
123export const ProviderRevertToCheckpointResult = Schema.Struct({
124 threadId: ThreadId,
125 turnCount: NonNegativeInt,
126 messageCount: NonNegativeInt,
127 rolledBackTurns: NonNegativeInt,
128 checkpoints: Schema.Array(ProviderCheckpoint),
145129});
130export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;
146131
147export 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
163export 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(),
132export const ProviderGetCheckpointDiffInput = Schema.Struct({
133 sessionId: ProviderSessionId,
134 ...TurnCountRange.fields,
168135});
136export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;
169137
170export const providerRespondToRequestInputSchema = z.object({
171 sessionId: z.string().min(1),
172 requestId: z.string().min(1),
173 decision: providerApprovalDecisionSchema,
138export const ProviderGetCheckpointDiffResult = Schema.Struct({
139 threadId: ThreadId,
140 ...TurnCountRange.fields,
141 diff: Schema.String,
174142});
143export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;
175144
176export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);
177
178export 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(),
145export const ProviderRespondToRequestInput = Schema.Struct({
146 sessionId: ProviderSessionId,
147 requestId: ApprovalRequestId,
148 decision: ProviderApprovalDecision,
149});
150export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type;
151
152export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
153export type ProviderEventKind = typeof ProviderEventKind.Type;
154
155export 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),
193170});
171export type ProviderEvent = typeof ProviderEvent.Type;
194172
195export type ProviderKind = z.infer<typeof providerKindSchema>;
196export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;
197export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
198export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;
199export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>;
200export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
201export type ProviderSession = z.infer<typeof providerSessionSchema>;
202export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
203export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>;
204export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
205export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
206export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
207export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
208export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
209export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
210export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>;
211export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>;
212export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>;
213export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>;
214export type ProviderRevertToCheckpointResult = z.infer<
215 typeof providerRevertToCheckpointResultSchema
216>;
217export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
218export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
219export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
220export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
221export type ProviderEvent = z.infer<typeof providerEventSchema>;
173export type ProviderSendTurnAttachment = typeof ChatAttachment.Type;
174export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type;
packages/contracts/src/providerRuntime.ts created+213
......@@ -0,0 +1,213 @@
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
3
4import {
5 ApprovalRequestId,
6 EventId,
7 NonNegativeInt,
8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 IsoDateTime,
13} from "./baseSchemas";
14import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration";
15
16const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
17
18export const ProviderRuntimeToolKind = Schema.Union([ProviderRequestKind, Schema.Literal("other")]);
19export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
20
21export const ProviderRuntimeTurnStatus = Schema.Literals([
22 "completed",
23 "failed",
24 "cancelled",
25 "interrupted",
26]);
27export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type;
28
29export 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});
38export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;
39
40export 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});
49export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;
50
51export 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});
59export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type;
60
61export 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});
70export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type;
71
72export 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});
83export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;
84
85export 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});
96export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type;
97
98export 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});
108export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type;
109
110export 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});
123export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;
124
125export 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});
138export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;
139
140export 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});
153export type ProviderRuntimeApprovalRequestedEvent =
154 typeof ProviderRuntimeApprovalRequestedEvent.Type;
155
156export 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});
169export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type;
170
171export 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});
182export type ProviderRuntimeCheckpointCapturedEvent =
183 typeof ProviderRuntimeCheckpointCapturedEvent.Type;
184
185export 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});
196export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
197
198export 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]);
213export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type;
packages/contracts/src/server.ts+41-11
......@@ -1,17 +1,47 @@
1import { z } from "zod";
2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
34
4export const serverConfigSchema = z.object({
5 cwd: z.string().min(1),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),
5export const KeybindingsMalformedConfigIssue = Schema.Struct({
6 kind: Schema.Literal("keybindings.malformed-config"),
7 message: TrimmedNonEmptyString,
78});
9export type KeybindingsMalformedConfigIssue = typeof KeybindingsMalformedConfigIssue.Type;
810
9export const serverUpsertKeybindingInputSchema = keybindingRuleSchema;
11export const KeybindingsInvalidEntryIssue = Schema.Struct({
12 kind: Schema.Literal("keybindings.invalid-entry"),
13 message: TrimmedNonEmptyString,
14 index: Schema.Number,
15});
16export type KeybindingsInvalidEntryIssue = typeof KeybindingsInvalidEntryIssue.Type;
17
18export const ServerConfigIssue = Schema.Union([
19 KeybindingsMalformedConfigIssue,
20 KeybindingsInvalidEntryIssue,
21]);
22export type ServerConfigIssue = typeof ServerConfigIssue.Type;
1023
11export const serverUpsertKeybindingResultSchema = z.object({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),
24export const ServerConfigIssues = Schema.Array(ServerConfigIssue);
25export type ServerConfigIssues = typeof ServerConfigIssues.Type;
26
27export const ServerConfig = Schema.Struct({
28 cwd: TrimmedNonEmptyString,
29 keybindingsConfigPath: TrimmedNonEmptyString,
30 keybindings: ResolvedKeybindingsConfig,
31 issues: ServerConfigIssues,
1332});
33export type ServerConfig = typeof ServerConfig.Type;
34
35export const ServerUpsertKeybindingInput = KeybindingRule;
36export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type;
1437
15export type ServerConfig = z.infer<typeof serverConfigSchema>;
16export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>;
17export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>;
38export const ServerUpsertKeybindingResult = Schema.Struct({
39 keybindings: ResolvedKeybindingsConfig,
40 issues: ServerConfigIssues,
41});
42export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;
43
44export const ServerConfigUpdatedPayload = Schema.Struct({
45 issues: ServerConfigIssues,
46});
47export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
packages/contracts/src/terminal.test.ts+127-102
......@@ -1,40 +1,56 @@
1import { Schema } from "effect";
12import { describe, expect, it } from "vitest";
23
34import {
45 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,
1314} from "./terminal";
1415
15describe("terminalOpenInputSchema", () => {
16function 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
20function 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
29describe("TerminalOpenInput", () => {
1630 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);
2439 });
2540
2641 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);
3450 });
3551
3652 it("defaults terminalId when missing", () => {
37 const parsed = terminalOpenInputSchema.parse({
53 const parsed = decodeSync(TerminalOpenInput, {
3854 threadId: "thread-1",
3955 cwd: "/tmp/project",
4056 cols: 100,
......@@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => {
4460 });
4561
4662 it("accepts optional env overrides", () => {
47 const parsed = terminalOpenInputSchema.parse({
63 const parsed = decodeSync(TerminalOpenInput, {
4864 threadId: "thread-1",
4965 cwd: "/tmp/project",
5066 cols: 100,
......@@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => {
6177 });
6278
6379 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);
7491 });
7592});
7693
77describe("terminalWriteInputSchema", () => {
94describe("TerminalWriteInput", () => {
7895 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);
84102 });
85103
86104 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);
92111 });
93112});
94113
95describe("terminalThreadInputSchema", () => {
114describe("TerminalThreadInput", () => {
96115 it("trims thread ids", () => {
97 const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " });
116 const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " });
98117 expect(parsed.threadId).toBe("thread-1");
99118 });
100119});
101120
102describe("terminalResizeInputSchema", () => {
121describe("TerminalResizeInput", () => {
103122 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);
110130 });
111131});
112132
113describe("terminalClearInputSchema", () => {
133describe("TerminalClearInput", () => {
114134 it("defaults terminal id", () => {
115 const parsed = terminalClearInputSchema.parse({
135 const parsed = decodeSync(TerminalClearInput, {
116136 threadId: "thread-1",
117137 });
118138 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
119139 });
120140});
121141
122describe("terminalCloseInputSchema", () => {
142describe("TerminalCloseInput", () => {
123143 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);
129150 });
130151});
131152
132describe("terminalSessionSnapshotSchema", () => {
153describe("TerminalSessionSnapshot", () => {
133154 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);
146168 });
147169});
148170
149describe("terminalEventSchema", () => {
171describe("TerminalEvent", () => {
150172 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);
159182 });
160183
161184 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);
171195 });
172196
173197 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);
182207 });
183208});
packages/contracts/src/terminal.ts+116-85
......@@ -1,119 +1,150 @@
1import { z } from "zod";
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
34export const DEFAULT_TERMINAL_ID = "default";
45
5const terminalColsSchema = z.number().int().min(20).max(400);
6const terminalRowsSchema = z.number().int().min(5).max(200);
7const terminalIdSchema = z.string().trim().min(1).max(128);
8const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128);
9const terminalEnvValueSchema = z.string().max(8_192);
10const terminalEnvSchema = z
11 .record(terminalEnvKeySchema, terminalEnvValueSchema)
12 .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars");
13
14export const terminalThreadInputSchema = z.object({
15 threadId: z.string().trim().min(1),
6const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
7const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
8 Schema.isLessThanOrEqualTo(400),
9);
10const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
11 Schema.isLessThanOrEqualTo(200),
12);
13const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
14const TerminalEnvKeySchema = Schema.String.check(
15 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
16).check(Schema.isMaxLength(128));
17const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
18const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
19 Schema.isMaxProperties(128),
20);
21
22const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
23 Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID),
24);
25
26export const TerminalThreadInput = Schema.Struct({
27 threadId: TrimmedNonEmptyStringSchema,
1628});
29export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>;
1730
18export const terminalSessionInputSchema = terminalThreadInputSchema.extend({
19 terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID),
31export const TerminalSessionInput = Schema.Struct({
32 ...TerminalThreadInput.fields,
33 terminalId: TerminalIdWithDefaultSchema,
2034});
21
22export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
23 cwd: z.string().trim().min(1),
24 cols: terminalColsSchema.optional(),
25 rows: terminalRowsSchema.optional(),
26 env: terminalEnvSchema.optional(),
35export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
36
37export 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),
2743});
44export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;
2845
29export const terminalWriteInputSchema = terminalSessionInputSchema.extend({
30 data: z.string().min(1).max(65_536),
46export const TerminalWriteInput = Schema.Struct({
47 ...TerminalSessionInput.fields,
48 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
3149});
50export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>;
3251
33export const terminalResizeInputSchema = terminalSessionInputSchema.extend({
34 cols: terminalColsSchema,
35 rows: terminalRowsSchema,
52export const TerminalResizeInput = Schema.Struct({
53 ...TerminalSessionInput.fields,
54 cols: TerminalColsSchema,
55 rows: TerminalRowsSchema,
3656});
57export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>;
3758
38export const terminalClearInputSchema = terminalSessionInputSchema;
59export const TerminalClearInput = TerminalSessionInput;
60export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>;
3961
40export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
41 terminalId: terminalIdSchema.optional(),
42 deleteHistory: z.boolean().optional(),
62export const TerminalRestartInput = Schema.Struct({
63 ...TerminalThreadInput.fields,
64 cwd: TrimmedNonEmptyStringSchema,
65 cols: TerminalColsSchema,
66 rows: TerminalRowsSchema,
67 env: Schema.optional(TerminalEnvSchema),
4368});
4469
45export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]);
46
47export 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(),
70export const TerminalCloseInput = Schema.Struct({
71 ...TerminalThreadInput.fields,
72 terminalId: Schema.optional(TerminalIdSchema),
73 deleteHistory: Schema.optional(Schema.Boolean),
5774});
75export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>;
76
77export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
78export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
79
80export 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});
91export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
5892
59const terminalEventBaseSchema = z.object({
60 threadId: z.string().min(1),
61 terminalId: z.string().min(1),
62 createdAt: z.string().datetime(),
93const TerminalEventBaseSchema = Schema.Struct({
94 threadId: Schema.String.check(Schema.isNonEmpty()),
95 terminalId: Schema.String.check(Schema.isNonEmpty()),
96 createdAt: Schema.String,
6397});
6498
65export const terminalStartedEventSchema = terminalEventBaseSchema.extend({
66 type: z.literal("started"),
67 snapshot: terminalSessionSnapshotSchema,
99export const TerminalStartedEvent = Schema.Struct({
100 ...TerminalEventBaseSchema.fields,
101 type: Schema.Literal("started"),
102 snapshot: TerminalSessionSnapshot,
68103});
69104
70export const terminalOutputEventSchema = terminalEventBaseSchema.extend({
71 type: z.literal("output"),
72 data: z.string(),
105export const TerminalOutputEvent = Schema.Struct({
106 ...TerminalEventBaseSchema.fields,
107 type: Schema.Literal("output"),
108 data: Schema.String,
73109});
74110
75export const terminalExitedEventSchema = terminalEventBaseSchema.extend({
76 type: z.literal("exited"),
77 exitCode: z.number().int().nullable(),
78 exitSignal: z.number().int().nullable(),
111export const TerminalExitedEvent = Schema.Struct({
112 ...TerminalEventBaseSchema.fields,
113 type: Schema.Literal("exited"),
114 exitCode: Schema.NullOr(Schema.Int),
115 exitSignal: Schema.NullOr(Schema.Int),
79116});
80117
81export const terminalErrorEventSchema = terminalEventBaseSchema.extend({
82 type: z.literal("error"),
83 message: z.string().min(1),
118export const TerminalErrorEvent = Schema.Struct({
119 ...TerminalEventBaseSchema.fields,
120 type: Schema.Literal("error"),
121 message: Schema.String.check(Schema.isNonEmpty()),
84122});
85123
86export const terminalClearedEventSchema = terminalEventBaseSchema.extend({
87 type: z.literal("cleared"),
124export const TerminalClearedEvent = Schema.Struct({
125 ...TerminalEventBaseSchema.fields,
126 type: Schema.Literal("cleared"),
88127});
89128
90export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({
91 type: z.literal("restarted"),
92 snapshot: terminalSessionSnapshotSchema,
129export const TerminalRestartedEvent = Schema.Struct({
130 ...TerminalEventBaseSchema.fields,
131 type: Schema.Literal("restarted"),
132 snapshot: TerminalSessionSnapshot,
93133});
94134
95export const terminalActivityEventSchema = terminalEventBaseSchema.extend({
96 type: z.literal("activity"),
97 hasRunningSubprocess: z.boolean(),
135export const TerminalActivityEvent = Schema.Struct({
136 ...TerminalEventBaseSchema.fields,
137 type: Schema.Literal("activity"),
138 hasRunningSubprocess: Schema.Boolean,
98139});
99140
100export const terminalEventSchema = z.discriminatedUnion("type", [
101 terminalStartedEventSchema,
102 terminalOutputEventSchema,
103 terminalExitedEventSchema,
104 terminalErrorEventSchema,
105 terminalClearedEventSchema,
106 terminalRestartedEventSchema,
107 terminalActivityEventSchema,
141export const TerminalEvent = Schema.Union([
142 TerminalStartedEvent,
143 TerminalOutputEvent,
144 TerminalExitedEvent,
145 TerminalErrorEvent,
146 TerminalClearedEvent,
147 TerminalRestartedEvent,
148 TerminalActivityEvent,
108149]);
109
110export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
111export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>;
112export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
113export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
114export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
115export type TerminalClearInput = z.input<typeof terminalClearInputSchema>;
116export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
117export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
118export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;
119export type TerminalEvent = z.infer<typeof terminalEventSchema>;
150export type TerminalEvent = typeof TerminalEvent.Type;
packages/contracts/src/todo.test.ts deleted-52
......@@ -1,52 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { newTodoInputSchema, todoSchema } from "./todo";
4
5describe("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
37describe("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 @@
1import { z } from "zod";
2
3export 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
10export const todoListSchema = z.array(todoSchema);
11
12export const newTodoInputSchema = z.object({
13 title: z.string().trim().min(1).max(280),
14});
15
16export const todoIdSchema = z.string().min(1);
17
18export type Todo = z.infer<typeof todoSchema>;
19export type NewTodoInput = z.infer<typeof newTodoInputSchema>;
packages/contracts/src/ws.test.ts created+58
......@@ -0,0 +1,58 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3
4import { ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WebSocketRequest } from "./ws";
6
7const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8
9it.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
24it.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
41it.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 @@
1import { z } from "zod";
1import { Schema, Struct } from "effect";
2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
3
4import {
5 ClientOrchestrationCommand,
6 OrchestrationGetFullThreadDiffInput,
7 ORCHESTRATION_WS_METHODS,
8 OrchestrationGetSnapshotInput,
9 OrchestrationGetTurnDiffInput,
10 OrchestrationReplayEventsInput,
11} from "./orchestration";
12import {
13 GitCheckoutInput,
14 GitCreateBranchInput,
15 GitCreateWorktreeInput,
16 GitInitInput,
17 GitListBranchesInput,
18 GitPullInput,
19 GitRemoveWorktreeInput,
20 GitRunStackedActionInput,
21 GitStatusInput,
22} from "./git";
23import {
24 TerminalClearInput,
25 TerminalCloseInput,
26 TerminalOpenInput,
27 TerminalResizeInput,
28 TerminalRestartInput,
29 TerminalWriteInput,
30} from "./terminal";
31import { KeybindingRule } from "./keybindings";
32import { ProjectSearchEntriesInput } from "./project";
33import { OpenInEditorInput } from "./editor";
234
335// ── WebSocket RPC Method Names ───────────────────────────────────────
436
537export 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
1738 // Project registry methods
1839 projectsList: "projects.list",
1940 projectsAdd: "projects.add",
2041 projectsRemove: "projects.remove",
2142 projectsSearchEntries: "projects.searchEntries",
22 projectsUpdateScripts: "projects.updateScripts",
2343
2444 // Shell methods
2545 shellOpenInEditor: "shell.openInEditor",
......@@ -51,54 +71,99 @@ export const WS_METHODS = {
5171// ── Push Event Channels ──────────────────────────────────────────────
5272
5373export const WS_CHANNELS = {
54 providerEvent: "providers.event",
5574 terminalEvent: "terminal.event",
5675 serverWelcome: "server.welcome",
76 serverConfigUpdated: "server.configUpdated",
5777} as const;
5878
59// ── Client → Server (request) ────────────────────────────────────────
60
61export const wsRequestSchema = z.object({
62 id: z.string().min(1),
63 method: z.string().min(1),
64 params: z.unknown().optional(),
65});
66
67export type WsRequest = z.infer<typeof wsRequestSchema>;
79// -- Tagged Union of all request body schemas ─────────────────────────
80
81const 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
91export 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),
68104
69// ── Server → Client (response to request) ────────────────────────────
105 // Shell methods
106 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),
70107
71export 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),
80118
81export 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),
82126
83// ── Server → Client (push event) ─────────────────────────────────────
127 // Server meta
128 tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})),
129 tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),
130]);
84131
85export const wsPushSchema = z.object({
86 type: z.literal("push"),
87 channel: z.string().min(1),
88 data: z.unknown(),
132export const WebSocketRequest = Schema.Struct({
133 id: TrimmedNonEmptyString,
134 body: WebSocketRequestBody,
89135});
136export type WebSocketRequest = typeof WebSocketRequest.Type;
137
138export 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});
147export type WebSocketResponse = typeof WebSocketResponse.Type;
90148
91export type WsPush = z.infer<typeof wsPushSchema>;
149export const WsPush = Schema.Struct({
150 type: Schema.Literal("push"),
151 channel: TrimmedNonEmptyString,
152 data: Schema.Unknown,
153});
154export type WsPush = typeof WsPush.Type;
92155
93156// ── Union of all server → client messages ─────────────────────────────
94157
95export type WsServerMessage = WsResponse | WsPush;
158export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
159export type WsResponse = typeof WsResponse.Type;
96160
97161// ── Server welcome payload ───────────────────────────────────────────
98162
99export const wsWelcomePayloadSchema = z.object({
100 cwd: z.string().min(1),
101 projectName: z.string().min(1),
163export const WsWelcomePayload = Schema.Struct({
164 cwd: TrimmedNonEmptyString,
165 projectName: TrimmedNonEmptyString,
166 bootstrapProjectId: Schema.optional(ProjectId),
167 bootstrapThreadId: Schema.optional(ThreadId),
102168});
103
104export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>;
169export type WsWelcomePayload = typeof WsWelcomePayload.Type;
packages/contracts/tsconfig.json+14
......@@ -1,4 +1,18 @@
11{
22 "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 },
317 "include": ["src"]
418}
scripts/build-desktop-dmg.mjs+51-2
......@@ -129,7 +129,14 @@ async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) {
129129 for (const size of iconSizes) {
130130 await run(
131131 "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 ],
133140 { stdio: "ignore" },
134141 );
135142 const retinaSize = size * 2;
......@@ -199,6 +206,42 @@ async function validateBundledClientAssets(clientDir) {
199206 }
200207}
201208
209function 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
223function 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
202245async function main() {
203246 const options = parseArgs(process.argv.slice(2));
204247 if (options.help) {
......@@ -210,6 +253,7 @@ async function main() {
210253 fail("This script only builds macOS .dmg artifacts and must run on macOS.");
211254 }
212255
256 const rootPkg = await readJson(path.join(repoRoot, "package.json"));
213257 const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json"));
214258 const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json"));
215259
......@@ -222,6 +266,11 @@ async function main() {
222266 if (!dependencies || Object.keys(dependencies).length === 0) {
223267 fail("Could not resolve production dependencies from apps/server/package.json.");
224268 }
269 const resolvedDependencies = resolveCatalogDependencies(
270 dependencies,
271 readWorkspaceCatalog(rootPkg),
272 "apps/server",
273 );
225274
226275 const appVersion = options.version ?? serverPkg.version ?? "0.1.0";
227276 const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage");
......@@ -284,7 +333,7 @@ async function main() {
284333 category: "public.app-category.developer-tools",
285334 },
286335 },
287 dependencies,
336 dependencies: resolvedDependencies,
288337 devDependencies: {
289338 electron: electronVersion,
290339 },
scripts/dev-runner.mjs+176-26
......@@ -1,8 +1,12 @@
11import { spawn } from "node:child_process";
2import { homedir } from "node:os";
3import path from "node:path";
4import { pathToFileURL } from "node:url";
25
36const BASE_SERVER_PORT = 3773;
4const BASE_WEB_PORT = 5173;
7const BASE_WEB_PORT = 5733;
58const MAX_HASH_OFFSET = 3000;
9export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
610const MODE_ARGS = {
711 dev: [
812 "run",
......@@ -17,6 +21,21 @@ const MODE_ARGS = {
1721 "dev:web": ["run", "dev", "--filter=@t3tools/web"],
1822 "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"],
1923};
24const 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};
2039
2140function parseInteger(value, envName) {
2241 const parsed = Number(value);
......@@ -35,8 +54,8 @@ function hashSeed(seed) {
3554 return hash >>> 0;
3655}
3756
38function resolveOffset() {
39 const explicitOffset = process.env.T3CODE_PORT_OFFSET?.trim();
57export function resolveOffset(env = process.env) {
58 const explicitOffset = env.T3CODE_PORT_OFFSET?.trim();
4059 if (explicitOffset) {
4160 const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET");
4261 if (parsed < 0) {
......@@ -45,7 +64,7 @@ function resolveOffset() {
4564 return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` };
4665 }
4766
48 const seed = process.env.T3CODE_DEV_INSTANCE?.trim();
67 const seed = env.T3CODE_DEV_INSTANCE?.trim();
4968 if (!seed) {
5069 return { offset: 0, source: "default ports" };
5170 }
......@@ -58,9 +77,141 @@ function resolveOffset() {
5877 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
5978}
6079
61function main() {
62 const mode = process.argv[2];
63 const isDryRun = process.argv.includes("--dry-run");
80function 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
93function 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
100export 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
154function 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
169export 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
212export function runDevRunner(argv = process.argv) {
213 const mode = argv[2];
214 const parsedArgs = parseDevRunnerArgs(argv.slice(3));
64215 if (!mode || !(mode in MODE_ARGS)) {
65216 const supportedModes = Object.keys(MODE_ARGS).join(", ");
66217 throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`);
......@@ -76,28 +227,23 @@ function main() {
76227 );
77228 }
78229
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 });
90236
91237 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}`,
93239 );
94240
95 if (isDryRun) {
241 if (parsedArgs.isDryRun) {
96242 return;
97243 }
98244
99245 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], {
101247 stdio: "inherit",
102248 env,
103249 });
......@@ -116,9 +262,13 @@ function main() {
116262 });
117263}
118264
119try {
120 main();
121} catch (error) {
122 console.error("[dev-runner]", error);
123 process.exit(1);
265const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
266
267if (import.meta.url === entrypointPath) {
268 try {
269 runDevRunner();
270 } catch (error) {
271 console.error("[dev-runner]", error);
272 process.exit(1);
273 }
124274}
scripts/dev-runner.test.ts created+125
......@@ -0,0 +1,125 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_DEV_STATE_DIR,
5 createDevRunnerEnv,
6 parseDevRunnerArgs,
7} from "./dev-runner.mjs";
8
9describe("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
41describe("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 @@
2525 },
2626 "typecheck": {
2727 "dependsOn": ["^typecheck"],
28 "outputs": []
28 "outputs": [],
29 "cache": false
2930 },
3031 "test": {
3132 "dependsOn": ["^build"],
33 "cache": false,
3234 "outputs": []
3335 }
3436 }