| author | |
| committer | |
| log | 223c3dc8005b583b19c502320f5de929958a7efd |
| tree | f2dfa92ecf3dcc712e6e43821e286003f7576b54 |
| parent | afb6e89bfdd9164c142162597bb865540b1909d2 |
| signature | Signed by PGP key B5690EEEBB952194 |
* 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: |
| 8 | 8 | ||
| 9 | jobs: | 9 | jobs: |
| 10 | quality: | 10 | quality: |
| 11 | name: Lint, Typecheck, Test, Browser Test, Build | 11 | name: Format, Lint, Typecheck, Test, Browser Test, Build |
| 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 13 | steps: | 13 | steps: |
| 14 | - name: Checkout | 14 | - name: Checkout |
| ... | @@ -45,6 +45,9 @@ jobs: | ... | @@ -45,6 +45,9 @@ jobs: |
| 45 | - name: Install dependencies | 45 | - name: Install dependencies |
| 46 | run: bun install --frozen-lockfile | 46 | run: bun install --frozen-lockfile |
| 47 | 47 | ||
| 48 | - name: Format | ||
| 49 | run: bun run fmt:check | ||
| 50 | |||
| 48 | - name: Lint | 51 | - name: Lint |
| 49 | run: bun run lint | 52 | run: bun run lint |
| 50 | 53 |
.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 Contracts | 1 | # Plan: Centralize Model Normalization in Contracts |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. | 5 | Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 11 | 13 | ||
| 12 | ## Scope | 14 | ## 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. |
| 16 | 19 | ||
| 17 | ## Proposed Changes | 20 | ## Proposed Changes |
| 21 | |||
| 18 | 1. Add `packages/contracts/src/model.ts` with: | 22 | 1. Add `packages/contracts/src/model.ts` with: |
| 19 | - Canonical model list | 23 | - Canonical model list |
| 20 | - Alias map | 24 | - 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. |
| 30 | 34 | ||
| 31 | ## Risks | 35 | ## 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. |
| 34 | 39 | ||
| 35 | ## Validation | 40 | ## 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. |
| 39 | 45 | ||
| 40 | ## Done Criteria | 46 | ## 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 Process | 1 | # Plan: Strengthen Typed IPC Boundaries in Main Process |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. | 5 | Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## 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. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: | 19 | 1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: |
| 16 | - Parse payload(s) with Zod schemas | 20 | - Parse payload(s) with Zod schemas |
| 17 | - Standardize typed handler signatures | 21 | - 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 |
| 24 | 4. Add tests for handler parsing failure paths (invalid payloads). | 28 | 4. Add tests for handler parsing failure paths (invalid payloads). |
| 25 | 29 | ||
| 26 | ## Risks | 30 | ## 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. |
| 29 | 34 | ||
| 30 | ## Validation | 35 | ## 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. |
| 34 | 40 | ||
| 35 | ## Done Criteria | 41 | ## 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 CodexAppServerManager | 1 | # Plan: Decompose CodexAppServerManager |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Split `CodexAppServerManager` into smaller modules with clear responsibilities. | 5 | Split `CodexAppServerManager` into smaller modules with clear responsibilities. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## Motivation |
| 8 | |||
| 7 | - `apps/desktop/src/codexAppServerManager.ts` is large and mixes: | 9 | - `apps/desktop/src/codexAppServerManager.ts` is large and mixes: |
| 8 | - Process lifecycle | 10 | - Process lifecycle |
| 9 | - JSON-RPC parsing/routing | 11 | - 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. |
| 13 | 15 | ||
| 14 | ## Scope | 16 | ## Scope |
| 17 | |||
| 15 | - Desktop provider internals only. | 18 | - Desktop provider internals only. |
| 16 | - Keep external behavior/API stable. | 19 | - Keep external behavior/API stable. |
| 17 | 20 | ||
| 18 | ## Proposed Changes | 21 | ## Proposed Changes |
| 22 | |||
| 19 | 1. Extract modules: | 23 | 1. 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 transitions | 33 | - Session state transitions |
| 30 | 34 | ||
| 31 | ## Risks | 35 | ## 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. |
| 34 | 39 | ||
| 35 | ## Validation | 40 | ## 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. |
| 38 | 44 | ||
| 39 | ## Done Criteria | 45 | ## 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 Units | 1 | # Plan: Split ChatView into Smaller UI/Logic Units |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. | 5 | Refactor `ChatView.tsx` into composable pieces with isolated responsibilities. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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 orchestration | 10 | - Session orchestration |
| 9 | - Send/interrupt actions | 11 | - 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. |
| 14 | 16 | ||
| 15 | ## Scope | 17 | ## Scope |
| 18 | |||
| 16 | - Renderer component boundaries and hooks. | 19 | - Renderer component boundaries and hooks. |
| 17 | - Keep visual behavior unchanged. | 20 | - Keep visual behavior unchanged. |
| 18 | 21 | ||
| 19 | ## Proposed Changes | 22 | ## Proposed Changes |
| 23 | |||
| 20 | 1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` | 24 | 1. 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. |
| 29 | 4. Add focused tests for hook behavior (error handling, session reuse). | 33 | 4. Add focused tests for hook behavior (error handling, session reuse). |
| 30 | 34 | ||
| 31 | ## Risks | 35 | ## 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). |
| 33 | 38 | ||
| 34 | ## Validation | 39 | ## 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. |
| 37 | 43 | ||
| 38 | ## Done Criteria | 44 | ## 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 Zod | 1 | # Plan: Move Renderer Persisted-State Validation to Zod |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Use explicit Zod schemas for localStorage state parsing and migration. | 5 | Use explicit Zod schemas for localStorage state parsing and migration. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## Scope |
| 13 | |||
| 11 | - Renderer state hydration/persistence path. | 14 | - Renderer state hydration/persistence path. |
| 12 | - No backend/protocol changes. | 15 | - No backend/protocol changes. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add schema module: `apps/renderer/src/persistenceSchema.ts` | 19 | 1. Add schema module: `apps/renderer/src/persistenceSchema.ts` |
| 16 | - Persisted payload versions (`v1`, `v2`) | 20 | - Persisted payload versions (`v1`, `v2`) |
| 17 | - Thread/message/project schemas | 21 | - 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 filtered | 27 | - Unknown thread/project references filtered |
| 24 | 28 | ||
| 25 | ## Risks | 29 | ## Risks |
| 30 | |||
| 26 | - Overly strict schemas could drop valid historical data unexpectedly. | 31 | - Overly strict schemas could drop valid historical data unexpectedly. |
| 27 | 32 | ||
| 28 | ## Validation | 33 | ## 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. |
| 31 | 37 | ||
| 32 | ## Done Criteria | 38 | ## 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 Management | 1 | # Plan: Add Provider Log Stream Lifecycle Management |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. | 5 | Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## Scope |
| 13 | |||
| 11 | - Desktop provider logging behavior. | 14 | - Desktop provider logging behavior. |
| 12 | - App shutdown integration. | 15 | - App shutdown integration. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add explicit `dispose()` on `ProviderManager`: | 19 | 1. Add explicit `dispose()` on `ProviderManager`: |
| 16 | - Remove event listeners | 20 | - Remove event listeners |
| 17 | - End/close log stream | 21 | - 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 |
| 20 | 4. Optional: per-session log files under `.logs/providers/`. | 24 | 4. Optional: per-session log files under `.logs/providers/`. |
| 21 | 25 | ||
| 22 | ## Risks | 26 | ## Risks |
| 27 | |||
| 23 | - Improper close sequencing may lose final log lines. | 28 | - Improper close sequencing may lose final log lines. |
| 24 | 29 | ||
| 25 | ## Validation | 30 | ## 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. |
| 28 | 34 | ||
| 29 | ## Done Criteria | 35 | ## 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 Gates | 1 | # Plan: Add CI Workflow for Core Quality Gates |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. | 5 | Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## Scope |
| 13 | |||
| 11 | - `.github/workflows/ci.yml` | 14 | - `.github/workflows/ci.yml` |
| 12 | - Bun + Turbo setup in CI. | 15 | - Bun + Turbo setup in CI. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add `ci.yml` with jobs: | 19 | 1. Add `ci.yml` with jobs: |
| 16 | - Setup Bun and Node environment | 20 | - Setup Bun and Node environment |
| 17 | - Install deps | 21 | - 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 |
| 22 | 3. Configure caching for Bun/Turbo as appropriate. | 26 | 3. Configure caching for Bun/Turbo as appropriate. |
| 23 | 27 | ||
| 24 | ## Risks | 28 | ## 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. |
| 27 | 32 | ||
| 28 | ## Validation | 33 | ## 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. |
| 31 | 37 | ||
| 32 | ## Done Criteria | 38 | ## 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 Hooks | 1 | # Plan: Add Pre-Commit Formatting/Lint Hooks |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Introduce pre-commit automation so formatting and basic lint checks happen before commits. | 5 | Introduce pre-commit automation so formatting and basic lint checks happen before commits. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## 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. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). | 19 | 1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). |
| 16 | 2. Configure staged-file tasks: | 20 | 2. 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 |
| 20 | 4. Keep checks fast to avoid developer friction. | 24 | 4. Keep checks fast to avoid developer friction. |
| 21 | 25 | ||
| 22 | ## Risks | 26 | ## 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. |
| 25 | 30 | ||
| 26 | ## Validation | 31 | ## 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. |
| 29 | 35 | ||
| 30 | ## Done Criteria | 36 | ## 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 Coverage | 1 | # Plan: Expand Event/State Transition Test Coverage |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Add focused tests for renderer event handling and session evolution logic. | 5 | Add focused tests for renderer event handling and session evolution logic. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## 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`. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Add tests for `evolveSession`: | 19 | 1. 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. |
| 24 | 3. Add reducer integration tests for `APPLY_EVENT`. | 28 | 3. Add reducer integration tests for `APPLY_EVENT`. |
| 25 | 29 | ||
| 26 | ## Risks | 30 | ## 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. |
| 28 | 33 | ||
| 29 | ## Validation | 34 | ## 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. |
| 32 | 38 | ||
| 33 | ## Done Criteria | 39 | ## 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 ProcessManager | 1 | # Plan: Unify Process and PTY Session Abstractions in ProcessManager |
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | |||
| 4 | Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. | 5 | Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes. |
| 5 | 6 | ||
| 6 | ## Motivation | 7 | ## 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. |
| 9 | 11 | ||
| 10 | ## Scope | 12 | ## Scope |
| 13 | |||
| 11 | - Desktop process execution internals. | 14 | - Desktop process execution internals. |
| 12 | - Preserve public `ProcessManager` API. | 15 | - Preserve public `ProcessManager` API. |
| 13 | 16 | ||
| 14 | ## Proposed Changes | 17 | ## Proposed Changes |
| 18 | |||
| 15 | 1. Introduce internal interface (e.g. `RuntimeSession`): | 19 | 1. 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 |
| 24 | 5. Add tests for both implementations. | 28 | 5. Add tests for both implementations. |
| 25 | 29 | ||
| 26 | ## Risks | 30 | ## 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. |
| 28 | 33 | ||
| 29 | ## Validation | 34 | ## 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. |
| 32 | 38 | ||
| 33 | ## Done Criteria | 39 | ## 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 | |||
| 3 | PR 1: Service contracts + error taxonomy | 1 | PR 1: Service contracts + error taxonomy |
| 4 | Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. | 2 | Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. |
| 5 | Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). | 3 | Add 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. |
| 39 | PR 10: Cleanup + deprecation removal | 37 | PR 10: Cleanup + deprecation removal |
| 40 | Remove legacy class implementations/adapters once parity is proven. | 38 | Remove legacy class implementations/adapters once parity is proven. |
| 41 | Finalize layer composition and startup graph docs. | 39 | Finalize layer composition and startup graph docs. |
| 42 | Add architecture notes for service boundaries and error model. | ||
| \ No newline at end of file | |||
| 40 | Add 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) |
| 2 | 2 | ||
| 3 | Current status summary: | 3 | Current 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. |
| 8 | 9 | ||
| 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. |
| 15 | 17 | ||
| 16 | ## PR 2: Runtime Composition + Startup Ownership | 18 | ## 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. |
| 21 | 24 | ||
| 22 | ## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants | 25 | ## 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. |
| 30 | 34 | ||
| 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. |
| 36 | 41 | ||
| 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 lifecycle | 45 | - 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. |
| 44 | 50 | ||
| 45 | ## PR 6: Codex Protocol Decode Hardening | 51 | ## 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. |
| 49 | 56 | ||
| 50 | ## PR 7: Remove Legacy Provider Stack | 57 | ## 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. |
| 54 | 62 | ||
| 55 | ## PR 8: Final Cleanup + Docs | 63 | ## 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 Plan | 1 | # ProviderService Integration Test Plan |
| 2 | 2 | ||
| 3 | Goal: | 3 | Goal: |
| 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 Harness | 14 | ## Test Harness |
| 14 | 15 | ||
| 15 | Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: | 16 | Build 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`. |
| 30 | 32 | ||
| 31 | Use real git-backed temporary workspaces in integration tests: | 33 | Use real git-backed temporary workspaces in integration tests: |
| 34 | |||
| 32 | - initialize repo with baseline commit | 35 | - initialize repo with baseline commit |
| 33 | - run provider turn in workspace | 36 | - run provider turn in workspace |
| 34 | - assert checkpoint diffs against real git refs | 37 | - assert checkpoint diffs against real git refs |
| 35 | 38 | ||
| 36 | ## Core Integration Specs | 39 | ## Core Integration Specs |
| 37 | 40 | ||
| 38 | 1) `startSession` initializes checkpoint root exactly once | 41 | 1. `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. |
| 45 | 49 | ||
| 46 | 2) Turn without filesystem change | 50 | 2. 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. |
| 56 | 61 | ||
| 57 | 3) Turn with filesystem change | 62 | 3. 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`. |
| 64 | 70 | ||
| 65 | 4) Multi-turn sequencing and checkpoint monotonicity | 71 | 4. Multi-turn sequencing and checkpoint monotonicity |
| 72 | |||
| 66 | - Arrange: | 73 | - Arrange: |
| 67 | - turn 1: no file change | 74 | - turn 1: no file change |
| 68 | - turn 2: file change | 75 | - 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. |
| 74 | 81 | ||
| 75 | 5) Revert to checkpoint | 82 | 5. 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. |
| 84 | 92 | ||
| 85 | 6) Capture failure surface | 93 | 6. 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) |
| 93 | 102 | ||
| 94 | Add one ws server integration spec: | 103 | Add 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 Split | 111 | ## Proposed PR Split |
| 102 | 112 | ||
| 103 | PR A: | 113 | PR 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). |
| 105 | 116 | ||
| 106 | PR B: | 117 | PR B: |
| 118 | |||
| 107 | - Core ProviderService integration specs (cases 1-4). | 119 | - Core ProviderService integration specs (cases 1-4). |
| 108 | 120 | ||
| 109 | PR C: | 121 | PR 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 Plan | 1 | # Server-Authoritative Event-Sourcing Cleanup Plan |
| 2 | 2 | ||
| 3 | Goal: | 3 | Goal: |
| 4 | |||
| 4 | - Move to a cleaner service architecture with: | 5 | - Move to a cleaner service architecture with: |
| 5 | - durable, server-authoritative event sourcing | 6 | - durable, server-authoritative event sourcing |
| 6 | - strict command routing/validation | 7 | - strict command routing/validation |
| ... | @@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite | ... | @@ -85,6 +86,7 @@ CheckpointCatalog ------> SQLite |
| 85 | ## Commit Series | 86 | ## Commit Series |
| 86 | 87 | ||
| 87 | ### Commit 1: Split public vs system orchestration command contracts | 88 | ### 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 commands | 102 | - preserve internal dispatch functionality for system commands |
| 101 | 103 | ||
| 102 | ### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary | 104 | ### 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 type | 116 | - handler happy-path tests per command type |
| 114 | 117 | ||
| 115 | ### Commit 3: Harden event store for idempotency + optimistic append metadata | 118 | ### 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 deterministic | 129 | - concurrent append behavior stays ordered and deterministic |
| 126 | 130 | ||
| 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 commands | 135 | - 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 behavior | 144 | - ws integration confirms same external push behavior |
| 140 | 145 | ||
| 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 semantics | 158 | - stale session cleanup semantics |
| 153 | 159 | ||
| 154 | ### Commit 6: Re-key checkpoint metadata from session to thread identity | 160 | ### 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 churn | 172 | - revert/diff still work after session churn |
| 166 | 173 | ||
| 167 | ### Commit 7: Add durable projection persistence for orchestration read models | 174 | ### 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 semantics | 185 | - projection rebuild from events yields same result as previous reducer semantics |
| 178 | 186 | ||
| 179 | ### Commit 8: Narrow `ProviderService` responsibilities | 187 | ### 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 coordinator | 197 | - checkpoint capture still triggered by turn completion through new coordinator |
| 189 | 198 | ||
| 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 everywhere | 202 | - Use effect/Schema everywhere |
| 193 | - Analyze which we need | 203 | - Analyze which we need |
| ... | @@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite | ... | @@ -196,6 +206,7 @@ CheckpointCatalog ------> SQLite |
| 196 | - Persistence entities | 206 | - Persistence entities |
| 197 | 207 | ||
| 198 | ### Commit 10: Remove dead legacy path and finalize docs | 208 | ### 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 behavior | 221 | - no regressions in WS protocol behavior |
| 211 | 222 | ||
| 212 | ## Risk Controls | 223 | ## 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 @@ |
| 1 | Rewrite `createServer` and `index.ts` to be Effect native. | 1 | Rewrite `createServer` and `index.ts` to be Effect native. |
| 2 | 2 | ||
| 3 | Maybe use `effect/unstable/Socket` for the web socket server | 3 | Maybe 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.ts | 5 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts |
| 5 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts | 6 | - https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts |
| 6 | 7 | ||
| 7 | - Migrate remaining runtime code to Effect | 8 | - 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. |
| 25 | 25 | ||
| 26 | Exit criteria: | 26 | Exit 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. |
| 28 | 29 | ||
| 29 | ## Phase 1: Runtime Survival and Critical Event Wiring | 30 | ## Phase 1: Runtime Survival and Critical Event Wiring |
| 30 | 31 | ||
| 31 | Related bug groups solved together: | 32 | Related 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). |
| 35 | 37 | ||
| 36 | Primary files: | 38 | Primary 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` |
| 40 | 43 | ||
| 41 | Exit criteria: | 44 | Exit 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 Ordering | 50 | ## Phase 2: State Consistency and Ordering |
| 47 | 51 | ||
| 48 | Related bug groups solved together: | 52 | Related 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. |
| 52 | 57 | ||
| 53 | Primary files: | 58 | Primary 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` |
| 58 | 64 | ||
| 59 | Exit criteria: | 65 | Exit 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 Bundle | 71 | ## Phase 3: Checkpointing Correctness Bundle |
| 65 | 72 | ||
| 66 | Related bug groups solved together: | 73 | Related 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. |
| 71 | 79 | ||
| 72 | Primary files: | 80 | Primary 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` |
| 78 | 87 | ||
| 79 | Exit criteria: | 88 | Exit 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 Hygiene | 94 | ## Phase 4: Memory and Lifecycle Hygiene |
| 85 | 95 | ||
| 86 | Related bug groups solved together: | 96 | Related 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. |
| 89 | 100 | ||
| 90 | Primary files: | 101 | Primary 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` |
| 94 | 106 | ||
| 95 | Exit criteria: | 107 | Exit 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. |
| 98 | 111 | ||
| 99 | ## Phase 5: Transport, Parsing, and Platform Edge Cases | 112 | ## Phase 5: Transport, Parsing, and Platform Edge Cases |
| 100 | 113 | ||
| 101 | Related bug groups solved together: | 114 | Related 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. |
| 106 | 120 | ||
| 107 | Primary files: | 121 | Primary 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` |
| 114 | 129 | ||
| 115 | Exit criteria: | 130 | Exit 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. |
| 118 | 134 | ||
| 119 | ## Phase 6: Build and Maintainability Cleanup | 135 | ## Phase 6: Build and Maintainability Cleanup |
| 120 | 136 | ||
| 121 | Related bug groups solved together: | 137 | Related 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. |
| 125 | 142 | ||
| 126 | Primary files: | 143 | Primary 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 phases | 147 | - Shared utility locations introduced during earlier phases |
| 130 | - `AGENTS.md` (if cleanup is still pending) | 148 | - `AGENTS.md` (if cleanup is still pending) |
| 131 | 149 | ||
| 132 | Exit criteria: | 150 | Exit 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. |
| 135 | 154 | ||
| ... | @@ -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. |
| 141 | 160 | ||
| 142 | Exit criteria: | 161 | Exit 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_ |
| 5 | This is the working checklist for remediation execution. | 5 | This is the working checklist for remediation execution. |
| 6 | 6 | ||
| 7 | Status values: | 7 | Status values: |
| 8 | |||
| 8 | - `TODO`: Not started | 9 | - `TODO`: Not started |
| 9 | - `IN_PROGRESS`: Currently being worked | 10 | - `IN_PROGRESS`: Currently being worked |
| 10 | - `BLOCKED`: Waiting on decision/dependency | 11 | - `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_kwDORLtfbc5whtrN | 153 | - 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. |
| 154 | 155 | ||
| 155 | ### Phase 2 | 156 | ### Phase 2 |
| 156 | 157 | ||
| ... | @@ -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_kwDORLtfbc5whxJO | 182 | - 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. |
| 183 | 184 | ||
| 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. |
| 203 | 204 | ||
| 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` |
| 250 | 251 | ||
| 251 | ### Phase 5 | 252 | ### Phase 5 |
| 252 | 253 | ||
| 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 @@ |
| 3 | Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. | 3 | Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. |
| 4 | 4 | ||
| 5 | Execution mode for this plan: | 5 | Execution 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. |
| 10 | 11 | ||
| 11 | ## 1. Freeze SPEC contract as source of truth | 12 | ## 1. Freeze SPEC contract as source of truth |
| 13 | |||
| 12 | Work units: | 14 | Work 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. |
| 18 | 21 | ||
| 19 | Deliverables: | 22 | Deliverables: |
| 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. |
| 22 | 26 | ||
| 23 | Breakage allowed: | 27 | Breakage allowed: |
| 28 | |||
| 24 | - No code changes required yet. | 29 | - No code changes required yet. |
| 25 | 30 | ||
| 26 | Exit criteria: | 31 | Exit 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. |
| 28 | 34 | ||
| 29 | ## 2. Hard cutover migrations (replace current migration set) | 35 | ## 2. Hard cutover migrations (replace current migration set) |
| 36 | |||
| 30 | Work units: | 37 | Work 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. |
| 48 | 56 | ||
| 49 | Deliverables: | 57 | Deliverables: |
| 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. |
| 52 | 61 | ||
| 53 | Breakage allowed: | 62 | Breakage 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. |
| 55 | 65 | ||
| 56 | Exit criteria: | 66 | Exit criteria: |
| 67 | |||
| 57 | - Fresh DB initializes with only canonical tables plus migration bookkeeping. | 68 | - Fresh DB initializes with only canonical tables plus migration bookkeeping. |
| 58 | 69 | ||
| 59 | ## 3. Align persistence row/request schemas to DB 1:1 | 70 | ## 3. Align persistence row/request schemas to DB 1:1 |
| 71 | |||
| 60 | Work units: | 72 | Work 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). |
| 66 | 79 | ||
| 67 | Deliverables: | 80 | Deliverables: |
| 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. |
| 70 | 84 | ||
| 71 | Breakage allowed: | 85 | Breakage allowed: |
| 86 | |||
| 72 | - Runtime can still fail while query layers are being rewired. | 87 | - Runtime can still fail while query layers are being rewired. |
| 73 | 88 | ||
| 74 | Exit criteria: | 89 | Exit 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. |
| 76 | 92 | ||
| 77 | ## 4. Rewrite event store for full persisted envelope | 93 | ## 4. Rewrite event store for full persisted envelope |
| 94 | |||
| 78 | Work units: | 95 | Work 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`). |
| 85 | 103 | ||
| 86 | Deliverables: | 104 | Deliverables: |
| 105 | |||
| 87 | - Event store append/replay fully aligned with canonical envelope. | 106 | - Event store append/replay fully aligned with canonical envelope. |
| 88 | 107 | ||
| 89 | Breakage allowed: | 108 | Breakage 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. |
| 91 | 111 | ||
| 92 | Exit criteria: | 112 | Exit criteria: |
| 113 | |||
| 93 | - Event store no longer depends on legacy event table shape. | 114 | - Event store no longer depends on legacy event table shape. |
| 94 | 115 | ||
| 95 | ## 5. Add command receipt idempotency | 116 | ## 5. Add command receipt idempotency |
| 117 | |||
| 96 | Work units: | 118 | Work 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. |
| 103 | 126 | ||
| 104 | Deliverables: | 127 | Deliverables: |
| 128 | |||
| 105 | - Dispatch path with idempotency behavior wired through receipts. | 129 | - Dispatch path with idempotency behavior wired through receipts. |
| 106 | 130 | ||
| 107 | Breakage allowed: | 131 | Breakage 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. |
| 109 | 134 | ||
| 110 | Exit criteria: | 135 | Exit criteria: |
| 136 | |||
| 111 | - Duplicate command IDs no longer create duplicate events. | 137 | - Duplicate command IDs no longer create duplicate events. |
| 112 | 138 | ||
| 113 | ## 6. Build DB-backed projection pipeline | 139 | ## 6. Build DB-backed projection pipeline |
| 140 | |||
| 114 | Work units: | 141 | Work 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`. |
| 121 | 149 | ||
| 122 | Deliverables: | 150 | Deliverables: |
| 151 | |||
| 123 | - Persistent projector pipeline writing all `projection_*` tables. | 152 | - Persistent projector pipeline writing all `projection_*` tables. |
| 124 | 153 | ||
| 125 | Breakage allowed: | 154 | Breakage 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. |
| 127 | 157 | ||
| 128 | Exit criteria: | 158 | Exit criteria: |
| 159 | |||
| 129 | - Events drive projection rows in DB; projection state advances transactionally. | 160 | - Events drive projection rows in DB; projection state advances transactionally. |
| 130 | 161 | ||
| 131 | ## 7. Move RPC reads to projections and diff blobs | 162 | ## 7. Move RPC reads to projections and diff blobs |
| 163 | |||
| 132 | Work units: | 164 | Work 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`. |
| 139 | 172 | ||
| 140 | Deliverables: | 173 | Deliverables: |
| 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. |
| 142 | 176 | ||
| 143 | Breakage allowed: | 177 | Breakage 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. |
| 145 | 180 | ||
| 146 | Exit criteria: | 181 | Exit 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. |
| 148 | 184 | ||
| 149 | ## 8. Migrate provider runtime persistence to canonical table | 185 | ## 8. Migrate provider runtime persistence to canonical table |
| 186 | |||
| 150 | Work units: | 187 | Work 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. |
| 156 | 194 | ||
| 157 | Deliverables: | 195 | Deliverables: |
| 196 | |||
| 158 | - Provider runtime state entirely backed by `provider_session_runtime`. | 197 | - Provider runtime state entirely backed by `provider_session_runtime`. |
| 159 | 198 | ||
| 160 | Breakage allowed: | 199 | Breakage allowed: |
| 200 | |||
| 161 | - Some legacy interfaces may still exist but should be disconnected. | 201 | - Some legacy interfaces may still exist but should be disconnected. |
| 162 | 202 | ||
| 163 | Exit criteria: | 203 | Exit criteria: |
| 204 | |||
| 164 | - Runtime restore no longer reads/writes legacy provider session persistence. | 205 | - Runtime restore no longer reads/writes legacy provider session persistence. |
| 165 | 206 | ||
| 166 | ## 9. Remove old cruft aggressively | 207 | ## 9. Remove old cruft aggressively |
| 208 | |||
| 167 | Work units: | 209 | Work 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. |
| 173 | 216 | ||
| 174 | Deliverables: | 217 | Deliverables: |
| 218 | |||
| 175 | - Legacy persistence and translation layers removed from active codebase. | 219 | - Legacy persistence and translation layers removed from active codebase. |
| 176 | 220 | ||
| 177 | Breakage allowed: | 221 | Breakage allowed: |
| 222 | |||
| 178 | - Temporary compile failures acceptable while deletion/refactor is in progress. | 223 | - Temporary compile failures acceptable while deletion/refactor is in progress. |
| 179 | 224 | ||
| 180 | Exit criteria: | 225 | Exit criteria: |
| 226 | |||
| 181 | - No production code path references deleted legacy tables/services. | 227 | - No production code path references deleted legacy tables/services. |
| 182 | 228 | ||
| 183 | ## 10. Final verification gate (first point where green is required) | 229 | ## 10. Final verification gate (first point where green is required) |
| 230 | |||
| 184 | Work units: | 231 | Work 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. |
| 193 | 241 | ||
| 194 | Deliverables: | 242 | Deliverables: |
| 243 | |||
| 195 | - Green checks with canonical schema + persistence model in place. | 244 | - Green checks with canonical schema + persistence model in place. |
| 196 | 245 | ||
| 197 | Breakage allowed: | 246 | Breakage allowed: |
| 247 | |||
| 198 | - None at end of step. | 248 | - None at end of step. |
| 199 | 249 | ||
| 200 | Exit criteria: | 250 | Exit 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) |
| 2 | 2 | ||
| 3 | Status legend: | 3 | Status 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 Tables | 10 | ## 7.1 Write-Side Persisted Tables |
| 10 | 11 | ||
| 11 | ### W1 | 12 | ### 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`. |
| 33 | 35 | ||
| 34 | ### W2 | 36 | ### 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. |
| 50 | 53 | ||
| 51 | ### W3 | 54 | ### 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. |
| 66 | 70 | ||
| 67 | ### W4 | 71 | ### 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 Schema | 91 | ## 7.2 Canonical Persisted Event Schema |
| 87 | 92 | ||
| 88 | ### E1 | 93 | ### 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. |
| 97 | 103 | ||
| 98 | ### E2 | 104 | ### 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. |
| 107 | 114 | ||
| 108 | ### E3 | 115 | ### 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. |
| 117 | 125 | ||
| 118 | ### E4 | 126 | ### 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) |
| 129 | 138 | ||
| 130 | ### P1 | 139 | ### 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. |
| 146 | 156 | ||
| 147 | ### P2 | 157 | ### 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. |
| 166 | 177 | ||
| 167 | ### P3 | 178 | ### 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. |
| 184 | 196 | ||
| 185 | ### P4 | 197 | ### 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. |
| 202 | 215 | ||
| 203 | ### P5 | 216 | ### 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. |
| 220 | 234 | ||
| 221 | ### P6 | 235 | ### 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. |
| 238 | 253 | ||
| 239 | ### P7 | 254 | ### 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. |
| 257 | 273 | ||
| 258 | ### P8 | 274 | ### 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. |
| 274 | 291 | ||
| 275 | ### P9 | 292 | ### 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. |
| 287 | 305 | ||
| 288 | ### P10 | 306 | ### 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. |
| 297 | 316 | ||
| 298 | ### P11 | 317 | ### 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 Requirements | 328 | ## 7.4 Snapshot and RPC Requirements |
| 309 | 329 | ||
| 310 | ### R1 | 330 | ### 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. |
| 319 | 340 | ||
| 320 | ### R2 | 341 | ### 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. |
| 329 | 351 | ||
| 330 | ### R3 | 352 | ### 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. |
| 339 | 362 | ||
| 340 | ### R4 | 363 | ### 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. |
| 349 | 373 | ||
| 350 | ### R5 | 374 | ### 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. |
| 359 | 384 | ||
| 360 | ### R6 | 385 | ### 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. |
| 369 | 395 | ||
| 370 | ### R7 | 396 | ### 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) |
| 381 | 408 | ||
| 382 | ### A1 | 409 | ### 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. |
| 385 | 413 | ||
| 386 | ### A2 | 414 | ### 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. |
| 389 | 418 | ||
| 390 | ### A3 | 419 | ### 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`). |
| 393 | 423 | ||
| 394 | ### A4 | 424 | ### 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`. |
| 397 | 428 | ||
| 398 | ## Checklist Completeness Statement | 429 | ## 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"; |
| 7 | 7 | ||
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); |
| 9 | const devServerUrl = `http://localhost:${port}`; | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | const requiredFiles = ["dist-electron/main.js", "dist-electron/preload.js", "../server/dist/index.mjs"]; | 10 | const requiredFiles = [ |
| 11 | "dist-electron/main.js", | ||
| 12 | "dist-electron/preload.js", | ||
| 13 | "../server/dist/index.mjs", | ||
| 14 | ]; | ||
| 11 | const watchedDirectories = [ | 15 | const 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() { |
| 148 | 152 | ||
| 149 | function startWatchers() { | 153 | function 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 | } | ||
| 155 | 162 | ||
| 156 | scheduleRestart(); | 163 | scheduleRestart(); |
| 157 | }); | 164 | }, |
| 165 | ); | ||
| 158 | 166 | ||
| 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"; |
| 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 17 | import { fixPath } from "./fixPath"; | 17 | import { fixPath } from "./fixPath"; |
| 18 | import { | 18 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; |
| 19 | getAutoUpdateDisabledReason, | ||
| 20 | shouldBroadcastDownloadProgress, | ||
| 21 | } from "./updateState"; | ||
| 22 | import { | 19 | import { |
| 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; |
| 765 | 764 | ||
| 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` so | 768 | // 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 the | 769 | // 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 | } |
| 27 | 27 | ||
| 28 | const hostArch = | 28 | const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; |
| 29 | appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; | ||
| 30 | 29 | ||
| 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 | ); | ||
| 87 | 90 | ||
| 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 | } |
| 74 | 74 | ||
| 75 | function mapRequestType(requestKind: unknown): | 75 | function 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 | >(); |
| 241 | 240 | ||
| 242 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); | 241 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); |
| 243 | 242 | ||
| 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 | } |
| 253 | 252 | ||
| 254 | sessionCount += 1; | 253 | sessionCount += 1; |
| 255 | const threadId = input.threadId; | 254 | const threadId = input.threadId; |
| 256 | const createdAt = nowIso(); | 255 | const createdAt = nowIso(); |
| 257 | 256 | ||
| 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 | }); | ||
| 279 | 267 | ||
| 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 | }); | ||
| 282 | 278 | ||
| 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 | } | ||
| 289 | 281 | ||
| 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 | } | ||
| 293 | 288 | ||
| 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 | } | ||
| 302 | 292 | ||
| 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 | } |
| 317 | 301 | ||
| 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 | } |
| 335 | 341 | ||
| 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 | }; | ||
| 338 | 356 | ||
| 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 | }; | ||
| 342 | 361 | ||
| 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 > 0 | 368 | threadId: state.snapshot.threadId, |
| 350 | ? [userItem, { type: "agentMessage", text: assistantText }] | 369 | turnId, |
| 351 | : [userItem]; | 370 | payload: { |
| 352 | 371 | state: "completed", | |
| 353 | const nextTurn: ProviderThreadTurnSnapshot = { | 372 | }, |
| 354 | id: turnId, | 373 | }); |
| 355 | items: nextItems, | 374 | } else { |
| 356 | }; | 375 | for (const completedEvent of deferredTurnCompletedEvents) { |
| 357 | 376 | 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 | } |
| 380 | 457 | ||
| 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), |
| 386 | 463 | }; | |
| 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> = { |
| 398 | 475 | 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 | }; |
| 415 | 492 | ||
| 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( |
| 421 | 498 | 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 | }) |
| 426 | 503 | : 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 | |
| 458 | 507 | 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 []; |
| 469 | 518 | } | |
| 470 | const stopAll: ProviderAdapterShape<ProviderAdapterError>["stopAll"] = () => | 519 | return [...state.rollbackCalls]; |
| 471 | Effect.sync(() => { | 520 | }; |
| 472 | sessions.clear(); | 521 | |
| 473 | }); | 522 | const getStartCount = (): number => sessionCount; |
| 474 | 523 | ||
| 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 | }> => { |
| 493 | 542 | 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 | state | 549 | 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, |
| 507 | 556 | 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"; |
| 6 | import { Command, Flag } from "effect/unstable/cli"; | 6 | import { Command, Flag } from "effect/unstable/cli"; |
| 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | 8 | ||
| 9 | import { DEVELOPMENT_ICON_OVERRIDES, PUBLISH_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; | 9 | import { |
| 10 | DEVELOPMENT_ICON_OVERRIDES, | ||
| 11 | PUBLISH_ICON_OVERRIDES, | ||
| 12 | } from "../../../scripts/lib/brand-assets.ts"; | ||
| 10 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; | 13 | import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; |
| 11 | import rootPackageJson from "../../../package.json" with { type: "json" }; | 14 | import rootPackageJson from "../../../package.json" with { type: "json" }; |
| 12 | import serverPackageJson from "../package.json" with { type: "json" }; | 15 | import 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" |
| 12 | const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; | 12 | const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; |
| 13 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; | 13 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; |
| 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; | 14 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 15 | const ATTACHMENT_ID_UUID_PATTERN = | 15 | const 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}"; | ||
| 17 | const ATTACHMENT_ID_PATTERN = new RegExp( | 16 | const 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 | }); |
| 750 | 750 | ||
| 751 | describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { | 751 | describe.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"); | ||
| 757 | 755 | ||
| 758 | const manager = new CodexAppServerManager(); | 756 | const manager = new CodexAppServerManager(); |
| 759 | 757 | ||
| 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_PATH | 766 | ...(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 | }); | ||
| 777 | 771 | ||
| 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 | }); |
| 782 | 776 | ||
| 783 | expect(firstTurn.threadId).toBe(firstSession.threadId); | 777 | expect(firstTurn.threadId).toBe(firstSession.threadId); |
| 784 | 778 | ||
| 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 | ); | ||
| 789 | 786 | ||
| 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; |
| 793 | 790 | ||
| 794 | manager.stopSession(firstSession.threadId); | 791 | manager.stopSession(firstSession.threadId); |
| 795 | 792 | ||
| 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_PATH | 801 | ...(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 | }); | ||
| 813 | 806 | ||
| 814 | expect(resumedSession.threadId).toBe(originalThreadId); | 807 | expect(resumedSession.threadId).toBe(originalThreadId); |
| 815 | 808 | ||
| 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); |
| 819 | 812 | ||
| 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 | }); |
| 824 | 817 | ||
| 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 | ); |
| 399 | 396 | ||
| 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 | } |
| 155 | 155 | ||
| 156 | function fetchGitPullRequestBranch( | 156 | function 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 | ); |
| 706 | 706 | ||
| 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"]); | ||
| 714 | 712 | ||
| 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]); |
| 721 | 719 | ||
| 722 | yield* createGitBranch({ cwd: source, branch: "feature" }); | 720 | yield* createGitBranch({ cwd: source, branch: "feature" }); |
| 723 | 721 | ||
| 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 | ); |
| 731 | 729 | ||
| 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 | }); |
| 956 | 954 | ||
| 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 | ); |
| 1327 | 1319 | ||
| 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; | ||
| 1333 | 1327 | ||
| 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"]); |
| 1335 | 1329 | ||
| 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 | }); |
| 1341 | 1335 | ||
| 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 | ); |
| 1346 | 1340 | ||
| 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 | } |
| 102 | 102 | ||
| 103 | function sanitizeRemoteName(value: string): string { | 103 | function 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 | } |
| 107 | 110 | ||
| 108 | function normalizeRemoteUrl(value: string): string { | 111 | function 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 | } |
| 111 | 118 | ||
| 112 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | 119 | function 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))); | ||
| 477 | 485 | ||
| 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 | } |
| 490 | 498 | ||
| 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 | }); |
| 494 | 507 | ||
| ... | @@ -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]; |
| 1101 | 1114 | ||
| 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 | ); | ||
| 1110 | 1118 | ||
| 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( |
| 120 | 120 | ||
| 121 | function initRepo( | 121 | function 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.includeBranch | 157 | ...(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.includeBranch | 720 | ...(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 | }); |
| 1252 | 1246 | ||
| 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 | .stdout | 1257 | "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"]); |
| 1336 | 1339 | ||
| ... | @@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1407,115 +1410,124 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1407 | }), | 1410 | }), |
| 1408 | ); | 1411 | ); |
| 1409 | 1412 | ||
| 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"]); |
| 1426 | 1429 | 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 | }); | ||
| 1448 | 1453 | ||
| 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 | }); |
| 1454 | 1459 | ||
| 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 | ); |
| 1464 | 1472 | ||
| 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"]); |
| 1482 | 1490 | 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 | }); | ||
| 1504 | 1514 | ||
| 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 | }); |
| 1510 | 1520 | ||
| 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 | .stdout | 1525 | "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 | ); |
| 1520 | 1532 | ||
| 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 | }); |
| 657 | 657 | ||
| 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* gitHubCli | 661 | .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))); | ||
| 667 | 666 | ||
| 668 | return { pullRequest }; | 667 | return { pullRequest }; |
| 669 | }); | 668 | }, |
| 669 | ); | ||
| 670 | 670 | ||
| 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* gitHubCli | 675 | 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); |
| 681 | 680 | ||
| 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 | }); |
| 716 | 715 | ||
| 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 | ); | ||
| 724 | 722 | ||
| 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>; | ||
| 171 | 169 | ||
| 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 | }); |
| 212 | 212 | ||
| 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 { |
| 40 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 40 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 41 | 41 | ||
| 42 | function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { | 42 | function 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 | } |
| 45 | 47 | ||
| 46 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { | 48 | function 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 | } |
| 247 | 247 |
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 | hasSession | 71 | 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* () { |
| 115 | 115 | ||
| 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); |
| 123 | 121 | ||
| ... | @@ -133,9 +131,7 @@ const make = Effect.gen(function* () { | ... | @@ -133,9 +131,7 @@ const make = Effect.gen(function* () { |
| 133 | }; | 131 | }; |
| 134 | 132 | ||
| 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 | } |
| 307 | 303 | ||
| 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 | ); |
| 56 | 56 | ||
| 57 | const projectionLayer = it.layer( | 57 | const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd())); |
| 58 | makeProjectionPipelineTestLayer(process.cwd()), | ||
| 59 | ); | ||
| 60 | 58 | ||
| 61 | projectionLayer("OrchestrationProjectionPipeline", (it) => { | 59 | projectionLayer("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 | ); |
| 452 | 450 | ||
| 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 | }); | ||
| 487 | 462 | ||
| 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 | }); | ||
| 510 | 483 | ||
| 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 | }); | ||
| 541 | 506 | ||
| 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 | }); |
| 572 | 537 | ||
| 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; | ||
| 574 | 570 | ||
| 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_messages | 575 | 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 | ); |
| 601 | 595 | ||
| 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); |
| 713 | 707 | ||
| 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))); |
| 748 | 738 | ||
| 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 | }); |
| 769 | 759 | ||
| 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 | }); |
| 792 | 782 | ||
| 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 | }); | ||
| 814 | 806 | ||
| 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 | }); | ||
| 845 | 837 | ||
| 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 | }); | ||
| 867 | 861 | ||
| 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 | }); | ||
| 898 | 892 | ||
| 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); | ||
| 917 | 907 | ||
| 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 | }); |
| 933 | 923 | ||
| 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))); |
| 963 | 953 | ||
| 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 | }); |
| 984 | 974 | ||
| 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 | }); |
| 1007 | 997 | ||
| 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 | }); | ||
| 1038 | 1028 | ||
| 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); |
| 1050 | 1040 | ||
| 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 | }); |
| 1066 | 1056 | ||
| 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 | ); |
| 1076 | 1066 | ||
| 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 | } |
| 79 | 79 | ||
| 80 | const materializeAttachmentsForProjection = Effect.fn( | 80 | const 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 | ); |
| 85 | 84 | ||
| 86 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { | 85 | function 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 | }); |
| 341 | 339 | ||
| 342 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | 340 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1-8| ... | @@ -1,11 +1,4 @@ | ... | @@ -1,11 +1,4 @@ |
| 1 | import { | 1 | import { 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"; | ||
| 9 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 10 | import { Effect, Layer } from "effect"; | 3 | import { Effect, Layer } from "effect"; |
| 11 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 4 | import * 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 => |
| 41 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 41 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 42 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 42 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 43 | 43 | ||
| 44 | async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2000): Promise<void> { | 44 | async 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.resumeCursor | 104 | ? 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.model | 111 | ? input.model |
| 106 | : undefined; | 112 | : undefined; |
| 107 | const threadId = | 113 | const threadId = |
| ... | @@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -474,7 +480,9 @@ describe("ProviderCommandReactor", () => { |
| 474 | 480 | ||
| 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", () => { |
| 563 | 571 | ||
| 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", () => { |
| 767 | 777 | ||
| 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 | }); |
| 774 | 788 | ||
| 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 | }); |
| 225 | 225 | ||
| 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))); |
| 230 | 230 | ||
| 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 ?? preferredProvider | 237 | ...((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 === undefined | 274 | 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"; | ||
| 278 | 279 | ||
| 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 === undefined | 355 | 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; | ||
| 357 | 359 | ||
| 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", () => { |
| 379 | 379 | ||
| 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"); |
| 385 | 387 | ||
| ... | @@ -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 | }); |
| 625 | 629 | ||
| 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 | }); |
| 527 | 531 | ||
| 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 | ); |
| 549 | 549 | ||
| 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); |
| 635 | 632 | ||
| 636 | const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); | 633 | const clearAssistantMessageState = (messageId: MessageId) => |
| 634 | clearBufferedAssistantText(messageId); | ||
| 637 | 635 | ||
| 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 | ? null | 863 | ? null |
| 866 | : (thread.session?.lastError ?? null); | 864 | : (thread.session?.lastError ?? null); |
| 867 | 865 | ||
| 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* () { |
| 1029 | 1030 | ||
| 1030 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD | 1031 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD |
| 1031 | ? true | 1032 | ? true |
| 1032 | : activeTurnId === null || | 1033 | : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); |
| 1033 | eventTurnId === undefined || | ||
| 1034 | sameId(activeTurnId, eventTurnId); | ||
| 1035 | 1034 | ||
| 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 | ); |
| 305 | 305 | ||
| 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* () { |
| 70 | 70 | ||
| 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 | ); |
| 77 | 75 | ||
| 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 | } |
| 22 | 22 | ||
| 23 | async function withRouteServer( | 23 | async 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"); |
| 109 | 110 | ||
| 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"); |
| 125 | 129 | ||
| 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 | } |
| 405 | 405 | ||
| 406 | function codexEventMessage(payload: Record<string, unknown> | undefined): Record<string, unknown> | undefined { | 406 | function codexEventMessage( |
| 407 | payload: Record<string, unknown> | undefined, | ||
| 408 | ): Record<string, unknown> | undefined { | ||
| 407 | return asObject(payload?.msg); | 409 | return asObject(payload?.msg); |
| 408 | } | 410 | } |
| 409 | 411 | ||
| ... | @@ -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) !== undefined | 1046 | ...(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 | }; |
| 1321 | 1323 | ||
| 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 = { |
| 29 | 29 | ||
| 30 | const layer = it.layer( | 30 | const 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 { |
| 23 | 23 | ||
| 24 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => | 24 | const 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])); |
| 31 | 28 | ||
| 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 | ); |
| 134 | 134 | ||
| 135 | it.effect( | 135 | it.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 | ); |
| 160 | 157 | ||
| 161 | it.effect("returns warning when login status command is unsupported", () => | 158 | it.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 | canonicalEventLogger | 144 | 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 | } |
| 293 | 293 | ||
| 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) => |
| 425 | 427 | ||
| 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* directory | 434 | 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"; |
| 2 | import { Effect, Layer, Option } from "effect"; | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | ||
| 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 5 | import { | 5 | import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; |
| 6 | ProviderSessionDirectoryPersistenceError, | ||
| 7 | ProviderValidationError, | ||
| 8 | } from "../Errors.ts"; | ||
| 9 | import { | 6 | import { |
| 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 | repository | 136 | 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 | ); | ||
| 142 | 141 | ||
| 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>; | ||
| 73 | 70 | ||
| 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>; | ||
| 113 | 108 | ||
| 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 | }); |
| 1238 | 1238 | ||
| 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 | ); | ||
| 1254 | 1252 | ||
| 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 | }); |
| 1569 | 1567 | ||
| 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 | }); |
| 1574 | 1574 |
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) { |
| 198 | 198 | ||
| 199 | function messageFromCause(cause: Cause.Cause<unknown>): string { | 199 | function 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 | } |
| 205 | 204 | ||
| ... | @@ -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 | } |
| 332 | 331 | ||
| 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", () => { |
| 61 | 61 | ||
| 62 | describe("getSlashModelOptions", () => { | 62 | describe("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 | ); | ||
| 70 | 65 | ||
| 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 | }); |
| 73 | 68 | ||
| 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 | ); | ||
| 81 | 71 | ||
| 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 | {...(onCheckoutPullRequestRequest | 133 | {...(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 && onCheckoutPullRequestRequest | 116 | 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 = canCreateBranch | 120 | 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 === 0 | 135 | 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> { |
| 414 | async function waitForProductionStyles(): Promise<void> { | 414 | async 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 | } |
| 456 | 456 | ||
| 457 | async function waitForInteractionModeButton(expectedLabel: "Chat" | "Plan"): Promise<HTMLButtonElement> { | 457 | async 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 | }); |
| 698 | 700 | ||
| 699 | try { | 701 | try { |
| 700 | const measurements: Array<UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }> = []; | 702 | const measurements: Array< |
| 703 | UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } | ||
| 704 | > = []; | ||
| 701 | 705 | ||
| 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 | } |
| 716 | 720 | ||
| 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); | ||
| 718 | 725 | ||
| 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 | ); |
| 758 | 765 | ||
| 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)", () => { |
| 844 | 852 | ||
| 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); |
| 767 | 768 | ||
| 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 | ); | ||
| 778 | 782 | ||
| 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 */} | ||
| 4135 | 4140 | ||
| 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 | <span | 5751 | <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 @@ |
| 1 | import { | 1 | import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; |
| 2 | LexicalComposer, | ||
| 3 | type InitialConfigType, | ||
| 4 | } from "@lexical/react/LexicalComposer"; | ||
| 5 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | 2 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; |
| 6 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; | 3 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; |
| 7 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; | 4 | import { 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 | } |
| 773 | 772 | ||
| 774 | export const ComposerPromptEditor = forwardRef<ComposerPromptEditorHandle, ComposerPromptEditorProps>( | 773 | export 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 | ); | ||
| 794 | 795 | ||
| 795 | return ( | 796 | return ( |
| 796 | <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}> | 797 | <LexicalComposer key={COMPOSER_EDITOR_HMR_KEY} initialConfig={initialConfig}> |
| 797 | <ComposerPromptEditorInner | 798 | <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"; |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; | 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 7 | import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 7 | import { |
| 8 | type WheelEvent as ReactWheelEvent, | ||
| 9 | useCallback, | ||
| 10 | useEffect, | ||
| 11 | useMemo, | ||
| 12 | useRef, | ||
| 13 | useState, | ||
| 14 | } from "react"; | ||
| 8 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | 15 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; |
| 9 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 16 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 10 | import { cn } from "~/lib/utils"; | 17 | import { 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 | }); |
| 578 | 578 | ||
| 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 | }); |
| 628 | 625 | ||
| 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; |
| 133 | 128 | ||
| 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); |
| 333 | 334 | ||
| 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 | : shouldOfferOpenPrCta | 378 | : 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 |
| 424 | 426 | ||
| 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 |
| 453 | 456 | ||
| 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 | <feColorMatrix | 77 | <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 | <feColorMatrix | 94 | <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 | <path | 182 | <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 | <path | 185 | <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 | <path | 188 | <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 | <path | 191 | <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 | <feGaussianBlur | 206 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="18" /> |
| 225 | result="effect1_foregroundBlur_69_17998" | ||
| 226 | stdDeviation="18" | ||
| 227 | /> | ||
| 228 | </filter> | 207 | </filter> |
| 229 | <filter | 208 | <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 | <feGaussianBlur | 219 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" /> |
| 241 | result="effect1_foregroundBlur_69_17998" | ||
| 242 | stdDeviation="32" | ||
| 243 | /> | ||
| 244 | </filter> | 220 | </filter> |
| 245 | <filter | 221 | <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 | <feGaussianBlur | 232 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" /> |
| 257 | result="effect1_foregroundBlur_69_17998" | ||
| 258 | stdDeviation="32" | ||
| 259 | /> | ||
| 260 | </filter> | 233 | </filter> |
| 261 | <filter | 234 | <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 | <feGaussianBlur | 245 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" /> |
| 273 | result="effect1_foregroundBlur_69_17998" | ||
| 274 | stdDeviation="32" | ||
| 275 | /> | ||
| 276 | </filter> | 246 | </filter> |
| 277 | <filter | 247 | <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 | <feGaussianBlur | 258 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" /> |
| 289 | result="effect1_foregroundBlur_69_17998" | ||
| 290 | stdDeviation="32" | ||
| 291 | /> | ||
| 292 | </filter> | 259 | </filter> |
| 293 | <filter | 260 | <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 | <feGaussianBlur | 271 | <feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32" /> |
| 305 | result="effect1_foregroundBlur_69_17998" | ||
| 306 | stdDeviation="32" | ||
| 307 | /> | ||
| 308 | </filter> | 272 | </filter> |
| 309 | <filter | 273 | <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 | <feGaussianBlur | 284 | <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 | <p | 202 | <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]); |
| 118 | 118 | ||
| 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 | ); | ||
| 149 | 152 | ||
| 150 | const validationMessage = | 153 | const validationMessage = !referenceDirty |
| 151 | !referenceDirty | 154 | ? null |
| 152 | ? null | 155 | : reference.trim().length === 0 |
| 153 | : reference.trim().length === 0 | 156 | ? "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 === null | 158 | ? "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.isError | 162 | (resolvedPullRequest === null && resolvePullRequestQuery.isError |
| 161 | ? (resolvePullRequestQuery.error instanceof Error | 163 | ? resolvePullRequestQuery.error instanceof Error |
| 162 | ? resolvePullRequestQuery.error.message | 164 | ? resolvePullRequestQuery.error.message |
| 163 | : "Failed to resolve pull request.") | 165 | : "Failed to resolve pull request." |
| 164 | : (preparePullRequestThreadMutation.error instanceof Error | 166 | : preparePullRequestThreadMutation.error instanceof Error |
| 165 | ? preparePullRequestThreadMutation.error.message | 167 | ? preparePullRequestThreadMutation.error.message |
| 166 | : preparePullRequestThreadMutation.error | 168 | : preparePullRequestThreadMutation.error |
| 167 | ? "Failed to prepare pull request thread." | 169 | ? "Failed to prepare pull request thread." |
| 168 | : null)); | 170 | : null); |
| 169 | 171 | ||
| 170 | return ( | 172 | return ( |
| 171 | <Dialog | 173 | <Dialog |
apps/web/src/components/Sidebar.logic.ts+1-2| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import type { Thread } from "../types"; | 1 | import type { Thread } from "../types"; |
| 2 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; | 2 | import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; |
| 3 | 3 | ||
| 4 | export const THREAD_SELECTION_SAFE_SELECTOR = | 4 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 5 | "[data-thread-item], [data-thread-selection-safe]"; | ||
| 6 | 5 | ||
| 7 | export interface ThreadStatusPill { | 6 | export interface ThreadStatusPill { |
| 8 | label: | 7 | label: |
apps/web/src/components/Sidebar.tsx+226-186| ... | @@ -83,10 +83,7 @@ import { | ... | @@ -83,10 +83,7 @@ import { |
| 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 83 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 84 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 85 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 86 | import { | 86 | import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; |
| 87 | resolveThreadStatusPill, | ||
| 88 | shouldClearThreadSelectionOnMouseDown, | ||
| 89 | } from "./Sidebar.logic"; | ||
| 90 | 87 | ||
| 91 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 88 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 92 | const THREAD_PREVIEW_LIMIT = 6; | 89 | const 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 | ); |
| 902 | 906 | ||
| 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 | ]); | ||
| 1081 | 1094 | ||
| 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 = threads | 1416 | 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 | <Collapsible | 1435 | <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 | <SidebarMenuButton | 1437 | <SidebarMenuButton |
| 1427 | size="sm" | 1438 | size="sm" |
| ... | @@ -1481,193 +1492,222 @@ export default function Sidebar() { | ... | @@ -1481,193 +1492,222 @@ export default function Sidebar() { |
| 1481 | 1492 | ||
| 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 | ); |
| 1498 | 1509 | const terminalStatus = terminalStatusFromRunningIds( | |
| 1499 | return ( | 1510 | selectThreadTerminalState(terminalStateByThreadId, thread.id) |
| 1500 | <SidebarMenuSubItem key={thread.id} className="w-full" data-thread-item> | 1511 | .runningTerminalIds, |
| 1501 | <SidebarMenuSubButton | 1512 | ); |
| 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 | isSelected | 1517 | className="w-full" |
| 1507 | ? "bg-primary/15 text-foreground dark:bg-primary/10" | 1518 | data-thread-item |
| 1508 | : isActive | 1519 | > |
| 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 | <span | 1597 | <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 | <TerminalIcon | 1603 | {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 | <span | 1654 | <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" |
| 1639 | 1669 | : "text-muted-foreground/40" | |
| 1640 | {hasHiddenThreads && !isThreadListExpanded && ( | 1670 | }`} |
| 1641 | <SidebarMenuSubItem className="w-full"> | 1671 | > |
| 1642 | <SidebarMenuSubButton | 1672 | {formatRelativeTime(thread.createdAt)} |
| 1643 | render={<button type="button" />} | 1673 | </span> |
| 1644 | data-thread-selection-safe | 1674 | </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 | <SidebarMenuSubButton | 1680 | {hasHiddenThreads && !isThreadListExpanded && ( |
| 1658 | render={<button type="button" />} | 1681 | <SidebarMenuSubItem className="w-full"> |
| 1659 | data-thread-selection-safe | 1682 | <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", () => { |
| 17 | 17 | ||
| 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 | }); |
| 24 | 22 | ||
| 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 = { |
| 31 | 31 | ||
| 32 | describe("desktop update button state", () => { | 32 | describe("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", () => { |
| 17 | 17 | ||
| 18 | describe("buildVisibleToastLayout", () => { | 18 | describe("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 | ]; | ||
| 21 | 25 | ||
| 22 | const layout = buildVisibleToastLayout(visibleToasts); | 26 | const layout = buildVisibleToastLayout(visibleToasts); |
| 23 | 27 | ||
| ... | @@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => { | ... | @@ -37,7 +41,11 @@ describe("buildVisibleToastLayout", () => { |
| 37 | }); | 41 | }); |
| 38 | 42 | ||
| 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 | ]); | ||
| 41 | 49 | ||
| 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 | } |
| 12 | 12 | ||
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ | 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; |
| 14 | "model", | ||
| 15 | "plan", | ||
| 16 | "default", | ||
| 17 | ]; | ||
| 18 | 14 | ||
| 19 | function clampCursor(text: string, cursor: number): number { | 15 | function 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 | } |
| 67 | 63 | ||
| 68 | function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { | 64 | function 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 | } |
| 71 | 69 | ||
| ... | @@ -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 | } |
| 166 | 164 | ||
| 167 | export function parseStandaloneComposerSlashCommand(text: string): Exclude< | 165 | export 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); |
| 253 | 253 | ||
| 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 | }); |
| 260 | 260 | ||
| ... | @@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => { | ... | @@ -380,7 +380,9 @@ describe("composerDraftStore setModel", () => { |
| 380 | 380 | ||
| 381 | store.setModel(threadId, "gpt-5.3-codex"); | 381 | store.setModel(threadId, "gpt-5.3-codex"); |
| 382 | 382 | ||
| 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 | }); |
| 386 | 388 |
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"; |
| 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 12 | import { | 12 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 13 | DEFAULT_INTERACTION_MODE, | ||
| 14 | DEFAULT_RUNTIME_MODE, | ||
| 15 | type ChatImageAttachment, | ||
| 16 | } from "./types"; | ||
| 17 | import { Debouncer } from "@tanstack/react-pacer"; | 13 | import { Debouncer } from "@tanstack/react-pacer"; |
| 18 | import { create } from "zustand"; | 14 | import { create } from "zustand"; |
| 19 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; | 15 | import { 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 | }); |
| 92 | 92 | ||
| 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 | ]); | ||
| 95 | 97 | ||
| 96 | expect(tree).toEqual([ | 98 | expect(tree).toEqual([ |
| 97 | { | 99 | { |
| ... | @@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => { | ... | @@ -157,7 +159,8 @@ describe("buildTurnDiffTree", () => { |
| 157 | 159 | ||
| 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 === 0 | 98 | 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", () => { |
| 31 | describe("buildCollapsedProposedPlanPreviewMarkdown", () => { | 31 | describe("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 | }); |
| 39 | 42 |
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 { |
| 5 | 5 | ||
| 6 | export function stripDisplayedPlanMarkdown(planMarkdown: string): string { | 6 | export 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 | } |
| 77 | 76 | ||
| 78 | export function resolvePlanFollowUpSubmission(input: { | 77 | export 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"; |
| 4 | 4 | ||
| 5 | describe("parsePullRequestReference", () => { | 5 | describe("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 | }); |
| 11 | 11 | ||
| 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 | 0 | 105 | 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]); |
| 117 | 117 | ||
| 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], |
| 150 | 163 | ); | |
| 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]); | ||
| 161 | 164 | ||
| 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"; |
| 10 | 10 | ||
| 11 | import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; | 11 | import type { |
| 12 | ChatMessage, | ||
| 13 | ProposedPlan, | ||
| 14 | SessionPhase, | ||
| 15 | ThreadSession, | ||
| 16 | TurnDiffSummary, | ||
| 17 | } from "./types"; | ||
| 12 | 18 | ||
| 13 | export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; | 19 | export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; |
| 14 | 20 | ||
| ... | @@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt( | ... | @@ -138,9 +144,7 @@ export function deriveActiveWorkStartedAt( |
| 138 | return sendStartedAt; | 144 | return sendStartedAt; |
| 139 | } | 145 | } |
| 140 | 146 | ||
| 141 | function requestKindFromRequestType( | 147 | function 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 | } |
| 489 | 493 | ||
| 490 | function collectChangedFiles( | 494 | function 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( |
| 150 | 152 | ||
| 151 | return mappedProjects | 153 | 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 | }); |
| 15 | 15 | ||
| 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"); |
| 33 | 36 | ||
| 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"); |
| 45 | 51 | ||
| 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"]); | ||
| 63 | 73 | ||
| 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 | }); |
| 67 | 80 | ||
| 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"); |
| 71 | 84 | ||
| 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 | }); |
| 75 | 91 | ||
| 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"); |
| 81 | 97 | ||
| 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 | } |
| 37 | 37 | ||
| 38 | function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] { | 38 | function 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 | } |
| 60 | 63 | ||
| 61 | function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { | 64 | function 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 | } |
| 64 | 70 | ||
| ... | @@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina | ... | @@ -183,7 +189,9 @@ function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTermina |
| 183 | ? state.activeTerminalId | 189 | ? 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.activeTerminalGroupId | 195 | ? 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 | } |
| 241 | 249 | ||
| 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); |
| 244 | 254 | ||
| 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 | } |
| 268 | 278 | ||
| 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 | } |
| 325 | 340 | ||
| 326 | function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | 341 | function 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 { |
| 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 13 | 13 | ||
| 14 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); | 14 | const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>(); |
| 15 | const showContextMenuFallbackMock = vi.fn< | 15 | const 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 | >(); | ||
| 21 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); | 22 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); |
| 22 | const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( | 23 | const 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", () => { |
| 400 | 401 | ||
| 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 | }); |
| 407 | 408 | ||
| 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"; |
| 9 | 9 | ||
| 10 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); | 10 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); |
| 11 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); | 11 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( |
| 12 | GitPreparePullRequestThreadInput, | ||
| 13 | ); | ||
| 12 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); | 14 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); |
| 13 | 15 | ||
| 14 | describe("GitCreateWorktreeInput", () => { | 16 | describe("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 | ]); |
| 16 | const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); | 16 | const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); |
| 17 | const GitPrStepStatus = Schema.Literals([ | 17 | const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); |
| 18 | "created", | ||
| 19 | "opened_existing", | ||
| 20 | "skipped_not_requested", | ||
| 21 | ]); | ||
| 22 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | 18 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); |
| 23 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; | 19 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; |
| 24 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); | 20 | const 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; |
| 28 | 28 | ||
| 29 | export const ProjectWriteFileInput = Schema.Struct({ | 29 | export 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 | }); |
| 36 | export type ProjectWriteFileInput = typeof ProjectWriteFileInput.Type; | 34 | export 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 | ]); |
| 62 | export type RuntimeThreadState = typeof RuntimeThreadState.Type; | 62 | export type RuntimeThreadState = typeof RuntimeThreadState.Type; |
| 63 | 63 | ||
| 64 | const RuntimeTurnState = Schema.Literals([ | 64 | const RuntimeTurnState = Schema.Literals(["completed", "failed", "interrupted", "cancelled"]); |
| 65 | "completed", | ||
| 66 | "failed", | ||
| 67 | "interrupted", | ||
| 68 | "cancelled", | ||
| 69 | ]); | ||
| 70 | export type RuntimeTurnState = typeof RuntimeTurnState.Type; | 65 | export type RuntimeTurnState = typeof RuntimeTurnState.Type; |
| 71 | 66 | ||
| 72 | const RuntimePlanStepStatus = Schema.Literals(["pending", "inProgress", "completed"]); | 67 | const 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 | }); |
| 576 | export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type; | 571 | export type ProviderRuntimeSessionConfiguredEvent = |
| 572 | typeof ProviderRuntimeSessionConfiguredEvent.Type; | ||
| 577 | 573 | ||
| 578 | const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ | 574 | const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ |
| 579 | ...ProviderRuntimeEventBase.fields, | 575 | ...ProviderRuntimeEventBase.fields, |
| 580 | type: SessionStateChangedType, | 576 | type: SessionStateChangedType, |
| 581 | payload: SessionStateChangedPayload, | 577 | payload: SessionStateChangedPayload, |
| 582 | }); | 578 | }); |
| 583 | export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type; | 579 | export type ProviderRuntimeSessionStateChangedEvent = |
| 580 | typeof ProviderRuntimeSessionStateChangedEvent.Type; | ||
| 584 | 581 | ||
| 585 | const ProviderRuntimeSessionExitedEvent = Schema.Struct({ | 582 | const 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 | }); |
| 604 | export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type; | 601 | export type ProviderRuntimeThreadStateChangedEvent = |
| 602 | typeof ProviderRuntimeThreadStateChangedEvent.Type; | ||
| 605 | 603 | ||
| 606 | const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({ | 604 | const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({ |
| 607 | ...ProviderRuntimeEventBase.fields, | 605 | ...ProviderRuntimeEventBase.fields, |
| 608 | type: ThreadMetadataUpdatedType, | 606 | type: ThreadMetadataUpdatedType, |
| 609 | payload: ThreadMetadataUpdatedPayload, | 607 | payload: ThreadMetadataUpdatedPayload, |
| 610 | }); | 608 | }); |
| 611 | export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; | 609 | export type ProviderRuntimeThreadMetadataUpdatedEvent = |
| 610 | typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; | ||
| 612 | 611 | ||
| 613 | const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ | 612 | const 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 | }); |
| 767 | export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type; | 766 | export type ProviderRuntimeUserInputResolvedEvent = |
| 767 | typeof ProviderRuntimeUserInputResolvedEvent.Type; | ||
| 768 | 768 | ||
| 769 | const ProviderRuntimeTaskStartedEvent = Schema.Struct({ | 769 | const 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 | }); |
| 859 | export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type; | 859 | export type ProviderRuntimeMcpOauthCompletedEvent = |
| 860 | typeof ProviderRuntimeMcpOauthCompletedEvent.Type; | ||
| 860 | 861 | ||
| 861 | const ProviderRuntimeModelReroutedEvent = Schema.Struct({ | 862 | const 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 | }); |
| 880 | export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type; | 881 | export type ProviderRuntimeDeprecationNoticeEvent = |
| 882 | typeof ProviderRuntimeDeprecationNoticeEvent.Type; | ||
| 881 | 883 | ||
| 882 | const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ | 884 | const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ |
| 883 | ...ProviderRuntimeEventBase.fields, | 885 | ...ProviderRuntimeEventBase.fields, |
| ... | @@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent | ... | @@ -969,12 +971,7 @@ const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent |
| 969 | export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; | 971 | export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; |
| 970 | 972 | ||
| 971 | // Legacy helper aliases retained for adapters/tests. | 973 | // Legacy helper aliases retained for adapters/tests. |
| 972 | const ProviderRuntimeToolKind = Schema.Literals([ | 974 | const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]); |
| 973 | "command", | ||
| 974 | "file-read", | ||
| 975 | "file-change", | ||
| 976 | "other", | ||
| 977 | ]); | ||
| 978 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; | 975 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; |
| 979 | 976 | ||
| 980 | export const ProviderRuntimeTurnStatus = RuntimeTurnState; | 977 | export 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"; |
| 5 | describe("extractPathFromShellOutput", () => { | 5 | describe("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 | }); |
| 11 | 13 |
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"]); |
| 23 | const RepoRoot = Effect.service(Path.Path).pipe( | 23 | const 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 | ); |
| 26 | const ProductionMacIconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | 26 | const 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 | ); |
| 29 | const ProductionLinuxIconSource = Effect.zipWith( | 31 | const 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) { |
| 399 | 399 | ||
| 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 | }; |
| 407 | 405 |
scripts/release-smoke.ts+29-8| ... | @@ -82,7 +82,12 @@ try { | ... | @@ -82,7 +82,12 @@ try { |
| 82 | 82 | ||
| 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 | }); |
| 96 | 101 | ||
| 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 | ); | ||
| 99 | 108 | ||
| 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 | ); | ||
| 105 | 118 | ||
| 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 | ); | ||
| 109 | 130 | ||
| 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 | } |
| 90 | 90 | ||
| 91 | const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); | 91 | const isMain = |
| 92 | process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); | ||
| 92 | 93 | ||
| 93 | if (isMain) { | 94 | if (isMain) { |
| 94 | const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); | 95 | const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); |