authorgravatar for jason.laster.11@gmail.comJason Laster <jason.laster.11@gmail.com> 2026-03-10 00:17:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 00:17:18-07:00
log223c3dc8005b583b19c502320f5de929958a7efd
treef2dfa92ecf3dcc712e6e43821e286003f7576b54
parentafb6e89bfdd9164c142162597bb865540b1909d2
signature Signed by PGP key B5690EEEBB952194

chore(ci): add and apply `fmt:check` script (#779)

* Add formatting conventions * Add a fmt lint check * rely on defaults --------- Co-authored-by: Julius Marminge <julius0216@outlook.com>

106 files changed, 2137 insertions(+), 1868 deletions(-)

.github/workflows/ci.yml+4-1
...@@ -8,7 +8,7 @@ on:...@@ -8,7 +8,7 @@ on:
88
9jobs:9jobs:
10 quality:10 quality:
11 name: Lint, Typecheck, Test, Browser Test, Build11 name: Format, Lint, Typecheck, Test, Browser Test, Build
12 runs-on: blacksmith-4vcpu-ubuntu-240412 runs-on: blacksmith-4vcpu-ubuntu-2404
13 steps:13 steps:
14 - name: Checkout14 - name: Checkout
...@@ -45,6 +45,9 @@ jobs:...@@ -45,6 +45,9 @@ jobs:
45 - name: Install dependencies45 - name: Install dependencies
46 run: bun install --frozen-lockfile46 run: bun install --frozen-lockfile
4747
48 - name: Format
49 run: bun run fmt:check
50
48 - name: Lint51 - name: Lint
49 run: bun run lint52 run: bun run lint
5053
.oxfmtrc.json+3-2
...@@ -7,7 +7,8 @@...@@ -7,7 +7,8 @@
7 "node_modules",7 "node_modules",
8 "bun.lock",8 "bun.lock",
9 "*.tsbuildinfo",9 "*.tsbuildinfo",
10 "**/routeTree.gen.ts"10 "**/routeTree.gen.ts",
11 "apps/web/public/mockServiceWorker.js"
11 ],12 ],
12 "experimentalSortPackageJson": {}13 "sortPackageJson": {}
13}14}
.plans/01-shared-model-normalization.md+7
...@@ -1,20 +1,24 @@...@@ -1,20 +1,24 @@
1# Plan: Centralize Model Normalization in Contracts1# Plan: Centralize Model Normalization in Contracts
22
3## Summary3## Summary
4
4Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth.5Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth.
56
6## Motivation7## Motivation
8
7- Removes duplicated logic between:9- Removes duplicated logic between:
8 - `apps/desktop/src/codexAppServerManager.ts`10 - `apps/desktop/src/codexAppServerManager.ts`
9 - `apps/renderer/src/model-logic.ts`11 - `apps/renderer/src/model-logic.ts`
10- Prevents behavior drift when model aliases/defaults are updated.12- Prevents behavior drift when model aliases/defaults are updated.
1113
12## Scope14## Scope
15
13- Add shared model utilities to contracts.16- Add shared model utilities to contracts.
14- Update desktop and renderer to consume shared utilities.17- Update desktop and renderer to consume shared utilities.
15- Keep renderer-specific display options in renderer.18- Keep renderer-specific display options in renderer.
1619
17## Proposed Changes20## Proposed Changes
21
181. Add `packages/contracts/src/model.ts` with:221. Add `packages/contracts/src/model.ts` with:
19 - Canonical model list23 - Canonical model list
20 - Alias map24 - Alias map
...@@ -29,14 +33,17 @@ Move model alias/default normalization into `packages/contracts` so desktop and...@@ -29,14 +33,17 @@ Move model alias/default normalization into `packages/contracts` so desktop and
29 - Keep renderer tests focused on renderer-only behavior.33 - Keep renderer tests focused on renderer-only behavior.
3034
31## Risks35## Risks
36
32- Desktop/renderer may currently rely on slightly different fallback behavior.37- Desktop/renderer may currently rely on slightly different fallback behavior.
33- Import graph must avoid bundling issues for Electron main/preload.38- Import graph must avoid bundling issues for Electron main/preload.
3439
35## Validation40## Validation
41
36- `bun run test`42- `bun run test`
37- `bun run typecheck`43- `bun run typecheck`
38- Manual check that model selection and session start still send expected model slug.44- Manual check that model selection and session start still send expected model slug.
3945
40## Done Criteria46## Done Criteria
47
41- No duplicated alias/default map in desktop and renderer.48- No duplicated alias/default map in desktop and renderer.
42- Shared model utilities are contract-tested.49- Shared model utilities are contract-tested.
.plans/02-typed-ipc-boundaries.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Strengthen Typed IPC Boundaries in Main Process1# Plan: Strengthen Typed IPC Boundaries in Main Process
22
3## Summary3## Summary
4
4Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers.5Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers.
56
6## Motivation7## Motivation
8
7- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`.9- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`.
8- Casts can hide contract breakages until runtime.10- Casts can hide contract breakages until runtime.
911
10## Scope12## Scope
13
11- Desktop main process IPC registration.14- Desktop main process IPC registration.
12- Optional shared helper for handler registration.15- Optional shared helper for handler registration.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to:191. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to:
16 - Parse payload(s) with Zod schemas20 - Parse payload(s) with Zod schemas
17 - Standardize typed handler signatures21 - Standardize typed handler signatures
...@@ -24,14 +28,17 @@ Replace loose payload casting in IPC handlers with strict schema parsing and typ...@@ -24,14 +28,17 @@ Replace loose payload casting in IPC handlers with strict schema parsing and typ
244. Add tests for handler parsing failure paths (invalid payloads).284. Add tests for handler parsing failure paths (invalid payloads).
2529
26## Risks30## Risks
31
27- Refactor can subtly change IPC error shape/messages.32- Refactor can subtly change IPC error shape/messages.
28- Helper abstraction should stay simple and not obscure control flow.33- Helper abstraction should stay simple and not obscure control flow.
2934
30## Validation35## Validation
36
31- `bun run test`37- `bun run test`
32- `bun run typecheck`38- `bun run typecheck`
33- Manual invalid payload check from renderer/devtools to confirm fast failure.39- Manual invalid payload check from renderer/devtools to confirm fast failure.
3440
35## Done Criteria41## Done Criteria
42
36- No provider handler uses `payload as Parameters<...>`.43- No provider handler uses `payload as Parameters<...>`.
37- All IPC entrypoints parse unknown payloads at boundary.44- All IPC entrypoints parse unknown payloads at boundary.
.plans/03-split-codex-app-server-manager.md+7
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1# Plan: Decompose CodexAppServerManager1# Plan: Decompose CodexAppServerManager
22
3## Summary3## Summary
4
4Split `CodexAppServerManager` into smaller modules with clear responsibilities.5Split `CodexAppServerManager` into smaller modules with clear responsibilities.
56
6## Motivation7## Motivation
8
7- `apps/desktop/src/codexAppServerManager.ts` is large and mixes:9- `apps/desktop/src/codexAppServerManager.ts` is large and mixes:
8 - Process lifecycle10 - Process lifecycle
9 - JSON-RPC parsing/routing11 - JSON-RPC parsing/routing
...@@ -12,10 +14,12 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities....@@ -12,10 +14,12 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities.
12- This increases regression risk and slows changes.14- This increases regression risk and slows changes.
1315
14## Scope16## Scope
17
15- Desktop provider internals only.18- Desktop provider internals only.
16- Keep external behavior/API stable.19- Keep external behavior/API stable.
1720
18## Proposed Changes21## Proposed Changes
22
191. Extract modules:231. Extract modules:
20 - `codex/processLifecycle.ts`24 - `codex/processLifecycle.ts`
21 - `codex/jsonrpcRouter.ts`25 - `codex/jsonrpcRouter.ts`
...@@ -29,13 +33,16 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities....@@ -29,13 +33,16 @@ Split `CodexAppServerManager` into smaller modules with clear responsibilities.
29 - Session state transitions33 - Session state transitions
3034
31## Risks35## Risks
36
32- Reordering event handling can change behavior.37- Reordering event handling can change behavior.
33- Must preserve pending request timeout/cancellation semantics.38- Must preserve pending request timeout/cancellation semantics.
3439
35## Validation40## Validation
41
36- Existing tests pass.42- Existing tests pass.
37- Add module-level tests for parsing and transition logic.43- Add module-level tests for parsing and transition logic.
3844
39## Done Criteria45## Done Criteria
46
40- Main manager file materially smaller and orchestration-focused.47- Main manager file materially smaller and orchestration-focused.
41- Core protocol/state logic covered by focused tests.48- Core protocol/state logic covered by focused tests.
.plans/04-split-chatview-component.md+7
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1# Plan: Split ChatView into Smaller UI/Logic Units1# Plan: Split ChatView into Smaller UI/Logic Units
22
3## Summary3## Summary
4
4Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.5Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
56
6## Motivation7## Motivation
8
7- `apps/renderer/src/components/ChatView.tsx` is large and handles:9- `apps/renderer/src/components/ChatView.tsx` is large and handles:
8 - Session orchestration10 - Session orchestration
9 - Send/interrupt actions11 - Send/interrupt actions
...@@ -13,10 +15,12 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities....@@ -13,10 +15,12 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
13- Hard to test and maintain as one component.15- Hard to test and maintain as one component.
1416
15## Scope17## Scope
18
16- Renderer component boundaries and hooks.19- Renderer component boundaries and hooks.
17- Keep visual behavior unchanged.20- Keep visual behavior unchanged.
1821
19## Proposed Changes22## Proposed Changes
23
201. Create hook: `apps/renderer/src/hooks/useChatSession.ts`241. Create hook: `apps/renderer/src/hooks/useChatSession.ts`
21 - `ensureSession`25 - `ensureSession`
22 - `sendTurn`26 - `sendTurn`
...@@ -29,12 +33,15 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities....@@ -29,12 +33,15 @@ Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
294. Add focused tests for hook behavior (error handling, session reuse).334. Add focused tests for hook behavior (error handling, session reuse).
3034
31## Risks35## Risks
36
32- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send).37- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send).
3338
34## Validation39## Validation
40
35- `bun run test`41- `bun run test`
36- Manual smoke: send, stream, interrupt, model switch.42- Manual smoke: send, stream, interrupt, model switch.
3743
38## Done Criteria44## Done Criteria
45
39- `ChatView.tsx` significantly reduced and easier to scan.46- `ChatView.tsx` significantly reduced and easier to scan.
40- Session logic isolated from rendering.47- Session logic isolated from rendering.
.plans/05-zod-persisted-state-validation.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Move Renderer Persisted-State Validation to Zod1# Plan: Move Renderer Persisted-State Validation to Zod
22
3## Summary3## Summary
4
4Use explicit Zod schemas for localStorage state parsing and migration.5Use explicit Zod schemas for localStorage state parsing and migration.
56
6## Motivation7## Motivation
8
7- `apps/renderer/src/store.ts` has large manual sanitize functions.9- `apps/renderer/src/store.ts` has large manual sanitize functions.
8- Manual type guards are verbose and easier to get wrong during schema evolution.10- Manual type guards are verbose and easier to get wrong during schema evolution.
911
10## Scope12## Scope
13
11- Renderer state hydration/persistence path.14- Renderer state hydration/persistence path.
12- No backend/protocol changes.15- No backend/protocol changes.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add schema module: `apps/renderer/src/persistenceSchema.ts`191. Add schema module: `apps/renderer/src/persistenceSchema.ts`
16 - Persisted payload versions (`v1`, `v2`)20 - Persisted payload versions (`v1`, `v2`)
17 - Thread/message/project schemas21 - Thread/message/project schemas
...@@ -23,12 +27,15 @@ Use explicit Zod schemas for localStorage state parsing and migration....@@ -23,12 +27,15 @@ Use explicit Zod schemas for localStorage state parsing and migration.
23 - Unknown thread/project references filtered27 - Unknown thread/project references filtered
2428
25## Risks29## Risks
30
26- Overly strict schemas could drop valid historical data unexpectedly.31- Overly strict schemas could drop valid historical data unexpectedly.
2732
28## Validation33## Validation
34
29- Unit tests for migration/hydration.35- Unit tests for migration/hydration.
30- Manual reload test with existing localStorage data.36- Manual reload test with existing localStorage data.
3137
32## Done Criteria38## Done Criteria
39
33- Store hydration logic is schema-driven.40- Store hydration logic is schema-driven.
34- Migration behavior is tested and documented.41- Migration behavior is tested and documented.
.plans/06-provider-logstream-lifecycle.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Add Provider Log Stream Lifecycle Management1# Plan: Add Provider Log Stream Lifecycle Management
22
3## Summary3## Summary
4
4Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely.5Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely.
56
6## Motivation7## Motivation
8
7- `apps/desktop/src/providerManager.ts` opens a write stream in constructor.9- `apps/desktop/src/providerManager.ts` opens a write stream in constructor.
8- Stream lifecycle is not explicit on shutdown.10- Stream lifecycle is not explicit on shutdown.
911
10## Scope12## Scope
13
11- Desktop provider logging behavior.14- Desktop provider logging behavior.
12- App shutdown integration.15- App shutdown integration.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add explicit `dispose()` on `ProviderManager`:191. Add explicit `dispose()` on `ProviderManager`:
16 - Remove event listeners20 - Remove event listeners
17 - End/close log stream21 - End/close log stream
...@@ -20,12 +24,15 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and...@@ -20,12 +24,15 @@ Ensure `ProviderManager` logging stream is initialized, rotated/structured, and
204. Optional: per-session log files under `.logs/providers/`.244. Optional: per-session log files under `.logs/providers/`.
2125
22## Risks26## Risks
27
23- Improper close sequencing may lose final log lines.28- Improper close sequencing may lose final log lines.
2429
25## Validation30## Validation
31
26- Manual run/quit cycle to ensure no open handle warnings.32- Manual run/quit cycle to ensure no open handle warnings.
27- Confirm logs flush on quit and file descriptors are not leaked.33- Confirm logs flush on quit and file descriptors are not leaked.
2834
29## Done Criteria35## Done Criteria
36
30- ProviderManager owns complete log stream lifecycle.37- ProviderManager owns complete log stream lifecycle.
31- Shutdown path explicitly disposes provider resources.38- Shutdown path explicitly disposes provider resources.
.plans/07-ci-quality-gates.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Add CI Workflow for Core Quality Gates1# Plan: Add CI Workflow for Core Quality Gates
22
3## Summary3## Summary
4
4Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs.5Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs.
56
6## Motivation7## Motivation
8
7- Repository currently has no CI workflow files.9- Repository currently has no CI workflow files.
8- Quality checks are only local/manual.10- Quality checks are only local/manual.
911
10## Scope12## Scope
13
11- `.github/workflows/ci.yml`14- `.github/workflows/ci.yml`
12- Bun + Turbo setup in CI.15- Bun + Turbo setup in CI.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add `ci.yml` with jobs:191. Add `ci.yml` with jobs:
16 - Setup Bun and Node environment20 - Setup Bun and Node environment
17 - Install deps21 - Install deps
...@@ -22,13 +26,16 @@ Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-tes...@@ -22,13 +26,16 @@ Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-tes
223. Configure caching for Bun/Turbo as appropriate.263. Configure caching for Bun/Turbo as appropriate.
2327
24## Risks28## Risks
29
25- Smoke test may be flaky in headless CI environments.30- Smoke test may be flaky in headless CI environments.
26- CI runtime can grow if caching is misconfigured.31- CI runtime can grow if caching is misconfigured.
2732
28## Validation33## Validation
34
29- Verify workflow runs on a branch PR.35- Verify workflow runs on a branch PR.
30- Ensure failures surface clearly by job name.36- Ensure failures surface clearly by job name.
3137
32## Done Criteria38## Done Criteria
39
33- CI blocks regressions in lint/typecheck/test.40- CI blocks regressions in lint/typecheck/test.
34- Workflow docs added to README.41- Workflow docs added to README.
.plans/08-precommit-format-and-lint.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Add Pre-Commit Formatting/Lint Hooks1# Plan: Add Pre-Commit Formatting/Lint Hooks
22
3## Summary3## Summary
4
4Introduce pre-commit automation so formatting and basic lint checks happen before commits.5Introduce pre-commit automation so formatting and basic lint checks happen before commits.
56
6## Motivation7## Motivation
8
7- Current lint failures include formatting-only issues.9- Current lint failures include formatting-only issues.
8- Shift-left feedback reduces noisy CI failures and cleanup churn.10- Shift-left feedback reduces noisy CI failures and cleanup churn.
911
10## Scope12## Scope
13
11- Root tooling config and package scripts.14- Root tooling config and package scripts.
12- No runtime code changes.15- No runtime code changes.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add hook tooling (e.g. Husky + lint-staged or Lefthook).191. Add hook tooling (e.g. Husky + lint-staged or Lefthook).
162. Configure staged-file tasks:202. Configure staged-file tasks:
17 - `biome format --write`21 - `biome format --write`
...@@ -20,13 +24,16 @@ Introduce pre-commit automation so formatting and basic lint checks happen befor...@@ -20,13 +24,16 @@ Introduce pre-commit automation so formatting and basic lint checks happen befor
204. Keep checks fast to avoid developer friction.244. Keep checks fast to avoid developer friction.
2125
22## Risks26## Risks
27
23- Slow hooks can frustrate contributors and be bypassed.28- Slow hooks can frustrate contributors and be bypassed.
24- Need to ensure compatibility with Bun workspace setup.29- Need to ensure compatibility with Bun workspace setup.
2530
26## Validation31## Validation
32
27- Create sample staged changes and verify hook behavior.33- Create sample staged changes and verify hook behavior.
28- Confirm formatting fixes are applied automatically.34- Confirm formatting fixes are applied automatically.
2935
30## Done Criteria36## Done Criteria
37
31- Pre-commit hook installed and documented.38- Pre-commit hook installed and documented.
32- Formatting-only lint failures drop significantly.39- Formatting-only lint failures drop significantly.
.plans/09-event-state-test-expansion.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Expand Event/State Transition Test Coverage1# Plan: Expand Event/State Transition Test Coverage
22
3## Summary3## Summary
4
4Add focused tests for renderer event handling and session evolution logic.5Add focused tests for renderer event handling and session evolution logic.
56
6## Motivation7## Motivation
8
7- Core behavior is event-driven and stateful.9- Core behavior is event-driven and stateful.
8- Existing renderer tests cover only a subset of timeline/model behavior.10- Existing renderer tests cover only a subset of timeline/model behavior.
911
10## Scope12## Scope
13
11- `apps/renderer/src/session-logic.test.ts`14- `apps/renderer/src/session-logic.test.ts`
12- Optional reducer tests for `apps/renderer/src/store.ts`.15- Optional reducer tests for `apps/renderer/src/store.ts`.
1316
14## Proposed Changes17## Proposed Changes
18
151. Add tests for `evolveSession`:191. Add tests for `evolveSession`:
16 - `thread/started`20 - `thread/started`
17 - `turn/started`21 - `turn/started`
...@@ -24,12 +28,15 @@ Add focused tests for renderer event handling and session evolution logic....@@ -24,12 +28,15 @@ Add focused tests for renderer event handling and session evolution logic.
243. Add reducer integration tests for `APPLY_EVENT`.283. Add reducer integration tests for `APPLY_EVENT`.
2529
26## Risks30## Risks
31
27- Tests may be brittle if event payload fixtures are too coupled to implementation details.32- Tests may be brittle if event payload fixtures are too coupled to implementation details.
2833
29## Validation34## Validation
35
30- `bun run test`36- `bun run test`
31- Ensure new tests remain deterministic and fast.37- Ensure new tests remain deterministic and fast.
3238
33## Done Criteria39## Done Criteria
40
34- High-risk event transitions are covered by unit tests.41- High-risk event transitions are covered by unit tests.
35- Regressions in stream assembly/session status are caught quickly.42- Regressions in stream assembly/session status are caught quickly.
.plans/10-unify-process-session-abstraction.md+7
...@@ -1,17 +1,21 @@...@@ -1,17 +1,21 @@
1# Plan: Unify Process and PTY Session Abstractions in ProcessManager1# Plan: Unify Process and PTY Session Abstractions in ProcessManager
22
3## Summary3## Summary
4
4Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes.5Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes.
56
6## Motivation7## Motivation
8
7- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic.9- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic.
8- New execution backends/providers will multiply complexity.10- New execution backends/providers will multiply complexity.
911
10## Scope12## Scope
13
11- Desktop process execution internals.14- Desktop process execution internals.
12- Preserve public `ProcessManager` API.15- Preserve public `ProcessManager` API.
1316
14## Proposed Changes17## Proposed Changes
18
151. Introduce internal interface (e.g. `RuntimeSession`):191. Introduce internal interface (e.g. `RuntimeSession`):
16 - `write(data)`20 - `write(data)`
17 - `kill()`21 - `kill()`
...@@ -24,12 +28,15 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-pr...@@ -24,12 +28,15 @@ Refactor `ProcessManager` to use a single runtime-session interface for child-pr
245. Add tests for both implementations.285. Add tests for both implementations.
2529
26## Risks30## Risks
31
27- PTY behavior differs by platform; abstraction must not hide required differences.32- PTY behavior differs by platform; abstraction must not hide required differences.
2833
29## Validation34## Validation
35
30- Existing `processManager.test.ts` passes.36- Existing `processManager.test.ts` passes.
31- Add PTY-path tests where feasible.37- Add PTY-path tests where feasible.
3238
33## Done Criteria39## Done Criteria
40
34- Manager no longer branches per backend in `write/kill/killAll`.41- Manager no longer branches per backend in `write/kill/killAll`.
35- Session backends are independently testable.42- Session backends are independently testable.
.plans/11-effect.md+1-3
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1
2
3PR 1: Service contracts + error taxonomy1PR 1: Service contracts + error taxonomy
4Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs.2Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs.
5Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each).3Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each).
...@@ -39,4 +37,4 @@ Remove throw-based flow entirely from provider path....@@ -39,4 +37,4 @@ Remove throw-based flow entirely from provider path.
39PR 10: Cleanup + deprecation removal37PR 10: Cleanup + deprecation removal
40Remove legacy class implementations/adapters once parity is proven.38Remove legacy class implementations/adapters once parity is proven.
41Finalize layer composition and startup graph docs.39Finalize layer composition and startup graph docs.
42Add architecture notes for service boundaries and error model.
\ No newline at end of file
40Add architecture notes for service boundaries and error model.
.plans/12-effect-new.md+9
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1# Effect Migration Plan (From Current State)1# Effect Migration Plan (From Current State)
22
3Current status summary:3Current status summary:
4
4- Service contracts, typed errors, and most checkpoint/persistence services exist.5- Service contracts, typed errors, and most checkpoint/persistence services exist.
5- `ProviderServiceLive` is already native orchestration (not a thin adapter).6- `ProviderServiceLive` is already native orchestration (not a thin adapter).
6- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`.7- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`.
7- Checkpoint flow now avoids snapshot re-sync and is write-time driven.8- Checkpoint flow now avoids snapshot re-sync and is write-time driven.
89
9## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer`10## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer`
11
10- Build one runtime layer graph for provider + checkpoint + persistence + orchestration.12- Build one runtime layer graph for provider + checkpoint + persistence + orchestration.
11- Resolve `ProviderService` from runtime in `wsServer`.13- Resolve `ProviderService` from runtime in `wsServer`.
12- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls.14- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls.
...@@ -14,12 +16,14 @@ Current status summary:...@@ -14,12 +16,14 @@ Current status summary:
14- Keep WS method/push payloads identical.16- Keep WS method/push payloads identical.
1517
16## PR 2: Runtime Composition + Startup Ownership18## PR 2: Runtime Composition + Startup Ownership
19
17- Create/centralize `AppLive` composition for server startup.20- Create/centralize `AppLive` composition for server startup.
18- Ensure outer runtime provides Node/platform services once.21- Ensure outer runtime provides Node/platform services once.
19- Ensure migrations run at startup via scoped/layer startup path.22- Ensure migrations run at startup via scoped/layer startup path.
20- Remove ad-hoc service initialization in request-time paths.23- Remove ad-hoc service initialization in request-time paths.
2124
22## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants25## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants
26
23- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`.27- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`.
24- Remove per-session lock/cwd map leaks.28- Remove per-session lock/cwd map leaks.
25- Keep strict invariant model:29- Keep strict invariant model:
...@@ -29,12 +33,14 @@ Current status summary:...@@ -29,12 +33,14 @@ Current status summary:
29- Add tests for lifecycle cleanup and invariant-failure surfaces.33- Add tests for lifecycle cleanup and invariant-failure surfaces.
3034
31## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation)35## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation)
36
32- Keep `ProviderService` as the public event surface.37- Keep `ProviderService` as the public event surface.
33- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control.38- 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.39- Keep API as `subscribeToEvents` unless we explicitly choose stream API later.
35- Add tests for ordering and subscriber isolation under load.40- Add tests for ordering and subscriber isolation under load.
3641
37## PR 5: Codex Runtime Split (Scoped Effect Core)42## PR 5: Codex Runtime Split (Scoped Effect Core)
43
38- Extract `CodexAppServerManager` responsibilities into Effect-native layers:44- Extract `CodexAppServerManager` responsibilities into Effect-native layers:
39 - scoped process lifecycle45 - scoped process lifecycle
40 - RPC request/response + pending map via `Deferred`46 - RPC request/response + pending map via `Deferred`
...@@ -43,16 +49,19 @@ Current status summary:...@@ -43,16 +49,19 @@ Current status summary:
43- Preserve protocol behavior and timeout semantics.49- Preserve protocol behavior and timeout semantics.
4450
45## PR 6: Codex Protocol Decode Hardening51## PR 6: Codex Protocol Decode Hardening
52
46- Replace ad-hoc unknown parsing with runtime schema decode.53- Replace ad-hoc unknown parsing with runtime schema decode.
47- Map decode failures to typed tagged errors with `cause` retained.54- Map decode failures to typed tagged errors with `cause` retained.
48- Add regression tests for malformed/partial protocol frames.55- Add regression tests for malformed/partial protocol frames.
4956
50## PR 7: Remove Legacy Provider Stack57## PR 7: Remove Legacy Provider Stack
58
51- Remove `ProviderManager` + legacy checkpoint integration from runtime path.59- Remove `ProviderManager` + legacy checkpoint integration from runtime path.
52- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling).60- 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.61- Update tests to assert only Effect service path is used.
5462
55## PR 8: Final Cleanup + Docs63## PR 8: Final Cleanup + Docs
64
56- Update architecture docs with final layer graph and service boundaries.65- Update architecture docs with final layer graph and service boundaries.
57- Document error model and recovery semantics.66- Document error model and recovery semantics.
58- Trim dead compatibility code and stale plan references.67- Trim dead compatibility code and stale plan references.
.plans/13-provider-service-integration-tests.md+19-6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1# ProviderService Integration Test Plan1# ProviderService Integration Test Plan
22
3Goal:3Goal:
4
4- Validate end-to-end `ProviderService` behavior with real layers:5- Validate end-to-end `ProviderService` behavior with real layers:
5 - `ProviderServiceLive`6 - `ProviderServiceLive`
6 - `CheckpointServiceLive`7 - `CheckpointServiceLive`
...@@ -13,6 +14,7 @@ Goal:...@@ -13,6 +14,7 @@ Goal:
13## Test Harness14## Test Harness
1415
15Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`:16Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`:
17
16- Service contract: `ProviderAdapterShape<ProviderAdapterError>`.18- Service contract: `ProviderAdapterShape<ProviderAdapterError>`.
17- Internal state:19- Internal state:
18 - session registry (session + cwd + threadId)20 - session registry (session + cwd + threadId)
...@@ -29,13 +31,15 @@ Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Lay...@@ -29,13 +31,15 @@ Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Lay
29 - `readThread`, `rollbackThread`, `stopSession`, `stopAll`.31 - `readThread`, `rollbackThread`, `stopSession`, `stopAll`.
3032
31Use real git-backed temporary workspaces in integration tests:33Use real git-backed temporary workspaces in integration tests:
34
32- initialize repo with baseline commit35- initialize repo with baseline commit
33- run provider turn in workspace36- run provider turn in workspace
34- assert checkpoint diffs against real git refs37- assert checkpoint diffs against real git refs
3538
36## Core Integration Specs39## Core Integration Specs
3740
381) `startSession` initializes checkpoint root exactly once411. `startSession` initializes checkpoint root exactly once
42
39- Arrange:43- Arrange:
40 - start provider session in git repo.44 - start provider session in git repo.
41- Assert:45- Assert:
...@@ -43,7 +47,8 @@ Use real git-backed temporary workspaces in integration tests:...@@ -43,7 +47,8 @@ Use real git-backed temporary workspaces in integration tests:
43 - checkpoint ref exists in git.47 - checkpoint ref exists in git.
44 - second `startSession` for new session creates a new independent root.48 - second `startSession` for new session creates a new independent root.
4549
462) Turn without filesystem change502. Turn without filesystem change
51
47- Arrange:52- Arrange:
48 - emit normal turn events, no file mutation.53 - emit normal turn events, no file mutation.
49- Assert:54- Assert:
...@@ -54,7 +59,8 @@ Use real git-backed temporary workspaces in integration tests:...@@ -54,7 +59,8 @@ Use real git-backed temporary workspaces in integration tests:
54 - `listCheckpoints` returns root + turn 1.59 - `listCheckpoints` returns root + turn 1.
55 - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff.60 - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff.
5661
573) Turn with filesystem change623. Turn with filesystem change
63
58- Arrange:64- Arrange:
59 - mutate `README.md` during turn.65 - mutate `README.md` during turn.
60- Assert:66- Assert:
...@@ -62,7 +68,8 @@ Use real git-backed temporary workspaces in integration tests:...@@ -62,7 +68,8 @@ Use real git-backed temporary workspaces in integration tests:
62 - `getCheckpointDiff(0 -> 1)` contains file path and hunk.68 - `getCheckpointDiff(0 -> 1)` contains file path and hunk.
63 - persisted checkpoint metadata includes non-empty `checkpointRef`.69 - persisted checkpoint metadata includes non-empty `checkpointRef`.
6470
654) Multi-turn sequencing and checkpoint monotonicity714. Multi-turn sequencing and checkpoint monotonicity
72
66- Arrange:73- Arrange:
67 - turn 1: no file change74 - turn 1: no file change
68 - turn 2: file change75 - turn 2: file change
...@@ -72,7 +79,8 @@ Use real git-backed temporary workspaces in integration tests:...@@ -72,7 +79,8 @@ Use real git-backed temporary workspaces in integration tests:
72 - latest checkpoint is marked current.79 - latest checkpoint is marked current.
73 - diffs for adjacent turns map to expected filesystem deltas.80 - diffs for adjacent turns map to expected filesystem deltas.
7481
755) Revert to checkpoint825. Revert to checkpoint
83
76- Arrange:84- Arrange:
77 - execute 3 turns with at least one file-changing turn.85 - execute 3 turns with at least one file-changing turn.
78 - call `revertToCheckpoint(turnCount=1)`.86 - call `revertToCheckpoint(turnCount=1)`.
...@@ -82,7 +90,8 @@ Use real git-backed temporary workspaces in integration tests:...@@ -82,7 +90,8 @@ Use real git-backed temporary workspaces in integration tests:
82 - DB rows for turns >1 are removed.90 - DB rows for turns >1 are removed.
83 - later refs are deleted from git.91 - later refs are deleted from git.
8492
856) Capture failure surface936. Capture failure surface
94
86- Arrange:95- Arrange:
87 - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails.96 - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails.
88- Assert:97- Assert:
...@@ -92,6 +101,7 @@ Use real git-backed temporary workspaces in integration tests:...@@ -92,6 +101,7 @@ Use real git-backed temporary workspaces in integration tests:
92## WebSocket Coverage (Thin Integration)101## WebSocket Coverage (Thin Integration)
93102
94Add one ws server integration spec:103Add one ws server integration spec:
104
95- Subscribe to `providers.event`.105- Subscribe to `providers.event`.
96- Run a deterministic provider turn through ws methods.106- Run a deterministic provider turn through ws methods.
97- Assert push stream includes:107- Assert push stream includes:
...@@ -101,10 +111,13 @@ Add one ws server integration spec:...@@ -101,10 +111,13 @@ Add one ws server integration spec:
101## Proposed PR Split111## Proposed PR Split
102112
103PR A:113PR A:
114
104- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup).115- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup).
105116
106PR B:117PR B:
118
107- Core ProviderService integration specs (cases 1-4).119- Core ProviderService integration specs (cases 1-4).
108120
109PR C:121PR C:
122
110- Revert + failure-path specs (cases 5-6) + ws thin integration spec.123- Revert + failure-path specs (cases 5-6) + ws thin integration spec.
.plans/14-server-authoritative-event-sourcing-cleanup.md+12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1# Server-Authoritative Event-Sourcing Cleanup Plan1# Server-Authoritative Event-Sourcing Cleanup Plan
22
3Goal:3Goal:
4
4- Move to a cleaner service architecture with:5- Move to a cleaner service architecture with:
5 - durable, server-authoritative event sourcing6 - durable, server-authoritative event sourcing
6 - strict command routing/validation7 - strict command routing/validation
...@@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite...@@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite
85## Commit Series86## Commit Series
8687
87### Commit 1: Split public vs system orchestration command contracts88### Commit 1: Split public vs system orchestration command contracts
89
88- Create separate schemas/types:90- Create separate schemas/types:
89 - `ClientOrchestrationCommandSchema`91 - `ClientOrchestrationCommandSchema`
90 - `SystemOrchestrationCommandSchema`92 - `SystemOrchestrationCommandSchema`
...@@ -100,6 +102,7 @@ CheckpointCatalog ------> SQLite...@@ -100,6 +102,7 @@ CheckpointCatalog ------> SQLite
100 - preserve internal dispatch functionality for system commands102 - preserve internal dispatch functionality for system commands
101103
102### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary104### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary
105
103- Add dedicated router service to validate, authorize, and route commands.106- Add dedicated router service to validate, authorize, and route commands.
104- Move command-to-event mapping out of `orchestration/Layer.ts` into handlers.107- 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.).108- Add aggregate-level invariant checks before append (thread exists, project exists, etc.).
...@@ -113,6 +116,7 @@ CheckpointCatalog ------> SQLite...@@ -113,6 +116,7 @@ CheckpointCatalog ------> SQLite
113 - handler happy-path tests per command type116 - handler happy-path tests per command type
114117
115### Commit 3: Harden event store for idempotency + optimistic append metadata118### Commit 3: Harden event store for idempotency + optimistic append metadata
119
116- Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null).120- 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`.121- 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.122- Add optional aggregate version metadata for future optimistic concurrency.
...@@ -125,6 +129,7 @@ CheckpointCatalog ------> SQLite...@@ -125,6 +129,7 @@ CheckpointCatalog ------> SQLite
125 - concurrent append behavior stays ordered and deterministic129 - concurrent append behavior stays ordered and deterministic
126130
127### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer`131### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer`
132
128- Create `ProviderRuntimeIngestionService` that:133- Create `ProviderRuntimeIngestionService` that:
129 - subscribes to `ProviderService.streamEvents`134 - subscribes to `ProviderService.streamEvents`
130 - translates runtime events into orchestration commands135 - translates runtime events into orchestration commands
...@@ -139,6 +144,7 @@ CheckpointCatalog ------> SQLite...@@ -139,6 +144,7 @@ CheckpointCatalog ------> SQLite
139 - ws integration confirms same external push behavior144 - ws integration confirms same external push behavior
140145
141### Commit 5: Make session directory durable (`ProviderSessionRegistry`)146### Commit 5: Make session directory durable (`ProviderSessionRegistry`)
147
142- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry.148- Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry.
143- Keep in-memory cache optional, but source of truth must be persistent.149- Keep in-memory cache optional, but source of truth must be persistent.
144- Add startup reconciliation to prune dead sessions / keep known thread mapping.150- Add startup reconciliation to prune dead sessions / keep known thread mapping.
...@@ -152,6 +158,7 @@ CheckpointCatalog ------> SQLite...@@ -152,6 +158,7 @@ CheckpointCatalog ------> SQLite
152 - stale session cleanup semantics158 - stale session cleanup semantics
153159
154### Commit 6: Re-key checkpoint metadata from session to thread identity160### Commit 6: Re-key checkpoint metadata from session to thread identity
161
155- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`.162- Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`.
156- Keep `session_id` as nullable metadata only.163- Keep `session_id` as nullable metadata only.
157- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity.164- Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity.
...@@ -165,6 +172,7 @@ CheckpointCatalog ------> SQLite...@@ -165,6 +172,7 @@ CheckpointCatalog ------> SQLite
165 - revert/diff still work after session churn172 - revert/diff still work after session churn
166173
167### Commit 7: Add durable projection persistence for orchestration read models174### Commit 7: Add durable projection persistence for orchestration read models
175
168- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency.176- Introduce projection tables/snapshots persisted in DB to avoid full replay dependency.
169- Keep event stream as source of truth; projection rebuild stays deterministic.177- Keep event stream as source of truth; projection rebuild stays deterministic.
170- `getSnapshot` reads from projection store (memory cache optional).178- `getSnapshot` reads from projection store (memory cache optional).
...@@ -177,6 +185,7 @@ CheckpointCatalog ------> SQLite...@@ -177,6 +185,7 @@ CheckpointCatalog ------> SQLite
177 - projection rebuild from events yields same result as previous reducer semantics185 - projection rebuild from events yields same result as previous reducer semantics
178186
179### Commit 8: Narrow `ProviderService` responsibilities187### Commit 8: Narrow `ProviderService` responsibilities
188
180- Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream.189- 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.190- Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service.
182- Preserve adapter pluggability and provider-neutral contracts.191- Preserve adapter pluggability and provider-neutral contracts.
...@@ -188,6 +197,7 @@ CheckpointCatalog ------> SQLite...@@ -188,6 +197,7 @@ CheckpointCatalog ------> SQLite
188 - checkpoint capture still triggered by turn completion through new coordinator197 - checkpoint capture still triggered by turn completion through new coordinator
189198
190### Commit 9: Look over schemas (contracts and events)199### Commit 9: Look over schemas (contracts and events)
200
191- Scan for unused schemas.201- Scan for unused schemas.
192- Use effect/Schema everywhere202- Use effect/Schema everywhere
193- Analyze which we need203- Analyze which we need
...@@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite...@@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite
196 - Persistence entities206 - Persistence entities
197207
198### Commit 10: Remove dead legacy path and finalize docs208### Commit 10: Remove dead legacy path and finalize docs
209
199- Remove unused legacy manager/store path from active architecture:210- Remove unused legacy manager/store path from active architecture:
200 - `providerManager.ts`211 - `providerManager.ts`
201 - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools)212 - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools)
...@@ -210,6 +221,7 @@ CheckpointCatalog ------> SQLite...@@ -210,6 +221,7 @@ CheckpointCatalog ------> SQLite
210 - no regressions in WS protocol behavior221 - no regressions in WS protocol behavior
211222
212## Risk Controls223## Risk Controls
224
213- Keep WS method names and payload contracts stable throughout.225- Keep WS method names and payload contracts stable throughout.
214- Gate each commit with targeted integration tests before moving forward.226- 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.227- Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows.
.plans/15-effect-server.md+2-1
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1Rewrite `createServer` and `index.ts` to be Effect native.1Rewrite `createServer` and `index.ts` to be Effect native.
22
3Maybe use `effect/unstable/Socket` for the web socket server3Maybe use `effect/unstable/Socket` for the web socket server
4
4- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts5- 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.ts6- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts
67
7- Migrate remaining runtime code to Effect8- Migrate remaining runtime code to Effect
8 - `gitManager` -> `src/git`9 - `gitManager` -> `src/git`
9 - `terminalManager` -> `src/terminal` (Manager + PTY)10 - `terminalManager` -> `src/terminal` (Manager + PTY)
10 - ...
\ No newline at end of file
11 - ...
.plans/16-pr89-review-remediation-phases.md+20
...@@ -24,21 +24,25 @@...@@ -24,21 +24,25 @@
24- Mark invalid/false-positive items with explicit rationale.24- Mark invalid/false-positive items with explicit rationale.
2525
26Exit criteria:26Exit criteria:
27
27- Every open thread is mapped to one canonical fix item or marked invalid.28- Every open thread is mapped to one canonical fix item or marked invalid.
2829
29## Phase 1: Runtime Survival and Critical Event Wiring30## Phase 1: Runtime Survival and Critical Event Wiring
3031
31Related bug groups solved together:32Related bug groups solved together:
33
32- Worker loop/fiber fatal error handling in orchestration reactors.34- Worker loop/fiber fatal error handling in orchestration reactors.
33- WebSocket message error boundaries and unhandled rejection guards.35- WebSocket message error boundaries and unhandled rejection guards.
34- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected).36- Close invalid `providers.event` review findings as documented architecture mismatch (no code change expected).
3537
36Primary files:38Primary files:
39
37- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`40- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
38- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`41- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`
39- `apps/server/src/wsServer.ts`42- `apps/server/src/wsServer.ts`
4043
41Exit criteria:44Exit criteria:
45
42- A single event-processing failure cannot permanently stop ingestion/reactor loops.46- A single event-processing failure cannot permanently stop ingestion/reactor loops.
43- WS message handling cannot produce unhandled promise rejections.47- WS message handling cannot produce unhandled promise rejections.
44- Invalid provider-event-channel review findings are closed with architecture rationale.48- Invalid provider-event-channel review findings are closed with architecture rationale.
...@@ -46,17 +50,20 @@ Exit criteria:...@@ -46,17 +50,20 @@ Exit criteria:
46## Phase 2: State Consistency and Ordering50## Phase 2: State Consistency and Ordering
4751
48Related bug groups solved together:52Related bug groups solved together:
53
49- Fire-and-forget revert completion causing consistency windows.54- Fire-and-forget revert completion causing consistency windows.
50- Non-atomic append/projection paths and retry behavior.55- Non-atomic append/projection paths and retry behavior.
51- Race-sensitive thread/event association issues.56- Race-sensitive thread/event association issues.
5257
53Primary files:58Primary files:
59
54- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`60- `apps/server/src/orchestration/Layers/CheckpointReactor.ts`
55- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`61- `apps/server/src/orchestration/Layers/OrchestrationEngine.ts`
56- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`62- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
57- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`63- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
5864
59Exit criteria:65Exit criteria:
66
60- Revert flow is deterministically reflected in read model updates.67- Revert flow is deterministically reflected in read model updates.
61- Append/project failure mode is explicit and safe under retry.68- Append/project failure mode is explicit and safe under retry.
62- No cross-thread misassociation under concurrent runtime events.69- No cross-thread misassociation under concurrent runtime events.
...@@ -64,12 +71,14 @@ Exit criteria:...@@ -64,12 +71,14 @@ Exit criteria:
64## Phase 3: Checkpointing Correctness Bundle71## Phase 3: Checkpointing Correctness Bundle
6572
66Related bug groups solved together:73Related bug groups solved together:
74
67- Checkpoint input normalization consistency.75- Checkpoint input normalization consistency.
68- Snapshot/projector coverage mismatches.76- Snapshot/projector coverage mismatches.
69- Checkpoint ref/workspace CWD utility duplication.77- Checkpoint ref/workspace CWD utility duplication.
70- Checkpoint diff/error handling behavior gaps.78- Checkpoint diff/error handling behavior gaps.
7179
72Primary files:80Primary files:
81
73- `apps/server/src/checkpointing/Layers/CheckpointStore.ts`82- `apps/server/src/checkpointing/Layers/CheckpointStore.ts`
74- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts`83- `apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts`
75- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`84- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`
...@@ -77,6 +86,7 @@ Primary files:...@@ -77,6 +86,7 @@ Primary files:
77- `apps/server/src/wsServer.ts`86- `apps/server/src/wsServer.ts`
7887
79Exit criteria:88Exit criteria:
89
80- Checkpoint capture/restore/revert paths use one normalization policy.90- Checkpoint capture/restore/revert paths use one normalization policy.
81- Required projectors are actually represented in snapshot reads.91- Required projectors are actually represented in snapshot reads.
82- Shared checkpoint/ref/CWD helpers are centralized.92- Shared checkpoint/ref/CWD helpers are centralized.
...@@ -84,27 +94,32 @@ Exit criteria:...@@ -84,27 +94,32 @@ Exit criteria:
84## Phase 4: Memory and Lifecycle Hygiene94## Phase 4: Memory and Lifecycle Hygiene
8595
86Related bug groups solved together:96Related bug groups solved together:
97
87- Unbounded in-memory dedup sets/maps.98- Unbounded in-memory dedup sets/maps.
88- Missing cleanup/lifecycle protections in long-lived effects/resources.99- Missing cleanup/lifecycle protections in long-lived effects/resources.
89100
90Primary files:101Primary files:
102
91- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`103- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`
92- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`104- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts`
93- `apps/server/src/config.ts`105- `apps/server/src/config.ts`
94106
95Exit criteria:107Exit criteria:
108
96- Long-running server memory does not grow unbounded from dedup bookkeeping.109- Long-running server memory does not grow unbounded from dedup bookkeeping.
97- Resource cleanup paths are registered for interruption/shutdown.110- Resource cleanup paths are registered for interruption/shutdown.
98111
99## Phase 5: Transport, Parsing, and Platform Edge Cases112## Phase 5: Transport, Parsing, and Platform Edge Cases
100113
101Related bug groups solved together:114Related bug groups solved together:
115
102- UTF-8 chunk boundary decode correctness.116- UTF-8 chunk boundary decode correctness.
103- Markdown/file-link parsing edge cases.117- Markdown/file-link parsing edge cases.
104- Shell/OS-specific PATH parsing behavior.118- Shell/OS-specific PATH parsing behavior.
105- Git rename parsing and small keybinding edge cases.119- Git rename parsing and small keybinding edge cases.
106120
107Primary files:121Primary files:
122
108- `apps/server/src/wsServer.ts`123- `apps/server/src/wsServer.ts`
109- `apps/server/src/git/Layers/CodexTextGeneration.ts`124- `apps/server/src/git/Layers/CodexTextGeneration.ts`
110- `apps/web/src/markdown-links.ts`125- `apps/web/src/markdown-links.ts`
...@@ -113,23 +128,27 @@ Primary files:...@@ -113,23 +128,27 @@ Primary files:
113- `apps/server/src/keybindings.ts`128- `apps/server/src/keybindings.ts`
114129
115Exit criteria:130Exit criteria:
131
116- Edge-case parsers are robust across valid but non-trivial inputs.132- Edge-case parsers are robust across valid but non-trivial inputs.
117- Platform-dependent command behavior has safe fallbacks.133- Platform-dependent command behavior has safe fallbacks.
118134
119## Phase 6: Build and Maintainability Cleanup135## Phase 6: Build and Maintainability Cleanup
120136
121Related bug groups solved together:137Related bug groups solved together:
138
122- Build script/runtime assumption cleanup.139- Build script/runtime assumption cleanup.
123- Redundant error-union declarations and utility/type duplication.140- Redundant error-union declarations and utility/type duplication.
124- Non-functional cleanup comments/docs markers.141- Non-functional cleanup comments/docs markers.
125142
126Primary files:143Primary files:
144
127- `apps/server/package.json`145- `apps/server/package.json`
128- `apps/server/src/checkpointing/Errors.ts`146- `apps/server/src/checkpointing/Errors.ts`
129- Shared utility locations introduced during earlier phases147- Shared utility locations introduced during earlier phases
130- `AGENTS.md` (if cleanup is still pending)148- `AGENTS.md` (if cleanup is still pending)
131149
132Exit criteria:150Exit criteria:
151
133- Build path is explicit and environment-safe.152- Build path is explicit and environment-safe.
134- Redundant types/utilities are removed in favor of single sources of truth.153- Redundant types/utilities are removed in favor of single sources of truth.
135154
...@@ -140,6 +159,7 @@ Exit criteria:...@@ -140,6 +159,7 @@ Exit criteria:
140- Resolve threads with fix references per canonical checklist item.159- Resolve threads with fix references per canonical checklist item.
141160
142Exit criteria:161Exit criteria:
162
143- Lint passes.163- Lint passes.
144- Backend tests pass.164- Backend tests pass.
145- All actionable review threads are resolved or explicitly justified.165- All actionable review threads are resolved or explicitly justified.
.plans/16c-pr89-remediation-checklist.md+5-4
...@@ -5,6 +5,7 @@ _Last updated: 2026-02-26_...@@ -5,6 +5,7 @@ _Last updated: 2026-02-26_
5This is the working checklist for remediation execution.5This is the working checklist for remediation execution.
66
7Status values:7Status values:
8
8- `TODO`: Not started9- `TODO`: Not started
9- `IN_PROGRESS`: Currently being worked10- `IN_PROGRESS`: Currently being worked
10- `BLOCKED`: Waiting on decision/dependency11- `BLOCKED`: Waiting on decision/dependency
...@@ -150,7 +151,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`...@@ -150,7 +151,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
150 - Area: `WebSocket robustness`151 - Area: `WebSocket robustness`
151 - File: `apps/web/src/wsTransport.ts:59`152 - File: `apps/web/src/wsTransport.ts:59`
152 - Threads: PRRT_kwDORLtfbc5whtrN153 - Threads: PRRT_kwDORLtfbc5whtrN
153 - Audit note: Transport _tag override risk exists but current callsites are constrained.154 - Audit note: Transport \_tag override risk exists but current callsites are constrained.
154155
155### Phase 2156### Phase 2
156157
...@@ -181,7 +182,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`...@@ -181,7 +182,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
181 - Threads: PRRT_kwDORLtfbc5whxJO182 - Threads: PRRT_kwDORLtfbc5whxJO
182 - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated.183 - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated.
183184
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- [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 - Status: `DONE`
186 - Verdict: `valid`187 - Verdict: `valid`
187 - Severity: `Medium`188 - Severity: `Medium`
...@@ -198,7 +199,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`...@@ -198,7 +199,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
198 - Severity: `Medium`199 - Severity: `Medium`
199 - Area: `Checkpointing correctness`200 - Area: `Checkpointing correctness`
200 - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94`201 - 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 - 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 - 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.
203204
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- [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>
...@@ -250,7 +251,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`...@@ -250,7 +251,7 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
250251
251### Phase 5252### Phase 5
252253
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- [ ] `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 - Status: `TODO`
255 - Verdict: `valid`256 - Verdict: `valid`
256 - Severity: `Medium`257 - Severity: `Medium`
.plans/spec-1-1-cutover-plan.md+51
...@@ -3,13 +3,16 @@...@@ -3,13 +3,16 @@
3Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft.3Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft.
44
5Execution mode for this plan:5Execution mode for this plan:
6
6- Hard cutover only. Existing DB and migration history are disposable.7- Hard cutover only. Existing DB and migration history are disposable.
7- Intermediate steps are allowed to break runtime, tests, typecheck, and lint.8- Intermediate steps are allowed to break runtime, tests, typecheck, and lint.
8- We optimize for small, reviewable work units, not continuous app operability.9- We optimize for small, reviewable work units, not continuous app operability.
9- Only the final gate requires everything to run cleanly.10- Only the final gate requires everything to run cleanly.
1011
11## 1. Freeze SPEC contract as source of truth12## 1. Freeze SPEC contract as source of truth
13
12Work units:14Work units:
15
13- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`.16- 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.17- 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.18- Add app-level requirements per row: writer path, reader path, and owning module.
...@@ -17,17 +20,22 @@ Work units:...@@ -17,17 +20,22 @@ Work units:
17- Identify any ambiguous spec lines and record a concrete interpretation in the matrix.20- Identify any ambiguous spec lines and record a concrete interpretation in the matrix.
1821
19Deliverables:22Deliverables:
23
20- Complete matrix file with no unclassified rows.24- Complete matrix file with no unclassified rows.
21- Single source checklist used by all later steps.25- Single source checklist used by all later steps.
2226
23Breakage allowed:27Breakage allowed:
28
24- No code changes required yet.29- No code changes required yet.
2530
26Exit criteria:31Exit criteria:
32
27- Every requirement in `7.1`-`7.4` has exactly one matrix row.33- Every requirement in `7.1`-`7.4` has exactly one matrix row.
2834
29## 2. Hard cutover migrations (replace current migration set)35## 2. Hard cutover migrations (replace current migration set)
36
30Work units:37Work units:
38
31- Delete the current legacy migration files and rewrite migration loader ordering.39- 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.40- Create `001_orchestration_events.ts` with full envelope columns and required event indexes.
33- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes.41- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes.
...@@ -47,17 +55,22 @@ Work units:...@@ -47,17 +55,22 @@ Work units:
47- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated.55- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated.
4856
49Deliverables:57Deliverables:
58
50- New 5-file migration chain.59- New 5-file migration chain.
51- Updated migration loader references only new migrations.60- Updated migration loader references only new migrations.
5261
53Breakage allowed:62Breakage allowed:
63
54- Repositories/services can be temporarily broken due to removed old tables.64- Repositories/services can be temporarily broken due to removed old tables.
5565
56Exit criteria:66Exit criteria:
67
57- Fresh DB initializes with only canonical tables plus migration bookkeeping.68- Fresh DB initializes with only canonical tables plus migration bookkeeping.
5869
59## 3. Align persistence row/request schemas to DB 1:170## 3. Align persistence row/request schemas to DB 1:1
71
60Work units:72Work units:
73
61- Define row schemas for each canonical table (contracts or persistence layer module).74- 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.75- Define request schemas for every insert/update/query operation touching canonical tables.
63- Remove or deprecate row/request schemas tied to deleted legacy tables.76- Remove or deprecate row/request schemas tied to deleted legacy tables.
...@@ -65,17 +78,22 @@ Work units:...@@ -65,17 +78,22 @@ Work units:
65- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms).78- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms).
6679
67Deliverables:80Deliverables:
81
68- Canonical row/request schemas committed.82- Canonical row/request schemas committed.
69- Zero references to legacy row schemas in active code paths.83- Zero references to legacy row schemas in active code paths.
7084
71Breakage allowed:85Breakage allowed:
86
72- Runtime can still fail while query layers are being rewired.87- Runtime can still fail while query layers are being rewired.
7388
74Exit criteria:89Exit criteria:
90
75- Every canonical table used in code has a typed row schema and typed request schema.91- Every canonical table used in code has a typed row schema and typed request schema.
7692
77## 4. Rewrite event store for full persisted envelope93## 4. Rewrite event store for full persisted envelope
94
78Work units:95Work units:
96
79- Refactor append path to write full envelope fields:97- 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`98 - `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.99- Implement stream version assignment/checking per aggregate stream.
...@@ -84,16 +102,21 @@ Work units:...@@ -84,16 +102,21 @@ Work units:
84- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`).102- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`).
85103
86Deliverables:104Deliverables:
105
87- Event store append/replay fully aligned with canonical envelope.106- Event store append/replay fully aligned with canonical envelope.
88107
89Breakage allowed:108Breakage allowed:
109
90- Command dispatch flow can be partially broken until receipts/projectors are updated.110- Command dispatch flow can be partially broken until receipts/projectors are updated.
91111
92Exit criteria:112Exit criteria:
113
93- Event store no longer depends on legacy event table shape.114- Event store no longer depends on legacy event table shape.
94115
95## 5. Add command receipt idempotency116## 5. Add command receipt idempotency
117
96Work units:118Work units:
119
97- Introduce persistence access layer for `orchestration_command_receipts`.120- Introduce persistence access layer for `orchestration_command_receipts`.
98- In command dispatch flow, check existing receipt by `commandId` before append.121- In command dispatch flow, check existing receipt by `commandId` before append.
99- On first execution, persist accepted receipt with `resultSequence`.122- On first execution, persist accepted receipt with `resultSequence`.
...@@ -102,16 +125,21 @@ Work units:...@@ -102,16 +125,21 @@ Work units:
102- Ensure receipt write and event append ordering is deterministic.125- Ensure receipt write and event append ordering is deterministic.
103126
104Deliverables:127Deliverables:
128
105- Dispatch path with idempotency behavior wired through receipts.129- Dispatch path with idempotency behavior wired through receipts.
106130
107Breakage allowed:131Breakage allowed:
132
108- Snapshot/read model may still be inconsistent until projectors are fully wired.133- Snapshot/read model may still be inconsistent until projectors are fully wired.
109134
110Exit criteria:135Exit criteria:
136
111- Duplicate command IDs no longer create duplicate events.137- Duplicate command IDs no longer create duplicate events.
112138
113## 6. Build DB-backed projection pipeline139## 6. Build DB-backed projection pipeline
140
114Work units:141Work units:
142
115- Create projector runner that consumes events and applies table-specific projections.143- Create projector runner that consumes events and applies table-specific projections.
116- Implement projector handlers for each projection table.144- 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.145- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction.
...@@ -120,16 +148,21 @@ Work units:...@@ -120,16 +148,21 @@ Work units:
120- Add safe resume logic from projector `last_applied_sequence`.148- Add safe resume logic from projector `last_applied_sequence`.
121149
122Deliverables:150Deliverables:
151
123- Persistent projector pipeline writing all `projection_*` tables.152- Persistent projector pipeline writing all `projection_*` tables.
124153
125Breakage allowed:154Breakage allowed:
155
126- Web/API layer may still read old in-memory model until step 7.156- Web/API layer may still read old in-memory model until step 7.
127157
128Exit criteria:158Exit criteria:
159
129- Events drive projection rows in DB; projection state advances transactionally.160- Events drive projection rows in DB; projection state advances transactionally.
130161
131## 7. Move RPC reads to projections and diff blobs162## 7. Move RPC reads to projections and diff blobs
163
132Work units:164Work units:
165
133- Implement snapshot query service reading only projection tables.166- Implement snapshot query service reading only projection tables.
134- Build thread hydration from projection rows: messages, activities, checkpoints, session.167- Build thread hydration from projection rows: messages, activities, checkpoints, session.
135- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`.168- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`.
...@@ -138,16 +171,21 @@ Work units:...@@ -138,16 +171,21 @@ Work units:
138- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`.171- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`.
139172
140Deliverables:173Deliverables:
174
141- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store.175- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store.
142176
143Breakage allowed:177Breakage allowed:
178
144- Provider runtime persistence may still be partially legacy until step 8.179- Provider runtime persistence may still be partially legacy until step 8.
145180
146Exit criteria:181Exit criteria:
182
147- No orchestration read RPC depends on legacy tables or in-memory-only state.183- No orchestration read RPC depends on legacy tables or in-memory-only state.
148184
149## 8. Migrate provider runtime persistence to canonical table185## 8. Migrate provider runtime persistence to canonical table
186
150Work units:187Work units:
188
151- Create repository/service for `provider_session_runtime`.189- Create repository/service for `provider_session_runtime`.
152- Update adapter/session manager to persist runtime/resume cursor in new table.190- 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`.191- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`.
...@@ -155,16 +193,21 @@ Work units:...@@ -155,16 +193,21 @@ Work units:
155- Verify restart/resume path reads runtime state from canonical table only.193- Verify restart/resume path reads runtime state from canonical table only.
156194
157Deliverables:195Deliverables:
196
158- Provider runtime state entirely backed by `provider_session_runtime`.197- Provider runtime state entirely backed by `provider_session_runtime`.
159198
160Breakage allowed:199Breakage allowed:
200
161- Some legacy interfaces may still exist but should be disconnected.201- Some legacy interfaces may still exist but should be disconnected.
162202
163Exit criteria:203Exit criteria:
204
164- Runtime restore no longer reads/writes legacy provider session persistence.205- Runtime restore no longer reads/writes legacy provider session persistence.
165206
166## 9. Remove old cruft aggressively207## 9. Remove old cruft aggressively
208
167Work units:209Work units:
210
168- Delete legacy repositories/services that map to removed tables.211- Delete legacy repositories/services that map to removed tables.
169- Remove dead migration imports and obsolete persistence service interfaces.212- Remove dead migration imports and obsolete persistence service interfaces.
170- Remove compatibility code paths that translate legacy row shapes.213- Remove compatibility code paths that translate legacy row shapes.
...@@ -172,16 +215,21 @@ Work units:...@@ -172,16 +215,21 @@ Work units:
172- Update internal docs/comments to reference canonical projection/event model only.215- Update internal docs/comments to reference canonical projection/event model only.
173216
174Deliverables:217Deliverables:
218
175- Legacy persistence and translation layers removed from active codebase.219- Legacy persistence and translation layers removed from active codebase.
176220
177Breakage allowed:221Breakage allowed:
222
178- Temporary compile failures acceptable while deletion/refactor is in progress.223- Temporary compile failures acceptable while deletion/refactor is in progress.
179224
180Exit criteria:225Exit criteria:
226
181- No production code path references deleted legacy tables/services.227- No production code path references deleted legacy tables/services.
182228
183## 10. Final verification gate (first point where green is required)229## 10. Final verification gate (first point where green is required)
230
184Work units:231Work units:
232
185- Add migration tests that assert canonical tables, columns, constraints, and indexes.233- Add migration tests that assert canonical tables, columns, constraints, and indexes.
186- Add event store tests for envelope persistence, metadata, actor kind, and replay.234- Add event store tests for envelope persistence, metadata, actor kind, and replay.
187- Add receipt idempotency tests for accept/reject/duplicate paths.235- Add receipt idempotency tests for accept/reject/duplicate paths.
...@@ -192,10 +240,13 @@ Work units:...@@ -192,10 +240,13 @@ Work units:
192- Run project lint/typecheck/tests and fix failures.240- Run project lint/typecheck/tests and fix failures.
193241
194Deliverables:242Deliverables:
243
195- Green checks with canonical schema + persistence model in place.244- Green checks with canonical schema + persistence model in place.
196245
197Breakage allowed:246Breakage allowed:
247
198- None at end of step.248- None at end of step.
199249
200Exit criteria:250Exit criteria:
251
201- SPEC `7.1`-`7.4` requirements satisfied and validated by tests.252- SPEC `7.1`-`7.4` requirements satisfied and validated by tests.
.plans/spec-contract-matrix.md+32
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1# SPEC Contract Matrix (Sections 7.1-7.4)1# SPEC Contract Matrix (Sections 7.1-7.4)
22
3Status legend:3Status legend:
4
4- `required`: requirement acknowledged, no current implementation claim yet.5- `required`: requirement acknowledged, no current implementation claim yet.
5- `implemented`: requirement currently satisfied in code + schema.6- `implemented`: requirement currently satisfied in code + schema.
6- `to-replace`: partial/misaligned implementation exists and must be replaced.7- `to-replace`: partial/misaligned implementation exists and must be replaced.
...@@ -9,6 +10,7 @@ Status legend:...@@ -9,6 +10,7 @@ Status legend:
9## 7.1 Write-Side Persisted Tables10## 7.1 Write-Side Persisted Tables
1011
11### W112### W1
13
12- Spec ref: `7.1.1 orchestration_events`14- Spec ref: `7.1.1 orchestration_events`
13- Requirement: append-only event store with canonical envelope columns.15- Requirement: append-only event store with canonical envelope columns.
14- SQL contract:16- SQL contract:
...@@ -32,6 +34,7 @@ Status legend:...@@ -32,6 +34,7 @@ Status legend:
32- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`.34- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`.
3335
34### W236### W2
37
35- Spec ref: `7.1.2 orchestration_command_receipts`38- Spec ref: `7.1.2 orchestration_command_receipts`
36- Requirement: command idempotency + ack replay receipts table.39- Requirement: command idempotency + ack replay receipts table.
37- SQL contract:40- SQL contract:
...@@ -49,6 +52,7 @@ Status legend:...@@ -49,6 +52,7 @@ Status legend:
49- Notes: missing table and missing idempotency flow.52- Notes: missing table and missing idempotency flow.
5053
51### W354### W3
55
52- Spec ref: `7.1.3 checkpoint_diff_blobs`56- Spec ref: `7.1.3 checkpoint_diff_blobs`
53- Requirement: store large plaintext diffs separate from checkpoint summaries.57- Requirement: store large plaintext diffs separate from checkpoint summaries.
54- SQL contract:58- SQL contract:
...@@ -65,6 +69,7 @@ Status legend:...@@ -65,6 +69,7 @@ Status legend:
65- Notes: no canonical diff blob table yet.69- Notes: no canonical diff blob table yet.
6670
67### W471### W4
72
68- Spec ref: `7.1.4 provider_session_runtime`73- Spec ref: `7.1.4 provider_session_runtime`
69- Requirement: server-internal provider runtime/resume state.74- Requirement: server-internal provider runtime/resume state.
70- SQL contract:75- SQL contract:
...@@ -86,6 +91,7 @@ Status legend:...@@ -86,6 +91,7 @@ Status legend:
86## 7.2 Canonical Persisted Event Schema91## 7.2 Canonical Persisted Event Schema
8792
88### E193### E1
94
89- Spec ref: `7.2 OrchestrationPersistedEventSchema`95- Spec ref: `7.2 OrchestrationPersistedEventSchema`
90- Requirement: full typed persisted event envelope in shared contracts.96- Requirement: full typed persisted event envelope in shared contracts.
91- SQL contract: envelope fields in W1 must map 1:1 to contracts schema.97- SQL contract: envelope fields in W1 must map 1:1 to contracts schema.
...@@ -96,6 +102,7 @@ Status legend:...@@ -96,6 +102,7 @@ Status legend:
96- Notes: contract schema exists; DB + store mapping still incomplete.102- Notes: contract schema exists; DB + store mapping still incomplete.
97103
98### E2104### E2
105
99- Spec ref: `7.2 Rules/payload discriminated by eventType`106- Spec ref: `7.2 Rules/payload discriminated by eventType`
100- Requirement: `payload` validation keyed by `eventType`.107- Requirement: `payload` validation keyed by `eventType`.
101- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected.108- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected.
...@@ -106,6 +113,7 @@ Status legend:...@@ -106,6 +113,7 @@ Status legend:
106- Notes: decode is present but DB does not persist full envelope columns.113- Notes: decode is present but DB does not persist full envelope columns.
107114
108### E3115### E3
116
109- Spec ref: `7.2 Rules/provider ids scope`117- Spec ref: `7.2 Rules/provider ids scope`
110- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement.118- 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.119- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id.
...@@ -116,6 +124,7 @@ Status legend:...@@ -116,6 +124,7 @@ Status legend:
116- Notes: metadata plumbing is incomplete in persistence path.124- Notes: metadata plumbing is incomplete in persistence path.
117125
118### E4126### E4
127
119- Spec ref: `7.2 Rules/streamVersion concurrency guard`128- Spec ref: `7.2 Rules/streamVersion concurrency guard`
120- Requirement: stream version monotonic per aggregate stream; enforced on write.129- Requirement: stream version monotonic per aggregate stream; enforced on write.
121- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream.130- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream.
...@@ -128,6 +137,7 @@ Status legend:...@@ -128,6 +137,7 @@ Status legend:
128## 7.3 Required Projected Tables (Read Models)137## 7.3 Required Projected Tables (Read Models)
129138
130### P1139### P1
140
131- Spec ref: `7.3.1 projection_projects`141- Spec ref: `7.3.1 projection_projects`
132- Requirement: persisted project projection table.142- Requirement: persisted project projection table.
133- SQL contract:143- SQL contract:
...@@ -145,6 +155,7 @@ Status legend:...@@ -145,6 +155,7 @@ Status legend:
145- Notes: legacy `projects` table is separate concept and should be removed from orchestration model.155- Notes: legacy `projects` table is separate concept and should be removed from orchestration model.
146156
147### P2157### P2
158
148- Spec ref: `7.3.2 projection_threads`159- Spec ref: `7.3.2 projection_threads`
149- Requirement: persisted thread projection table.160- Requirement: persisted thread projection table.
150- SQL contract:161- SQL contract:
...@@ -165,6 +176,7 @@ Status legend:...@@ -165,6 +176,7 @@ Status legend:
165- Notes: missing table and projector writes.176- Notes: missing table and projector writes.
166177
167### P3178### P3
179
168- Spec ref: `7.3.3 projection_thread_messages`180- Spec ref: `7.3.3 projection_thread_messages`
169- Requirement: persisted thread message projection table.181- Requirement: persisted thread message projection table.
170- SQL contract:182- SQL contract:
...@@ -183,6 +195,7 @@ Status legend:...@@ -183,6 +195,7 @@ Status legend:
183- Notes: missing table and message projection writes.195- Notes: missing table and message projection writes.
184196
185### P4197### P4
198
186- Spec ref: `7.3.4 projection_thread_activities`199- Spec ref: `7.3.4 projection_thread_activities`
187- Requirement: persisted thread activity projection table.200- Requirement: persisted thread activity projection table.
188- SQL contract:201- SQL contract:
...@@ -201,6 +214,7 @@ Status legend:...@@ -201,6 +214,7 @@ Status legend:
201- Notes: no canonical activity projection persistence.214- Notes: no canonical activity projection persistence.
202215
203### P5216### P5
217
204- Spec ref: `7.3.5 projection_thread_sessions`218- Spec ref: `7.3.5 projection_thread_sessions`
205- Requirement: persisted thread session projection table.219- Requirement: persisted thread session projection table.
206- SQL contract:220- SQL contract:
...@@ -219,6 +233,7 @@ Status legend:...@@ -219,6 +233,7 @@ Status legend:
219- Notes: current provider session table is not this domain projection.233- Notes: current provider session table is not this domain projection.
220234
221### P6235### P6
236
222- Spec ref: `7.3.6 projection_thread_turns`237- Spec ref: `7.3.6 projection_thread_turns`
223- Requirement: persisted thread turn projection table.238- Requirement: persisted thread turn projection table.
224- SQL contract:239- SQL contract:
...@@ -237,6 +252,7 @@ Status legend:...@@ -237,6 +252,7 @@ Status legend:
237- Notes: missing table and projection logic.252- Notes: missing table and projection logic.
238253
239### P7254### P7
255
240- Spec ref: `7.3.7 projection_checkpoints`256- Spec ref: `7.3.7 projection_checkpoints`
241- Requirement: persisted checkpoint summary projection table.257- Requirement: persisted checkpoint summary projection table.
242- SQL contract:258- SQL contract:
...@@ -256,6 +272,7 @@ Status legend:...@@ -256,6 +272,7 @@ Status legend:
256- Notes: current table semantics do not match canonical checkpoint projection schema.272- Notes: current table semantics do not match canonical checkpoint projection schema.
257273
258### P8274### P8
275
259- Spec ref: `7.3.8 projection_pending_approvals`276- Spec ref: `7.3.8 projection_pending_approvals`
260- Requirement: persisted pending-approval projection table.277- Requirement: persisted pending-approval projection table.
261- SQL contract:278- SQL contract:
...@@ -273,6 +290,7 @@ Status legend:...@@ -273,6 +290,7 @@ Status legend:
273- Notes: missing table and projection logic.290- Notes: missing table and projection logic.
274291
275### P9292### P9
293
276- Spec ref: `7.3.9 projection_state`294- Spec ref: `7.3.9 projection_state`
277- Requirement: projector progress tracking table.295- Requirement: projector progress tracking table.
278- SQL contract:296- SQL contract:
...@@ -286,6 +304,7 @@ Status legend:...@@ -286,6 +304,7 @@ Status legend:
286- Notes: missing table and projector bookkeeping.304- Notes: missing table and projector bookkeeping.
287305
288### P10306### P10
307
289- Spec ref: `7.3 Projection consistency rules`308- Spec ref: `7.3 Projection consistency rules`
290- Requirement: projector row updates and `projection_state` update must be atomic per event.309- 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.310- SQL contract: per-projector transaction boundary covering both projection write and state update.
...@@ -296,6 +315,7 @@ Status legend:...@@ -296,6 +315,7 @@ Status legend:
296- Notes: requires transactional projection executor.315- Notes: requires transactional projection executor.
297316
298### P11317### P11
318
299- Spec ref: `7.3 Optional debug field`319- Spec ref: `7.3 Optional debug field`
300- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness.320- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness.
301- SQL contract: optional; not required in baseline schema.321- SQL contract: optional; not required in baseline schema.
...@@ -308,6 +328,7 @@ Status legend:...@@ -308,6 +328,7 @@ Status legend:
308## 7.4 Snapshot and RPC Requirements328## 7.4 Snapshot and RPC Requirements
309329
310### R1330### R1
331
311- Spec ref: `7.4.1`332- Spec ref: `7.4.1`
312- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`.333- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`.
313- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`.334- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`.
...@@ -318,6 +339,7 @@ Status legend:...@@ -318,6 +339,7 @@ Status legend:
318- Notes: current in-memory read model path must be removed for SPEC compliance.339- Notes: current in-memory read model path must be removed for SPEC compliance.
319340
320### R2341### R2
342
321- Spec ref: `7.4.2`343- Spec ref: `7.4.2`
322- Requirement: snapshot `projects[]` source is `projection_projects`.344- Requirement: snapshot `projects[]` source is `projection_projects`.
323- SQL contract: `projects` collection assembled from `projection_projects` rows.345- SQL contract: `projects` collection assembled from `projection_projects` rows.
...@@ -328,6 +350,7 @@ Status legend:...@@ -328,6 +350,7 @@ Status legend:
328- Notes: no DB project projection reader exists yet.350- Notes: no DB project projection reader exists yet.
329351
330### R3352### R3
353
331- Spec ref: `7.4.3`354- Spec ref: `7.4.3`
332- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields.355- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields.
333- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`.356- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`.
...@@ -338,6 +361,7 @@ Status legend:...@@ -338,6 +361,7 @@ Status legend:
338- Notes: canonical projection table and reader not implemented.361- Notes: canonical projection table and reader not implemented.
339362
340### R4363### R4
364
341- Spec ref: `7.4.4`365- Spec ref: `7.4.4`
342- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs.366- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs.
343- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only.367- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only.
...@@ -348,6 +372,7 @@ Status legend:...@@ -348,6 +372,7 @@ Status legend:
348- Notes: current checkpoint repository is not canonical source.372- Notes: current checkpoint repository is not canonical source.
349373
350### R5374### R5
375
351- Spec ref: `7.4.5`376- Spec ref: `7.4.5`
352- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`.377- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`.
353- SQL contract: session lookup by `thread_id` from projection table.378- SQL contract: session lookup by `thread_id` from projection table.
...@@ -358,6 +383,7 @@ Status legend:...@@ -358,6 +383,7 @@ Status legend:
358- Notes: remove provider-session-as-routing-key behavior.383- Notes: remove provider-session-as-routing-key behavior.
359384
360### R6385### R6
386
361- Spec ref: `7.4.6`387- Spec ref: `7.4.6`
362- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors.388- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors.
363- SQL contract: `MIN(last_applied_sequence)` across required projector keys.389- SQL contract: `MIN(last_applied_sequence)` across required projector keys.
...@@ -368,6 +394,7 @@ Status legend:...@@ -368,6 +394,7 @@ Status legend:
368- Notes: must move from in-memory sequence to DB projection-state semantics.394- Notes: must move from in-memory sequence to DB projection-state semantics.
369395
370### R7396### R7
397
371- Spec ref: `7.4.7`398- Spec ref: `7.4.7`
372- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence).399- 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.400- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start.
...@@ -380,22 +407,27 @@ Status legend:...@@ -380,22 +407,27 @@ Status legend:
380## Ambiguous/Interpretation Decisions (tracked upfront)407## Ambiguous/Interpretation Decisions (tracked upfront)
381408
382### A1409### A1
410
383- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming.411- 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.412- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code.
385413
386### A2414### A2
415
387- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload.416- 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.417- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries.
389418
390### A3419### A3
420
391- Topic: `snapshotSequence` dependency set for min-sequence computation.421- 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`).422- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`).
393423
394### A4424### A4
425
395- Topic: no-gap handoff mechanism in `7.4.7`.426- Topic: no-gap handoff mechanism in `7.4.7`.
396- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`.427- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`.
397428
398## Checklist Completeness Statement429## Checklist Completeness Statement
430
399- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`.431- 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`.432- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`.
401- Unclassified rows: `0`.433- Unclassified rows: `0`.
apps/desktop/scripts/dev-electron.mjs+15-7
...@@ -7,7 +7,11 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";...@@ -7,7 +7,11 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
77
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
9const devServerUrl = `http://localhost:${port}`;9const devServerUrl = `http://localhost:${port}`;
10const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"];10const requiredFiles = [
11 "dist-electron/main.js",
12 "dist-electron/preload.js",
13 "../server/dist/index.mjs",
14];
11const watchedDirectories = [15const watchedDirectories = [
12 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },16 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
13 { directory: "../server/dist", files: new Set(["index.mjs"]) },17 { directory: "../server/dist", files: new Set(["index.mjs"]) },
...@@ -148,13 +152,17 @@ function scheduleRestart() {...@@ -148,13 +152,17 @@ function scheduleRestart() {
148152
149function startWatchers() {153function startWatchers() {
150 for (const { directory, files } of watchedDirectories) {154 for (const { directory, files } of watchedDirectories) {
151 const watcher = watch(join(desktopDir, directory), { persistent: true }, (_eventType, filename) => {155 const watcher = watch(
152 if (typeof filename !== "string" || !files.has(filename)) {156 join(desktopDir, directory),
153 return;157 { persistent: true },
154 }158 (_eventType, filename) => {
159 if (typeof filename !== "string" || !files.has(filename)) {
160 return;
161 }
155162
156 scheduleRestart();163 scheduleRestart();
157 });164 },
165 );
158166
159 watchers.push(watcher);167 watchers.push(watcher);
160 }168 }
apps/desktop/src/main.ts+12-9
...@@ -15,10 +15,7 @@ import { NetService } from "@t3tools/shared/Net";...@@ -15,10 +15,7 @@ import { NetService } from "@t3tools/shared/Net";
15import { RotatingFileSink } from "@t3tools/shared/logging";15import { RotatingFileSink } from "@t3tools/shared/logging";
16import { showDesktopConfirmDialog } from "./confirmDialog";16import { showDesktopConfirmDialog } from "./confirmDialog";
17import { fixPath } from "./fixPath";17import { fixPath } from "./fixPath";
18import {18import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
19 getAutoUpdateDisabledReason,
20 shouldBroadcastDownloadProgress,
21} from "./updateState";
22import {19import {
23 createInitialDesktopUpdateState,20 createInitialDesktopUpdateState,
24 reduceDesktopUpdateStateOnCheckFailure,21 reduceDesktopUpdateStateOnCheckFailure,
...@@ -702,7 +699,9 @@ async function checkForUpdates(reason: string): Promise<void> {...@@ -702,7 +699,9 @@ async function checkForUpdates(reason: string): Promise<void> {
702 await autoUpdater.checkForUpdates();699 await autoUpdater.checkForUpdates();
703 } catch (error: unknown) {700 } catch (error: unknown) {
704 const message = error instanceof Error ? error.message : String(error);701 const message = error instanceof Error ? error.message : String(error);
705 setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()));702 setUpdateState(
703 reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
704 );
706 console.error(`[desktop-updater] Failed to check for updates: ${message}`);705 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
707 } finally {706 } finally {
708 updateCheckInFlight = false;707 updateCheckInFlight = false;
...@@ -764,9 +763,7 @@ function configureAutoUpdater(): void {...@@ -764,9 +763,7 @@ function configureAutoUpdater(): void {
764 updaterConfigured = true;763 updaterConfigured = true;
765764
766 const githubToken =765 const githubToken =
767 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() ||766 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
768 process.env.GH_TOKEN?.trim() ||
769 "";
770 if (githubToken) {767 if (githubToken) {
771 // When a token is provided, re-configure the feed with `private: true` so768 // When a token is provided, re-configure the feed with `private: true` so
772 // electron-updater uses the GitHub API (api.github.com) instead of the769 // electron-updater uses the GitHub API (api.github.com) instead of the
...@@ -801,7 +798,13 @@ function configureAutoUpdater(): void {...@@ -801,7 +798,13 @@ function configureAutoUpdater(): void {
801 console.info("[desktop-updater] Looking for updates...");798 console.info("[desktop-updater] Looking for updates...");
802 });799 });
803 autoUpdater.on("update-available", (info) => {800 autoUpdater.on("update-available", (info) => {
804 setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString()));801 setUpdateState(
802 reduceDesktopUpdateStateOnUpdateAvailable(
803 updateState,
804 info.version,
805 new Date().toISOString(),
806 ),
807 );
805 lastLoggedDownloadMilestone = -1;808 lastLoggedDownloadMilestone = -1;
806 console.info(`[desktop-updater] Update available: ${info.version}`);809 console.info(`[desktop-updater] Update available: ${info.version}`);
807 });810 });
apps/desktop/src/runtimeArch.ts+1-2
...@@ -25,8 +25,7 @@ export function resolveDesktopRuntimeInfo(...@@ -25,8 +25,7 @@ export function resolveDesktopRuntimeInfo(
25 };25 };
26 }26 }
2727
28 const hostArch =28 const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
29 appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
3029
31 return {30 return {
32 hostArch,31 hostArch,
apps/desktop/src/updateMachine.test.ts+4-1
...@@ -83,7 +83,10 @@ describe("updateMachine", () => {...@@ -83,7 +83,10 @@ describe("updateMachine", () => {
83 },83 },
84 "1.1.0",84 "1.1.0",
85 );85 );
86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out");86 const failedInstall = reduceDesktopUpdateStateOnInstallFailure(
87 downloaded,
88 "backend shutdown timed out",
89 );
8790
88 expect(downloaded.status).toBe("downloaded");91 expect(downloaded.status).toBe("downloaded");
89 expect(downloaded.downloadedVersion).toBe("1.1.0");92 expect(downloaded.downloadedVersion).toBe("1.1.0");
apps/server/integration/TestProviderAdapter.integration.ts+301-302
...@@ -72,10 +72,9 @@ function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupt...@@ -72,10 +72,9 @@ function normalizeTurnState(value: unknown): "completed" | "failed" | "interrupt
72 return "completed";72 return "completed";
73}73}
7474
75function mapRequestType(requestKind: unknown):75function mapRequestType(
76 | "command_execution_approval"76 requestKind: unknown,
77 | "file_change_approval"77): "command_execution_approval" | "file_change_approval" | "unknown" {
78 | "unknown" {
79 if (requestKind === "command") {78 if (requestKind === "command") {
80 return "command_execution_approval";79 return "command_execution_approval";
81 }80 }
...@@ -239,323 +238,323 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -239,323 +238,323 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
239 }>238 }>
240 >();239 >();
241240
242 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);241 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
243242
244 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>243 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
245 Effect.gen(function* () {244 Effect.gen(function* () {
246 if (input.provider !== undefined && input.provider !== provider) {245 if (input.provider !== undefined && input.provider !== provider) {
247 return yield* new ProviderAdapterValidationError({246 return yield* new ProviderAdapterValidationError({
248 provider,247 provider,
249 operation: "startSession",248 operation: "startSession",
250 issue: `Expected provider '${provider}' but received '${input.provider}'.`,249 issue: `Expected provider '${provider}' but received '${input.provider}'.`,
251 });250 });
252 }251 }
253252
254 sessionCount += 1;253 sessionCount += 1;
255 const threadId = input.threadId;254 const threadId = input.threadId;
256 const createdAt = nowIso();255 const createdAt = nowIso();
257256
258 const session: ProviderSession = {257 const session: ProviderSession = {
259 provider,258 provider,
260 status: "ready",259 status: "ready",
261 runtimeMode: input.runtimeMode,260 runtimeMode: input.runtimeMode,
262 threadId,
263 cwd: input.cwd,
264 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
265 createdAt,
266 updatedAt: createdAt,
267 };
268
269 sessions.set(threadId, {
270 session,
271 snapshot: {
272 threadId,261 threadId,
273 turns: [],262 cwd: input.cwd,
274 },263 resumeCursor: input.resumeCursor ?? { threadId: String(threadId), seed: sessionCount },
275 turnCount: 0,264 createdAt,
276 queuedResponses: queuedResponsesForNextSession.splice(0),265 updatedAt: createdAt,
277 rollbackCalls: [],266 };
278 });
279267
280 return session;268 sessions.set(threadId, {
281 });269 session,
270 snapshot: {
271 threadId,
272 turns: [],
273 },
274 turnCount: 0,
275 queuedResponses: queuedResponsesForNextSession.splice(0),
276 rollbackCalls: [],
277 });
282278
283 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>279 return session;
284 Effect.gen(function* () {280 });
285 const state = sessions.get(input.threadId);
286 if (!state) {
287 return yield* missingSessionEffect(provider, input.threadId);
288 }
289281
290 state.turnCount += 1;282 const sendTurn: ProviderAdapterShape<ProviderAdapterError>["sendTurn"] = (input) =>
291 const turnCount = state.turnCount;283 Effect.gen(function* () {
292 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);284 const state = sessions.get(input.threadId);
285 if (!state) {
286 return yield* missingSessionEffect(provider, input.threadId);
287 }
293288
294 const response = state.queuedResponses.shift();289 state.turnCount += 1;
295 if (!response) {290 const turnCount = state.turnCount;
296 return yield* new ProviderAdapterValidationError({291 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
297 provider,
298 operation: "sendTurn",
299 issue: `No queued turn response for thread ${input.threadId}.`,
300 });
301 }
302292
303 const assistantDeltas: string[] = [];293 const response = state.queuedResponses.shift();
304 const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = [];294 if (!response) {
305 for (const fixtureEvent of response.events) {295 return yield* new ProviderAdapterValidationError({
306 const rawEvent: Record<string, unknown> = {296 provider,
307 ...(fixtureEvent as Record<string, unknown>),297 operation: "sendTurn",
308 eventId: randomUUID(),298 issue: `No queued turn response for thread ${input.threadId}.`,
309 provider,299 });
310 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
311 createdAt: nowIso(),
312 };
313 rawEvent.threadId = state.snapshot.threadId;
314 if (Object.hasOwn(rawEvent, "turnId")) {
315 rawEvent.turnId = turnId;
316 }300 }
317301
318 const runtimeEvent = normalizeFixtureEvent(rawEvent);302 const assistantDeltas: string[] = [];
319 const runtimeType = (runtimeEvent as { type: string }).type;303 const deferredTurnCompletedEvents: ProviderRuntimeEvent[] = [];
320 if (runtimeType === "content.delta") {304 for (const fixtureEvent of response.events) {
321 const payload = runtimeEvent.payload as { delta?: unknown } | undefined;305 const rawEvent: Record<string, unknown> = {
322 if (typeof payload?.delta === "string") {306 ...(fixtureEvent as Record<string, unknown>),
323 assistantDeltas.push(payload.delta);307 eventId: randomUUID(),
308 provider,
309 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
310 createdAt: nowIso(),
311 };
312 rawEvent.threadId = state.snapshot.threadId;
313 if (Object.hasOwn(rawEvent, "turnId")) {
314 rawEvent.turnId = turnId;
315 }
316
317 const runtimeEvent = normalizeFixtureEvent(rawEvent);
318 const runtimeType = (runtimeEvent as { type: string }).type;
319 if (runtimeType === "content.delta") {
320 const payload = runtimeEvent.payload as { delta?: unknown } | undefined;
321 if (typeof payload?.delta === "string") {
322 assistantDeltas.push(payload.delta);
323 }
324 } else if (runtimeType === "message.delta") {
325 const legacyDelta = (runtimeEvent as { delta?: unknown }).delta;
326 if (typeof legacyDelta === "string") {
327 assistantDeltas.push(legacyDelta);
328 }
324 }329 }
325 } else if (runtimeType === "message.delta") {330 if (runtimeEvent.type === "turn.completed") {
326 const legacyDelta = (runtimeEvent as { delta?: unknown }).delta;331 deferredTurnCompletedEvents.push(runtimeEvent);
327 if (typeof legacyDelta === "string") {332 continue;
328 assistantDeltas.push(legacyDelta);
329 }333 }
334
335 yield* emit(runtimeEvent);
330 }336 }
331 if (runtimeEvent.type === "turn.completed") {337
332 deferredTurnCompletedEvents.push(runtimeEvent);338 if (response.mutateWorkspace && state.session.cwd) {
333 continue;339 yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount });
334 }340 }
335341
336 yield* emit(runtimeEvent);342 const userItem = {
337 }343 type: "userMessage",
344 content: [{ type: "text", text: input.input }],
345 } as const;
346 const assistantText = assistantDeltas.join("");
347 const nextItems: Array<unknown> =
348 assistantText.length > 0
349 ? [userItem, { type: "agentMessage", text: assistantText }]
350 : [userItem];
351
352 const nextTurn: ProviderThreadTurnSnapshot = {
353 id: turnId,
354 items: nextItems,
355 };
338356
339 if (response.mutateWorkspace && state.session.cwd) {357 state.snapshot = {
340 yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount });358 threadId: state.snapshot.threadId,
341 }359 turns: [...state.snapshot.turns, nextTurn],
360 };
342361
343 const userItem = {362 if (deferredTurnCompletedEvents.length === 0) {
344 type: "userMessage",363 yield* emit({
345 content: [{ type: "text", text: input.input }],364 type: "turn.completed",
346 } as const;365 eventId: EventId.makeUnsafe(randomUUID()),
347 const assistantText = assistantDeltas.join("");366 provider,
348 const nextItems: Array<unknown> =367 createdAt: nowIso(),
349 assistantText.length > 0368 threadId: state.snapshot.threadId,
350 ? [userItem, { type: "agentMessage", text: assistantText }]369 turnId,
351 : [userItem];370 payload: {
352371 state: "completed",
353 const nextTurn: ProviderThreadTurnSnapshot = {372 },
354 id: turnId,373 });
355 items: nextItems,374 } else {
356 };375 for (const completedEvent of deferredTurnCompletedEvents) {
357376 yield* emit(completedEvent);
358 state.snapshot = {377 }
359 threadId: state.snapshot.threadId,378 }
360 turns: [...state.snapshot.turns, nextTurn],379
361 };380 return {
362
363 if (deferredTurnCompletedEvents.length === 0) {
364 yield* emit({
365 type: "turn.completed",
366 eventId: EventId.makeUnsafe(randomUUID()),
367 provider,
368 createdAt: nowIso(),
369 threadId: state.snapshot.threadId,381 threadId: state.snapshot.threadId,
370 turnId,382 turnId,
371 payload: {383 } satisfies ProviderTurnStartResult;
372 state: "completed",384 });
373 },385
374 });386 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
375 } else {387 threadId,
376 for (const completedEvent of deferredTurnCompletedEvents) {388 turnId,
377 yield* emit(completedEvent);389 ) =>
378 }390 sessions.has(threadId)
391 ? Effect.sync(() => {
392 const existing = interruptCallsBySession.get(threadId) ?? [];
393 existing.push(turnId);
394 interruptCallsBySession.set(threadId, existing);
395 })
396 : missingSessionEffect(provider, threadId);
397
398 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
399 threadId,
400 requestId,
401 decision,
402 ) =>
403 sessions.has(threadId)
404 ? Effect.sync(() => {
405 const existing = approvalResponsesBySession.get(threadId) ?? [];
406 existing.push({
407 threadId,
408 requestId,
409 decision,
410 });
411 approvalResponsesBySession.set(threadId, existing);
412 })
413 : missingSessionEffect(provider, threadId);
414
415 const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = (
416 threadId,
417 _requestId,
418 _answers,
419 ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId));
420
421 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>
422 Effect.sync(() => {
423 sessions.delete(threadId);
424 });
425
426 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>
427 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
428
429 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
430 Effect.succeed(sessions.has(threadId));
431
432 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
433 const state = sessions.get(threadId);
434 if (!state) {
435 return missingSessionEffect(provider, threadId);
436 }
437 return Effect.succeed(state.snapshot);
438 };
439
440 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
441 threadId,
442 numTurns,
443 ) => {
444 const state = sessions.get(threadId);
445 if (!state) {
446 return missingSessionEffect(provider, threadId);
447 }
448 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
449 return Effect.fail(
450 new ProviderAdapterValidationError({
451 provider,
452 operation: "rollbackThread",
453 issue: "numTurns must be an integer between 0 and current turn count.",
454 }),
455 );
379 }456 }
380457
381 return {458 return Effect.sync(() => {
382 threadId: state.snapshot.threadId,459 state.rollbackCalls.push(numTurns);
383 turnId,460 state.snapshot = {
384 } satisfies ProviderTurnStartResult;461 threadId: state.snapshot.threadId,
385 });462 turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns),
386463 };
387 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (464 state.turnCount = state.snapshot.turns.length;
388 threadId,465 return state.snapshot;
389 turnId,466 });
390 ) =>467 };
391 sessions.has(threadId)468
392 ? Effect.sync(() => {469 const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () =>
393 const existing = interruptCallsBySession.get(threadId) ?? [];470 Effect.sync(() => {
394 existing.push(turnId);471 sessions.clear();
395 interruptCallsBySession.set(threadId, existing);472 });
396 })473
397 : missingSessionEffect(provider, threadId);474 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
398475 provider,
399 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (476 capabilities: {
400 threadId,477 sessionModelSwitch: "in-session",
401 requestId,478 },
402 decision,479 startSession,
403 ) =>480 sendTurn,
404 sessions.has(threadId)481 interruptTurn,
405 ? Effect.sync(() => {482 respondToRequest,
406 const existing = approvalResponsesBySession.get(threadId) ?? [];483 respondToUserInput,
407 existing.push({484 stopSession,
408 threadId,485 listSessions,
409 requestId,486 hasSession,
410 decision,487 readThread,
411 });488 rollbackThread,
412 approvalResponsesBySession.set(threadId, existing);489 stopAll,
413 })490 streamEvents: Stream.fromQueue(runtimeEvents),
414 : missingSessionEffect(provider, threadId);491 };
415492
416 const respondToUserInput: ProviderAdapterShape<ProviderAdapterError>["respondToUserInput"] = (493 const queueTurnResponse = (
417 threadId,494 threadId: ThreadId,
418 _requestId,495 response: TestTurnResponse,
419 _answers,496 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>
420 ) => (sessions.has(threadId) ? Effect.void : missingSessionEffect(provider, threadId));497 Effect.sync(() => sessions.get(threadId)).pipe(
421498 Effect.flatMap((state) =>
422 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (threadId) =>499 state
423 Effect.sync(() => {500 ? Effect.sync(() => {
424 sessions.delete(threadId);501 state.queuedResponses.push(response);
425 });502 })
426503 : Effect.fail(sessionNotFound(provider, threadId)),
427 const listSessions: ProviderAdapterShape<ProviderAdapterError>["listSessions"] = () =>504 ),
428 Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
429
430 const hasSession: ProviderAdapterShape<ProviderAdapterError>["hasSession"] = (threadId) =>
431 Effect.succeed(sessions.has(threadId));
432
433 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (threadId) => {
434 const state = sessions.get(threadId);
435 if (!state) {
436 return missingSessionEffect(provider, threadId);
437 }
438 return Effect.succeed(state.snapshot);
439 };
440
441 const rollbackThread: ProviderAdapterShape<ProviderAdapterError>["rollbackThread"] = (
442 threadId,
443 numTurns,
444 ) => {
445 const state = sessions.get(threadId);
446 if (!state) {
447 return missingSessionEffect(provider, threadId);
448 }
449 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
450 return Effect.fail(
451 new ProviderAdapterValidationError({
452 provider,
453 operation: "rollbackThread",
454 issue: "numTurns must be an integer between 0 and current turn count.",
455 }),
456 );505 );
457 }506
458507 const queueTurnResponseForNextSession = (
459 return Effect.sync(() => {508 response: TestTurnResponse,
460 state.rollbackCalls.push(numTurns);509 ): Effect.Effect<void, never> =>
461 state.snapshot = {510 Effect.sync(() => {
462 threadId: state.snapshot.threadId,511 queuedResponsesForNextSession.push(response);
463 turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns),512 });
464 };513
465 state.turnCount = state.snapshot.turns.length;514 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
466 return state.snapshot;515 const state = sessions.get(threadId);
467 });516 if (!state) {
468 };517 return [];
469518 }
470 const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () =>519 return [...state.rollbackCalls];
471 Effect.sync(() => {520 };
472 sessions.clear();521
473 });522 const getStartCount = (): number => sessionCount;
474523
475 const adapter: ProviderAdapterShape<ProviderAdapterError> = {524 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
476 provider,525 const calls = interruptCallsBySession.get(threadId);
477 capabilities: {526 if (!calls) {
478 sessionModelSwitch: "in-session",527 return [];
479 },528 }
480 startSession,529 return [...calls];
481 sendTurn,530 };
482 interruptTurn,531
483 respondToRequest,532 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
484 respondToUserInput,533 Array.from(sessions.values(), (state) => state.session.threadId);
485 stopSession,534
486 listSessions,535 const getApprovalResponses = (
487 hasSession,536 threadId: ThreadId,
488 readThread,537 ): ReadonlyArray<{
489 rollbackThread,538 readonly threadId: ThreadId;
490 stopAll,539 readonly requestId: ApprovalRequestId;
491 streamEvents: Stream.fromQueue(runtimeEvents),540 readonly decision: ProviderApprovalDecision;
492 };541 }> => {
493542 const responses = approvalResponsesBySession.get(threadId);
494 const queueTurnResponse = (543 if (!responses) {
495 threadId: ThreadId,544 return [];
496 response: TestTurnResponse,545 }
497 ): Effect.Effect<void, ProviderAdapterSessionNotFoundError> =>546 return [...responses];
498 Effect.sync(() => sessions.get(threadId)).pipe(547 };
499 Effect.flatMap((state) =>548
500 state549 return {
501 ? Effect.sync(() => {550 adapter,
502 state.queuedResponses.push(response);551 provider,
503 })552 queueTurnResponse,
504 : Effect.fail(sessionNotFound(provider, threadId)),553 queueTurnResponseForNextSession,
505 ),554 getStartCount,
506 );555 getRollbackCalls,
507556 getInterruptCalls,
508 const queueTurnResponseForNextSession = (557 listActiveSessionIds,
509 response: TestTurnResponse,558 getApprovalResponses,
510 ): Effect.Effect<void, never> =>559 } satisfies TestProviderAdapterHarness;
511 Effect.sync(() => {560 });
512 queuedResponsesForNextSession.push(response);
513 });
514
515 const getRollbackCalls = (threadId: ThreadId): ReadonlyArray<number> => {
516 const state = sessions.get(threadId);
517 if (!state) {
518 return [];
519 }
520 return [...state.rollbackCalls];
521 };
522
523 const getStartCount = (): number => sessionCount;
524
525 const getInterruptCalls = (threadId: ThreadId): ReadonlyArray<TurnId | undefined> => {
526 const calls = interruptCallsBySession.get(threadId);
527 if (!calls) {
528 return [];
529 }
530 return [...calls];
531 };
532
533 const listActiveSessionIds = (): ReadonlyArray<ThreadId> =>
534 Array.from(sessions.values(), (state) => state.session.threadId);
535
536 const getApprovalResponses = (
537 threadId: ThreadId,
538 ): ReadonlyArray<{
539 readonly threadId: ThreadId;
540 readonly requestId: ApprovalRequestId;
541 readonly decision: ProviderApprovalDecision;
542 }> => {
543 const responses = approvalResponsesBySession.get(threadId);
544 if (!responses) {
545 return [];
546 }
547 return [...responses];
548 };
549
550 return {
551 adapter,
552 provider,
553 queueTurnResponse,
554 queueTurnResponseForNextSession,
555 getStartCount,
556 getRollbackCalls,
557 getInterruptCalls,
558 listActiveSessionIds,
559 getApprovalResponses,
560 } satisfies TestProviderAdapterHarness;
561});
apps/server/scripts/cli.ts+5-4
...@@ -6,7 +6,10 @@ import { Data, Effect, FileSystem, Logger, Option, Path } from "effect";...@@ -6,7 +6,10 @@ import { Data, Effect, FileSystem, Logger, Option, Path } from "effect";
6import { Command, Flag } from "effect/unstable/cli";6import { Command, Flag } from "effect/unstable/cli";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
88
9import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts";9import {
10 DEVELOPMENT_ICON_OVERRIDES,
11 PUBLISH_ICON_OVERRIDES,
12} from "../../../scripts/lib/brand-assets.ts";
10import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";13import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts";
11import rootPackageJson from "../../../package.json" with { type: "json" };14import rootPackageJson from "../../../package.json" with { type: "json" };
12import serverPackageJson from "../package.json" with { type: "json" };15import serverPackageJson from "../package.json" with { type: "json" };
...@@ -237,9 +240,7 @@ const publishCmd = Command.make(...@@ -237,9 +240,7 @@ const publishCmd = Command.make(
237 Effect.gen(function* () {240 Effect.gen(function* () {
238 yield* restorePublishIconOverrides(resource.iconBackups).pipe(241 yield* restorePublishIconOverrides(resource.iconBackups).pipe(
239 Effect.catch((error) =>242 Effect.catch((error) =>
240 Effect.logError(243 Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`),
241 `[cli] Failed to restore publish icon overrides: ${String(error)}`,
242 ),
243 ),244 ),
244 );245 );
245 yield* fs.rename(backupPath, packageJsonPath);246 yield* fs.rename(backupPath, packageJsonPath);
apps/server/src/attachmentStore.ts+1-2
...@@ -12,8 +12,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"...@@ -12,8 +12,7 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"
12const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];12const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
13const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;13const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
15const ATTACHMENT_ID_UUID_PATTERN =15const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
16 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
17const ATTACHMENT_ID_PATTERN = new RegExp(16const ATTACHMENT_ID_PATTERN = new RegExp(
18 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,17 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
19 "i",18 "i",
apps/server/src/codexAppServerManager.test.ts+60-66
...@@ -749,88 +749,82 @@ describe("respondToUserInput", () => {...@@ -749,88 +749,82 @@ describe("respondToUserInput", () => {
749});749});
750750
751describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {751describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
752 it(752 it("keeps prior thread history when resuming with a changed runtime mode", async () => {
753 "keeps prior thread history when resuming with a changed runtime mode",753 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
754 async () => {754 writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8");
755 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
756 writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8");
757755
758 const manager = new CodexAppServerManager();756 const manager = new CodexAppServerManager();
759757
760 try {758 try {
761 const firstSession = await manager.startSession({759 const firstSession = await manager.startSession({
762 threadId: asThreadId("thread-live"),760 threadId: asThreadId("thread-live"),
763 provider: "codex",761 provider: "codex",
764 cwd: workspaceDir,762 cwd: workspaceDir,
765 runtimeMode: "full-access",763 runtimeMode: "full-access",
766 providerOptions: {764 providerOptions: {
767 codex: {765 codex: {
768 ...(process.env.CODEX_BINARY_PATH766 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
769 ? { binaryPath: process.env.CODEX_BINARY_PATH }767 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
770 : {}),
771 ...(process.env.CODEX_HOME_PATH
772 ? { homePath: process.env.CODEX_HOME_PATH }
773 : {}),
774 },
775 },768 },
776 });769 },
770 });
777771
778 const firstTurn = await manager.sendTurn({772 const firstTurn = await manager.sendTurn({
779 threadId: firstSession.threadId,773 threadId: firstSession.threadId,
780 input: `Reply with exactly the word ALPHA ${randomUUID()}`,774 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
781 });775 });
782776
783 expect(firstTurn.threadId).toBe(firstSession.threadId);777 expect(firstTurn.threadId).toBe(firstSession.threadId);
784778
785 await vi.waitFor(async () => {779 await vi.waitFor(
780 async () => {
786 const snapshot = await manager.readThread(firstSession.threadId);781 const snapshot = await manager.readThread(firstSession.threadId);
787 expect(snapshot.turns.length).toBeGreaterThan(0);782 expect(snapshot.turns.length).toBeGreaterThan(0);
788 }, { timeout: 120_000, interval: 1_000 });783 },
784 { timeout: 120_000, interval: 1_000 },
785 );
789786
790 const firstSnapshot = await manager.readThread(firstSession.threadId);787 const firstSnapshot = await manager.readThread(firstSession.threadId);
791 const originalThreadId = firstSnapshot.threadId;788 const originalThreadId = firstSnapshot.threadId;
792 const originalTurnCount = firstSnapshot.turns.length;789 const originalTurnCount = firstSnapshot.turns.length;
793790
794 manager.stopSession(firstSession.threadId);791 manager.stopSession(firstSession.threadId);
795792
796 const resumedSession = await manager.startSession({793 const resumedSession = await manager.startSession({
797 threadId: firstSession.threadId,794 threadId: firstSession.threadId,
798 provider: "codex",795 provider: "codex",
799 cwd: workspaceDir,796 cwd: workspaceDir,
800 runtimeMode: "approval-required",797 runtimeMode: "approval-required",
801 resumeCursor: firstSession.resumeCursor,798 resumeCursor: firstSession.resumeCursor,
802 providerOptions: {799 providerOptions: {
803 codex: {800 codex: {
804 ...(process.env.CODEX_BINARY_PATH801 ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}),
805 ? { binaryPath: process.env.CODEX_BINARY_PATH }802 ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}),
806 : {}),
807 ...(process.env.CODEX_HOME_PATH
808 ? { homePath: process.env.CODEX_HOME_PATH }
809 : {}),
810 },
811 },803 },
812 });804 },
805 });
813806
814 expect(resumedSession.threadId).toBe(originalThreadId);807 expect(resumedSession.threadId).toBe(originalThreadId);
815808
816 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);809 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.threadId);
817 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);810 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
818 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);811 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
819812
820 await manager.sendTurn({813 await manager.sendTurn({
821 threadId: resumedSession.threadId,814 threadId: resumedSession.threadId,
822 input: `Reply with exactly the word BETA ${randomUUID()}`,815 input: `Reply with exactly the word BETA ${randomUUID()}`,
823 });816 });
824817
825 await vi.waitFor(async () => {818 await vi.waitFor(
819 async () => {
826 const snapshot = await manager.readThread(resumedSession.threadId);820 const snapshot = await manager.readThread(resumedSession.threadId);
827 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);821 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
828 }, { timeout: 120_000, interval: 1_000 });822 },
829 } finally {823 { timeout: 120_000, interval: 1_000 },
830 manager.stopAll();824 );
831 rmSync(workspaceDir, { recursive: true, force: true });825 } finally {
832 }826 manager.stopAll();
833 },827 rmSync(workspaceDir, { recursive: true, force: true });
834 180_000,828 }
835 );829 }, 180_000);
836});830});
apps/server/src/git/Layers/CodexTextGeneration.test.ts+2-5
...@@ -363,8 +363,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -363,8 +363,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363 Effect.gen(function* () {363 Effect.gen(function* () {
364 const fs = yield* FileSystem.FileSystem;364 const fs = yield* FileSystem.FileSystem;
365 const path = yield* Path.Path;365 const path = yield* Path.Path;
366 const attachmentId =366 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
367 `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
368 const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);367 const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);
369 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });368 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });
370 yield* fs.writeFile(imagePath, Buffer.from("hello"));369 yield* fs.writeFile(imagePath, Buffer.from("hello"));
...@@ -392,9 +391,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -392,9 +391,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
392 }),391 }),
393 ),392 ),
394 ),393 ),
395 Effect.ensuring(394 Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))),
396 fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)),
397 ),
398 );395 );
399396
400 expect(generated.branch).toBe("fix/ui-regression");397 expect(generated.branch).toBe("fix/ui-regression");
apps/server/src/git/Layers/GitCore.test.ts+39-45
...@@ -153,9 +153,7 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0])...@@ -153,9 +153,7 @@ function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0])
153 });153 });
154}154}
155155
156function fetchGitPullRequestBranch(156function fetchGitPullRequestBranch(input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0]) {
157 input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0],
158) {
159 return Effect.gen(function* () {157 return Effect.gen(function* () {
160 const core = yield* GitCore;158 const core = yield* GitCore;
161 return yield* core.fetchPullRequestBranch(input);159 return yield* core.fetchPullRequestBranch(input);
...@@ -441,7 +439,9 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -441,7 +439,9 @@ it.layer(TestLayer)("git integration", (it) => {
441 true,439 true,
442 );440 );
443 expect(441 expect(
444 result.branches.some((branch) => branch.name === "feature/local-only" && !branch.isRemote),442 result.branches.some(
443 (branch) => branch.name === "feature/local-only" && !branch.isRemote,
444 ),
445 ).toBe(true);445 ).toBe(true);
446 expect(446 expect(
447 result.branches.some(447 result.branches.some(
...@@ -704,29 +704,27 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -704,29 +704,27 @@ it.layer(TestLayer)("git integration", (it) => {
704 }),704 }),
705 );705 );
706706
707 it.effect(707 it.effect("does not silently checkout a local branch when a remote ref no longer exists", () =>
708 "does not silently checkout a local branch when a remote ref no longer exists",708 Effect.gen(function* () {
709 () =>709 const remote = yield* makeTmpDir();
710 Effect.gen(function* () {710 const source = yield* makeTmpDir();
711 const remote = yield* makeTmpDir();711 yield* git(remote, ["init", "--bare"]);
712 const source = yield* makeTmpDir();
713 yield* git(remote, ["init", "--bare"]);
714712
715 yield* initRepoWithCommit(source);713 yield* initRepoWithCommit(source);
716 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(714 const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find(
717 (branch) => branch.current,715 (branch) => branch.current,
718 )!.name;716 )!.name;
719 yield* git(source, ["remote", "add", "origin", remote]);717 yield* git(source, ["remote", "add", "origin", remote]);
720 yield* git(source, ["push", "-u", "origin", defaultBranch]);718 yield* git(source, ["push", "-u", "origin", defaultBranch]);
721719
722 yield* createGitBranch({ cwd: source, branch: "feature" });720 yield* createGitBranch({ cwd: source, branch: "feature" });
723721
724 const checkoutResult = yield* Effect.result(722 const checkoutResult = yield* Effect.result(
725 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),723 checkoutGitBranch({ cwd: source, branch: "origin/feature" }),
726 );724 );
727 expect(checkoutResult._tag).toBe("Failure");725 expect(checkoutResult._tag).toBe("Failure");
728 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);726 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
729 }),727 }),
730 );728 );
731729
732 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>730 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>
...@@ -955,13 +953,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -955,13 +953,7 @@ it.layer(TestLayer)("git integration", (it) => {
955 });953 });
956954
957 expect(renamed.branch).toBe("feature/new-name");955 expect(renamed.branch).toBe("feature/new-name");
958 expect(renameArgs).toEqual([956 expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]);
959 "branch",
960 "-m",
961 "--",
962 "feature/old-name",
963 "feature/new-name",
964 ]);
965 }),957 }),
966 );958 );
967 });959 });
...@@ -1325,23 +1317,25 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1325,23 +1317,25 @@ it.layer(TestLayer)("git integration", (it) => {
1325 }),1317 }),
1326 );1318 );
13271319
1328 it.effect("reuses an existing remote when the target URL only differs by a trailing slash after .git", () =>1320 it.effect(
1329 Effect.gen(function* () {1321 "reuses an existing remote when the target URL only differs by a trailing slash after .git",
1330 const tmp = yield* makeTmpDir();1322 () =>
1331 yield* initRepoWithCommit(tmp);1323 Effect.gen(function* () {
1332 const core = yield* GitCore;1324 const tmp = yield* makeTmpDir();
1325 yield* initRepoWithCommit(tmp);
1326 const core = yield* GitCore;
13331327
1334 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);1328 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);
13351329
1336 const remoteName = yield* core.ensureRemote({1330 const remoteName = yield* core.ensureRemote({
1337 cwd: tmp,1331 cwd: tmp,
1338 preferredName: "origin",1332 preferredName: "origin",
1339 url: "git@github.com:pingdotgg/t3code.git/",1333 url: "git@github.com:pingdotgg/t3code.git/",
1340 });1334 });
13411335
1342 expect(remoteName).toBe("origin");1336 expect(remoteName).toBe("origin");
1343 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);1337 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);
1344 }),1338 }),
1345 );1339 );
13461340
1347 it.effect("reports status details and dirty state", () =>1341 it.effect("reports status details and dirty state", () =>
apps/server/src/git/Layers/GitCore.ts+23-15
...@@ -101,12 +101,19 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {...@@ -101,12 +101,19 @@ function parseRemoteNames(stdout: string): ReadonlyArray<string> {
101}101}
102102
103function sanitizeRemoteName(value: string): string {103function sanitizeRemoteName(value: string): string {
104 const sanitized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");104 const sanitized = value
105 .trim()
106 .replace(/[^A-Za-z0-9._-]+/g, "-")
107 .replace(/^-+|-+$/g, "");
105 return sanitized.length > 0 ? sanitized : "fork";108 return sanitized.length > 0 ? sanitized : "fork";
106}109}
107110
108function normalizeRemoteUrl(value: string): string {111function normalizeRemoteUrl(value: string): string {
109 return value.trim().replace(/\/+$/g, "").replace(/\.git$/i, "").toLowerCase();112 return value
113 .trim()
114 .replace(/\/+$/g, "")
115 .replace(/\.git$/i, "")
116 .toLowerCase();
110}117}
111118
112function parseRemoteFetchUrls(stdout: string): Map<string, string> {119function parseRemoteFetchUrls(stdout: string): Map<string, string> {
...@@ -470,10 +477,11 @@ const makeGitCore = Effect.gen(function* () {...@@ -470,10 +477,11 @@ const makeGitCore = Effect.gen(function* () {
470 Effect.gen(function* () {477 Effect.gen(function* () {
471 const preferredName = sanitizeRemoteName(input.preferredName);478 const preferredName = sanitizeRemoteName(input.preferredName);
472 const normalizedTargetUrl = normalizeRemoteUrl(input.url);479 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
473 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [480 const remoteFetchUrls = yield* runGitStdout(
474 "remote",481 "GitCore.ensureRemote.listRemoteUrls",
475 "-v",482 input.cwd,
476 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));483 ["remote", "-v"],
484 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
477485
478 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {486 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
479 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {487 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
...@@ -488,7 +496,12 @@ const makeGitCore = Effect.gen(function* () {...@@ -488,7 +496,12 @@ const makeGitCore = Effect.gen(function* () {
488 suffix += 1;496 suffix += 1;
489 }497 }
490498
491 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);499 yield* runGit("GitCore.ensureRemote.add", input.cwd, [
500 "remote",
501 "add",
502 remoteName,
503 input.url,
504 ]);
492 return remoteName;505 return remoteName;
493 });506 });
494507
...@@ -1099,14 +1112,9 @@ const makeGitCore = Effect.gen(function* () {...@@ -1099,14 +1112,9 @@ const makeGitCore = Effect.gen(function* () {
1099 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]1112 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1100 : ["worktree", "add", worktreePath, input.branch];1113 : ["worktree", "add", worktreePath, input.branch];
11011114
1102 yield* executeGit(1115 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1103 "GitCore.createWorktree",1116 fallbackErrorMessage: "git worktree add failed",
1104 input.cwd,1117 });
1105 args,
1106 {
1107 fallbackErrorMessage: "git worktree add failed",
1108 },
1109 );
11101118
1111 return {1119 return {
1112 worktree: {1120 worktree: {
apps/server/src/git/Layers/GitManager.test.ts+137-125
...@@ -120,7 +120,11 @@ function runGit(...@@ -120,7 +120,11 @@ function runGit(
120120
121function initRepo(121function initRepo(
122 cwd: string,122 cwd: string,
123): Effect.Effect<void, PlatformError.PlatformError | GitCommandError, FileSystem.FileSystem | Scope.Scope | GitService> {123): Effect.Effect<
124 void,
125 PlatformError.PlatformError | GitCommandError,
126 FileSystem.FileSystem | Scope.Scope | GitService
127> {
124 return Effect.gen(function* () {128 return Effect.gen(function* () {
125 const fs = yield* FileSystem.FileSystem;129 const fs = yield* FileSystem.FileSystem;
126 yield* runGit(cwd, ["init", "--initial-branch=main"]);130 yield* runGit(cwd, ["init", "--initial-branch=main"]);
...@@ -150,9 +154,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -150,9 +154,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
150 Effect.succeed({154 Effect.succeed({
151 subject: "Implement stacked git actions",155 subject: "Implement stacked git actions",
152 body: "",156 body: "",
153 ...(input.includeBranch157 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
154 ? { branch: "feature/implement-stacked-git-actions" }
155 : {}),
156 }),158 }),
157 generatePrContent: () =>159 generatePrContent: () =>
158 Effect.succeed({160 Effect.succeed({
...@@ -416,9 +418,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -416,9 +418,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
416 "--json",418 "--json",
417 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",419 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
418 ],420 ],
419 }).pipe(421 }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)),
420 Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary),
421 ),
422 getRepositoryCloneUrls: (input) =>422 getRepositoryCloneUrls: (input) =>
423 execute({423 execute({
424 cwd: input.cwd,424 cwd: input.cwd,
...@@ -427,12 +427,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -427,12 +427,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
427 checkoutPullRequest: (input) =>427 checkoutPullRequest: (input) =>
428 execute({428 execute({
429 cwd: input.cwd,429 cwd: input.cwd,
430 args: [430 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
431 "pr",
432 "checkout",
433 input.reference,
434 ...(input.force ? ["--force"] : []),
435 ],
436 }).pipe(Effect.asVoid),431 }).pipe(Effect.asVoid),
437 },432 },
438 ghCalls,433 ghCalls,
...@@ -722,9 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -722,9 +717,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
722 return {717 return {
723 subject: "Implement stacked git actions",718 subject: "Implement stacked git actions",
724 body: "",719 body: "",
725 ...(input.includeBranch720 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
726 ? { branch: "feature/implement-stacked-git-actions" }
727 : {}),
728 };721 };
729 }),722 }),
730 },723 },
...@@ -1199,9 +1192,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1199,9 +1192,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1199 expect(result.branch).toBe("feature/pr-worktree");1192 expect(result.branch).toBe("feature/pr-worktree");
1200 expect(result.worktreePath).not.toBeNull();1193 expect(result.worktreePath).not.toBeNull();
1201 expect(fs.existsSync(result.worktreePath as string)).toBe(true);1194 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
1202 const worktreeBranch = (1195 const worktreeBranch = (yield* runGit(result.worktreePath as string, [
1203 yield* runGit(result.worktreePath as string, ["branch", "--show-current"])1196 "branch",
1204 ).stdout.trim();1197 "--show-current",
1198 ])).stdout.trim();
1205 expect(worktreeBranch).toBe("feature/pr-worktree");1199 expect(worktreeBranch).toBe("feature/pr-worktree");
1206 }),1200 }),
1207 );1201 );
...@@ -1251,15 +1245,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1251,15 +1245,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1251 });1245 });
12521246
1253 expect(result.worktreePath).not.toBeNull();1247 expect(result.worktreePath).not.toBeNull();
1254 const upstreamRef = (1248 const upstreamRef = (yield* runGit(result.worktreePath as string, [
1255 yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"])1249 "rev-parse",
1256 ).stdout.trim();1250 "--abbrev-ref",
1251 "@{upstream}",
1252 ])).stdout.trim();
1257 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");1253 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
1258 expect(upstreamRef.startsWith("origin/")).toBe(false);1254 expect(upstreamRef.startsWith("origin/")).toBe(false);
1259 expect(1255 expect(
1260 (yield* runGit(result.worktreePath as string, ["config", "--get", "remote.fork-seed.url"]))1256 (yield* runGit(result.worktreePath as string, [
1261 .stdout1257 "config",
1262 .trim(),1258 "--get",
1259 "remote.fork-seed.url",
1260 ])).stdout.trim(),
1263 ).toBe(forkDir);1261 ).toBe(forkDir);
1264 }),1262 }),
1265 );1263 );
...@@ -1330,7 +1328,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1330,7 +1328,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1330 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");1328 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");
1331 yield* runGit(repoDir, ["add", "derived-fork.txt"]);1329 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
1332 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);1330 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
1333 yield* runGit(repoDir, ["push", "-u", "binbandit-seed", "fix/git-action-default-without-origin"]);1331 yield* runGit(repoDir, [
1332 "push",
1333 "-u",
1334 "binbandit-seed",
1335 "fix/git-action-default-without-origin",
1336 ]);
1334 yield* runGit(repoDir, ["checkout", "main"]);1337 yield* runGit(repoDir, ["checkout", "main"]);
1335 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);1338 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
13361339
...@@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1407 }),1410 }),
1408 );1411 );
14091412
1410 it.effect("does not block fork PR worktree prep when the fork head branch collides with root main", () =>1413 it.effect(
1411 Effect.gen(function* () {1414 "does not block fork PR worktree prep when the fork head branch collides with root main",
1412 const repoDir = yield* makeTempDir("t3code-git-manager-");1415 () =>
1413 yield* initRepo(repoDir);1416 Effect.gen(function* () {
1414 const originDir = yield* createBareRemote();1417 const repoDir = yield* makeTempDir("t3code-git-manager-");
1415 const forkDir = yield* createBareRemote();1418 yield* initRepo(repoDir);
1416 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);1419 const originDir = yield* createBareRemote();
1417 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);1420 const forkDir = yield* createBareRemote();
1418 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);1421 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1419 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);1422 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1420 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");1423 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1421 yield* runGit(repoDir, ["add", "fork-main.txt"]);1424 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1422 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);1425 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
1423 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);1426 yield* runGit(repoDir, ["add", "fork-main.txt"]);
1424 yield* runGit(repoDir, ["checkout", "main"]);1427 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
1425 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();1428 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
14261429 yield* runGit(repoDir, ["checkout", "main"]);
1427 const { manager } = yield* makeManager({1430 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1428 ghScenario: {1431
1429 pullRequest: {1432 const { manager } = yield* makeManager({
1430 number: 91,1433 ghScenario: {
1431 title: "Fork main PR",1434 pullRequest: {
1432 url: "https://github.com/pingdotgg/codething-mvp/pull/91",1435 number: 91,
1433 baseRefName: "main",1436 title: "Fork main PR",
1434 headRefName: "main",1437 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
1435 state: "open",1438 baseRefName: "main",
1436 isCrossRepository: true,1439 headRefName: "main",
1437 headRepositoryNameWithOwner: "octocat/codething-mvp",1440 state: "open",
1438 headRepositoryOwnerLogin: "octocat",1441 isCrossRepository: true,
1439 },1442 headRepositoryNameWithOwner: "octocat/codething-mvp",
1440 repositoryCloneUrls: {1443 headRepositoryOwnerLogin: "octocat",
1441 "octocat/codething-mvp": {1444 },
1442 url: forkDir,1445 repositoryCloneUrls: {
1443 sshUrl: forkDir,1446 "octocat/codething-mvp": {
1447 url: forkDir,
1448 sshUrl: forkDir,
1449 },
1444 },1450 },
1445 },1451 },
1446 },1452 });
1447 });
14481453
1449 const result = yield* preparePullRequestThread(manager, {1454 const result = yield* preparePullRequestThread(manager, {
1450 cwd: repoDir,1455 cwd: repoDir,
1451 reference: "91",1456 reference: "91",
1452 mode: "worktree",1457 mode: "worktree",
1453 });1458 });
14541459
1455 expect(result.branch).toBe("t3code/pr-91/main");1460 expect(result.branch).toBe("t3code/pr-91/main");
1456 expect(result.worktreePath).not.toBeNull();1461 expect(result.worktreePath).not.toBeNull();
1457 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");1462 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
1458 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);1463 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
1459 expect(1464 expect(
1460 (yield* runGit(result.worktreePath as string, ["branch", "--show-current"])).stdout.trim(),1465 (yield* runGit(result.worktreePath as string, [
1461 ).toBe("t3code/pr-91/main");1466 "branch",
1462 }),1467 "--show-current",
1468 ])).stdout.trim(),
1469 ).toBe("t3code/pr-91/main");
1470 }),
1463 );1471 );
14641472
1465 it.effect("does not overwrite an existing local main branch when preparing a fork PR worktree", () =>1473 it.effect(
1466 Effect.gen(function* () {1474 "does not overwrite an existing local main branch when preparing a fork PR worktree",
1467 const repoDir = yield* makeTempDir("t3code-git-manager-");1475 () =>
1468 yield* initRepo(repoDir);1476 Effect.gen(function* () {
1469 const originDir = yield* createBareRemote();1477 const repoDir = yield* makeTempDir("t3code-git-manager-");
1470 const forkDir = yield* createBareRemote();1478 yield* initRepo(repoDir);
1471 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);1479 const originDir = yield* createBareRemote();
1472 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);1480 const forkDir = yield* createBareRemote();
1473 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);1481 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1474 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);1482 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1475 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");1483 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1476 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);1484 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
1477 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);1485 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
1478 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);1486 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
1479 yield* runGit(repoDir, ["checkout", "main"]);1487 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
1480 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();1488 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
1481 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);1489 yield* runGit(repoDir, ["checkout", "main"]);
14821490 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
1483 const { manager } = yield* makeManager({1491 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
1484 ghScenario: {1492
1485 pullRequest: {1493 const { manager } = yield* makeManager({
1486 number: 92,1494 ghScenario: {
1487 title: "Fork main overwrite PR",1495 pullRequest: {
1488 url: "https://github.com/pingdotgg/codething-mvp/pull/92",1496 number: 92,
1489 baseRefName: "main",1497 title: "Fork main overwrite PR",
1490 headRefName: "main",1498 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
1491 state: "open",1499 baseRefName: "main",
1492 isCrossRepository: true,1500 headRefName: "main",
1493 headRepositoryNameWithOwner: "octocat/codething-mvp",1501 state: "open",
1494 headRepositoryOwnerLogin: "octocat",1502 isCrossRepository: true,
1495 },1503 headRepositoryNameWithOwner: "octocat/codething-mvp",
1496 repositoryCloneUrls: {1504 headRepositoryOwnerLogin: "octocat",
1497 "octocat/codething-mvp": {1505 },
1498 url: forkDir,1506 repositoryCloneUrls: {
1499 sshUrl: forkDir,1507 "octocat/codething-mvp": {
1508 url: forkDir,
1509 sshUrl: forkDir,
1510 },
1500 },1511 },
1501 },1512 },
1502 },1513 });
1503 });
15041514
1505 const result = yield* preparePullRequestThread(manager, {1515 const result = yield* preparePullRequestThread(manager, {
1506 cwd: repoDir,1516 cwd: repoDir,
1507 reference: "92",1517 reference: "92",
1508 mode: "worktree",1518 mode: "worktree",
1509 });1519 });
15101520
1511 expect(result.branch).toBe("t3code/pr-92/main");1521 expect(result.branch).toBe("t3code/pr-92/main");
1512 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);1522 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
1513 expect(1523 expect(
1514 (yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"]))1524 (yield* runGit(result.worktreePath as string, [
1515 .stdout1525 "rev-parse",
1516 .trim(),1526 "--abbrev-ref",
1517 ).toBe("fork-seed/main");1527 "@{upstream}",
1518 }),1528 ])).stdout.trim(),
1529 ).toBe("fork-seed/main");
1530 }),
1519 );1531 );
15201532
1521 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>1533 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
apps/server/src/git/Layers/GitManager.ts+20-22
...@@ -655,28 +655,27 @@ export const makeGitManager = Effect.gen(function* () {...@@ -655,28 +655,27 @@ export const makeGitManager = Effect.gen(function* () {
655 };655 };
656 });656 });
657657
658 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(function* (658 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fnUntraced(
659 input,659 function* (input) {
660 ) {660 const pullRequest = yield* gitHubCli
661 const pullRequest = yield* gitHubCli661 .getPullRequest({
662 .getPullRequest({662 cwd: input.cwd,
663 cwd: input.cwd,663 reference: normalizePullRequestReference(input.reference),
664 reference: normalizePullRequestReference(input.reference),664 })
665 })665 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
666 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
667666
668 return { pullRequest };667 return { pullRequest };
669 });668 },
669 );
670670
671 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced(671 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fnUntraced(
672 function* (input) {672 function* (input) {
673 const normalizedReference = normalizePullRequestReference(input.reference);673 const normalizedReference = normalizePullRequestReference(input.reference);
674 const rootWorktreePath = canonicalizeExistingPath(input.cwd);674 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
675 const pullRequestSummary = yield* gitHubCli675 const pullRequestSummary = yield* gitHubCli.getPullRequest({
676 .getPullRequest({676 cwd: input.cwd,
677 cwd: input.cwd,677 reference: normalizedReference,
678 reference: normalizedReference,678 });
679 });
680 const pullRequest = toResolvedPullRequest(pullRequestSummary);679 const pullRequest = toResolvedPullRequest(pullRequestSummary);
681680
682 if (input.mode === "local") {681 if (input.mode === "local") {
...@@ -715,12 +714,11 @@ export const makeGitManager = Effect.gen(function* () {...@@ -715,12 +714,11 @@ export const makeGitManager = Effect.gen(function* () {
715 });714 });
716715
717 const pullRequestWithRemoteInfo = {716 const pullRequestWithRemoteInfo = {
718 ...pullRequest,717 ...pullRequest,
719 ...toPullRequestHeadRemoteInfo(pullRequestSummary),718 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
720 } as const;719 } as const;
721 const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(720 const localPullRequestBranch =
722 pullRequestWithRemoteInfo,721 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
723 );
724722
725 const findLocalHeadBranch = (cwd: string) =>723 const findLocalHeadBranch = (cwd: string) =>
726 gitCore.listBranches({ cwd }).pipe(724 gitCore.listBranches({ cwd }).pipe(
apps/server/src/git/Services/GitCore.ts+1-3
...@@ -165,9 +165,7 @@ export interface GitCoreShape {...@@ -165,9 +165,7 @@ export interface GitCoreShape {
165 /**165 /**
166 * Ensure a named remote exists for the provided URL, returning the reused or created remote name.166 * Ensure a named remote exists for the provided URL, returning the reused or created remote name.
167 */167 */
168 readonly ensureRemote: (168 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
169 input: GitEnsureRemoteInput,
170 ) => Effect.Effect<string, GitCommandError>;
171169
172 /**170 /**
173 * Fetch a remote branch into a local branch without checkout.171 * Fetch a remote branch into a local branch without checkout.
apps/server/src/keybindings.test.ts+3-1
...@@ -211,7 +211,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -211,7 +211,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
211 });211 });
212212
213 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));213 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
214 assert.isFalse(configState.keybindings.some((entry) => String(entry.command) === "invalid.command"));214 assert.isFalse(
215 configState.keybindings.some((entry) => String(entry.command) === "invalid.command"),
216 );
215 assert.deepEqual(configState.issues, [217 assert.deepEqual(configState.issues, [
216 {218 {
217 kind: "keybindings.invalid-entry",219 kind: "keybindings.invalid-entry",
apps/server/src/open.ts+4-4
...@@ -40,7 +40,9 @@ interface CommandAvailabilityOptions {...@@ -40,7 +40,9 @@ interface CommandAvailabilityOptions {
40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
4141
42function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {42function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
43 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);43 return (
44 (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target)
45 );
44}46}
4547
46function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {48function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
...@@ -239,9 +241,7 @@ export const launchDetached = (launch: EditorLaunch) =>...@@ -239,9 +241,7 @@ export const launchDetached = (launch: EditorLaunch) =>
239 });241 });
240 } catch (error) {242 } catch (error) {
241 return resume(243 return resume(
242 Effect.fail(244 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
243 new OpenError({ message: "failed to spawn detached process", cause: error }),
244 ),
245 );245 );
246 }246 }
247247
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+11-11
...@@ -69,7 +69,7 @@ function createProviderServiceHarness(...@@ -69,7 +69,7 @@ function createProviderServiceHarness(
69 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;69 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
70 const listSessions = () =>70 const listSessions = () =>
71 hasSession71 hasSession
72 ? Effect.succeed([72 ? Effect.succeed([
73 {73 {
74 provider: providerName,74 provider: providerName,
75 status: "ready",75 status: "ready",
...@@ -350,7 +350,7 @@ describe("CheckpointReactor", () => {...@@ -350,7 +350,7 @@ describe("CheckpointReactor", () => {
350 type: "turn.started",350 type: "turn.started",
351 eventId: EventId.makeUnsafe("evt-turn-started-1"),351 eventId: EventId.makeUnsafe("evt-turn-started-1"),
352 provider: "codex",352 provider: "codex",
353 353
354 createdAt: new Date().toISOString(),354 createdAt: new Date().toISOString(),
355 threadId: ThreadId.makeUnsafe("thread-1"),355 threadId: ThreadId.makeUnsafe("thread-1"),
356 turnId: asTurnId("turn-1"),356 turnId: asTurnId("turn-1"),
...@@ -365,7 +365,7 @@ describe("CheckpointReactor", () => {...@@ -365,7 +365,7 @@ describe("CheckpointReactor", () => {
365 type: "turn.completed",365 type: "turn.completed",
366 eventId: EventId.makeUnsafe("evt-turn-completed-1"),366 eventId: EventId.makeUnsafe("evt-turn-completed-1"),
367 provider: "codex",367 provider: "codex",
368 368
369 createdAt: new Date().toISOString(),369 createdAt: new Date().toISOString(),
370 threadId: ThreadId.makeUnsafe("thread-1"),370 threadId: ThreadId.makeUnsafe("thread-1"),
371 turnId: asTurnId("turn-1"),371 turnId: asTurnId("turn-1"),
...@@ -426,7 +426,7 @@ describe("CheckpointReactor", () => {...@@ -426,7 +426,7 @@ describe("CheckpointReactor", () => {
426 type: "turn.started",426 type: "turn.started",
427 eventId: EventId.makeUnsafe("evt-turn-started-main"),427 eventId: EventId.makeUnsafe("evt-turn-started-main"),
428 provider: "codex",428 provider: "codex",
429 429
430 createdAt: new Date().toISOString(),430 createdAt: new Date().toISOString(),
431 threadId: ThreadId.makeUnsafe("thread-1"),431 threadId: ThreadId.makeUnsafe("thread-1"),
432 turnId: asTurnId("turn-main"),432 turnId: asTurnId("turn-main"),
...@@ -442,7 +442,7 @@ describe("CheckpointReactor", () => {...@@ -442,7 +442,7 @@ describe("CheckpointReactor", () => {
442 type: "turn.completed",442 type: "turn.completed",
443 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),443 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),
444 provider: "codex",444 provider: "codex",
445 445
446 createdAt: new Date().toISOString(),446 createdAt: new Date().toISOString(),
447 threadId: ThreadId.makeUnsafe("thread-1"),447 threadId: ThreadId.makeUnsafe("thread-1"),
448 turnId: asTurnId("turn-aux"),448 turnId: asTurnId("turn-aux"),
...@@ -460,7 +460,7 @@ describe("CheckpointReactor", () => {...@@ -460,7 +460,7 @@ describe("CheckpointReactor", () => {
460 type: "turn.completed",460 type: "turn.completed",
461 eventId: EventId.makeUnsafe("evt-turn-completed-main"),461 eventId: EventId.makeUnsafe("evt-turn-completed-main"),
462 provider: "codex",462 provider: "codex",
463 463
464 createdAt: new Date().toISOString(),464 createdAt: new Date().toISOString(),
465 threadId: ThreadId.makeUnsafe("thread-1"),465 threadId: ThreadId.makeUnsafe("thread-1"),
466 turnId: asTurnId("turn-main"),466 turnId: asTurnId("turn-main"),
...@@ -500,7 +500,7 @@ describe("CheckpointReactor", () => {...@@ -500,7 +500,7 @@ describe("CheckpointReactor", () => {
500 type: "turn.completed",500 type: "turn.completed",
501 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),501 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),
502 provider: "codex",502 provider: "codex",
503 503
504 createdAt: new Date().toISOString(),504 createdAt: new Date().toISOString(),
505 threadId: ThreadId.makeUnsafe("thread-1"),505 threadId: ThreadId.makeUnsafe("thread-1"),
506 turnId: asTurnId("turn-missing-baseline"),506 turnId: asTurnId("turn-missing-baseline"),
...@@ -589,7 +589,7 @@ describe("CheckpointReactor", () => {...@@ -589,7 +589,7 @@ describe("CheckpointReactor", () => {
589 type: "turn.completed",589 type: "turn.completed",
590 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),590 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
591 provider: "codex",591 provider: "codex",
592 592
593 createdAt: new Date().toISOString(),593 createdAt: new Date().toISOString(),
594 threadId: ThreadId.makeUnsafe("thread-1"),594 threadId: ThreadId.makeUnsafe("thread-1"),
595 turnId: asTurnId("turn-missing-cwd"),595 turnId: asTurnId("turn-missing-cwd"),
...@@ -635,7 +635,7 @@ describe("CheckpointReactor", () => {...@@ -635,7 +635,7 @@ describe("CheckpointReactor", () => {
635 type: "checkpoint.captured",635 type: "checkpoint.captured",
636 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),636 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
637 provider: "codex",637 provider: "codex",
638 638
639 createdAt: new Date().toISOString(),639 createdAt: new Date().toISOString(),
640 threadId: ThreadId.makeUnsafe("thread-1"),640 threadId: ThreadId.makeUnsafe("thread-1"),
641 turnId: asTurnId("turn-3"),641 turnId: asTurnId("turn-3"),
...@@ -685,7 +685,7 @@ describe("CheckpointReactor", () => {...@@ -685,7 +685,7 @@ describe("CheckpointReactor", () => {
685 type: "turn.completed",685 type: "turn.completed",
686 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),686 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
687 provider: "codex",687 provider: "codex",
688 688
689 createdAt: new Date().toISOString(),689 createdAt: new Date().toISOString(),
690 threadId: ThreadId.makeUnsafe("thread-1"),690 threadId: ThreadId.makeUnsafe("thread-1"),
691 turnId: asTurnId("turn-runtime-failure"),691 turnId: asTurnId("turn-runtime-failure"),
...@@ -696,7 +696,7 @@ describe("CheckpointReactor", () => {...@@ -696,7 +696,7 @@ describe("CheckpointReactor", () => {
696 type: "turn.started",696 type: "turn.started",
697 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),697 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
698 provider: "codex",698 provider: "codex",
699 699
700 createdAt: new Date().toISOString(),700 createdAt: new Date().toISOString(),
701 threadId: ThreadId.makeUnsafe("thread-1"),701 threadId: ThreadId.makeUnsafe("thread-1"),
702 turnId: asTurnId("turn-after-runtime-failure"),702 turnId: asTurnId("turn-after-runtime-failure"),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-9
...@@ -115,9 +115,7 @@ const make = Effect.gen(function* () {...@@ -115,9 +115,7 @@ const make = Effect.gen(function* () {
115115
116 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (116 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (
117 threadId: ThreadId,117 threadId: ThreadId,
118 ): Effect.fn.Return<118 ): Effect.fn.Return<Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>> {
119 Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>
120 > {
121 const readModel = yield* orchestrationEngine.getReadModel();119 const readModel = yield* orchestrationEngine.getReadModel();
122 const thread = readModel.threads.find((entry) => entry.id === threadId);120 const thread = readModel.threads.find((entry) => entry.id === threadId);
123121
...@@ -133,9 +131,7 @@ const make = Effect.gen(function* () {...@@ -133,9 +131,7 @@ const make = Effect.gen(function* () {
133 };131 };
134132
135 if (thread) {133 if (thread) {
136 const projectedSession = sessions.find(134 const projectedSession = sessions.find((session) => session.threadId === thread.id);
137 (session) => session.threadId === thread.id,
138 );
139 const fromProjected = findSessionWithCwd(projectedSession);135 const fromProjected = findSessionWithCwd(projectedSession);
140 if (Option.isSome(fromProjected)) {136 if (Option.isSome(fromProjected)) {
141 return fromProjected;137 return fromProjected;
...@@ -306,9 +302,7 @@ const make = Effect.gen(function* () {...@@ -306,9 +302,7 @@ const make = Effect.gen(function* () {
306 }302 }
307303
308 const readModel = yield* orchestrationEngine.getReadModel();304 const readModel = yield* orchestrationEngine.getReadModel();
309 const thread = readModel.threads.find(305 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
310 (entry) => entry.id === event.threadId,
311 );
312 if (!thread) {306 if (!thread) {
313 return;307 return;
314 }308 }
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+407-415
...@@ -54,9 +54,7 @@ const runWithProjectionPipelineLayer = <A, E>(...@@ -54,9 +54,7 @@ const runWithProjectionPipelineLayer = <A, E>(
54 (runtime) => Effect.promise(() => runtime.dispose()),54 (runtime) => Effect.promise(() => runtime.dispose()),
55 );55 );
5656
57const projectionLayer = it.layer(57const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd()));
58 makeProjectionPipelineTestLayer(process.cwd()),
59);
6058
61projectionLayer("OrchestrationProjectionPipeline", (it) => {59projectionLayer("OrchestrationProjectionPipeline", (it) => {
62 it.effect("bootstraps all projection states and writes projection rows", () =>60 it.effect("bootstraps all projection states and writes projection rows", () =>
...@@ -450,153 +448,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -450,153 +448,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
450 }),448 }),
451 );449 );
452450
453 it.effect(451 it.effect("overwrites stored attachment references when a message updates attachments", () =>
454 "overwrites stored attachment references when a message updates attachments",452 Effect.sync(() =>
455 () =>453 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
456 Effect.sync(() =>454 ).pipe(
457 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),455 Effect.flatMap((stateDir) =>
458 ).pipe(456 Effect.gen(function* () {
459 Effect.flatMap((stateDir) =>457 const projectionPipeline = yield* OrchestrationProjectionPipeline;
460 Effect.gen(function* () {458 const eventStore = yield* OrchestrationEventStore;
461 const projectionPipeline = yield* OrchestrationProjectionPipeline;459 const sql = yield* SqlClient.SqlClient;
462 const eventStore = yield* OrchestrationEventStore;460 const now = new Date().toISOString();
463 const sql = yield* SqlClient.SqlClient;461 const later = new Date(Date.now() + 1_000).toISOString();
464 const now = new Date().toISOString();
465 const later = new Date(Date.now() + 1_000).toISOString();
466
467 yield* eventStore.append({
468 type: "project.created",
469 eventId: EventId.makeUnsafe("evt-overwrite-1"),
470 aggregateKind: "project",
471 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
472 occurredAt: now,
473 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
474 causationEventId: null,
475 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
476 metadata: {},
477 payload: {
478 projectId: ProjectId.makeUnsafe("project-overwrite"),
479 title: "Project Overwrite",
480 workspaceRoot: "/tmp/project-overwrite",
481 defaultModel: null,
482 scripts: [],
483 createdAt: now,
484 updatedAt: now,
485 },
486 });
487462
488 yield* eventStore.append({463 yield* eventStore.append({
489 type: "thread.created",464 type: "project.created",
490 eventId: EventId.makeUnsafe("evt-overwrite-2"),465 eventId: EventId.makeUnsafe("evt-overwrite-1"),
491 aggregateKind: "thread",466 aggregateKind: "project",
492 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),467 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
493 occurredAt: now,468 occurredAt: now,
494 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),469 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
495 causationEventId: null,470 causationEventId: null,
496 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),471 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
497 metadata: {},472 metadata: {},
498 payload: {473 payload: {
499 threadId: ThreadId.makeUnsafe("thread-overwrite"),474 projectId: ProjectId.makeUnsafe("project-overwrite"),
500 projectId: ProjectId.makeUnsafe("project-overwrite"),475 title: "Project Overwrite",
501 title: "Thread Overwrite",476 workspaceRoot: "/tmp/project-overwrite",
502 model: "gpt-5-codex",477 defaultModel: null,
503 runtimeMode: "full-access",478 scripts: [],
504 branch: null,479 createdAt: now,
505 worktreePath: null,480 updatedAt: now,
506 createdAt: now,481 },
507 updatedAt: now,482 });
508 },
509 });
510483
511 yield* eventStore.append({484 yield* eventStore.append({
512 type: "thread.message-sent",485 type: "thread.created",
513 eventId: EventId.makeUnsafe("evt-overwrite-3"),486 eventId: EventId.makeUnsafe("evt-overwrite-2"),
514 aggregateKind: "thread",487 aggregateKind: "thread",
515 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),488 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
516 occurredAt: now,489 occurredAt: now,
517 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),490 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
518 causationEventId: null,491 causationEventId: null,
519 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),492 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
520 metadata: {},493 metadata: {},
521 payload: {494 payload: {
522 threadId: ThreadId.makeUnsafe("thread-overwrite"),495 threadId: ThreadId.makeUnsafe("thread-overwrite"),
523 messageId: MessageId.makeUnsafe("message-overwrite"),496 projectId: ProjectId.makeUnsafe("project-overwrite"),
524 role: "user",497 title: "Thread Overwrite",
525 text: "first image",498 model: "gpt-5-codex",
526 attachments: [499 runtimeMode: "full-access",
527 {500 branch: null,
528 type: "image",501 worktreePath: null,
529 id: "thread-overwrite-att-1",502 createdAt: now,
530 name: "file.png",503 updatedAt: now,
531 mimeType: "image/png",504 },
532 sizeBytes: 5,505 });
533 },
534 ],
535 turnId: null,
536 streaming: false,
537 createdAt: now,
538 updatedAt: now,
539 },
540 });
541506
542 yield* eventStore.append({507 yield* eventStore.append({
543 type: "thread.message-sent",508 type: "thread.message-sent",
544 eventId: EventId.makeUnsafe("evt-overwrite-4"),509 eventId: EventId.makeUnsafe("evt-overwrite-3"),
545 aggregateKind: "thread",510 aggregateKind: "thread",
546 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),511 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
547 occurredAt: later,512 occurredAt: now,
548 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),513 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
549 causationEventId: null,514 causationEventId: null,
550 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),515 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
551 metadata: {},516 metadata: {},
552 payload: {517 payload: {
553 threadId: ThreadId.makeUnsafe("thread-overwrite"),518 threadId: ThreadId.makeUnsafe("thread-overwrite"),
554 messageId: MessageId.makeUnsafe("message-overwrite"),519 messageId: MessageId.makeUnsafe("message-overwrite"),
555 role: "user",520 role: "user",
556 text: "",521 text: "first image",
557 attachments: [522 attachments: [
558 {523 {
559 type: "image",524 type: "image",
560 id: "thread-overwrite-att-2",525 id: "thread-overwrite-att-1",
561 name: "file.png",526 name: "file.png",
562 mimeType: "image/png",527 mimeType: "image/png",
563 sizeBytes: 5,528 sizeBytes: 5,
564 },529 },
565 ],530 ],
566 turnId: null,531 turnId: null,
567 streaming: false,532 streaming: false,
568 createdAt: now,533 createdAt: now,
569 updatedAt: later,534 updatedAt: now,
570 },535 },
571 });536 });
572537
573 yield* projectionPipeline.bootstrap;538 yield* eventStore.append({
539 type: "thread.message-sent",
540 eventId: EventId.makeUnsafe("evt-overwrite-4"),
541 aggregateKind: "thread",
542 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
543 occurredAt: later,
544 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
545 causationEventId: null,
546 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
547 metadata: {},
548 payload: {
549 threadId: ThreadId.makeUnsafe("thread-overwrite"),
550 messageId: MessageId.makeUnsafe("message-overwrite"),
551 role: "user",
552 text: "",
553 attachments: [
554 {
555 type: "image",
556 id: "thread-overwrite-att-2",
557 name: "file.png",
558 mimeType: "image/png",
559 sizeBytes: 5,
560 },
561 ],
562 turnId: null,
563 streaming: false,
564 createdAt: now,
565 updatedAt: later,
566 },
567 });
568
569 yield* projectionPipeline.bootstrap;
574570
575 const rows = yield* sql<{571 const rows = yield* sql<{
576 readonly attachmentsJson: string | null;572 readonly attachmentsJson: string | null;
577 }>`573 }>`
578 SELECT attachments_json AS "attachmentsJson"574 SELECT attachments_json AS "attachmentsJson"
579 FROM projection_thread_messages575 FROM projection_thread_messages
580 WHERE message_id = 'message-overwrite'576 WHERE message_id = 'message-overwrite'
581 `;577 `;
582 assert.equal(rows.length, 1);578 assert.equal(rows.length, 1);
583 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [579 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
584 {580 {
585 type: "image",581 type: "image",
586 id: "thread-overwrite-att-2",582 id: "thread-overwrite-att-2",
587 name: "file.png",583 name: "file.png",
588 mimeType: "image/png",584 mimeType: "image/png",
589 sizeBytes: 5,585 sizeBytes: 5,
590 },586 },
591 ]);587 ]);
592 }).pipe(588 }).pipe(
593 (effect) => runWithProjectionPipelineLayer(stateDir, effect),589 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
594 Effect.ensuring(590 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
595 Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true })),
596 ),
597 ),
598 ),591 ),
599 ),592 ),
593 ),
600 );594 );
601595
602 it.effect("does not persist attachment files when projector transaction rolls back", () =>596 it.effect("does not persist attachment files when projector transaction rolls back", () =>
...@@ -711,11 +705,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -711,11 +705,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
711 `;705 `;
712 assert.equal(rows[0]?.count ?? 0, 0);706 assert.equal(rows[0]?.count ?? 0, 0);
713707
714 const attachmentPath = path.join(708 const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png");
715 stateDir,
716 "attachments",
717 "thread-rollback-att-1.png",
718 );
719 assert.equal(fs.existsSync(attachmentPath), false);709 assert.equal(fs.existsSync(attachmentPath), false);
720 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;710 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
721 }).pipe(711 }).pipe(
...@@ -747,189 +737,189 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -747,189 +737,189 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
747 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));737 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
748738
749 yield* appendAndProject({739 yield* appendAndProject({
750 type: "project.created",740 type: "project.created",
751 eventId: EventId.makeUnsafe("evt-revert-files-1"),741 eventId: EventId.makeUnsafe("evt-revert-files-1"),
752 aggregateKind: "project",742 aggregateKind: "project",
753 aggregateId: ProjectId.makeUnsafe("project-revert-files"),743 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
754 occurredAt: now,744 occurredAt: now,
755 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),745 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
756 causationEventId: null,746 causationEventId: null,
757 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),747 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
758 metadata: {},748 metadata: {},
759 payload: {749 payload: {
760 projectId: ProjectId.makeUnsafe("project-revert-files"),750 projectId: ProjectId.makeUnsafe("project-revert-files"),
761 title: "Project Revert Files",751 title: "Project Revert Files",
762 workspaceRoot: "/tmp/project-revert-files",752 workspaceRoot: "/tmp/project-revert-files",
763 defaultModel: null,753 defaultModel: null,
764 scripts: [],754 scripts: [],
765 createdAt: now,755 createdAt: now,
766 updatedAt: now,756 updatedAt: now,
767 },757 },
768 });758 });
769759
770 yield* appendAndProject({760 yield* appendAndProject({
771 type: "thread.created",761 type: "thread.created",
772 eventId: EventId.makeUnsafe("evt-revert-files-2"),762 eventId: EventId.makeUnsafe("evt-revert-files-2"),
773 aggregateKind: "thread",763 aggregateKind: "thread",
774 aggregateId: threadId,764 aggregateId: threadId,
775 occurredAt: now,765 occurredAt: now,
776 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),766 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
777 causationEventId: null,767 causationEventId: null,
778 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),768 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
779 metadata: {},769 metadata: {},
780 payload: {770 payload: {
781 threadId,771 threadId,
782 projectId: ProjectId.makeUnsafe("project-revert-files"),772 projectId: ProjectId.makeUnsafe("project-revert-files"),
783 title: "Thread Revert Files",773 title: "Thread Revert Files",
784 model: "gpt-5-codex",774 model: "gpt-5-codex",
785 runtimeMode: "full-access",775 runtimeMode: "full-access",
786 branch: null,776 branch: null,
787 worktreePath: null,777 worktreePath: null,
788 createdAt: now,778 createdAt: now,
789 updatedAt: now,779 updatedAt: now,
790 },780 },
791 });781 });
792782
793 yield* appendAndProject({783 yield* appendAndProject({
794 type: "thread.turn-diff-completed",784 type: "thread.turn-diff-completed",
795 eventId: EventId.makeUnsafe("evt-revert-files-3"),785 eventId: EventId.makeUnsafe("evt-revert-files-3"),
796 aggregateKind: "thread",786 aggregateKind: "thread",
797 aggregateId: threadId,787 aggregateId: threadId,
798 occurredAt: now,788 occurredAt: now,
799 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),789 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
800 causationEventId: null,790 causationEventId: null,
801 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),791 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
802 metadata: {},792 metadata: {},
803 payload: {793 payload: {
804 threadId,794 threadId,
805 turnId: TurnId.makeUnsafe("turn-keep"),795 turnId: TurnId.makeUnsafe("turn-keep"),
806 checkpointTurnCount: 1,796 checkpointTurnCount: 1,
807 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),797 checkpointRef: CheckpointRef.makeUnsafe(
808 status: "ready",798 "refs/t3/checkpoints/thread-revert-files/turn/1",
809 files: [],799 ),
810 assistantMessageId: MessageId.makeUnsafe("message-keep"),800 status: "ready",
811 completedAt: now,801 files: [],
812 },802 assistantMessageId: MessageId.makeUnsafe("message-keep"),
813 });803 completedAt: now,
804 },
805 });
814806
815 yield* appendAndProject({807 yield* appendAndProject({
816 type: "thread.message-sent",808 type: "thread.message-sent",
817 eventId: EventId.makeUnsafe("evt-revert-files-4"),809 eventId: EventId.makeUnsafe("evt-revert-files-4"),
818 aggregateKind: "thread",810 aggregateKind: "thread",
819 aggregateId: threadId,811 aggregateId: threadId,
820 occurredAt: now,812 occurredAt: now,
821 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),813 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
822 causationEventId: null,814 causationEventId: null,
823 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),815 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
824 metadata: {},816 metadata: {},
825 payload: {817 payload: {
826 threadId,818 threadId,
827 messageId: MessageId.makeUnsafe("message-keep"),819 messageId: MessageId.makeUnsafe("message-keep"),
828 role: "assistant",820 role: "assistant",
829 text: "Keep",821 text: "Keep",
830 attachments: [822 attachments: [
831 {823 {
832 type: "image",824 type: "image",
833 id: keepAttachmentId,825 id: keepAttachmentId,
834 name: "keep.png",826 name: "keep.png",
835 mimeType: "image/png",827 mimeType: "image/png",
836 sizeBytes: 5,828 sizeBytes: 5,
829 },
830 ],
831 turnId: TurnId.makeUnsafe("turn-keep"),
832 streaming: false,
833 createdAt: now,
834 updatedAt: now,
837 },835 },
838 ],836 });
839 turnId: TurnId.makeUnsafe("turn-keep"),
840 streaming: false,
841 createdAt: now,
842 updatedAt: now,
843 },
844 });
845837
846 yield* appendAndProject({838 yield* appendAndProject({
847 type: "thread.turn-diff-completed",839 type: "thread.turn-diff-completed",
848 eventId: EventId.makeUnsafe("evt-revert-files-5"),840 eventId: EventId.makeUnsafe("evt-revert-files-5"),
849 aggregateKind: "thread",841 aggregateKind: "thread",
850 aggregateId: threadId,842 aggregateId: threadId,
851 occurredAt: now,843 occurredAt: now,
852 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),844 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
853 causationEventId: null,845 causationEventId: null,
854 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),846 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
855 metadata: {},847 metadata: {},
856 payload: {848 payload: {
857 threadId,849 threadId,
858 turnId: TurnId.makeUnsafe("turn-remove"),850 turnId: TurnId.makeUnsafe("turn-remove"),
859 checkpointTurnCount: 2,851 checkpointTurnCount: 2,
860 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),852 checkpointRef: CheckpointRef.makeUnsafe(
861 status: "ready",853 "refs/t3/checkpoints/thread-revert-files/turn/2",
862 files: [],854 ),
863 assistantMessageId: MessageId.makeUnsafe("message-remove"),855 status: "ready",
864 completedAt: now,856 files: [],
865 },857 assistantMessageId: MessageId.makeUnsafe("message-remove"),
866 });858 completedAt: now,
859 },
860 });
867861
868 yield* appendAndProject({862 yield* appendAndProject({
869 type: "thread.message-sent",863 type: "thread.message-sent",
870 eventId: EventId.makeUnsafe("evt-revert-files-6"),864 eventId: EventId.makeUnsafe("evt-revert-files-6"),
871 aggregateKind: "thread",865 aggregateKind: "thread",
872 aggregateId: threadId,866 aggregateId: threadId,
873 occurredAt: now,867 occurredAt: now,
874 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),868 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
875 causationEventId: null,869 causationEventId: null,
876 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),870 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
877 metadata: {},871 metadata: {},
878 payload: {872 payload: {
879 threadId,873 threadId,
880 messageId: MessageId.makeUnsafe("message-remove"),874 messageId: MessageId.makeUnsafe("message-remove"),
881 role: "assistant",875 role: "assistant",
882 text: "Remove",876 text: "Remove",
883 attachments: [877 attachments: [
884 {878 {
885 type: "image",879 type: "image",
886 id: removeAttachmentId,880 id: removeAttachmentId,
887 name: "remove.png",881 name: "remove.png",
888 mimeType: "image/png",882 mimeType: "image/png",
889 sizeBytes: 5,883 sizeBytes: 5,
884 },
885 ],
886 turnId: TurnId.makeUnsafe("turn-remove"),
887 streaming: false,
888 createdAt: now,
889 updatedAt: now,
890 },890 },
891 ],891 });
892 turnId: TurnId.makeUnsafe("turn-remove"),
893 streaming: false,
894 createdAt: now,
895 updatedAt: now,
896 },
897 });
898892
899 const keepPath = path.join(893 const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`);
900 stateDir,894 const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`);
901 "attachments",895 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
902 `${keepAttachmentId}.png`,896 fs.writeFileSync(keepPath, Buffer.from("keep"));
903 );897 fs.writeFileSync(removePath, Buffer.from("remove"));
904 const removePath = path.join(898 const otherThreadPath = path.join(
905 stateDir,899 stateDir,
906 "attachments",900 "attachments",
907 `${removeAttachmentId}.png`,901 `${otherThreadAttachmentId}.png`,
908 );902 );
909 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });903 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
910 fs.writeFileSync(keepPath, Buffer.from("keep"));904 assert.equal(fs.existsSync(keepPath), true);
911 fs.writeFileSync(removePath, Buffer.from("remove"));905 assert.equal(fs.existsSync(removePath), true);
912 const otherThreadPath = path.join(stateDir, "attachments", `${otherThreadAttachmentId}.png`);906 assert.equal(fs.existsSync(otherThreadPath), true);
913 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
914 assert.equal(fs.existsSync(keepPath), true);
915 assert.equal(fs.existsSync(removePath), true);
916 assert.equal(fs.existsSync(otherThreadPath), true);
917907
918 yield* appendAndProject({908 yield* appendAndProject({
919 type: "thread.reverted",909 type: "thread.reverted",
920 eventId: EventId.makeUnsafe("evt-revert-files-7"),910 eventId: EventId.makeUnsafe("evt-revert-files-7"),
921 aggregateKind: "thread",911 aggregateKind: "thread",
922 aggregateId: threadId,912 aggregateId: threadId,
923 occurredAt: now,913 occurredAt: now,
924 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),914 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
925 causationEventId: null,915 causationEventId: null,
926 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),916 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
927 metadata: {},917 metadata: {},
928 payload: {918 payload: {
929 threadId,919 threadId,
930 turnCount: 1,920 turnCount: 1,
931 },921 },
932 });922 });
933923
934 assert.equal(fs.existsSync(keepPath), true);924 assert.equal(fs.existsSync(keepPath), true);
935 assert.equal(fs.existsSync(removePath), false);925 assert.equal(fs.existsSync(removePath), false);
...@@ -962,107 +952,107 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -962,107 +952,107 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
962 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));952 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
963953
964 yield* appendAndProject({954 yield* appendAndProject({
965 type: "project.created",955 type: "project.created",
966 eventId: EventId.makeUnsafe("evt-delete-files-1"),956 eventId: EventId.makeUnsafe("evt-delete-files-1"),
967 aggregateKind: "project",957 aggregateKind: "project",
968 aggregateId: ProjectId.makeUnsafe("project-delete-files"),958 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
969 occurredAt: now,959 occurredAt: now,
970 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),960 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
971 causationEventId: null,961 causationEventId: null,
972 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),962 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
973 metadata: {},963 metadata: {},
974 payload: {964 payload: {
975 projectId: ProjectId.makeUnsafe("project-delete-files"),965 projectId: ProjectId.makeUnsafe("project-delete-files"),
976 title: "Project Delete Files",966 title: "Project Delete Files",
977 workspaceRoot: "/tmp/project-delete-files",967 workspaceRoot: "/tmp/project-delete-files",
978 defaultModel: null,968 defaultModel: null,
979 scripts: [],969 scripts: [],
980 createdAt: now,970 createdAt: now,
981 updatedAt: now,971 updatedAt: now,
982 },972 },
983 });973 });
984974
985 yield* appendAndProject({975 yield* appendAndProject({
986 type: "thread.created",976 type: "thread.created",
987 eventId: EventId.makeUnsafe("evt-delete-files-2"),977 eventId: EventId.makeUnsafe("evt-delete-files-2"),
988 aggregateKind: "thread",978 aggregateKind: "thread",
989 aggregateId: threadId,979 aggregateId: threadId,
990 occurredAt: now,980 occurredAt: now,
991 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),981 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
992 causationEventId: null,982 causationEventId: null,
993 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),983 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
994 metadata: {},984 metadata: {},
995 payload: {985 payload: {
996 threadId,986 threadId,
997 projectId: ProjectId.makeUnsafe("project-delete-files"),987 projectId: ProjectId.makeUnsafe("project-delete-files"),
998 title: "Thread Delete Files",988 title: "Thread Delete Files",
999 model: "gpt-5-codex",989 model: "gpt-5-codex",
1000 runtimeMode: "full-access",990 runtimeMode: "full-access",
1001 branch: null,991 branch: null,
1002 worktreePath: null,992 worktreePath: null,
1003 createdAt: now,993 createdAt: now,
1004 updatedAt: now,994 updatedAt: now,
1005 },995 },
1006 });996 });
1007997
1008 yield* appendAndProject({998 yield* appendAndProject({
1009 type: "thread.message-sent",999 type: "thread.message-sent",
1010 eventId: EventId.makeUnsafe("evt-delete-files-3"),1000 eventId: EventId.makeUnsafe("evt-delete-files-3"),
1011 aggregateKind: "thread",1001 aggregateKind: "thread",
1012 aggregateId: threadId,1002 aggregateId: threadId,
1013 occurredAt: now,1003 occurredAt: now,
1014 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),1004 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
1015 causationEventId: null,1005 causationEventId: null,
1016 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),1006 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1017 metadata: {},1007 metadata: {},
1018 payload: {1008 payload: {
1019 threadId,1009 threadId,
1020 messageId: MessageId.makeUnsafe("message-delete-files"),1010 messageId: MessageId.makeUnsafe("message-delete-files"),
1021 role: "user",1011 role: "user",
1022 text: "Delete",1012 text: "Delete",
1023 attachments: [1013 attachments: [
1024 {1014 {
1025 type: "image",1015 type: "image",
1026 id: attachmentId,1016 id: attachmentId,
1027 name: "delete.png",1017 name: "delete.png",
1028 mimeType: "image/png",1018 mimeType: "image/png",
1029 sizeBytes: 5,1019 sizeBytes: 5,
1020 },
1021 ],
1022 turnId: null,
1023 streaming: false,
1024 createdAt: now,
1025 updatedAt: now,
1030 },1026 },
1031 ],1027 });
1032 turnId: null,
1033 streaming: false,
1034 createdAt: now,
1035 updatedAt: now,
1036 },
1037 });
10381028
1039 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);1029 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1040 const otherThreadAttachmentPath = path.join(1030 const otherThreadAttachmentPath = path.join(
1041 stateDir,1031 stateDir,
1042 "attachments",1032 "attachments",
1043 `${otherThreadAttachmentId}.png`,1033 `${otherThreadAttachmentId}.png`,
1044 );1034 );
1045 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });1035 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
1046 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));1036 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1047 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));1037 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));
1048 assert.equal(fs.existsSync(threadAttachmentPath), true);1038 assert.equal(fs.existsSync(threadAttachmentPath), true);
1049 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);1039 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
10501040
1051 yield* appendAndProject({1041 yield* appendAndProject({
1052 type: "thread.deleted",1042 type: "thread.deleted",
1053 eventId: EventId.makeUnsafe("evt-delete-files-4"),1043 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1054 aggregateKind: "thread",1044 aggregateKind: "thread",
1055 aggregateId: threadId,1045 aggregateId: threadId,
1056 occurredAt: now,1046 occurredAt: now,
1057 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),1047 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1058 causationEventId: null,1048 causationEventId: null,
1059 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),1049 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1060 metadata: {},1050 metadata: {},
1061 payload: {1051 payload: {
1062 threadId,1052 threadId,
1063 deletedAt: now,1053 deletedAt: now,
1064 },1054 },
1065 });1055 });
10661056
1067 assert.equal(fs.existsSync(threadAttachmentPath), false);1057 assert.equal(fs.existsSync(threadAttachmentPath), false);
1068 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);1058 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
...@@ -1075,7 +1065,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -1075,7 +1065,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
1075 );1065 );
10761066
1077 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>1067 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1078 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"))).pipe(1068 Effect.sync(() =>
1069 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")),
1070 ).pipe(
1079 Effect.flatMap((stateDir) =>1071 Effect.flatMap((stateDir) =>
1080 Effect.gen(function* () {1072 Effect.gen(function* () {
1081 const projectionPipeline = yield* OrchestrationProjectionPipeline;1073 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-4
...@@ -78,9 +78,8 @@ interface AttachmentSideEffects {...@@ -78,9 +78,8 @@ interface AttachmentSideEffects {
78}78}
7979
80const materializeAttachmentsForProjection = Effect.fn(80const materializeAttachmentsForProjection = Effect.fn(
81 (input: {81 (input: { readonly attachments: ReadonlyArray<ChatAttachment> }) =>
82 readonly attachments: ReadonlyArray<ChatAttachment>;82 Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
83 }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
84);83);
8584
86function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {85function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
...@@ -336,7 +335,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid...@@ -336,7 +335,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
336 },335 },
337 { concurrency: 1 },336 { concurrency: 1 },
338 );337 );
339
340});338});
341339
342const makeOrchestrationProjectionPipeline = Effect.gen(function* () {340const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1-8
...@@ -1,11 +1,4 @@...@@ -1,11 +1,4 @@
1import {1import { CheckpointRef, EventId, MessageId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
9import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
10import { Effect, Layer } from "effect";3import { Effect, Layer } from "effect";
11import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+20-6
...@@ -41,7 +41,10 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>...@@ -41,7 +41,10 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
42const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);42const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4343
44async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2000): Promise<void> {44async function waitFor(
45 predicate: () => boolean | Promise<boolean>,
46 timeoutMs = 2000,
47): Promise<void> {
45 const deadline = Date.now() + timeoutMs;48 const deadline = Date.now() + timeoutMs;
46 const poll = async (): Promise<void> => {49 const poll = async (): Promise<void> => {
47 if (await predicate()) {50 if (await predicate()) {
...@@ -101,7 +104,10 @@ describe("ProviderCommandReactor", () => {...@@ -101,7 +104,10 @@ describe("ProviderCommandReactor", () => {
101 ? input.resumeCursor104 ? input.resumeCursor
102 : undefined;105 : undefined;
103 const model =106 const model =
104 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"107 typeof input === "object" &&
108 input !== null &&
109 "model" in input &&
110 typeof input.model === "string"
105 ? input.model111 ? input.model
106 : undefined;112 : undefined;
107 const threadId =113 const threadId =
...@@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => {...@@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => {
474480
475 await waitFor(async () => {481 await waitFor(async () => {
476 const readModel = await Effect.runPromise(harness.engine.getReadModel());482 const readModel = await Effect.runPromise(harness.engine.getReadModel());
477 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));483 const thread = readModel.threads.find(
484 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
485 );
478 return thread?.runtimeMode === "approval-required";486 return thread?.runtimeMode === "approval-required";
479 });487 });
480 await waitFor(() => harness.startSession.mock.calls.length === 2);488 await waitFor(() => harness.startSession.mock.calls.length === 2);
...@@ -563,7 +571,9 @@ describe("ProviderCommandReactor", () => {...@@ -563,7 +571,9 @@ describe("ProviderCommandReactor", () => {
563571
564 await waitFor(async () => {572 await waitFor(async () => {
565 const readModel = await Effect.runPromise(harness.engine.getReadModel());573 const readModel = await Effect.runPromise(harness.engine.getReadModel());
566 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));574 const thread = readModel.threads.find(
575 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
576 );
567 return thread?.runtimeMode === "approval-required";577 return thread?.runtimeMode === "approval-required";
568 });578 });
569 await waitFor(() => harness.startSession.mock.calls.length === 2);579 await waitFor(() => harness.startSession.mock.calls.length === 2);
...@@ -767,9 +777,13 @@ describe("ProviderCommandReactor", () => {...@@ -767,9 +777,13 @@ describe("ProviderCommandReactor", () => {
767777
768 await waitFor(async () => {778 await waitFor(async () => {
769 const readModel = await Effect.runPromise(harness.engine.getReadModel());779 const readModel = await Effect.runPromise(harness.engine.getReadModel());
770 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));780 const thread = readModel.threads.find(
781 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
782 );
771 if (!thread) return false;783 if (!thread) return false;
772 return thread.activities.some((activity) => activity.kind === "provider.approval.respond.failed");784 return thread.activities.some(
785 (activity) => activity.kind === "provider.approval.respond.failed",
786 );
773 });787 });
774788
775 const readModel = await Effect.runPromise(harness.engine.getReadModel());789 const readModel = await Effect.runPromise(harness.engine.getReadModel());
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+23-17
...@@ -224,9 +224,9 @@ const make = Effect.gen(function* () {...@@ -224,9 +224,9 @@ const make = Effect.gen(function* () {
224 });224 });
225225
226 const resolveActiveSession = (threadId: ThreadId) =>226 const resolveActiveSession = (threadId: ThreadId) =>
227 providerService.listSessions().pipe(227 providerService
228 Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)),228 .listSessions()
229 );229 .pipe(Effect.map((sessions) => sessions.find((session) => session.threadId === threadId)));
230230
231 const startProviderSession = (input?: {231 const startProviderSession = (input?: {
232 readonly resumeCursor?: unknown;232 readonly resumeCursor?: unknown;
...@@ -234,13 +234,15 @@ const make = Effect.gen(function* () {...@@ -234,13 +234,15 @@ const make = Effect.gen(function* () {
234 }) =>234 }) =>
235 providerService.startSession(threadId, {235 providerService.startSession(threadId, {
236 threadId,236 threadId,
237 ...(input?.provider ?? preferredProvider237 ...((input?.provider ?? preferredProvider)
238 ? { provider: input?.provider ?? preferredProvider }238 ? { provider: input?.provider ?? preferredProvider }
239 : {}),239 : {}),
240 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),240 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
241 ...(desiredModel ? { model: desiredModel } : {}),241 ...(desiredModel ? { model: desiredModel } : {}),
242 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),242 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
243 ...(options?.providerOptions !== undefined ? { providerOptions: options.providerOptions } : {}),243 ...(options?.providerOptions !== undefined
244 ? { providerOptions: options.providerOptions }
245 : {}),
244 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),246 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
245 runtimeMode: desiredRuntimeMode,247 runtimeMode: desiredRuntimeMode,
246 });248 });
...@@ -265,16 +267,15 @@ const make = Effect.gen(function* () {...@@ -265,16 +267,15 @@ const make = Effect.gen(function* () {
265 thread.session && thread.session.status !== "stopped" ? thread.id : null;267 thread.session && thread.session.status !== "stopped" ? thread.id : null;
266 if (existingSessionThreadId) {268 if (existingSessionThreadId) {
267 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;269 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
268 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;270 const providerChanged =
271 options?.provider !== undefined && options.provider !== currentProvider;
269 const activeSession = yield* resolveActiveSession(existingSessionThreadId);272 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
270 const sessionModelSwitch =273 const sessionModelSwitch =
271 currentProvider === undefined274 currentProvider === undefined
272 ? "in-session"275 ? "in-session"
273 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;276 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
274 const modelChanged =277 const modelChanged = options?.model !== undefined && options.model !== activeSession?.model;
275 options?.model !== undefined && options.model !== activeSession?.model;278 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
276 const shouldRestartForModelChange =
277 modelChanged && sessionModelSwitch === "restart-session";
278279
279 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {280 if (!runtimeModeChanged && !providerChanged && !shouldRestartForModelChange) {
280 return existingSessionThreadId;281 return existingSessionThreadId;
...@@ -345,15 +346,16 @@ const make = Effect.gen(function* () {...@@ -345,15 +346,16 @@ const make = Effect.gen(function* () {
345 });346 });
346 const normalizedInput = toNonEmptyProviderInput(input.messageText);347 const normalizedInput = toNonEmptyProviderInput(input.messageText);
347 const normalizedAttachments = input.attachments ?? [];348 const normalizedAttachments = input.attachments ?? [];
348 const activeSession = yield* providerService.listSessions().pipe(349 const activeSession = yield* providerService
349 Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),350 .listSessions()
350 );351 .pipe(
352 Effect.map((sessions) => sessions.find((session) => session.threadId === input.threadId)),
353 );
351 const sessionModelSwitch =354 const sessionModelSwitch =
352 activeSession === undefined355 activeSession === undefined
353 ? "in-session"356 ? "in-session"
354 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;357 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
355 const modelForTurn =358 const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
356 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
357359
358 yield* providerService.sendTurn({360 yield* providerService.sendTurn({
359 threadId: input.threadId,361 threadId: input.threadId,
...@@ -474,8 +476,12 @@ const make = Effect.gen(function* () {...@@ -474,8 +476,12 @@ const make = Effect.gen(function* () {
474 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),476 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
475 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),477 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
476 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),478 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
477 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),479 ...(event.payload.modelOptions !== undefined
478 ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}),480 ? { modelOptions: event.payload.modelOptions }
481 : {}),
482 ...(event.payload.providerOptions !== undefined
483 ? { providerOptions: event.payload.providerOptions }
484 : {}),
479 interactionMode: event.payload.interactionMode,485 interactionMode: event.payload.interactionMode,
480 createdAt: event.payload.createdAt,486 createdAt: event.payload.createdAt,
481 });487 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+8-3
...@@ -379,7 +379,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -379,7 +379,9 @@ describe("ProviderRuntimeIngestion", () => {
379379
380 await Effect.runPromise(Effect.sleep("40 millis"));380 await Effect.runPromise(Effect.sleep("40 millis"));
381 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());381 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
382 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));382 const midThread = midReadModel.threads.find(
383 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
384 );
383 expect(midThread?.session?.status).toBe("running");385 expect(midThread?.session?.status).toBe("running");
384 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");386 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");
385387
...@@ -620,7 +622,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -620,7 +622,9 @@ describe("ProviderRuntimeIngestion", () => {
620 const proposedPlan = thread.proposedPlans.find(622 const proposedPlan = thread.proposedPlans.find(
621 (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final",623 (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-final",
622 );624 );
623 expect(proposedPlan?.planMarkdown).toBe("## Ship plan\n\n- wire projection\n- render follow-up");625 expect(proposedPlan?.planMarkdown).toBe(
626 "## Ship plan\n\n- wire projection\n- render follow-up",
627 );
624 });628 });
625629
626 it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => {630 it("finalizes buffered proposed-plan deltas into a first-class proposed plan on turn completion", async () => {
...@@ -683,7 +687,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -683,7 +687,8 @@ describe("ProviderRuntimeIngestion", () => {
683 ),687 ),
684 );688 );
685 const proposedPlan = thread.proposedPlans.find(689 const proposedPlan = thread.proposedPlans.find(
686 (entry: ProviderRuntimeTestProposedPlan) => entry.id === "plan:thread-1:turn:turn-plan-buffer",690 (entry: ProviderRuntimeTestProposedPlan) =>
691 entry.id === "plan:thread-1:turn:turn-plan-buffer",
687 );692 );
688 expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second");693 expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second");
689 });694 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+25-26
...@@ -210,9 +210,9 @@ function runtimeEventToActivities(...@@ -210,9 +210,9 @@ function runtimeEventToActivities(
210 ? "Command approval requested"210 ? "Command approval requested"
211 : requestKind === "file-read"211 : requestKind === "file-read"
212 ? "File-read approval requested"212 ? "File-read approval requested"
213 : requestKind === "file-change"213 : requestKind === "file-change"
214 ? "File-change approval requested"214 ? "File-change approval requested"
215 : "Approval requested",215 : "Approval requested",
216 payload: {216 payload: {
217 requestId: toApprovalRequestId(event.requestId),217 requestId: toApprovalRequestId(event.requestId),
218 ...(requestKind ? { requestKind } : {}),218 ...(requestKind ? { requestKind } : {}),
...@@ -298,7 +298,9 @@ function runtimeEventToActivities(...@@ -298,7 +298,9 @@ function runtimeEventToActivities(
298 summary: "Plan updated",298 summary: "Plan updated",
299 payload: {299 payload: {
300 plan: event.payload.plan,300 plan: event.payload.plan,
301 ...(event.payload.explanation !== undefined ? { explanation: event.payload.explanation } : {}),301 ...(event.payload.explanation !== undefined
302 ? { explanation: event.payload.explanation }
303 : {}),
302 },304 },
303 turnId: toTurnId(event.turnId) ?? null,305 turnId: toTurnId(event.turnId) ?? null,
304 ...maybeSequence,306 ...maybeSequence,
...@@ -358,7 +360,9 @@ function runtimeEventToActivities(...@@ -358,7 +360,9 @@ function runtimeEventToActivities(
358 payload: {360 payload: {
359 taskId: event.payload.taskId,361 taskId: event.payload.taskId,
360 ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}),362 ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}),
361 ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}),363 ...(event.payload.description
364 ? { detail: truncateDetail(event.payload.description) }
365 : {}),
362 },366 },
363 turnId: toTurnId(event.turnId) ?? null,367 turnId: toTurnId(event.turnId) ?? null,
364 ...maybeSequence,368 ...maybeSequence,
...@@ -525,11 +529,7 @@ const make = Effect.gen(function* () {...@@ -525,11 +529,7 @@ const make = Effect.gen(function* () {
525 return isGitRepository(workspaceCwd);529 return isGitRepository(workspaceCwd);
526 });530 });
527531
528 const rememberAssistantMessageId = (532 const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
529 threadId: ThreadId,
530 turnId: TurnId,
531 messageId: MessageId,
532 ) =>
533 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(533 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
534 Effect.flatMap((existingIds) =>534 Effect.flatMap((existingIds) =>
535 Cache.set(535 Cache.set(
...@@ -547,11 +547,7 @@ const make = Effect.gen(function* () {...@@ -547,11 +547,7 @@ const make = Effect.gen(function* () {
547 ),547 ),
548 );548 );
549549
550 const forgetAssistantMessageId = (550 const forgetAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
551 threadId: ThreadId,
552 turnId: TurnId,
553 messageId: MessageId,
554 ) =>
555 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(551 Cache.getOption(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId)).pipe(
556 Effect.flatMap((existingIds) =>552 Effect.flatMap((existingIds) =>
557 Option.match(existingIds, {553 Option.match(existingIds, {
...@@ -616,7 +612,8 @@ const make = Effect.gen(function* () {...@@ -616,7 +612,8 @@ const make = Effect.gen(function* () {
616 const existing = Option.getOrUndefined(existingEntry);612 const existing = Option.getOrUndefined(existingEntry);
617 return Cache.set(bufferedProposedPlanById, planId, {613 return Cache.set(bufferedProposedPlanById, planId, {
618 text: `${existing?.text ?? ""}${delta}`,614 text: `${existing?.text ?? ""}${delta}`,
619 createdAt: existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt,615 createdAt:
616 existing?.createdAt && existing.createdAt.length > 0 ? existing.createdAt : createdAt,
620 });617 });
621 }),618 }),
622 );619 );
...@@ -633,7 +630,8 @@ const make = Effect.gen(function* () {...@@ -633,7 +630,8 @@ const make = Effect.gen(function* () {
633 const clearBufferedProposedPlan = (planId: string) =>630 const clearBufferedProposedPlan = (planId: string) =>
634 Cache.invalidate(bufferedProposedPlanById, planId);631 Cache.invalidate(bufferedProposedPlanById, planId);
635632
636 const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId);633 const clearAssistantMessageState = (messageId: MessageId) =>
634 clearBufferedAssistantText(messageId);
637635
638 const finalizeAssistantMessage = (input: {636 const finalizeAssistantMessage = (input: {
639 event: ProviderRuntimeEvent;637 event: ProviderRuntimeEvent;
...@@ -862,8 +860,8 @@ const make = Effect.gen(function* () {...@@ -862,8 +860,8 @@ const make = Effect.gen(function* () {
862 : event.type === "turn.completed" && runtimeTurnState(event) === "failed"860 : event.type === "turn.completed" && runtimeTurnState(event) === "failed"
863 ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")861 ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
864 : status === "ready"862 : status === "ready"
865 ? null863 ? null
866 : (thread.session?.lastError ?? null);864 : (thread.session?.lastError ?? null);
867865
868 if (shouldApplyThreadLifecycle) {866 if (shouldApplyThreadLifecycle) {
869 yield* orchestrationEngine.dispatch({867 yield* orchestrationEngine.dispatch({
...@@ -935,7 +933,9 @@ const make = Effect.gen(function* () {...@@ -935,7 +933,9 @@ const make = Effect.gen(function* () {
935 const assistantCompletion =933 const assistantCompletion =
936 event.type === "item.completed" && event.payload.itemType === "assistant_message"934 event.type === "item.completed" && event.payload.itemType === "assistant_message"
937 ? {935 ? {
938 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),936 messageId: MessageId.makeUnsafe(
937 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
938 ),
939 fallbackText: event.payload.detail,939 fallbackText: event.payload.detail,
940 }940 }
941 : undefined;941 : undefined;
...@@ -951,10 +951,11 @@ const make = Effect.gen(function* () {...@@ -951,10 +951,11 @@ const make = Effect.gen(function* () {
951 if (assistantCompletion) {951 if (assistantCompletion) {
952 const assistantMessageId = assistantCompletion.messageId;952 const assistantMessageId = assistantCompletion.messageId;
953 const turnId = toTurnId(event.turnId);953 const turnId = toTurnId(event.turnId);
954 const existingAssistantMessage = thread.messages.find((entry) => entry.id === assistantMessageId);954 const existingAssistantMessage = thread.messages.find(
955 (entry) => entry.id === assistantMessageId,
956 );
955 const shouldApplyFallbackCompletionText =957 const shouldApplyFallbackCompletionText =
956 !existingAssistantMessage ||958 !existingAssistantMessage || existingAssistantMessage.text.length === 0;
957 existingAssistantMessage.text.length === 0;
958 if (turnId) {959 if (turnId) {
959 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);960 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
960 }961 }
...@@ -1029,9 +1030,7 @@ const make = Effect.gen(function* () {...@@ -1029,9 +1030,7 @@ const make = Effect.gen(function* () {
10291030
1030 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD1031 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
1031 ? true1032 ? true
1032 : activeTurnId === null ||1033 : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);
1033 eventTurnId === undefined ||
1034 sameId(activeTurnId, eventTurnId);
10351034
1036 if (shouldApplyRuntimeError) {1035 if (shouldApplyRuntimeError) {
1037 yield* orchestrationEngine.dispatch({1036 yield* orchestrationEngine.dispatch({
apps/server/src/orchestration/decider.ts+3-1
...@@ -302,7 +302,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -302,7 +302,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
302 ...(command.provider !== undefined ? { provider: command.provider } : {}),302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303 ...(command.model !== undefined ? { model: command.model } : {}),303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305 ...(command.providerOptions !== undefined ? { providerOptions: command.providerOptions } : {}),305 ...(command.providerOptions !== undefined
306 ? { providerOptions: command.providerOptions }
307 : {}),
306 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,308 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
307 runtimeMode:309 runtimeMode:
308 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??310 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
apps/server/src/orchestration/projector.ts+1-6
...@@ -304,12 +304,7 @@ export function projectEvent(...@@ -304,12 +304,7 @@ export function projectEvent(
304 );304 );
305305
306 case "thread.runtime-mode-set":306 case "thread.runtime-mode-set":
307 return decodeForEvent(307 return decodeForEvent(ThreadRuntimeModeSetPayload, event.payload, event.type, "payload").pipe(
308 ThreadRuntimeModeSetPayload,
309 event.payload,
310 event.type,
311 "payload",
312 ).pipe(
313 Effect.map((payload) => ({308 Effect.map((payload) => ({
314 ...nextBase,309 ...nextBase,
315 threads: updateThread(nextBase.threads, payload.threadId, {310 threads: updateThread(nextBase.threads, payload.threadId, {
apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts+1-3
...@@ -70,9 +70,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {...@@ -70,9 +70,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () {
7070
71 const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) =>71 const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) =>
72 upsertProjectionThreadProposedPlanRow(row).pipe(72 upsertProjectionThreadProposedPlanRow(row).pipe(
73 Effect.mapError(73 Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")),
74 toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query"),
75 ),
76 );74 );
7775
78 const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) =>76 const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) =>
apps/server/src/projectFaviconRoute.test.ts+9-5
...@@ -20,9 +20,7 @@ function makeTempDir(prefix: string): string {...@@ -20,9 +20,7 @@ function makeTempDir(prefix: string): string {
20 return dir;20 return dir;
21}21}
2222
23async function withRouteServer(23async function withRouteServer(run: (baseUrl: string) => Promise<void>): Promise<void> {
24 run: (baseUrl: string) => Promise<void>,
25): Promise<void> {
26 const server = http.createServer((req, res) => {24 const server = http.createServer((req, res) => {
27 const url = new URL(req.url ?? "/", "http://127.0.0.1");25 const url = new URL(req.url ?? "/", "http://127.0.0.1");
28 if (tryHandleProjectFaviconRequest(url, res)) {26 if (tryHandleProjectFaviconRequest(url, res)) {
...@@ -104,7 +102,10 @@ describe("tryHandleProjectFaviconRequest", () => {...@@ -104,7 +102,10 @@ describe("tryHandleProjectFaviconRequest", () => {
104 const projectDir = makeTempDir("t3code-favicon-route-source-");102 const projectDir = makeTempDir("t3code-favicon-route-source-");
105 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");103 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
106 fs.mkdirSync(path.dirname(iconPath), { recursive: true });104 fs.mkdirSync(path.dirname(iconPath), { recursive: true });
107 fs.writeFileSync(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">');105 fs.writeFileSync(
106 path.join(projectDir, "index.html"),
107 '<link rel="icon" href="/brand/logo.svg">',
108 );
108 fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");109 fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8");
109110
110 await withRouteServer(async (baseUrl) => {111 await withRouteServer(async (baseUrl) => {
...@@ -120,7 +121,10 @@ describe("tryHandleProjectFaviconRequest", () => {...@@ -120,7 +121,10 @@ describe("tryHandleProjectFaviconRequest", () => {
120 const projectDir = makeTempDir("t3code-favicon-route-html-order-");121 const projectDir = makeTempDir("t3code-favicon-route-html-order-");
121 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");122 const iconPath = path.join(projectDir, "public", "brand", "logo.svg");
122 fs.mkdirSync(path.dirname(iconPath), { recursive: true });123 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(
125 path.join(projectDir, "index.html"),
126 '<link href="/brand/logo.svg" rel="icon">',
127 );
124 fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");128 fs.writeFileSync(iconPath, "<svg>brand-html-order</svg>", "utf8");
125129
126 await withRouteServer(async (baseUrl) => {130 await withRouteServer(async (baseUrl) => {
apps/server/src/provider/Layers/CodexAdapter.ts+7-5
...@@ -403,7 +403,9 @@ function asRuntimeTaskId(taskId: string): RuntimeTaskId {...@@ -403,7 +403,9 @@ function asRuntimeTaskId(taskId: string): RuntimeTaskId {
403 return RuntimeTaskId.makeUnsafe(taskId);403 return RuntimeTaskId.makeUnsafe(taskId);
404}404}
405405
406function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined {406function codexEventMessage(
407 payload: Record<string, unknown> | undefined,
408): Record<string, unknown> | undefined {
407 return asObject(payload?.msg);409 return asObject(payload?.msg);
408}410}
409411
...@@ -1037,7 +1039,9 @@ function mapToRuntimeEvents(...@@ -1037,7 +1039,9 @@ function mapToRuntimeEvents(
1037 type: "content.delta",1039 type: "content.delta",
1038 payload: {1040 payload: {
1039 streamKind:1041 streamKind:
1040 asNumber(msg?.summary_index) !== undefined ? "reasoning_summary_text" : "reasoning_text",1042 asNumber(msg?.summary_index) !== undefined
1043 ? "reasoning_summary_text"
1044 : "reasoning_text",
1041 delta,1045 delta,
1042 ...(asNumber(msg?.summary_index) !== undefined1046 ...(asNumber(msg?.summary_index) !== undefined
1043 ? { summaryIndex: asNumber(msg?.summary_index) }1047 ? { summaryIndex: asNumber(msg?.summary_index) }
...@@ -1314,9 +1318,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1314,9 +1318,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1314 detail: toMessage(cause, "Failed to start Codex adapter session."),1318 detail: toMessage(cause, "Failed to start Codex adapter session."),
1315 cause,1319 cause,
1316 }),1320 }),
1317 }).pipe(1321 }).pipe(Effect.map((session) => session));
1318 Effect.map((session) => session),
1319 );
1320 };1322 };
13211323
1322 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>1324 const sendTurn: CodexAdapterShape["sendTurn"] = (input) =>
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-4
...@@ -29,10 +29,7 @@ const fakeCodexAdapter: CodexAdapterShape = {...@@ -29,10 +29,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
2929
30const layer = it.layer(30const layer = it.layer(
31 Layer.mergeAll(31 Layer.mergeAll(
32 Layer.provide(32 Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)),
33 ProviderAdapterRegistryLive,
34 Layer.succeed(CodexAdapter, fakeCodexAdapter),
35 ),
36 NodeServices.layer,33 NodeServices.layer,
37 ),34 ),
38);35);
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-4
...@@ -23,10 +23,7 @@ export interface ProviderAdapterRegistryLiveOptions {...@@ -23,10 +23,7 @@ export interface ProviderAdapterRegistryLiveOptions {
2323
24const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>24const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>
25 Effect.gen(function* () {25 Effect.gen(function* () {
26 const adapters =26 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];
27 options?.adapters !== undefined
28 ? options.adapters
29 : [yield* CodexAdapter];
30 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));27 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3128
32 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {29 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderHealth.test.ts+20-23
...@@ -132,30 +132,27 @@ it.effect("returns unauthenticated when auth probe reports login required", () =...@@ -132,30 +132,27 @@ it.effect("returns unauthenticated when auth probe reports login required", () =
132 ),132 ),
133);133);
134134
135it.effect(135it.effect("returns unauthenticated when login status output includes 'not logged in'", () =>
136 "returns unauthenticated when login status output includes 'not logged in'",136 Effect.gen(function* () {
137 () =>137 const status = yield* checkCodexProviderStatus;
138 Effect.gen(function* () {138 assert.strictEqual(status.provider, "codex");
139 const status = yield* checkCodexProviderStatus;139 assert.strictEqual(status.status, "error");
140 assert.strictEqual(status.provider, "codex");140 assert.strictEqual(status.available, true);
141 assert.strictEqual(status.status, "error");141 assert.strictEqual(status.authStatus, "unauthenticated");
142 assert.strictEqual(status.available, true);142 assert.strictEqual(
143 assert.strictEqual(status.authStatus, "unauthenticated");143 status.message,
144 assert.strictEqual(144 "Codex CLI is not authenticated. Run `codex login` and try again.",
145 status.message,145 );
146 "Codex CLI is not authenticated. Run `codex login` and try again.",146 }).pipe(
147 );147 Effect.provide(
148 }).pipe(148 mockSpawnerLayer((args) => {
149 Effect.provide(149 const joined = args.join(" ");
150 mockSpawnerLayer((args) => {150 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
151 const joined = args.join(" ");151 if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 };
152 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };152 throw new Error(`Unexpected args: ${joined}`);
153 if (joined === "login status")153 }),
154 return { stdout: "Not logged in\n", stderr: "", code: 1 };
155 throw new Error(`Unexpected args: ${joined}`);
156 }),
157 ),
158 ),154 ),
155 ),
159);156);
160157
161it.effect("returns warning when login status command is unsupported", () =>158it.effect("returns warning when login status command is unsupported", () =>
apps/server/src/provider/Layers/ProviderService.test.ts-1
...@@ -635,7 +635,6 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -635,7 +635,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
635 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");635 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
636 }636 }
637 }637 }
638
639 }),638 }),
640 );639 );
641});640});
apps/server/src/provider/Layers/ProviderService.ts+22-20
...@@ -141,9 +141,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -141,9 +141,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
141 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>141 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
142 Effect.succeed(event).pipe(142 Effect.succeed(event).pipe(
143 Effect.tap((canonicalEvent) =>143 Effect.tap((canonicalEvent) =>
144 canonicalEventLogger144 canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void,
145 ? canonicalEventLogger.write(canonicalEvent, null)
146 : Effect.void,
147 ),145 ),
148 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),146 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
149 Effect.asVoid,147 Effect.asVoid,
...@@ -193,7 +191,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -193,7 +191,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
193 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);191 const hasActiveSession = yield* adapter.hasSession(input.binding.threadId);
194 if (hasActiveSession) {192 if (hasActiveSession) {
195 const activeSessions = yield* adapter.listSessions();193 const activeSessions = yield* adapter.listSessions();
196 const existing = activeSessions.find((session) => session.threadId === input.binding.threadId);194 const existing = activeSessions.find(
195 (session) => session.threadId === input.binding.threadId,
196 );
197 if (existing) {197 if (existing) {
198 yield* upsertSessionBinding(existing, input.binding.threadId);198 yield* upsertSessionBinding(existing, input.binding.threadId);
199 yield* analytics.record("provider.session.recovered", {199 yield* analytics.record("provider.session.recovered", {
...@@ -292,7 +292,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -292,7 +292,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
292 }292 }
293293
294 yield* upsertSessionBinding(session, threadId, {294 yield* upsertSessionBinding(session, threadId, {
295 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),295 ...(input.providerOptions !== undefined
296 ? { providerOptions: input.providerOptions }
297 : {}),
296 });298 });
297 yield* analytics.record("provider.session.started", {299 yield* analytics.record("provider.session.started", {
298 provider: session.provider,300 provider: session.provider,
...@@ -425,23 +427,23 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -425,23 +427,23 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
425427
426 const listSessions: ProviderServiceShape["listSessions"] = () =>428 const listSessions: ProviderServiceShape["listSessions"] = () =>
427 Effect.gen(function* () {429 Effect.gen(function* () {
428 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) => adapter.listSessions());430 const sessionsByProvider = yield* Effect.forEach(adapters, (adapter) =>
431 adapter.listSessions(),
432 );
429 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);433 const activeSessions = sessionsByProvider.flatMap((sessions) => sessions);
430 const persistedBindings = yield* directory434 const persistedBindings = yield* directory.listThreadIds().pipe(
431 .listThreadIds()435 Effect.flatMap((threadIds) =>
432 .pipe(436 Effect.forEach(
433 Effect.flatMap((threadIds) =>437 threadIds,
434 Effect.forEach(438 (threadId) =>
435 threadIds,439 directory
436 (threadId) =>440 .getBinding(threadId)
437 directory.getBinding(threadId).pipe(441 .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
438 Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>()),442 { concurrency: "unbounded" },
439 ),
440 { concurrency: "unbounded" },
441 ),
442 ),443 ),
443 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),444 ),
444 );445 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
446 );
445 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();447 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();
446 for (const bindingOption of persistedBindings) {448 for (const bindingOption of persistedBindings) {
447 const binding = Option.getOrUndefined(bindingOption);449 const binding = Option.getOrUndefined(bindingOption);
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+4-5
...@@ -2,10 +2,7 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts";...@@ -2,10 +2,7 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
33
4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import {5import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts";
6 ProviderSessionDirectoryPersistenceError,
7 ProviderValidationError,
8} from "../Errors.ts";
9import {6import {
10 ProviderSessionDirectory,7 ProviderSessionDirectory,
11 type ProviderRuntimeBinding,8 type ProviderRuntimeBinding,
...@@ -138,7 +135,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -138,7 +135,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
138 const remove: ProviderSessionDirectoryShape["remove"] = (threadId) =>135 const remove: ProviderSessionDirectoryShape["remove"] = (threadId) =>
139 repository136 repository
140 .deleteByThreadId({ threadId })137 .deleteByThreadId({ threadId })
141 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")));138 .pipe(
139 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")),
140 );
142141
143 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>142 const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () =>
144 repository.list().pipe(143 repository.list().pipe(
apps/server/src/provider/Services/ProviderAdapter.ts+2-7
...@@ -66,10 +66,7 @@ export interface ProviderAdapterShape<TError> {...@@ -66,10 +66,7 @@ export interface ProviderAdapterShape<TError> {
66 /**66 /**
67 * Interrupt an active turn.67 * Interrupt an active turn.
68 */68 */
69 readonly interruptTurn: (69 readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect<void, TError>;
70 threadId: ThreadId,
71 turnId?: TurnId,
72 ) => Effect.Effect<void, TError>;
7370
74 /**71 /**
75 * Respond to an interactive approval request.72 * Respond to an interactive approval request.
...@@ -107,9 +104,7 @@ export interface ProviderAdapterShape<TError> {...@@ -107,9 +104,7 @@ export interface ProviderAdapterShape<TError> {
107 /**104 /**
108 * Read a provider thread snapshot.105 * Read a provider thread snapshot.
109 */106 */
110 readonly readThread: (107 readonly readThread: (threadId: ThreadId) => Effect.Effect<ProviderThreadSnapshot, TError>;
111 threadId: ThreadId,
112 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
113108
114 /**109 /**
115 * Roll back a provider thread by N turns.110 * Roll back a provider thread by N turns.
apps/server/src/wsServer.test.ts+16-16
...@@ -1236,21 +1236,19 @@ describe("WebSocket Server", () => {...@@ -1236,21 +1236,19 @@ describe("WebSocket Server", () => {
1236 return event.type === "thread.session-set";1236 return event.type === "thread.session-set";
1237 });1237 });
12381238
1239 emitRuntimeEvent(1239 emitRuntimeEvent({
1240 {1240 type: "content.delta",
1241 type: "content.delta",1241 eventId: asEventId("evt-ws-runtime-message-delta"),
1242 eventId: asEventId("evt-ws-runtime-message-delta"),1242 provider: "codex",
1243 provider: "codex",1243 threadId: asThreadId("thread-1"),
1244 threadId: asThreadId("thread-1"),1244 createdAt: new Date().toISOString(),
1245 createdAt: new Date().toISOString(),1245 turnId: asTurnId("turn-1"),
1246 turnId: asTurnId("turn-1"),1246 itemId: asProviderItemId("item-1"),
1247 itemId: asProviderItemId("item-1"),1247 payload: {
1248 payload: {1248 streamKind: "assistant_text",
1249 streamKind: "assistant_text",1249 delta: "hello from runtime",
1250 delta: "hello from runtime",1250 },
1251 },1251 } as unknown as ProviderRuntimeEvent);
1252 } as unknown as ProviderRuntimeEvent,
1253 );
12541252
1255 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {1253 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
1256 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };1254 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
...@@ -1568,7 +1566,9 @@ describe("WebSocket Server", () => {...@@ -1568,7 +1566,9 @@ describe("WebSocket Server", () => {
1568 });1566 });
15691567
1570 expect(response.result).toBeUndefined();1568 expect(response.result).toBeUndefined();
1571 expect(response.error?.message).toContain("Workspace file path must stay within the project root.");1569 expect(response.error?.message).toContain(
1570 "Workspace file path must stay within the project root.",
1571 );
1572 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);1572 expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false);
1573 });1573 });
15741574
apps/server/src/wsServer.ts+12-14
...@@ -198,8 +198,7 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {...@@ -198,8 +198,7 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
198198
199function messageFromCause(cause: Cause.Cause<unknown>): string {199function messageFromCause(cause: Cause.Cause<unknown>): string {
200 const squashed = Cause.squash(cause);200 const squashed = Cause.squash(cause);
201 const message =201 const message = squashed instanceof Error ? squashed.message.trim() : String(squashed).trim();
202 squashed instanceof Error ? squashed.message.trim() : String(squashed).trim();
203 return message.length > 0 ? message : Cause.pretty(cause);202 return message.length > 0 ? message : Cause.pretty(cause);
204}203}
205204
...@@ -330,10 +329,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -330,10 +329,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
330 } satisfies OrchestrationCommand;329 } satisfies OrchestrationCommand;
331 }330 }
332331
333 if (332 if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) {
334 input.command.type === "project.meta.update" &&
335 input.command.workspaceRoot !== undefined
336 ) {
337 return {333 return {
338 ...input.command,334 ...input.command,
339 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),335 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
...@@ -793,14 +789,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -793,14 +789,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
793 relativePath: body.relativePath,789 relativePath: body.relativePath,
794 path,790 path,
795 });791 });
796 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(792 yield* fileSystem
797 Effect.mapError(793 .makeDirectory(path.dirname(target.absolutePath), { recursive: true })
798 (cause) =>794 .pipe(
799 new RouteRequestError({795 Effect.mapError(
800 message: `Failed to prepare workspace path: ${String(cause)}`,796 (cause) =>
801 }),797 new RouteRequestError({
802 ),798 message: `Failed to prepare workspace path: ${String(cause)}`,
803 );799 }),
800 ),
801 );
804 yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe(802 yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe(
805 Effect.mapError(803 Effect.mapError(
806 (cause) =>804 (cause) =>
apps/web/src/appSettings.test.ts+2-12
...@@ -61,23 +61,13 @@ describe("resolveAppModelSelection", () => {...@@ -61,23 +61,13 @@ describe("resolveAppModelSelection", () => {
6161
62describe("getSlashModelOptions", () => {62describe("getSlashModelOptions", () => {
63 it("includes saved custom model slugs for /model command suggestions", () => {63 it("includes saved custom model slugs for /model command suggestions", () => {
64 const options = getSlashModelOptions(64 const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex");
65 "codex",
66 ["custom/internal-model"],
67 "",
68 "gpt-5.3-codex",
69 );
7065
71 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);66 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
72 });67 });
7368
74 it("filters slash-model suggestions across built-in and custom model names", () => {69 it("filters slash-model suggestions across built-in and custom model names", () => {
75 const options = getSlashModelOptions(70 const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex");
76 "codex",
77 ["openai/gpt-oss-120b"],
78 "oss",
79 "gpt-5.3-codex",
80 );
8171
82 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);72 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
83 });73 });
apps/web/src/components/BranchToolbar.tsx+1-3
...@@ -130,9 +130,7 @@ export default function BranchToolbar({...@@ -130,9 +130,7 @@ export default function BranchToolbar({
130 effectiveEnvMode={effectiveEnvMode}130 effectiveEnvMode={effectiveEnvMode}
131 envLocked={envLocked}131 envLocked={envLocked}
132 onSetThreadBranch={setThreadBranch}132 onSetThreadBranch={setThreadBranch}
133 {...(onCheckoutPullRequestRequest133 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
134 ? { onCheckoutPullRequestRequest }
135 : {})}
136 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}134 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
137 />135 />
138 </div>136 </div>
apps/web/src/components/BranchToolbarBranchSelector.tsx+13-19
...@@ -113,27 +113,23 @@ export function BranchToolbarBranchSelector({...@@ -113,27 +113,23 @@ export function BranchToolbarBranchSelector({
113 const prReference = parsePullRequestReference(trimmedBranchQuery);113 const prReference = parsePullRequestReference(trimmedBranchQuery);
114 const isSelectingWorktreeBase =114 const isSelectingWorktreeBase =
115 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;115 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
116 const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest116 const checkoutPullRequestItemValue =
117 ? `__checkout_pull_request__:${prReference}`117 prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null;
118 : null;
119 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;118 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
120 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);119 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
121 const createBranchItemValue = canCreateBranch120 const createBranchItemValue = canCreateBranch
122 ? `__create_new_branch__:${trimmedBranchQuery}`121 ? `__create_new_branch__:${trimmedBranchQuery}`
123 : null;122 : null;
124 const branchPickerItems = useMemo(123 const branchPickerItems = useMemo(() => {
125 () => {124 const items = [...branchNames];
126 const items = [...branchNames];125 if (createBranchItemValue && !hasExactBranchMatch) {
127 if (createBranchItemValue && !hasExactBranchMatch) {126 items.push(createBranchItemValue);
128 items.push(createBranchItemValue);127 }
129 }128 if (checkoutPullRequestItemValue) {
130 if (checkoutPullRequestItemValue) {129 items.unshift(checkoutPullRequestItemValue);
131 items.unshift(checkoutPullRequestItemValue);130 }
132 }131 return items;
133 return items;132 }, [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch]);
134 },
135 [branchNames, checkoutPullRequestItemValue, createBranchItemValue, hasExactBranchMatch],
136 );
137 const filteredBranchPickerItems = useMemo(133 const filteredBranchPickerItems = useMemo(
138 () =>134 () =>
139 normalizedDeferredBranchQuery.length === 0135 normalizedDeferredBranchQuery.length === 0
...@@ -397,9 +393,7 @@ export function BranchToolbarBranchSelector({...@@ -397,9 +393,7 @@ export function BranchToolbarBranchSelector({
397 >393 >
398 <div className="flex w-full items-center justify-between gap-2">394 <div className="flex w-full items-center justify-between gap-2">
399 <span className="truncate">{itemValue}</span>395 <span className="truncate">{itemValue}</span>
400 {badge && (396 {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
401 <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>
402 )}
403 </div>397 </div>
404 </ComboboxItem>398 </ComboboxItem>
405 );399 );
apps/web/src/components/ChatView.browser.tsx+18-8
...@@ -414,9 +414,9 @@ async function setViewport(viewport: ViewportSpec): Promise<void> {...@@ -414,9 +414,9 @@ async function setViewport(viewport: ViewportSpec): Promise<void> {
414async function waitForProductionStyles(): Promise<void> {414async function waitForProductionStyles(): Promise<void> {
415 await vi.waitFor(415 await vi.waitFor(
416 () => {416 () => {
417 expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe(417 expect(
418 "",418 getComputedStyle(document.documentElement).getPropertyValue("--background").trim(),
419 );419 ).not.toBe("");
420 expect(getComputedStyle(document.body).marginTop).toBe("0px");420 expect(getComputedStyle(document.body).marginTop).toBe("0px");
421 },421 },
422 {422 {
...@@ -454,7 +454,9 @@ async function waitForComposerEditor(): Promise<HTMLElement> {...@@ -454,7 +454,9 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
454 );454 );
455}455}
456456
457async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise<HTMLButtonElement> {457async function waitForInteractionModeButton(
458 expectedLabel: "Chat" | "Plan",
459): Promise<HTMLButtonElement> {
458 return waitForElement(460 return waitForElement(
459 () =>461 () =>
460 Array.from(document.querySelectorAll("button")).find(462 Array.from(document.querySelectorAll("button")).find(
...@@ -697,7 +699,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -697,7 +699,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
697 });699 });
698700
699 try {701 try {
700 const measurements: Array<UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }> = [];702 const measurements: Array<
703 UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }
704 > = [];
701705
702 for (const viewport of TEXT_VIEWPORT_MATRIX) {706 for (const viewport of TEXT_VIEWPORT_MATRIX) {
703 await mounted.setViewport(viewport);707 await mounted.setViewport(viewport);
...@@ -714,7 +718,10 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -714,7 +718,10 @@ describe("ChatView timeline estimator parity (full app)", () => {
714 measurements.push({ ...measurement, viewport, estimatedHeightPx });718 measurements.push({ ...measurement, viewport, estimatedHeightPx });
715 }719 }
716720
717 expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3);721 expect(
722 new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx)))
723 .size,
724 ).toBeGreaterThanOrEqual(3);
718725
719 const byMeasuredWidth = measurements.toSorted(726 const byMeasuredWidth = measurements.toSorted(
720 (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx,727 (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx,
...@@ -756,7 +763,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -756,7 +763,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
756 { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx },763 { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx },
757 );764 );
758765
759 const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;766 const measuredDeltaPx =
767 mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx;
760 const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx;768 const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx;
761 expect(measuredDeltaPx).toBeGreaterThan(0);769 expect(measuredDeltaPx).toBeGreaterThan(0);
762 expect(estimatedDeltaPx).toBeGreaterThan(0);770 expect(estimatedDeltaPx).toBeGreaterThan(0);
...@@ -844,7 +852,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -844,7 +852,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
844852
845 await vi.waitFor(853 await vi.waitFor(
846 () => {854 () => {
847 const openRequest = wsRequests.find((request) => request._tag === WS_METHODS.shellOpenInEditor);855 const openRequest = wsRequests.find(
856 (request) => request._tag === WS_METHODS.shellOpenInEditor,
857 );
848 expect(openRequest).toMatchObject({858 expect(openRequest).toMatchObject({
849 _tag: WS_METHODS.shellOpenInEditor,859 _tag: WS_METHODS.shellOpenInEditor,
850 cwd: "/repo/project",860 cwd: "/repo/project",
apps/web/src/components/ChatView.tsx+21-19
...@@ -615,7 +615,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -615,7 +615,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
615 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);615 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
616 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);616 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
617 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);617 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
618 const getDraftThreadByProjectId = useComposerDraftStore((store) => store.getDraftThreadByProjectId);618 const getDraftThreadByProjectId = useComposerDraftStore(
619 (store) => store.getDraftThreadByProjectId,
620 );
619 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);621 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
620 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);622 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
621 const clearProjectDraftThreadId = useComposerDraftStore(623 const clearProjectDraftThreadId = useComposerDraftStore(
...@@ -657,9 +659,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -657,9 +659,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
657 const [nowTick, setNowTick] = useState(() => Date.now());659 const [nowTick, setNowTick] = useState(() => Date.now());
658 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);660 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
659 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);661 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
660 const [pullRequestDialogState, setPullRequestDialogState] = useState<PullRequestDialogState | null>(662 const [pullRequestDialogState, setPullRequestDialogState] =
661 null,663 useState<PullRequestDialogState | null>(null);
662 );
663 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<664 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
664 Record<string, string[]>665 Record<string, string[]>
665 >({});666 >({});
...@@ -765,16 +766,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -765,16 +766,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
765 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);766 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
766 const activeProject = projects.find((p) => p.id === activeThread?.projectId);767 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
767768
768 const openPullRequestDialog = useCallback((reference?: string) => {769 const openPullRequestDialog = useCallback(
769 if (!canCheckoutPullRequestIntoThread) {770 (reference?: string) => {
770 return;771 if (!canCheckoutPullRequestIntoThread) {
771 }772 return;
772 setPullRequestDialogState({773 }
773 initialReference: reference ?? null,774 setPullRequestDialogState({
774 key: Date.now(),775 initialReference: reference ?? null,
775 });776 key: Date.now(),
776 setComposerHighlightedItemId(null);777 });
777 }, [canCheckoutPullRequestIntoThread]);778 setComposerHighlightedItemId(null);
779 },
780 [canCheckoutPullRequestIntoThread],
781 );
778782
779 const closePullRequestDialog = useCallback(() => {783 const closePullRequestDialog = useCallback(() => {
780 setPullRequestDialogState(null);784 setPullRequestDialogState(null);
...@@ -4131,7 +4135,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4131,7 +4135,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
4131 onPrepared={handlePreparedPullRequestThread}4135 onPrepared={handlePreparedPullRequestThread}
4132 />4136 />
4133 ) : null}4137 ) : null}
4134 </div>{/* end chat column */}4138 </div>
4139 {/* end chat column */}
41354140
4136 {/* Plan sidebar */}4141 {/* Plan sidebar */}
4137 {planSidebarOpen ? (4142 {planSidebarOpen ? (
...@@ -5744,10 +5749,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5744,10 +5749,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5744 }5749 }
5745 >5750 >
5746 <span5751 <span
5747 className={cn(5752 className={cn("flex min-w-0 items-center gap-2", props.compact ? "max-w-36" : undefined)}
5748 "flex min-w-0 items-center gap-2",
5749 props.compact ? "max-w-36" : undefined,
5750 )}
5751 >5753 >
5752 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />5754 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
5753 <span className="truncate">{selectedModelLabel}</span>5755 <span className="truncate">{selectedModelLabel}</span>
apps/web/src/components/ComposerPromptEditor.tsx+48-48
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1import {1import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer";
2 LexicalComposer,
3 type InitialConfigType,
4} from "@lexical/react/LexicalComposer";
5import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";2import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
6import { ContentEditable } from "@lexical/react/LexicalContentEditable";3import { ContentEditable } from "@lexical/react/LexicalContentEditable";
7import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";4import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
...@@ -331,12 +328,11 @@ function $setSelectionAtComposerOffset(nextOffset: number): void {...@@ -331,12 +328,11 @@ function $setSelectionAtComposerOffset(nextOffset: number): void {
331 const composerLength = $getComposerRootLength();328 const composerLength = $getComposerRootLength();
332 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));329 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));
333 const remainingRef = { value: boundedOffset };330 const remainingRef = { value: boundedOffset };
334 const point =331 const point = findSelectionPointAtOffset(root, remainingRef) ?? {
335 findSelectionPointAtOffset(root, remainingRef) ?? {332 key: root.getKey(),
336 key: root.getKey(),333 offset: root.getChildren().length,
337 offset: root.getChildren().length,334 type: "element" as const,
338 type: "element" as const,335 };
339 };
340 const selection = $createRangeSelection();336 const selection = $createRangeSelection();
341 selection.anchor.set(point.key, point.offset, point.type);337 selection.anchor.set(point.key, point.offset, point.type);
342 selection.focus.set(point.key, point.offset, point.type);338 selection.focus.set(point.key, point.offset, point.type);
...@@ -724,7 +720,10 @@ function ComposerPromptEditorInner({...@@ -724,7 +720,10 @@ function ComposerPromptEditorInner({
724 editorState.read(() => {720 editorState.read(() => {
725 const nextValue = $getRoot().getTextContent();721 const nextValue = $getRoot().getTextContent();
726 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);722 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
727 const nextCursor = clampCursor(nextValue, $readSelectionOffsetFromEditorState(fallbackCursor));723 const nextCursor = clampCursor(
724 nextValue,
725 $readSelectionOffsetFromEditorState(fallbackCursor),
726 );
728 const previousSnapshot = snapshotRef.current;727 const previousSnapshot = snapshotRef.current;
729 if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {728 if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {
730 return;729 return;
...@@ -771,41 +770,42 @@ function ComposerPromptEditorInner({...@@ -771,41 +770,42 @@ function ComposerPromptEditorInner({
771 );770 );
772}771}
773772
774export const ComposerPromptEditor = forwardRef<ComposerPromptEditorHandle, ComposerPromptEditorProps>(773export const ComposerPromptEditor = forwardRef<
775 function ComposerPromptEditor(774 ComposerPromptEditorHandle,
776 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },775 ComposerPromptEditorProps
777 ref,776>(function ComposerPromptEditor(
778 ) {777 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },
779 const initialValueRef = useRef(value);778 ref,
780 const initialConfig = useMemo<InitialConfigType>(779) {
781 () => ({780 const initialValueRef = useRef(value);
782 namespace: "t3tools-composer-editor",781 const initialConfig = useMemo<InitialConfigType>(
783 editable: true,782 () => ({
784 nodes: [ComposerMentionNode],783 namespace: "t3tools-composer-editor",
785 editorState: () => {784 editable: true,
786 $setComposerEditorPrompt(initialValueRef.current);785 nodes: [ComposerMentionNode],
787 },786 editorState: () => {
788 onError: (error) => {787 $setComposerEditorPrompt(initialValueRef.current);
789 throw error;788 },
790 },789 onError: (error) => {
791 }),790 throw error;
792 [],791 },
793 );792 }),
793 [],
794 );
794795
795 return (796 return (
796 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>797 <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}>
797 <ComposerPromptEditorInner798 <ComposerPromptEditorInner
798 value={value}799 value={value}
799 cursor={cursor}800 cursor={cursor}
800 disabled={disabled}801 disabled={disabled}
801 placeholder={placeholder}802 placeholder={placeholder}
802 onChange={onChange}803 onChange={onChange}
803 onPaste={onPaste}804 onPaste={onPaste}
804 editorRef={ref}805 editorRef={ref}
805 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}806 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
806 {...(className ? { className } : {})}807 {...(className ? { className } : {})}
807 />808 />
808 </LexicalComposer>809 </LexicalComposer>
809 );810 );
810 },811});
811);
apps/web/src/components/DiffPanel.tsx+8-1
...@@ -4,7 +4,14 @@ import { useQuery } from "@tanstack/react-query";...@@ -4,7 +4,14 @@ import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";5import { ThreadId, type TurnId } from "@t3tools/contracts";
6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
7import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";7import {
8 type WheelEvent as ReactWheelEvent,
9 useCallback,
10 useEffect,
11 useMemo,
12 useRef,
13 useState,
14} from "react";
8import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";15import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
9import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";16import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
10import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
apps/web/src/components/GitActionsControl.logic.test.ts+2-8
...@@ -577,10 +577,7 @@ describe("when: branch has no upstream configured", () => {...@@ -577,10 +577,7 @@ describe("when: branch has no upstream configured", () => {
577 });577 });
578578
579 it("buildMenuItems disables push and create PR when no commits are ahead", () => {579 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
580 const items = buildMenuItems(580 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false);
581 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
582 false,
583 );
584 assert.deepEqual(items, [581 assert.deepEqual(items, [
585 {582 {
586 id: "commit",583 id: "commit",
...@@ -627,10 +624,7 @@ describe("when: branch has no upstream configured", () => {...@@ -627,10 +624,7 @@ describe("when: branch has no upstream configured", () => {
627 });624 });
628625
629 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {626 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {
630 const items = buildMenuItems(627 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false);
631 status({ hasUpstream: false, pr: null, aheadCount: 2 }),
632 false,
633 );
634 assert.deepEqual(items, [628 assert.deepEqual(items, [
635 {629 {
636 id: "commit",630 id: "commit",
apps/web/src/components/GitActionsControl.logic.ts+1-6
...@@ -123,12 +123,7 @@ export function buildMenuItems(...@@ -123,12 +123,7 @@ export function buildMenuItems(
123 const canCommit = !isBusy && hasChanges;123 const canCommit = !isBusy && hasChanges;
124 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;124 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
125 const canCreatePr =125 const canCreatePr =
126 !isBusy &&126 !isBusy && hasBranch && !hasChanges && !hasOpenPr && gitStatus.aheadCount > 0 && !isBehind;
127 hasBranch &&
128 !hasChanges &&
129 !hasOpenPr &&
130 gitStatus.aheadCount > 0 &&
131 !isBehind;
132 const canOpenPr = !isBusy && hasOpenPr;127 const canOpenPr = !isBusy && hasOpenPr;
133128
134 return [129 return [
apps/web/src/components/GitActionsControl.tsx+17-11
...@@ -255,7 +255,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -255,7 +255,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
255 }) => {255 }) => {
256 const actionStatus = statusOverride ?? gitStatusForActions;256 const actionStatus = statusOverride ?? gitStatusForActions;
257 const actionBranch = actionStatus?.branch ?? null;257 const actionBranch = actionStatus?.branch ?? null;
258 const actionIsDefaultBranch = isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);258 const actionIsDefaultBranch =
259 isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch);
259 const includesCommit =260 const includesCommit =
260 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);261 !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges);
261 if (262 if (
...@@ -331,7 +332,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -331,7 +332,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
331 stopProgressUpdates();332 stopProgressUpdates();
332 const resultToast = summarizeGitResult(result);333 const resultToast = summarizeGitResult(result);
333334
334 const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;335 const existingOpenPrUrl =
336 actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined;
335 const prUrl = result.pr.url ?? existingOpenPrUrl;337 const prUrl = result.pr.url ?? existingOpenPrUrl;
336 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";338 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
337 const shouldOfferOpenPrCta =339 const shouldOfferOpenPrCta =
...@@ -374,12 +376,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -374,12 +376,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
374 },376 },
375 }377 }
376 : shouldOfferOpenPrCta378 : shouldOfferOpenPrCta
377 ? {379 ? {
378 actionProps: {380 actionProps: {
379 children: "View PR",381 children: "View PR",
380 onClick: () => {382 onClick: () => {
381 const api = readNativeApi();383 const api = readNativeApi();
382 if (!api) return;384 if (!api) return;
383 closeResultToast();385 closeResultToast();
384 void api.shell.openExternal(prUrl);386 void api.shell.openExternal(prUrl);
385 },387 },
...@@ -424,7 +426,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -424,7 +426,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
424426
425 const continuePendingDefaultBranchAction = useCallback(() => {427 const continuePendingDefaultBranchAction = useCallback(() => {
426 if (!pendingDefaultBranchAction) return;428 if (!pendingDefaultBranchAction) return;
427 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;429 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
430 pendingDefaultBranchAction;
428 setPendingDefaultBranchAction(null);431 setPendingDefaultBranchAction(null);
429 void runGitActionWithToast({432 void runGitActionWithToast({
430 action,433 action,
...@@ -453,7 +456,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -453,7 +456,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
453456
454 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {457 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
455 if (!pendingDefaultBranchAction) return;458 if (!pendingDefaultBranchAction) return;
456 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;459 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
460 pendingDefaultBranchAction;
457 setPendingDefaultBranchAction(null);461 setPendingDefaultBranchAction(null);
458 checkoutNewBranchAndRunAction({462 checkoutNewBranchAndRunAction({
459 action,463 action,
...@@ -725,7 +729,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -725,7 +729,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
725 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">729 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
726 <span className="text-muted-foreground">Branch</span>730 <span className="text-muted-foreground">Branch</span>
727 <span className="flex items-center justify-between gap-2">731 <span className="flex items-center justify-between gap-2">
728 <span className="font-medium">{gitStatusForActions?.branch ?? "(detached HEAD)"}</span>732 <span className="font-medium">
733 {gitStatusForActions?.branch ?? "(detached HEAD)"}
734 </span>
729 {isDefaultBranch && (735 {isDefaultBranch && (
730 <span className="text-right text-warning text-xs">Warning: default branch</span>736 <span className="text-right text-warning text-xs">Warning: default branch</span>
731 )}737 )}
apps/web/src/components/Icons.tsx+13-52
...@@ -74,10 +74,7 @@ export const VisualStudioCode: Icon = (props) => {...@@ -74,10 +74,7 @@ export const VisualStudioCode: Icon = (props) => {
74 filterUnits="userSpaceOnUse"74 filterUnits="userSpaceOnUse"
75 >75 >
76 <feFlood floodOpacity="0" result="BackgroundImageFix" />76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix77 <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
81 <feOffset />78 <feOffset />
82 <feGaussianBlur stdDeviation="4.167" />79 <feGaussianBlur stdDeviation="4.167" />
83 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />80 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
...@@ -94,10 +91,7 @@ export const VisualStudioCode: Icon = (props) => {...@@ -94,10 +91,7 @@ export const VisualStudioCode: Icon = (props) => {
94 filterUnits="userSpaceOnUse"91 filterUnits="userSpaceOnUse"
95 >92 >
96 <feFlood floodOpacity="0" result="BackgroundImageFix" />93 <feFlood floodOpacity="0" result="BackgroundImageFix" />
97 <feColorMatrix94 <feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
98 in="SourceAlpha"
99 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
100 />
101 <feOffset />95 <feOffset />
102 <feGaussianBlur stdDeviation="4.167" />96 <feGaussianBlur stdDeviation="4.167" />
103 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />97 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
...@@ -185,28 +179,16 @@ export const Gemini: Icon = (props) => (...@@ -185,28 +179,16 @@ export const Gemini: Icon = (props) => (
185 <ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" />179 <ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5" />
186 </g>180 </g>
187 <g filter="url(#gemini__e)">181 <g filter="url(#gemini__e)">
188 <path182 <path fill="#FA4340" d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z" />
189 fill="#FA4340"
190 d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z"
191 />
192 </g>183 </g>
193 <g filter="url(#gemini__f)">184 <g filter="url(#gemini__f)">
194 <path185 <path fill="#FA4340" d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z" />
195 fill="#FA4340"
196 d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z"
197 />
198 </g>186 </g>
199 <g filter="url(#gemini__g)">187 <g filter="url(#gemini__g)">
200 <path188 <path fill="#14BB69" d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z" />
201 fill="#14BB69"
202 d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z"
203 />
204 </g>189 </g>
205 <g filter="url(#gemini__h)">190 <g filter="url(#gemini__h)">
206 <path191 <path fill="#14BB69" d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z" />
207 fill="#14BB69"
208 d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z"
209 />
210 </g>192 </g>
211 </g>193 </g>
212 <defs>194 <defs>
...@@ -221,10 +203,7 @@ export const Gemini: Icon = (props) => (...@@ -221,10 +203,7 @@ export const Gemini: Icon = (props) => (
221 >203 >
222 <feFlood floodOpacity="0" result="BackgroundImageFix" />204 <feFlood floodOpacity="0" result="BackgroundImageFix" />
223 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />205 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
224 <feGaussianBlur206 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="18" />
225 result="effect1_foregroundBlur_69_17998"
226 stdDeviation="18"
227 />
228 </filter>207 </filter>
229 <filter208 <filter
230 id="gemini__c"209 id="gemini__c"
...@@ -237,10 +216,7 @@ export const Gemini: Icon = (props) => (...@@ -237,10 +216,7 @@ export const Gemini: Icon = (props) => (
237 >216 >
238 <feFlood floodOpacity="0" result="BackgroundImageFix" />217 <feFlood floodOpacity="0" result="BackgroundImageFix" />
239 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />218 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
240 <feGaussianBlur219 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
241 result="effect1_foregroundBlur_69_17998"
242 stdDeviation="32"
243 />
244 </filter>220 </filter>
245 <filter221 <filter
246 id="gemini__d"222 id="gemini__d"
...@@ -253,10 +229,7 @@ export const Gemini: Icon = (props) => (...@@ -253,10 +229,7 @@ export const Gemini: Icon = (props) => (
253 >229 >
254 <feFlood floodOpacity="0" result="BackgroundImageFix" />230 <feFlood floodOpacity="0" result="BackgroundImageFix" />
255 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />231 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
256 <feGaussianBlur232 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
257 result="effect1_foregroundBlur_69_17998"
258 stdDeviation="32"
259 />
260 </filter>233 </filter>
261 <filter234 <filter
262 id="gemini__e"235 id="gemini__e"
...@@ -269,10 +242,7 @@ export const Gemini: Icon = (props) => (...@@ -269,10 +242,7 @@ export const Gemini: Icon = (props) => (
269 >242 >
270 <feFlood floodOpacity="0" result="BackgroundImageFix" />243 <feFlood floodOpacity="0" result="BackgroundImageFix" />
271 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />244 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
272 <feGaussianBlur245 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
273 result="effect1_foregroundBlur_69_17998"
274 stdDeviation="32"
275 />
276 </filter>246 </filter>
277 <filter247 <filter
278 id="gemini__f"248 id="gemini__f"
...@@ -285,10 +255,7 @@ export const Gemini: Icon = (props) => (...@@ -285,10 +255,7 @@ export const Gemini: Icon = (props) => (
285 >255 >
286 <feFlood floodOpacity="0" result="BackgroundImageFix" />256 <feFlood floodOpacity="0" result="BackgroundImageFix" />
287 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />257 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
288 <feGaussianBlur258 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
289 result="effect1_foregroundBlur_69_17998"
290 stdDeviation="32"
291 />
292 </filter>259 </filter>
293 <filter260 <filter
294 id="gemini__g"261 id="gemini__g"
...@@ -301,10 +268,7 @@ export const Gemini: Icon = (props) => (...@@ -301,10 +268,7 @@ export const Gemini: Icon = (props) => (
301 >268 >
302 <feFlood floodOpacity="0" result="BackgroundImageFix" />269 <feFlood floodOpacity="0" result="BackgroundImageFix" />
303 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />270 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
304 <feGaussianBlur271 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
305 result="effect1_foregroundBlur_69_17998"
306 stdDeviation="32"
307 />
308 </filter>272 </filter>
309 <filter273 <filter
310 id="gemini__h"274 id="gemini__h"
...@@ -317,10 +281,7 @@ export const Gemini: Icon = (props) => (...@@ -317,10 +281,7 @@ export const Gemini: Icon = (props) => (
317 >281 >
318 <feFlood floodOpacity="0" result="BackgroundImageFix" />282 <feFlood floodOpacity="0" result="BackgroundImageFix" />
319 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />283 <feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
320 <feGaussianBlur284 <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" />
321 result="effect1_foregroundBlur_69_17998"
322 stdDeviation="32"
323 />
324 </filter>285 </filter>
325 </defs>286 </defs>
326 </svg>287 </svg>
apps/web/src/components/PlanSidebar.tsx+3-8
...@@ -106,8 +106,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -106,8 +106,7 @@ const PlanSidebar = memo(function PlanSidebar({
106 toastManager.add({106 toastManager.add({
107 type: "error",107 type: "error",
108 title: "Could not save plan",108 title: "Could not save plan",
109 description:109 description: error instanceof Error ? error.message : "An error occurred.",
110 error instanceof Error ? error.message : "An error occurred.",
111 });110 });
112 })111 })
113 .then(112 .then(
...@@ -199,9 +198,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -199,9 +198,7 @@ const PlanSidebar = memo(function PlanSidebar({
199 step.status === "completed" && "bg-emerald-500/5",198 step.status === "completed" && "bg-emerald-500/5",
200 )}199 )}
201 >200 >
202 <div className="mt-0.5">201 <div className="mt-0.5">{stepStatusIcon(step.status)}</div>
203 {stepStatusIcon(step.status)}
204 </div>
205 <p202 <p
206 className={cn(203 className={cn(
207 "text-[13px] leading-snug",204 "text-[13px] leading-snug",
...@@ -251,9 +248,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -251,9 +248,7 @@ const PlanSidebar = memo(function PlanSidebar({
251 {/* Empty state */}248 {/* Empty state */}
252 {!activePlan && !planMarkdown ? (249 {!activePlan && !planMarkdown ? (
253 <div className="flex flex-col items-center justify-center py-12 text-center">250 <div className="flex flex-col items-center justify-center py-12 text-center">
254 <p className="text-[13px] text-muted-foreground/40">251 <p className="text-[13px] text-muted-foreground/40">No active plan yet.</p>
255 No active plan yet.
256 </p>
257 <p className="mt-1 text-[11px] text-muted-foreground/30">252 <p className="mt-1 text-[11px] text-muted-foreground/30">
258 Plans will appear here when generated.253 Plans will appear here when generated.
259 </p>254 </p>
apps/web/src/components/PullRequestThreadDialog.tsx+48-46
...@@ -116,56 +116,58 @@ export function PullRequestThreadDialog({...@@ -116,56 +116,58 @@ export function PullRequestThreadDialog({
116 }116 }
117 }, [resolvedPullRequest?.state]);117 }, [resolvedPullRequest?.state]);
118118
119 const handleConfirm = useCallback(async (mode: "local" | "worktree") => {119 const handleConfirm = useCallback(
120 if (!parsedReference) {120 async (mode: "local" | "worktree") => {
121 setReferenceDirty(true);121 if (!parsedReference) {
122 return;122 setReferenceDirty(true);
123 }123 return;
124 if (!parsedReference || !resolvedPullRequest || !cwd) {124 }
125 return;125 if (!parsedReference || !resolvedPullRequest || !cwd) {
126 }126 return;
127 setPreparingMode(mode);127 }
128 try {128 setPreparingMode(mode);
129 const result = await preparePullRequestThreadMutation.mutateAsync({129 try {
130 reference: parsedReference,130 const result = await preparePullRequestThreadMutation.mutateAsync({
131 mode,131 reference: parsedReference,
132 });132 mode,
133 await onPrepared({133 });
134 branch: result.branch,134 await onPrepared({
135 worktreePath: result.worktreePath,135 branch: result.branch,
136 });136 worktreePath: result.worktreePath,
137 onOpenChange(false);137 });
138 } finally {138 onOpenChange(false);
139 setPreparingMode(null);139 } finally {
140 }140 setPreparingMode(null);
141 }, [141 }
142 cwd,142 },
143 onOpenChange,143 [
144 onPrepared,144 cwd,
145 parsedReference,145 onOpenChange,
146 preparePullRequestThreadMutation,146 onPrepared,
147 resolvedPullRequest,147 parsedReference,
148 ]);148 preparePullRequestThreadMutation,
149 resolvedPullRequest,
150 ],
151 );
149152
150 const validationMessage =153 const validationMessage = !referenceDirty
151 !referenceDirty154 ? null
152 ? null155 : reference.trim().length === 0
153 : reference.trim().length === 0156 ? "Paste a GitHub pull request URL or enter 123 / #123."
154 ? "Paste a GitHub pull request URL or enter 123 / #123."157 : parsedReference === null
155 : parsedReference === null158 ? "Use a GitHub pull request URL, 123, or #123."
156 ? "Use a GitHub pull request URL, 123, or #123."159 : null;
157 : null;
158 const errorMessage =160 const errorMessage =
159 validationMessage ??161 validationMessage ??
160 (resolvedPullRequest === null && resolvePullRequestQuery.isError162 (resolvedPullRequest === null && resolvePullRequestQuery.isError
161 ? (resolvePullRequestQuery.error instanceof Error163 ? resolvePullRequestQuery.error instanceof Error
162 ? resolvePullRequestQuery.error.message164 ? resolvePullRequestQuery.error.message
163 : "Failed to resolve pull request.")165 : "Failed to resolve pull request."
164 : (preparePullRequestThreadMutation.error instanceof Error166 : preparePullRequestThreadMutation.error instanceof Error
165 ? preparePullRequestThreadMutation.error.message167 ? preparePullRequestThreadMutation.error.message
166 : preparePullRequestThreadMutation.error168 : preparePullRequestThreadMutation.error
167 ? "Failed to prepare pull request thread."169 ? "Failed to prepare pull request thread."
168 : null));170 : null);
169171
170 return (172 return (
171 <Dialog173 <Dialog
apps/web/src/components/Sidebar.logic.ts+1-2
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import type { Thread } from "../types";1import type { Thread } from "../types";
2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";2import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
33
4export const THREAD_SELECTION_SAFE_SELECTOR =4export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
5 "[data-thread-item], [data-thread-selection-safe]";
65
7export interface ThreadStatusPill {6export interface ThreadStatusPill {
8 label:7 label:
apps/web/src/components/Sidebar.tsx+226-186
...@@ -83,10 +83,7 @@ import {...@@ -83,10 +83,7 @@ import {
83import { useThreadSelectionStore } from "../threadSelectionStore";83import { useThreadSelectionStore } from "../threadSelectionStore";
84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";84import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
85import { isNonEmpty as isNonEmptyString } from "effect/String";85import { isNonEmpty as isNonEmptyString } from "effect/String";
86import {86import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic";
87 resolveThreadStatusPill,
88 shouldClearThreadSelectionOnMouseDown,
89} from "./Sidebar.logic";
9087
91const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];88const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
92const THREAD_PREVIEW_LIMIT = 6;89const THREAD_PREVIEW_LIMIT = 6;
...@@ -897,7 +894,14 @@ export default function Sidebar() {...@@ -897,7 +894,14 @@ export default function Sidebar() {
897 params: { threadId },894 params: { threadId },
898 });895 });
899 },896 },
900 [clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection],897 [
898 clearSelection,
899 navigate,
900 rangeSelectTo,
901 selectedThreadIds.size,
902 setSelectionAnchor,
903 toggleThreadSelection,
904 ],
901 );905 );
902906
903 const handleProjectContextMenu = useCallback(907 const handleProjectContextMenu = useCallback(
...@@ -1077,7 +1081,16 @@ export default function Sidebar() {...@@ -1077,7 +1081,16 @@ export default function Sidebar() {
1077 window.removeEventListener("keydown", onWindowKeyDown);1081 window.removeEventListener("keydown", onWindowKeyDown);
1078 window.removeEventListener("mousedown", onMouseDown);1082 window.removeEventListener("mousedown", onMouseDown);
1079 };1083 };
1080 }, [clearSelection, getDraftThread, handleNewThread, keybindings, projects, routeThreadId, selectedThreadIds.size, threads]);1084 }, [
1085 clearSelection,
1086 getDraftThread,
1087 handleNewThread,
1088 keybindings,
1089 projects,
1090 routeThreadId,
1091 selectedThreadIds.size,
1092 threads,
1093 ]);
10811094
1082 useEffect(() => {1095 useEffect(() => {
1083 if (!isElectron) return;1096 if (!isElectron) return;
...@@ -1403,7 +1416,8 @@ export default function Sidebar() {...@@ -1403,7 +1416,8 @@ export default function Sidebar() {
1403 const projectThreads = threads1416 const projectThreads = threads
1404 .filter((thread) => thread.projectId === project.id)1417 .filter((thread) => thread.projectId === project.id)
1405 .toSorted((a, b) => {1418 .toSorted((a, b) => {
1406 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();1419 const byDate =
1420 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
1407 if (byDate !== 0) return byDate;1421 if (byDate !== 0) return byDate;
1408 return b.id.localeCompare(a.id);1422 return b.id.localeCompare(a.id);
1409 });1423 });
...@@ -1418,10 +1432,7 @@ export default function Sidebar() {...@@ -1418,10 +1432,7 @@ export default function Sidebar() {
1418 return (1432 return (
1419 <SortableProjectItem key={project.id} projectId={project.id}>1433 <SortableProjectItem key={project.id} projectId={project.id}>
1420 {(dragHandleProps) => (1434 {(dragHandleProps) => (
1421 <Collapsible1435 <Collapsible className="group/collapsible" open={project.expanded}>
1422 className="group/collapsible"
1423 open={project.expanded}
1424 >
1425 <div className="group/project-header relative">1436 <div className="group/project-header relative">
1426 <SidebarMenuButton1437 <SidebarMenuButton
1427 size="sm"1438 size="sm"
...@@ -1481,193 +1492,222 @@ export default function Sidebar() {...@@ -1481,193 +1492,222 @@ export default function Sidebar() {
14811492
1482 <CollapsibleContent keepMounted>1493 <CollapsibleContent keepMounted>
1483 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0">1494 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0.5 px-1.5 py-0">
1484 {visibleThreads.map((thread) => {1495 {visibleThreads.map((thread) => {
1485 const isActive = routeThreadId === thread.id;1496 const isActive = routeThreadId === thread.id;
1486 const isSelected = selectedThreadIds.has(thread.id);1497 const isSelected = selectedThreadIds.has(thread.id);
1487 const isHighlighted = isActive || isSelected;1498 const isHighlighted = isActive || isSelected;
1488 const threadStatus = resolveThreadStatusPill({1499 const threadStatus = resolveThreadStatusPill({
1489 thread,1500 thread,
1490 hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true,1501 hasPendingApprovals:
1491 hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true,1502 pendingApprovalByThreadId.get(thread.id) === true,
1492 });1503 hasPendingUserInput:
1493 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);1504 pendingUserInputByThreadId.get(thread.id) === true,
1494 const terminalStatus = terminalStatusFromRunningIds(1505 });
1495 selectThreadTerminalState(terminalStateByThreadId, thread.id)1506 const prStatus = prStatusIndicator(
1496 .runningTerminalIds,1507 prByThreadId.get(thread.id) ?? null,
1497 );1508 );
14981509 const terminalStatus = terminalStatusFromRunningIds(
1499 return (1510 selectThreadTerminalState(terminalStateByThreadId, thread.id)
1500 <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item>1511 .runningTerminalIds,
1501 <SidebarMenuSubButton1512 );
1502 render={<div role="button" tabIndex={0} />}1513
1503 size="sm"1514 return (
1504 isActive={isActive}1515 <SidebarMenuSubItem
1505 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${1516 key={thread.id}
1506 isSelected1517 className="w-full"
1507 ? "bg-primary/15 text-foreground dark:bg-primary/10"1518 data-thread-item
1508 : isActive1519 >
1509 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"1520 <SidebarMenuSubButton
1510 : "text-muted-foreground"1521 render={<div role="button" tabIndex={0} />}
1511 }`}1522 size="sm"
1512 onClick={(event) => {1523 isActive={isActive}
1513 handleThreadClick(event, thread.id, orderedProjectThreadIds);1524 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${
1514 }}1525 isSelected
1515 onKeyDown={(event) => {1526 ? "bg-primary/15 text-foreground dark:bg-primary/10"
1516 if (event.key !== "Enter" && event.key !== " ") return;1527 : isActive
1517 event.preventDefault();1528 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1529 : "text-muted-foreground"
1530 }`}
1531 onClick={(event) => {
1532 handleThreadClick(
1533 event,
1534 thread.id,
1535 orderedProjectThreadIds,
1536 );
1537 }}
1538 onKeyDown={(event) => {
1539 if (event.key !== "Enter" && event.key !== " ") return;
1540 event.preventDefault();
1541 if (selectedThreadIds.size > 0) {
1542 clearSelection();
1543 }
1544 setSelectionAnchor(thread.id);
1545 void navigate({
1546 to: "/$threadId",
1547 params: { threadId: thread.id },
1548 });
1549 }}
1550 onContextMenu={(event) => {
1551 event.preventDefault();
1552 if (
1553 selectedThreadIds.size > 0 &&
1554 selectedThreadIds.has(thread.id)
1555 ) {
1556 void handleMultiSelectContextMenu({
1557 x: event.clientX,
1558 y: event.clientY,
1559 });
1560 } else {
1518 if (selectedThreadIds.size > 0) {1561 if (selectedThreadIds.size > 0) {
1519 clearSelection();1562 clearSelection();
1520 }1563 }
1521 setSelectionAnchor(thread.id);1564 void handleThreadContextMenu(thread.id, {
1522 void navigate({1565 x: event.clientX,
1523 to: "/$threadId",1566 y: event.clientY,
1524 params: { threadId: thread.id },
1525 });1567 });
1526 }}1568 }
1527 onContextMenu={(event) => {1569 }}
1528 event.preventDefault();1570 >
1529 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {1571 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1530 void handleMultiSelectContextMenu({1572 {prStatus && (
1531 x: event.clientX,1573 <Tooltip>
1532 y: event.clientY,1574 <TooltipTrigger
1533 });1575 render={
1534 } else {1576 <button
1535 if (selectedThreadIds.size > 0) {1577 type="button"
1536 clearSelection();1578 aria-label={prStatus.tooltip}
1537 }1579 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1538 void handleThreadContextMenu(thread.id, {1580 onClick={(event) => {
1539 x: event.clientX,1581 openPrLink(event, prStatus.url);
1540 y: event.clientY,1582 }}
1541 });1583 >
1542 }1584 <GitPullRequestIcon className="size-3" />
1543 }}1585 </button>
1544 >1586 }
1545 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1546 {prStatus && (
1547 <Tooltip>
1548 <TooltipTrigger
1549 render={
1550 <button
1551 type="button"
1552 aria-label={prStatus.tooltip}
1553 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1554 onClick={(event) => {
1555 openPrLink(event, prStatus.url);
1556 }}
1557 >
1558 <GitPullRequestIcon className="size-3" />
1559 </button>
1560 }
1561 />
1562 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1563 </Tooltip>
1564 )}
1565 {threadStatus && (
1566 <span
1567 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1568 >
1569 <span
1570 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1571 threadStatus.pulse ? "animate-pulse" : ""
1572 }`}
1573 />
1574 <span className="hidden md:inline">{threadStatus.label}</span>
1575 </span>
1576 )}
1577 {renamingThreadId === thread.id ? (
1578 <input
1579 ref={(el) => {
1580 if (el && renamingInputRef.current !== el) {
1581 renamingInputRef.current = el;
1582 el.focus();
1583 el.select();
1584 }
1585 }}
1586 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1587 value={renamingTitle}
1588 onChange={(e) => setRenamingTitle(e.target.value)}
1589 onKeyDown={(e) => {
1590 e.stopPropagation();
1591 if (e.key === "Enter") {
1592 e.preventDefault();
1593 renamingCommittedRef.current = true;
1594 void commitRename(thread.id, renamingTitle, thread.title);
1595 } else if (e.key === "Escape") {
1596 e.preventDefault();
1597 renamingCommittedRef.current = true;
1598 cancelRename();
1599 }
1600 }}
1601 onBlur={() => {
1602 if (!renamingCommittedRef.current) {
1603 void commitRename(thread.id, renamingTitle, thread.title);
1604 }
1605 }}
1606 onClick={(e) => e.stopPropagation()}
1607 />1587 />
1608 ) : (1588 <TooltipPopup side="top">
1609 <span className="min-w-0 flex-1 truncate text-xs">1589 {prStatus.tooltip}
1610 {thread.title}1590 </TooltipPopup>
1611 </span>1591 </Tooltip>
1612 )}1592 )}
1613 </div>1593 {threadStatus && (
1614 <div className="ml-auto flex shrink-0 items-center gap-1.5">1594 <span
1615 {terminalStatus && (1595 className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
1596 >
1616 <span1597 <span
1617 role="img"1598 className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
1618 aria-label={terminalStatus.label}1599 threadStatus.pulse ? "animate-pulse" : ""
1619 title={terminalStatus.label}1600 }`}
1620 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}1601 />
1621 >1602 <span className="hidden md:inline">
1622 <TerminalIcon1603 {threadStatus.label}
1623 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1624 />
1625 </span>1604 </span>
1626 )}1605 </span>
1606 )}
1607 {renamingThreadId === thread.id ? (
1608 <input
1609 ref={(el) => {
1610 if (el && renamingInputRef.current !== el) {
1611 renamingInputRef.current = el;
1612 el.focus();
1613 el.select();
1614 }
1615 }}
1616 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1617 value={renamingTitle}
1618 onChange={(e) => setRenamingTitle(e.target.value)}
1619 onKeyDown={(e) => {
1620 e.stopPropagation();
1621 if (e.key === "Enter") {
1622 e.preventDefault();
1623 renamingCommittedRef.current = true;
1624 void commitRename(
1625 thread.id,
1626 renamingTitle,
1627 thread.title,
1628 );
1629 } else if (e.key === "Escape") {
1630 e.preventDefault();
1631 renamingCommittedRef.current = true;
1632 cancelRename();
1633 }
1634 }}
1635 onBlur={() => {
1636 if (!renamingCommittedRef.current) {
1637 void commitRename(
1638 thread.id,
1639 renamingTitle,
1640 thread.title,
1641 );
1642 }
1643 }}
1644 onClick={(e) => e.stopPropagation()}
1645 />
1646 ) : (
1647 <span className="min-w-0 flex-1 truncate text-xs">
1648 {thread.title}
1649 </span>
1650 )}
1651 </div>
1652 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1653 {terminalStatus && (
1627 <span1654 <span
1628 className={`text-[10px] ${1655 role="img"
1629 isHighlighted ? "text-foreground/65" : "text-muted-foreground/40"1656 aria-label={terminalStatus.label}
1630 }`}1657 title={terminalStatus.label}
1658 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1631 >1659 >
1632 {formatRelativeTime(thread.createdAt)}1660 <TerminalIcon
1661 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1662 />
1633 </span>1663 </span>
1634 </div>1664 )}
1635 </SidebarMenuSubButton>1665 <span
1636 </SidebarMenuSubItem>1666 className={`text-[10px] ${
1637 );1667 isHighlighted
1638 })}1668 ? "text-foreground/65"
16391669 : "text-muted-foreground/40"
1640 {hasHiddenThreads && !isThreadListExpanded && (1670 }`}
1641 <SidebarMenuSubItem className="w-full">1671 >
1642 <SidebarMenuSubButton1672 {formatRelativeTime(thread.createdAt)}
1643 render={<button type="button" />}1673 </span>
1644 data-thread-selection-safe1674 </div>
1645 size="sm"
1646 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"
1647 onClick={() => {
1648 expandThreadListForProject(project.id);
1649 }}
1650 >
1651 <span>Show more</span>
1652 </SidebarMenuSubButton>1675 </SidebarMenuSubButton>
1653 </SidebarMenuSubItem>1676 </SidebarMenuSubItem>
1654 )}1677 );
1655 {hasHiddenThreads && isThreadListExpanded && (1678 })}
1656 <SidebarMenuSubItem className="w-full">1679
1657 <SidebarMenuSubButton1680 {hasHiddenThreads && !isThreadListExpanded && (
1658 render={<button type="button" />}1681 <SidebarMenuSubItem className="w-full">
1659 data-thread-selection-safe1682 <SidebarMenuSubButton
1660 size="sm"1683 render={<button type="button" />}
1661 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"1684 data-thread-selection-safe
1662 onClick={() => {1685 size="sm"
1663 collapseThreadListForProject(project.id);1686 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"
1664 }}1687 onClick={() => {
1665 >1688 expandThreadListForProject(project.id);
1666 <span>Show less</span>1689 }}
1667 </SidebarMenuSubButton>1690 >
1668 </SidebarMenuSubItem>1691 <span>Show more</span>
1669 )}1692 </SidebarMenuSubButton>
1670 </SidebarMenuSub>1693 </SidebarMenuSubItem>
1694 )}
1695 {hasHiddenThreads && isThreadListExpanded && (
1696 <SidebarMenuSubItem className="w-full">
1697 <SidebarMenuSubButton
1698 render={<button type="button" />}
1699 data-thread-selection-safe
1700 size="sm"
1701 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"
1702 onClick={() => {
1703 collapseThreadListForProject(project.id);
1704 }}
1705 >
1706 <span>Show less</span>
1707 </SidebarMenuSubButton>
1708 </SidebarMenuSubItem>
1709 )}
1710 </SidebarMenuSub>
1671 </CollapsibleContent>1711 </CollapsibleContent>
1672 </Collapsible>1712 </Collapsible>
1673 )}1713 )}
apps/web/src/components/composerFooterLayout.test.ts+1-3
...@@ -17,9 +17,7 @@ describe("shouldUseCompactComposerFooter", () => {...@@ -17,9 +17,7 @@ describe("shouldUseCompactComposerFooter", () => {
1717
18 it("stays expanded at and above the breakpoint", () => {18 it("stays expanded at and above the breakpoint", () => {
19 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false);19 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false);
20 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(20 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false);
21 false,
22 );
23 });21 });
2422
25 it("uses a higher breakpoint for wide action states", () => {23 it("uses a higher breakpoint for wide action states", () => {
apps/web/src/components/desktopUpdate.logic.test.ts+5-1
...@@ -31,7 +31,11 @@ const baseState: DesktopUpdateState = {...@@ -31,7 +31,11 @@ const baseState: DesktopUpdateState = {
3131
32describe("desktop update button state", () => {32describe("desktop update button state", () => {
33 it("shows a download action when an update is available", () => {33 it("shows a download action when an update is available", () => {
34 const state: DesktopUpdateState = { ...baseState, status: "available", availableVersion: "1.1.0" };34 const state: DesktopUpdateState = {
35 ...baseState,
36 status: "available",
37 availableVersion: "1.1.0",
38 };
35 expect(shouldShowDesktopUpdateButton(state)).toBe(true);39 expect(shouldShowDesktopUpdateButton(state)).toBe(true);
36 expect(resolveDesktopUpdateButtonAction(state)).toBe("download");40 expect(resolveDesktopUpdateButtonAction(state)).toBe("download");
37 });41 });
apps/web/src/components/ui/scroll-area.tsx+2-1
...@@ -24,7 +24,8 @@ function ScrollArea({...@@ -24,7 +24,8 @@ function ScrollArea({
24 scrollFade &&24 scrollFade &&
25 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",25 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
26 scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",26 scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
27 hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",27 hideScrollbars &&
28 "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
28 )}29 )}
29 data-slot="scroll-area-viewport"30 data-slot="scroll-area-viewport"
30 >31 >
apps/web/src/components/ui/toast.logic.test.ts+10-2
...@@ -17,7 +17,11 @@ describe("shouldHideCollapsedToastContent", () => {...@@ -17,7 +17,11 @@ describe("shouldHideCollapsedToastContent", () => {
1717
18describe("buildVisibleToastLayout", () => {18describe("buildVisibleToastLayout", () => {
19 it("computes indices and offsets from the visible subset", () => {19 it("computes indices and offsets from the visible subset", () => {
20 const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }];20 const visibleToasts = [
21 { id: "a", height: 48 },
22 { id: "b", height: 72 },
23 { id: "c", height: 24 },
24 ];
2125
22 const layout = buildVisibleToastLayout(visibleToasts);26 const layout = buildVisibleToastLayout(visibleToasts);
2327
...@@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => {...@@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => {
37 });41 });
3842
39 it("treats missing heights as zero", () => {43 it("treats missing heights as zero", () => {
40 const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]);44 const layout = buildVisibleToastLayout([
45 { id: "a" },
46 { id: "b", height: undefined },
47 { id: "c", height: 30 },
48 ]);
4149
42 assert.equal(layout.frontmostHeight, 0);50 assert.equal(layout.frontmostHeight, 0);
43 assert.deepEqual(51 assert.deepEqual(
apps/web/src/composer-logic.ts+7-10
...@@ -10,11 +10,7 @@ export interface ComposerTrigger {...@@ -10,11 +10,7 @@ export interface ComposerTrigger {
10 rangeEnd: number;10 rangeEnd: number;
11}11}
1212
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
14 "model",
15 "plan",
16 "default",
17];
1814
19function clampCursor(text: string, cursor: number): number {15function clampCursor(text: string, cursor: number): number {
20 if (!Number.isFinite(cursor)) return text.length;16 if (!Number.isFinite(cursor)) return text.length;
...@@ -65,7 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -65,7 +61,9 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
65 return expandedCursor;61 return expandedCursor;
66}62}
6763
68function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number {64function collapsedSegmentLength(
65 segment: { type: "text"; text: string } | { type: "mention" },
66): number {
69 return segment.type === "mention" ? 1 : segment.text.length;67 return segment.type === "mention" ? 1 : segment.text.length;
70}68}
7169
...@@ -164,10 +162,9 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -164,10 +162,9 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
164 };162 };
165}163}
166164
167export function parseStandaloneComposerSlashCommand(text: string): Exclude<165export function parseStandaloneComposerSlashCommand(
168 ComposerSlashCommand,166 text: string,
169 "model"167): Exclude<ComposerSlashCommand, "model"> | null {
170> | null {
171 const match = /^\/(plan|default)\s*$/i.exec(text.trim());168 const match = /^\/(plan|default)\s*$/i.exec(text.trim());
172 if (!match) {169 if (!match) {
173 return null;170 return null;
apps/web/src/composerDraftStore.test.ts+6-4
...@@ -252,9 +252,9 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -252,9 +252,9 @@ describe("composerDraftStore project draft thread mapping", () => {
252 store.clearProjectDraftThreadId(projectId);252 store.clearProjectDraftThreadId(projectId);
253253
254 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();254 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
255 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe(255 expect(
256 threadId,256 useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId,
257 );257 ).toBe(threadId);
258 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");258 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
259 });259 });
260260
...@@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => {...@@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => {
380380
381 store.setModel(threadId, "gpt-5.3-codex");381 store.setModel(threadId, "gpt-5.3-codex");
382382
383 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");383 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe(
384 "gpt-5.3-codex",
385 );
384 });386 });
385});387});
386388
apps/web/src/composerDraftStore.ts+8-11
...@@ -9,11 +9,7 @@ import {...@@ -9,11 +9,7 @@ import {
9 type RuntimeMode,9 type RuntimeMode,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { normalizeModelSlug } from "@t3tools/shared/model";11import { normalizeModelSlug } from "@t3tools/shared/model";
12import {12import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
13 DEFAULT_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,
15 type ChatImageAttachment,
16} from "./types";
17import { Debouncer } from "@tanstack/react-pacer";13import { Debouncer } from "@tanstack/react-pacer";
18import { create } from "zustand";14import { create } from "zustand";
19import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";15import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
...@@ -495,8 +491,7 @@ function hydreatePersistedComposerImageAttachment(...@@ -495,8 +491,7 @@ function hydreatePersistedComposerImageAttachment(
495 attachment: PersistedComposerImageAttachment,491 attachment: PersistedComposerImageAttachment,
496): File | null {492): File | null {
497 const commaIndex = attachment.dataUrl.indexOf(",");493 const commaIndex = attachment.dataUrl.indexOf(",");
498 const header =494 const header = commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
499 commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
500 const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1);495 const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1);
501 if (payload.length === 0) {496 if (payload.length === 0) {
502 return null;497 return null;
...@@ -605,7 +600,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -605,7 +600,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
605 const nextDraftThread: DraftThreadState = {600 const nextDraftThread: DraftThreadState = {
606 projectId,601 projectId,
607 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),602 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
608 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,603 runtimeMode:
604 options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
609 interactionMode:605 interactionMode:
610 options?.interactionMode ??606 options?.interactionMode ??
611 existingThread?.interactionMode ??607 existingThread?.interactionMode ??
...@@ -673,7 +669,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -673,7 +669,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
673 return state;669 return state;
674 }670 }
675 const nextWorktreePath =671 const nextWorktreePath =
676 options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null);672 options.worktreePath === undefined
673 ? existing.worktreePath
674 : (options.worktreePath ?? null);
677 const nextDraftThread: DraftThreadState = {675 const nextDraftThread: DraftThreadState = {
678 projectId: nextProjectId,676 projectId: nextProjectId,
679 createdAt:677 createdAt:
...@@ -685,8 +683,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -685,8 +683,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
685 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),683 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
686 worktreePath: nextWorktreePath,684 worktreePath: nextWorktreePath,
687 envMode:685 envMode:
688 options.envMode ??686 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
689 (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
690 };687 };
691 const isUnchanged =688 const isUnchanged =
692 nextDraftThread.projectId === existing.projectId &&689 nextDraftThread.projectId === existing.projectId &&
apps/web/src/lib/gitReactQuery.ts+1-7
...@@ -196,13 +196,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: {...@@ -196,13 +196,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
196 queryClient: QueryClient;196 queryClient: QueryClient;
197}) {197}) {
198 return mutationOptions({198 return mutationOptions({
199 mutationFn: async ({199 mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => {
200 reference,
201 mode,
202 }: {
203 reference: string;
204 mode: "local" | "worktree";
205 }) => {
206 const api = ensureNativeApi();200 const api = ensureNativeApi();
207 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");201 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
208 return api.git.preparePullRequestThread({202 return api.git.preparePullRequestThread({
apps/web/src/lib/turnDiffTree.test.ts+5-2
...@@ -91,7 +91,9 @@ describe("buildTurnDiffTree", () => {...@@ -91,7 +91,9 @@ describe("buildTurnDiffTree", () => {
91 });91 });
9292
93 it("normalizes file paths with windows separators", () => {93 it("normalizes file paths with windows separators", () => {
94 const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]);94 const tree = buildTurnDiffTree([
95 { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 },
96 ]);
9597
96 expect(tree).toEqual([98 expect(tree).toEqual([
97 {99 {
...@@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => {...@@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => {
157159
158 expect(tree).toHaveLength(2);160 expect(tree).toHaveLength(2);
159 const directoryNodes = tree.filter(161 const directoryNodes = tree.filter(
160 (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => node.kind === "directory",162 (node): node is Extract<(typeof tree)[number], { kind: "directory" }> =>
163 node.kind === "directory",
161 );164 );
162 expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]);165 expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]);
163 expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]);166 expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]);
apps/web/src/pendingUserInput.ts+1-3
...@@ -95,9 +95,7 @@ export function derivePendingUserInputProgress(...@@ -95,9 +95,7 @@ export function derivePendingUserInputProgress(
95 questionIndex: number,95 questionIndex: number,
96): PendingUserInputProgress {96): PendingUserInputProgress {
97 const normalizedQuestionIndex =97 const normalizedQuestionIndex =
98 questions.length === 098 questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
99 ? 0
100 : Math.max(0, Math.min(questionIndex, questions.length - 1));
101 const activeQuestion = questions[normalizedQuestionIndex] ?? null;99 const activeQuestion = questions[normalizedQuestionIndex] ?? null;
102 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;100 const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
103 const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft);101 const resolvedAnswer = resolvePendingUserInputAnswer(activeDraft);
apps/web/src/proposedPlan.test.ts+6-3
...@@ -31,9 +31,12 @@ describe("buildPlanImplementationPrompt", () => {...@@ -31,9 +31,12 @@ describe("buildPlanImplementationPrompt", () => {
31describe("buildCollapsedProposedPlanPreviewMarkdown", () => {31describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
32 it("drops the redundant title heading and preserves the following markdown lines", () => {32 it("drops the redundant title heading and preserves the following markdown lines", () => {
33 expect(33 expect(
34 buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2", {34 buildCollapsedProposedPlanPreviewMarkdown(
35 maxLines: 4,35 "# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2",
36 }),36 {
37 maxLines: 4,
38 },
39 ),
37 ).toBe("- step 1\n- step 2");40 ).toBe("- step 1\n- step 2");
38 });41 });
3942
apps/web/src/proposedPlan.ts+2-6
...@@ -5,8 +5,7 @@ export function proposedPlanTitle(planMarkdown: string): string | null {...@@ -5,8 +5,7 @@ export function proposedPlanTitle(planMarkdown: string): string | null {
55
6export function stripDisplayedPlanMarkdown(planMarkdown: string): string {6export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
7 const lines = planMarkdown.trimEnd().split(/\r?\n/);7 const lines = planMarkdown.trimEnd().split(/\r?\n/);
8 const sourceLines =8 const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
9 lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
10 while (sourceLines[0]?.trim().length === 0) {9 while (sourceLines[0]?.trim().length === 0) {
11 sourceLines.shift();10 sourceLines.shift();
12 }11 }
...@@ -75,10 +74,7 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string {...@@ -75,10 +74,7 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string {
75 return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`;74 return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`;
76}75}
7776
78export function resolvePlanFollowUpSubmission(input: {77export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): {
79 draftText: string;
80 planMarkdown: string;
81}): {
82 text: string;78 text: string;
83 interactionMode: "default" | "plan";79 interactionMode: "default" | "plan";
84} {80} {
apps/web/src/pullRequestReference.test.ts+3-3
...@@ -4,9 +4,9 @@ import { parsePullRequestReference } from "./pullRequestReference";...@@ -4,9 +4,9 @@ import { parsePullRequestReference } from "./pullRequestReference";
44
5describe("parsePullRequestReference", () => {5describe("parsePullRequestReference", () => {
6 it("accepts GitHub pull request URLs", () => {6 it("accepts GitHub pull request URLs", () => {
7 expect(7 expect(parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42")).toBe(
8 parsePullRequestReference("https://github.com/pingdotgg/t3code/pull/42"),8 "https://github.com/pingdotgg/t3code/pull/42",
9 ).toBe("https://github.com/pingdotgg/t3code/pull/42");9 );
10 });10 });
1111
12 it("accepts raw numbers", () => {12 it("accepts raw numbers", () => {
apps/web/src/routes/_chat.$threadId.tsx+6-6
...@@ -92,7 +92,9 @@ const DiffPanelInlineSidebar = (props: {...@@ -92,7 +92,9 @@ const DiffPanelInlineSidebar = (props: {
92 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,92 composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight,
93 );93 );
94 const formRect = composerForm.getBoundingClientRect();94 const formRect = composerForm.getBoundingClientRect();
95 const composerFooter = composerForm.querySelector<HTMLElement>("[data-chat-composer-footer='true']");95 const composerFooter = composerForm.querySelector<HTMLElement>(
96 "[data-chat-composer-footer='true']",
97 );
96 const composerRightActions = composerForm.querySelector<HTMLElement>(98 const composerRightActions = composerForm.querySelector<HTMLElement>(
97 "[data-chat-composer-actions='right']",99 "[data-chat-composer-actions='right']",
98 );100 );
...@@ -103,9 +105,7 @@ const DiffPanelInlineSidebar = (props: {...@@ -103,9 +105,7 @@ const DiffPanelInlineSidebar = (props: {
103 0105 0
104 : 0;106 : 0;
105 const minimumComposerWidth =107 const minimumComposerWidth =
106 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX +108 COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap;
107 composerRightActionsWidth +
108 composerFooterGap;
109 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;109 const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5;
110 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;110 const overflowsViewport = formRect.width > viewportContentWidth + 0.5;
111 const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;111 const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth;
...@@ -156,8 +156,8 @@ function ChatThreadRouteView() {...@@ -156,8 +156,8 @@ function ChatThreadRouteView() {
156 });156 });
157 const search = Route.useSearch();157 const search = Route.useSearch();
158 const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId));158 const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId));
159 const draftThreadExists = useComposerDraftStore(159 const draftThreadExists = useComposerDraftStore((store) =>
160 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),160 Object.hasOwn(store.draftThreadsByThreadId, threadId),
161 );161 );
162 const routeThreadExists = threadExists || draftThreadExists;162 const routeThreadExists = threadExists || draftThreadExists;
163 const diffOpen = search.diff === "1";163 const diffOpen = search.diff === "1";
apps/web/src/routes/_chat.settings.tsx+50-43
...@@ -115,54 +115,62 @@ function SettingsRouteView() {...@@ -115,54 +115,62 @@ function SettingsRouteView() {
115 });115 });
116 }, [keybindingsConfigPath]);116 }, [keybindingsConfigPath]);
117117
118 const addCustomModel = useCallback((provider: ProviderKind) => {118 const addCustomModel = useCallback(
119 const customModelInput = customModelInputByProvider[provider];119 (provider: ProviderKind) => {
120 const customModels = getCustomModelsForProvider(settings, provider);120 const customModelInput = customModelInputByProvider[provider];
121 const normalized = normalizeModelSlug(customModelInput, provider);121 const customModels = getCustomModelsForProvider(settings, provider);
122 if (!normalized) {122 const normalized = normalizeModelSlug(customModelInput, provider);
123 setCustomModelErrorByProvider((existing) => ({123 if (!normalized) {
124 ...existing,124 setCustomModelErrorByProvider((existing) => ({
125 [provider]: "Enter a model slug.",125 ...existing,
126 }));126 [provider]: "Enter a model slug.",
127 return;127 }));
128 }128 return;
129 if (getModelOptions(provider).some((option) => option.slug === normalized)) {129 }
130 setCustomModelErrorByProvider((existing) => ({130 if (getModelOptions(provider).some((option) => option.slug === normalized)) {
131 ...existing,131 setCustomModelErrorByProvider((existing) => ({
132 [provider]: "That model is already built in.",132 ...existing,
133 }));133 [provider]: "That model is already built in.",
134 return;134 }));
135 }135 return;
136 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {136 }
137 setCustomModelErrorByProvider((existing) => ({137 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
138 setCustomModelErrorByProvider((existing) => ({
139 ...existing,
140 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
141 }));
142 return;
143 }
144 if (customModels.includes(normalized)) {
145 setCustomModelErrorByProvider((existing) => ({
146 ...existing,
147 [provider]: "That custom model is already saved.",
148 }));
149 return;
150 }
151
152 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
153 setCustomModelInputByProvider((existing) => ({
138 ...existing,154 ...existing,
139 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,155 [provider]: "",
140 }));156 }));
141 return;
142 }
143 if (customModels.includes(normalized)) {
144 setCustomModelErrorByProvider((existing) => ({157 setCustomModelErrorByProvider((existing) => ({
145 ...existing,158 ...existing,
146 [provider]: "That custom model is already saved.",159 [provider]: null,
147 }));160 }));
148 return;161 },
149 }162 [customModelInputByProvider, settings, updateSettings],
150163 );
151 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
152 setCustomModelInputByProvider((existing) => ({
153 ...existing,
154 [provider]: "",
155 }));
156 setCustomModelErrorByProvider((existing) => ({
157 ...existing,
158 [provider]: null,
159 }));
160 }, [customModelInputByProvider, settings, updateSettings]);
161164
162 const removeCustomModel = useCallback(165 const removeCustomModel = useCallback(
163 (provider: ProviderKind, slug: string) => {166 (provider: ProviderKind, slug: string) => {
164 const customModels = getCustomModelsForProvider(settings, provider);167 const customModels = getCustomModelsForProvider(settings, provider);
165 updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug)));168 updateSettings(
169 patchCustomModels(
170 provider,
171 customModels.filter((model) => model !== slug),
172 ),
173 );
166 setCustomModelErrorByProvider((existing) => ({174 setCustomModelErrorByProvider((existing) => ({
167 ...existing,175 ...existing,
168 [provider]: null,176 [provider]: null,
...@@ -381,10 +389,9 @@ function SettingsRouteView() {...@@ -381,10 +389,9 @@ function SettingsRouteView() {
381 variant="outline"389 variant="outline"
382 onClick={() =>390 onClick={() =>
383 updateSettings(391 updateSettings(
384 patchCustomModels(392 patchCustomModels(provider, [
385 provider,393 ...getDefaultCustomModelsForProvider(defaults, provider),
386 [...getDefaultCustomModelsForProvider(defaults, provider)],394 ]),
387 ),
388 )395 )
389 }396 }
390 >397 >
apps/web/src/session-logic.ts+13-14
...@@ -8,7 +8,13 @@ import {...@@ -8,7 +8,13 @@ import {
8 type TurnId,8 type TurnId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
1010
11import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";11import type {
12 ChatMessage,
13 ProposedPlan,
14 SessionPhase,
15 ThreadSession,
16 TurnDiffSummary,
17} from "./types";
1218
13export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";19export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";
1420
...@@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt(...@@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt(
138 return sendStartedAt;144 return sendStartedAt;
139}145}
140146
141function requestKindFromRequestType(147function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
142 requestType: unknown,
143): PendingApproval["requestKind"] | null {
144 switch (requestType) {148 switch (requestType) {
145 case "command_execution_approval":149 case "command_execution_approval":
146 case "exec_command_approval":150 case "exec_command_approval":
...@@ -333,9 +337,7 @@ export function deriveActivePlanState(...@@ -333,9 +337,7 @@ export function deriveActivePlanState(
333 return null;337 return null;
334 }338 }
335 const status =339 const status =
336 record.status === "completed" || record.status === "inProgress"340 record.status === "completed" || record.status === "inProgress" ? record.status : "pending";
337 ? record.status
338 : "pending";
339 return {341 return {
340 step: record.step,342 step: record.step,
341 status,343 status,
...@@ -355,7 +357,9 @@ export function deriveActivePlanState(...@@ -355,7 +357,9 @@ export function deriveActivePlanState(
355 return {357 return {
356 createdAt: latest.createdAt,358 createdAt: latest.createdAt,
357 turnId: latest.turnId,359 turnId: latest.turnId,
358 ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}),360 ...(payload && "explanation" in payload
361 ? { explanation: payload.explanation as string | null }
362 : {}),
359 steps,363 steps,
360 };364 };
361}365}
...@@ -487,12 +491,7 @@ function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {...@@ -487,12 +491,7 @@ function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
487 target.push(normalized);491 target.push(normalized);
488}492}
489493
490function collectChangedFiles(494function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) {
491 value: unknown,
492 target: string[],
493 seen: Set<string>,
494 depth: number,
495) {
496 if (depth > 4 || target.length >= 12) {495 if (depth > 4 || target.length >= 12) {
497 return;496 return;
498 }497 }
apps/web/src/store.ts+5-2
...@@ -124,7 +124,9 @@ function mapProjectsFromReadModel(...@@ -124,7 +124,9 @@ function mapProjectsFromReadModel(
124 const previousById = new Map(previous.map((project) => [project.id, project] as const));124 const previousById = new Map(previous.map((project) => [project.id, project] as const));
125 const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));125 const previousByCwd = new Map(previous.map((project) => [project.cwd, project] as const));
126 const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));126 const previousOrderById = new Map(previous.map((project, index) => [project.id, index] as const));
127 const previousOrderByCwd = new Map(previous.map((project, index) => [project.cwd, index] as const));127 const previousOrderByCwd = new Map(
128 previous.map((project, index) => [project.cwd, index] as const),
129 );
128 const persistedOrderByCwd = new Map(130 const persistedOrderByCwd = new Map(
129 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),131 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
130 );132 );
...@@ -150,7 +152,8 @@ function mapProjectsFromReadModel(...@@ -150,7 +152,8 @@ function mapProjectsFromReadModel(
150152
151 return mappedProjects153 return mappedProjects
152 .map((project, incomingIndex) => {154 .map((project, incomingIndex) => {
153 const previousIndex = previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);155 const previousIndex =
156 previousOrderById.get(project.id) ?? previousOrderByCwd.get(project.cwd);
154 const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;157 const persistedIndex = usePersistedOrder ? persistedOrderByCwd.get(project.cwd) : undefined;
155 const orderIndex =158 const orderIndex =
156 previousIndex ??159 previousIndex ??
apps/web/src/terminalStateStore.test.ts+28-9
...@@ -14,7 +14,10 @@ describe("terminalStateStore actions", () => {...@@ -14,7 +14,10 @@ describe("terminalStateStore actions", () => {
14 });14 });
1515
16 it("returns a closed default terminal state for unknown threads", () => {16 it("returns a closed default terminal state for unknown threads", () => {
17 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);17 const terminalState = selectThreadTerminalState(
18 useTerminalStateStore.getState().terminalStateByThreadId,
19 THREAD_ID,
20 );
18 expect(terminalState).toEqual({21 expect(terminalState).toEqual({
19 terminalOpen: false,22 terminalOpen: false,
20 terminalHeight: 280,23 terminalHeight: 280,
...@@ -31,7 +34,10 @@ describe("terminalStateStore actions", () => {...@@ -31,7 +34,10 @@ describe("terminalStateStore actions", () => {
31 store.setTerminalOpen(THREAD_ID, true);34 store.setTerminalOpen(THREAD_ID, true);
32 store.splitTerminal(THREAD_ID, "terminal-2");35 store.splitTerminal(THREAD_ID, "terminal-2");
3336
34 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);37 const terminalState = selectThreadTerminalState(
38 useTerminalStateStore.getState().terminalStateByThreadId,
39 THREAD_ID,
40 );
35 expect(terminalState.terminalOpen).toBe(true);41 expect(terminalState.terminalOpen).toBe(true);
36 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);42 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
37 expect(terminalState.activeTerminalId).toBe("terminal-2");43 expect(terminalState.activeTerminalId).toBe("terminal-2");
...@@ -43,7 +49,10 @@ describe("terminalStateStore actions", () => {...@@ -43,7 +49,10 @@ describe("terminalStateStore actions", () => {
43 it("creates new terminals in a separate group", () => {49 it("creates new terminals in a separate group", () => {
44 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");50 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
4551
46 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);52 const terminalState = selectThreadTerminalState(
53 useTerminalStateStore.getState().terminalStateByThreadId,
54 THREAD_ID,
55 );
47 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);56 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
48 expect(terminalState.activeTerminalId).toBe("terminal-2");57 expect(terminalState.activeTerminalId).toBe("terminal-2");
49 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");58 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
...@@ -57,12 +66,16 @@ describe("terminalStateStore actions", () => {...@@ -57,12 +66,16 @@ describe("terminalStateStore actions", () => {
57 const store = useTerminalStateStore.getState();66 const store = useTerminalStateStore.getState();
58 store.splitTerminal(THREAD_ID, "terminal-2");67 store.splitTerminal(THREAD_ID, "terminal-2");
59 store.setTerminalActivity(THREAD_ID, "terminal-2", true);68 store.setTerminalActivity(THREAD_ID, "terminal-2", true);
60 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([69 expect(
61 "terminal-2",70 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
62 ]);71 .runningTerminalIds,
72 ).toEqual(["terminal-2"]);
6373
64 store.setTerminalActivity(THREAD_ID, "terminal-2", false);74 store.setTerminalActivity(THREAD_ID, "terminal-2", false);
65 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).runningTerminalIds).toEqual([]);75 expect(
76 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
77 .runningTerminalIds,
78 ).toEqual([]);
66 });79 });
6780
68 it("resets to default and clears persisted entry when closing the last terminal", () => {81 it("resets to default and clears persisted entry when closing the last terminal", () => {
...@@ -70,7 +83,10 @@ describe("terminalStateStore actions", () => {...@@ -70,7 +83,10 @@ describe("terminalStateStore actions", () => {
70 store.closeTerminal(THREAD_ID, "default");83 store.closeTerminal(THREAD_ID, "default");
7184
72 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();85 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();
73 expect(selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID).terminalIds).toEqual(["default"]);86 expect(
87 selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID)
88 .terminalIds,
89 ).toEqual(["default"]);
74 });90 });
7591
76 it("keeps a valid active terminal after closing an active split terminal", () => {92 it("keeps a valid active terminal after closing an active split terminal", () => {
...@@ -79,7 +95,10 @@ describe("terminalStateStore actions", () => {...@@ -79,7 +95,10 @@ describe("terminalStateStore actions", () => {
79 store.splitTerminal(THREAD_ID, "terminal-3");95 store.splitTerminal(THREAD_ID, "terminal-3");
80 store.closeTerminal(THREAD_ID, "terminal-3");96 store.closeTerminal(THREAD_ID, "terminal-3");
8197
82 const terminalState = selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID);98 const terminalState = selectThreadTerminalState(
99 useTerminalStateStore.getState().terminalStateByThreadId,
100 THREAD_ID,
101 );
83 expect(terminalState.activeTerminalId).toBe("terminal-2");102 expect(terminalState.activeTerminalId).toBe("terminal-2");
84 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);103 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
85 expect(terminalState.terminalGroups).toEqual([104 expect(terminalState.terminalGroups).toEqual([
apps/web/src/terminalStateStore.ts+28-10
...@@ -35,7 +35,10 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {...@@ -35,7 +35,10 @@ function normalizeTerminalIds(terminalIds: string[]): string[] {
35 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];35 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
36}36}
3737
38function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] {38function normalizeRunningTerminalIds(
39 runningTerminalIds: string[],
40 terminalIds: string[],
41): string[] {
39 if (runningTerminalIds.length === 0) return [];42 if (runningTerminalIds.length === 0) return [];
40 const validTerminalIdSet = new Set(terminalIds);43 const validTerminalIdSet = new Set(terminalIds);
41 return [...new Set(runningTerminalIds)]44 return [...new Set(runningTerminalIds)]
...@@ -58,7 +61,10 @@ function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string...@@ -58,7 +61,10 @@ function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string
58 return candidate;61 return candidate;
59}62}
6063
61function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {64function findGroupIndexByTerminalId(
65 terminalGroups: ThreadTerminalGroup[],
66 terminalId: string,
67): number {
62 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));68 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
63}69}
6470
...@@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina...@@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina
183 ? state.activeTerminalId189 ? state.activeTerminalId
184 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);190 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
185 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);191 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
186 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)192 const activeGroupIdFromState = terminalGroups.some(
193 (group) => group.id === state.activeTerminalGroupId,
194 )
187 ? state.activeTerminalGroupId195 ? state.activeTerminalGroupId
188 : null;196 : null;
189 const activeGroupIdFromTerminal =197 const activeGroupIdFromTerminal =
...@@ -239,14 +247,16 @@ function upsertTerminalIntoGroups(...@@ -239,14 +247,16 @@ function upsertTerminalIntoGroups(
239 return normalized;247 return normalized;
240 }248 }
241249
242 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;250 const terminalIds = isNewTerminal
251 ? [...normalized.terminalIds, terminalId]
252 : normalized.terminalIds;
243 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);253 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
244254
245 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);255 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
246 if (existingGroupIndex >= 0) {256 if (existingGroupIndex >= 0) {
247 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(257 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
248 (id) => id !== terminalId,258 existingGroupIndex
249 );259 ]!.terminalIds.filter((id) => id !== terminalId);
250 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {260 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
251 terminalGroups.splice(existingGroupIndex, 1);261 terminalGroups.splice(existingGroupIndex, 1);
252 }262 }
...@@ -266,13 +276,18 @@ function upsertTerminalIntoGroups(...@@ -266,13 +276,18 @@ function upsertTerminalIntoGroups(
266 });276 });
267 }277 }
268278
269 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);279 let activeGroupIndex = terminalGroups.findIndex(
280 (group) => group.id === normalized.activeTerminalGroupId,
281 );
270 if (activeGroupIndex < 0) {282 if (activeGroupIndex < 0) {
271 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);283 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
272 }284 }
273 if (activeGroupIndex < 0) {285 if (activeGroupIndex < 0) {
274 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));286 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
275 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);287 const nextGroupId = assignUniqueGroupId(
288 fallbackGroupId(normalized.activeTerminalId),
289 usedGroupIds,
290 );
276 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });291 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
277 activeGroupIndex = terminalGroups.length - 1;292 activeGroupIndex = terminalGroups.length - 1;
278 }293 }
...@@ -323,7 +338,10 @@ function newThreadTerminal(state: ThreadTerminalState, terminalId: string): Thre...@@ -323,7 +338,10 @@ function newThreadTerminal(state: ThreadTerminalState, terminalId: string): Thre
323 return upsertTerminalIntoGroups(state, terminalId, "new");338 return upsertTerminalIntoGroups(state, terminalId, "new");
324}339}
325340
326function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {341function setThreadActiveTerminal(
342 state: ThreadTerminalState,
343 terminalId: string,
344): ThreadTerminalState {
327 const normalized = normalizeThreadTerminalState(state);345 const normalized = normalizeThreadTerminalState(state);
328 if (!normalized.terminalIds.includes(terminalId)) {346 if (!normalized.terminalIds.includes(terminalId)) {
329 return normalized;347 return normalized;
apps/web/src/wsNativeApi.test.ts+11-10
...@@ -12,12 +12,13 @@ import {...@@ -12,12 +12,13 @@ import {
12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1313
14const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();14const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
15const showContextMenuFallbackMock = vi.fn<15const showContextMenuFallbackMock =
16 <T extends string>(16 vi.fn<
17 items: readonly ContextMenuItem<T>[],17 <T extends string>(
18 position?: { x: number; y: number },18 items: readonly ContextMenuItem<T>[],
19 ) => Promise<T | null>19 position?: { x: number; y: number },
20>();20 ) => Promise<T | null>
21 >();
21const channelListeners = new Map<string, Set<(data: unknown) => void>>();22const channelListeners = new Map<string, Set<(data: unknown) => void>>();
22const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(23const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(
23 (channel, listener) => {24 (channel, listener) => {
...@@ -400,10 +401,10 @@ describe("wsNativeApi", () => {...@@ -400,10 +401,10 @@ describe("wsNativeApi", () => {
400401
401 const { createWsNativeApi } = await import("./wsNativeApi");402 const { createWsNativeApi } = await import("./wsNativeApi");
402 const api = createWsNativeApi();403 const api = createWsNativeApi();
403 await api.contextMenu.show(404 await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], {
404 [{ id: "delete", label: "Delete", destructive: true }],405 x: 20,
405 { x: 20, y: 30 },406 y: 30,
406 );407 });
407408
408 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(409 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(
409 [{ id: "delete", label: "Delete", destructive: true }],410 [{ id: "delete", label: "Delete", destructive: true }],
package.json+1
...@@ -38,6 +38,7 @@...@@ -38,6 +38,7 @@
38 "test": "turbo run test",38 "test": "turbo run test",
39 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",39 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
40 "fmt": "oxfmt",40 "fmt": "oxfmt",
41 "fmt:check": "oxfmt --check",
41 "build:contracts": "turbo run build --filter=@t3tools/contracts",42 "build:contracts": "turbo run build --filter=@t3tools/contracts",
42 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",43 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",
43 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",44 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
packages/contracts/src/git.test.ts+3-1
...@@ -8,7 +8,9 @@ import {...@@ -8,7 +8,9 @@ import {
8} from "./git";8} from "./git";
99
10const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);10const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);
11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput);11const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
12 GitPreparePullRequestThreadInput,
13);
12const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);14const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1315
14describe("GitCreateWorktreeInput", () => {16describe("GitCreateWorktreeInput", () => {
packages/contracts/src/git.ts+1-5
...@@ -14,11 +14,7 @@ const GitPushStepStatus = Schema.Literals([...@@ -14,11 +14,7 @@ const GitPushStepStatus = Schema.Literals([
14 "skipped_up_to_date",14 "skipped_up_to_date",
15]);15]);
16const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);16const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);
17const GitPrStepStatus = Schema.Literals([17const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]);
18 "created",
19 "opened_existing",
20 "skipped_not_requested",
21]);
22const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);18const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
23const GitPullRequestReference = TrimmedNonEmptyStringSchema;19const GitPullRequestReference = TrimmedNonEmptyStringSchema;
24const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);20const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
packages/contracts/src/orchestration.ts+1-3
...@@ -266,9 +266,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -266,9 +266,7 @@ export const OrchestrationThread = Schema.Struct({
266 updatedAt: IsoDateTime,266 updatedAt: IsoDateTime,
267 deletedAt: Schema.NullOr(IsoDateTime),267 deletedAt: Schema.NullOr(IsoDateTime),
268 messages: Schema.Array(OrchestrationMessage),268 messages: Schema.Array(OrchestrationMessage),
269 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(269 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])),
270 Schema.withDecodingDefault(() => []),
271 ),
272 activities: Schema.Array(OrchestrationThreadActivity),270 activities: Schema.Array(OrchestrationThreadActivity),
273 checkpoints: Schema.Array(OrchestrationCheckpointSummary),271 checkpoints: Schema.Array(OrchestrationCheckpointSummary),
274 session: Schema.NullOr(OrchestrationSession),272 session: Schema.NullOr(OrchestrationSession),
packages/contracts/src/project.ts+1-3
...@@ -28,9 +28,7 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;...@@ -28,9 +28,7 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2828
29export const ProjectWriteFileInput = Schema.Struct({29export const ProjectWriteFileInput = Schema.Struct({
30 cwd: TrimmedNonEmptyString,30 cwd: TrimmedNonEmptyString,
31 relativePath: TrimmedNonEmptyString.check(31 relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)),
32 Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH),
33 ),
34 contents: Schema.String,32 contents: Schema.String,
35});33});
36export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;34export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type;
packages/contracts/src/providerRuntime.ts+16-19
...@@ -61,12 +61,7 @@ const RuntimeThreadState = Schema.Literals([...@@ -61,12 +61,7 @@ const RuntimeThreadState = Schema.Literals([
61]);61]);
62export type RuntimeThreadState = typeof RuntimeThreadState.Type;62export type RuntimeThreadState = typeof RuntimeThreadState.Type;
6363
64const RuntimeTurnState = Schema.Literals([64const RuntimeTurnState = Schema.Literals(["completed", "failed", "interrupted", "cancelled"]);
65 "completed",
66 "failed",
67 "interrupted",
68 "cancelled",
69]);
70export type RuntimeTurnState = typeof RuntimeTurnState.Type;65export type RuntimeTurnState = typeof RuntimeTurnState.Type;
7166
72const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]);67const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]);
...@@ -573,14 +568,16 @@ const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({...@@ -573,14 +568,16 @@ const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({
573 type: SessionConfiguredType,568 type: SessionConfiguredType,
574 payload: SessionConfiguredPayload,569 payload: SessionConfiguredPayload,
575});570});
576export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type;571export type ProviderRuntimeSessionConfiguredEvent =
572 typeof ProviderRuntimeSessionConfiguredEvent.Type;
577573
578const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({574const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({
579 ...ProviderRuntimeEventBase.fields,575 ...ProviderRuntimeEventBase.fields,
580 type: SessionStateChangedType,576 type: SessionStateChangedType,
581 payload: SessionStateChangedPayload,577 payload: SessionStateChangedPayload,
582});578});
583export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type;579export type ProviderRuntimeSessionStateChangedEvent =
580 typeof ProviderRuntimeSessionStateChangedEvent.Type;
584581
585const ProviderRuntimeSessionExitedEvent = Schema.Struct({582const ProviderRuntimeSessionExitedEvent = Schema.Struct({
586 ...ProviderRuntimeEventBase.fields,583 ...ProviderRuntimeEventBase.fields,
...@@ -601,14 +598,16 @@ const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({...@@ -601,14 +598,16 @@ const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({
601 type: ThreadStateChangedType,598 type: ThreadStateChangedType,
602 payload: ThreadStateChangedPayload,599 payload: ThreadStateChangedPayload,
603});600});
604export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type;601export type ProviderRuntimeThreadStateChangedEvent =
602 typeof ProviderRuntimeThreadStateChangedEvent.Type;
605603
606const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({604const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({
607 ...ProviderRuntimeEventBase.fields,605 ...ProviderRuntimeEventBase.fields,
608 type: ThreadMetadataUpdatedType,606 type: ThreadMetadataUpdatedType,
609 payload: ThreadMetadataUpdatedPayload,607 payload: ThreadMetadataUpdatedPayload,
610});608});
611export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type;609export type ProviderRuntimeThreadMetadataUpdatedEvent =
610 typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type;
612611
613const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({612const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({
614 ...ProviderRuntimeEventBase.fields,613 ...ProviderRuntimeEventBase.fields,
...@@ -764,7 +763,8 @@ const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({...@@ -764,7 +763,8 @@ const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({
764 type: UserInputResolvedType,763 type: UserInputResolvedType,
765 payload: UserInputResolvedPayload,764 payload: UserInputResolvedPayload,
766});765});
767export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type;766export type ProviderRuntimeUserInputResolvedEvent =
767 typeof ProviderRuntimeUserInputResolvedEvent.Type;
768768
769const ProviderRuntimeTaskStartedEvent = Schema.Struct({769const ProviderRuntimeTaskStartedEvent = Schema.Struct({
770 ...ProviderRuntimeEventBase.fields,770 ...ProviderRuntimeEventBase.fields,
...@@ -856,7 +856,8 @@ const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({...@@ -856,7 +856,8 @@ const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({
856 type: McpOauthCompletedType,856 type: McpOauthCompletedType,
857 payload: McpOauthCompletedPayload,857 payload: McpOauthCompletedPayload,
858});858});
859export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type;859export type ProviderRuntimeMcpOauthCompletedEvent =
860 typeof ProviderRuntimeMcpOauthCompletedEvent.Type;
860861
861const ProviderRuntimeModelReroutedEvent = Schema.Struct({862const ProviderRuntimeModelReroutedEvent = Schema.Struct({
862 ...ProviderRuntimeEventBase.fields,863 ...ProviderRuntimeEventBase.fields,
...@@ -877,7 +878,8 @@ const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({...@@ -877,7 +878,8 @@ const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({
877 type: DeprecationNoticeType,878 type: DeprecationNoticeType,
878 payload: DeprecationNoticePayload,879 payload: DeprecationNoticePayload,
879});880});
880export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type;881export type ProviderRuntimeDeprecationNoticeEvent =
882 typeof ProviderRuntimeDeprecationNoticeEvent.Type;
881883
882const ProviderRuntimeFilesPersistedEvent = Schema.Struct({884const ProviderRuntimeFilesPersistedEvent = Schema.Struct({
883 ...ProviderRuntimeEventBase.fields,885 ...ProviderRuntimeEventBase.fields,
...@@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent...@@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent
969export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;971export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent;
970972
971// Legacy helper aliases retained for adapters/tests.973// Legacy helper aliases retained for adapters/tests.
972const ProviderRuntimeToolKind = Schema.Literals([974const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]);
973 "command",
974 "file-read",
975 "file-change",
976 "other",
977]);
978export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;975export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
979976
980export const ProviderRuntimeTurnStatus = RuntimeTurnState;977export const ProviderRuntimeTurnStatus = RuntimeTurnState;
packages/shared/src/shell.test.ts+3-1
...@@ -5,7 +5,9 @@ import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";...@@ -5,7 +5,9 @@ import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
5describe("extractPathFromShellOutput", () => {5describe("extractPathFromShellOutput", () => {
6 it("extracts the path between capture markers", () => {6 it("extracts the path between capture markers", () => {
7 expect(7 expect(
8 extractPathFromShellOutput("__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n"),8 extractPathFromShellOutput(
9 "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n",
10 ),
9 ).toBe("/opt/homebrew/bin:/usr/bin");11 ).toBe("/opt/homebrew/bin:/usr/bin");
10 });12 });
1113
scripts/build-desktop-artifact.ts+4-2
...@@ -23,8 +23,10 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);...@@ -23,8 +23,10 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
23const RepoRoot = Effect.service(Path.Path).pipe(23const RepoRoot = Effect.service(Path.Path).pipe(
24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
25);25);
26const ProductionMacIconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>26const ProductionMacIconSource = Effect.zipWith(
27 path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),27 RepoRoot,
28 Effect.service(Path.Path),
29 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
28);30);
29const ProductionLinuxIconSource = Effect.zipWith(31const ProductionLinuxIconSource = Effect.zipWith(
30 RepoRoot,32 RepoRoot,
scripts/dev-runner.ts+1-3
...@@ -399,9 +399,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -399,9 +399,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
399399
400 const envOverrides = {400 const envOverrides = {
401 noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"),401 noBrowser: readOptionalBooleanEnv("T3CODE_NO_BROWSER"),
402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv(402 autoBootstrapProjectFromCwd: readOptionalBooleanEnv("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD"),
403 "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD",
404 ),
405 logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"),403 logWebSocketEvents: readOptionalBooleanEnv("T3CODE_LOG_WS_EVENTS"),
406 };404 };
407405
scripts/release-smoke.ts+29-8
...@@ -82,7 +82,12 @@ try {...@@ -82,7 +82,12 @@ try {
8282
83 execFileSync(83 execFileSync(
84 process.execPath,84 process.execPath,
85 [resolve(repoRoot, "scripts/update-release-package-versions.ts"), "9.9.9-smoke.0", "--root", tempRoot],85 [
86 resolve(repoRoot, "scripts/update-release-package-versions.ts"),
87 "9.9.9-smoke.0",
88 "--root",
89 tempRoot,
90 ],
86 {91 {
87 cwd: repoRoot,92 cwd: repoRoot,
88 stdio: "inherit",93 stdio: "inherit",
...@@ -95,17 +100,33 @@ try {...@@ -95,17 +100,33 @@ try {
95 });100 });
96101
97 const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8");102 const lockfile = readFileSync(resolve(tempRoot, "bun.lock"), "utf8");
98 assertContains(lockfile, `"version": "9.9.9-smoke.0"`, "Expected bun.lock to contain the smoke version.");103 assertContains(
104 lockfile,
105 `"version": "9.9.9-smoke.0"`,
106 "Expected bun.lock to contain the smoke version.",
107 );
99108
100 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);109 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
101 execFileSync(process.execPath, [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], {110 execFileSync(
102 cwd: repoRoot,111 process.execPath,
103 stdio: "inherit",112 [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path],
104 });113 {
114 cwd: repoRoot,
115 stdio: "inherit",
116 },
117 );
105118
106 const mergedManifest = readFileSync(arm64Path, "utf8");119 const mergedManifest = readFileSync(arm64Path, "utf8");
107 assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-arm64.zip", "Merged manifest is missing the arm64 asset.");120 assertContains(
108 assertContains(mergedManifest, "T3-Code-9.9.9-smoke.0-x64.zip", "Merged manifest is missing the x64 asset.");121 mergedManifest,
122 "T3-Code-9.9.9-smoke.0-arm64.zip",
123 "Merged manifest is missing the arm64 asset.",
124 );
125 assertContains(
126 mergedManifest,
127 "T3-Code-9.9.9-smoke.0-x64.zip",
128 "Merged manifest is missing the x64 asset.",
129 );
109130
110 console.log("Release smoke checks passed.");131 console.log("Release smoke checks passed.");
111} finally {132} finally {
scripts/update-release-package-versions.ts+2-1
...@@ -88,7 +88,8 @@ function parseArgs(argv: ReadonlyArray<string>): {...@@ -88,7 +88,8 @@ function parseArgs(argv: ReadonlyArray<string>): {
88 return { version, rootDir, writeGithubOutput };88 return { version, rootDir, writeGithubOutput };
89}89}
9090
91const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);91const isMain =
92 process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
9293
93if (isMain) {94if (isMain) {
94 const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2));95 const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2));