From 7e1ae4a57483e70681b7cf27ab2c11289fb129dc Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 10:29:04 +0200 Subject: [PATCH 001/263] fix: modal types --- src/renderer/app/modal-registry.ts | 10 +++++----- src/renderer/features/mcp/components/McpModal.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/app/modal-registry.ts b/src/renderer/app/modal-registry.ts index c4d40e9730..72741cfd33 100644 --- a/src/renderer/app/modal-registry.ts +++ b/src/renderer/app/modal-registry.ts @@ -17,16 +17,15 @@ import { ModalComponent } from '@renderer/lib/modal/modal-provider'; export type ModalSize = 'xs' | 'sm' | 'md' | 'lg'; -export type ModalRegistryEntry = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - component: ModalComponent; +export type ModalRegistryEntry = { + component: ModalComponent; size?: ModalSize; }; export function createModal( component: ModalComponent, config: Omit = {} -): ModalRegistryEntry { +): ModalRegistryEntry { return { component, ...config }; } @@ -46,4 +45,5 @@ export const modalRegistry = { renameTaskModal: createModal(RenameTaskModal, { size: 'xs' }), integrationSetupModal: createModal(IntegrationSetupModal, { size: 'md' }), addRemoteModal: createModal(AddRemoteModal), -} satisfies Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} satisfies Record>; diff --git a/src/renderer/features/mcp/components/McpModal.tsx b/src/renderer/features/mcp/components/McpModal.tsx index 4080c52889..069123f402 100644 --- a/src/renderer/features/mcp/components/McpModal.tsx +++ b/src/renderer/features/mcp/components/McpModal.tsx @@ -2,6 +2,7 @@ import { useForm } from '@tanstack/react-form'; import { Trash2 } from 'lucide-react'; import React, { useRef, useState } from 'react'; import type { McpCatalogEntry, McpProvidersResponse, McpServer } from '@shared/mcp/types'; +import { BaseModalProps } from '@renderer/lib/modal/modal-provider'; import { Button } from '@renderer/lib/ui/button'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; import { @@ -27,12 +28,11 @@ export type McpModalMode = | { type: 'add-custom' } | { type: 'edit'; server: McpServer }; -export interface McpModalProps { +interface McpModalProps extends BaseModalProps { mode: McpModalMode; providers: McpProvidersResponse[]; onSave: (server: McpServer) => Promise; onRemove?: (serverName: string) => void; - onSuccess: (result: unknown) => void; } export const McpModal: React.FC = ({ From 70ffe8c4b5354f45fb441ae0dfe063f300fef89e Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 10:36:23 +0200 Subject: [PATCH 002/263] fix: type and lint issues --- src/main/core/pull-requests/controller.ts | 14 +- src/renderer/App.tsx | 1 + .../components/pr-entry/merge-footer.tsx | 2 - .../components/pr-entry/pr-entry.tsx | 1 - src/renderer/lib/monaco/monaco-themes.test.ts | 142 ------------------ 5 files changed, 9 insertions(+), 151 deletions(-) delete mode 100644 src/renderer/lib/monaco/monaco-themes.test.ts diff --git a/src/main/core/pull-requests/controller.ts b/src/main/core/pull-requests/controller.ts index 3d3834a1d1..add40e5788 100644 --- a/src/main/core/pull-requests/controller.ts +++ b/src/main/core/pull-requests/controller.ts @@ -1,3 +1,4 @@ +import { RequestError } from '@octokit/request-error'; import { createRPCController } from '@shared/ipc/rpc'; import type { ListPrOptions, PullRequestFile } from '@shared/pull-requests'; import { log } from '@main/lib/logger'; @@ -166,13 +167,14 @@ export const pullRequestController = createRPCController({ capture('pr_creation_failed', { error_type: error instanceof Error ? error.name || 'error' : 'unknown_error', }); - const ghErrors = (error as any)?.response?.data?.errors; + const ghErrors = + error instanceof RequestError && + Array.isArray((error.response?.data as { errors?: unknown[] } | undefined)?.errors) + ? (error.response!.data as { errors: { message?: string }[] }).errors + : undefined; const message = - Array.isArray(ghErrors) && ghErrors[0]?.message - ? ghErrors[0].message - : error instanceof Error - ? error.message - : 'Unable to create pull request'; + ghErrors?.[0]?.message ?? + (error instanceof Error ? error.message : 'Unable to create pull request'); return { success: false as const, error: message }; } }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d8a70a7acc..cd80a5d2dc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -46,6 +46,7 @@ function AppContent() { legacyStatus.portStatus !== 'no-legacy-file' && !legacyStatus.hasExistingData; if (needsImport) computed.push('import'); + // eslint-disable-next-line react-hooks/exhaustive-deps setFrozenSteps(computed); } }, [view, isLoading, frozenSteps, session, legacyStatus]); diff --git a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/merge-footer.tsx b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/merge-footer.tsx index fbb0c061c3..52df68a719 100644 --- a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/merge-footer.tsx +++ b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/merge-footer.tsx @@ -24,13 +24,11 @@ export function MergeFooter({ uiState, mergeActions, isMerging, - onMarkReady, }: { uiState: MergeUiState; mergeActions: SplitButtonAction[]; isMerging: boolean; - onMarkReady: () => void; }) { const isDraft = uiState.kind === 'draft'; diff --git a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/pr-entry.tsx b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/pr-entry.tsx index ae6be7d022..b0a205cecf 100644 --- a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/pr-entry.tsx +++ b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/pr-entry.tsx @@ -213,7 +213,6 @@ export const PullRequestEntry = observer(function PullRequestEntry({ pr }: { pr: uiState={uiState} mergeActions={mergeActions} isMerging={isMerging} - onRefresh={() => prStore.refresh(pr.url)} onMarkReady={() => { prStore.markReadyForReview(pr.url).catch(() => {}); }} diff --git a/src/renderer/lib/monaco/monaco-themes.test.ts b/src/renderer/lib/monaco/monaco-themes.test.ts deleted file mode 100644 index 5a6b1e41d4..0000000000 --- a/src/renderer/lib/monaco/monaco-themes.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { defineMonacoThemes, getMonacoTheme } from './monaco-themes'; - -function makeCanvasMock(r: number, g: number, b: number, a = 255) { - return { - width: 0, - height: 0, - getContext: () => ({ - fillStyle: '', - fillRect: vi.fn(), - getImageData: () => ({ data: [r, g, b, a] }), - }), - }; -} - -describe('defineMonacoThemes', () => { - let appendSpy: ReturnType; - let canvasCallCount: number; - - beforeEach(() => { - canvasCallCount = 0; - - vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { - if (tag === 'canvas') { - canvasCallCount++; - // Alternate: odd canvas calls return a "dark" pixel, even return a "light" pixel. - const isDark = canvasCallCount % 2 === 1; - return makeCanvasMock( - isDark ? 25 : 248, - isDark ? 25 : 250, - isDark ? 25 : 252 - ) as unknown as HTMLElement; - } - const el = { - className: '', - style: { cssText: '' }, - remove: vi.fn(), - }; - return el as unknown as HTMLElement; - }); - - vi.spyOn(window, 'getComputedStyle').mockImplementation(() => { - return { - getPropertyValue: (prop: string) => { - const vars: Record = { - '--monaco-bg': 'color(display-p3 0.098 0.098 0.098)', - '--monaco-fg': 'color(display-p3 0.706 0.706 0.706)', - '--monaco-line-highlight': 'color(display-p3 0.192 0.192 0.192)', - '--monaco-line-number': 'color(display-p3 0.227 0.227 0.227)', - '--monaco-gutter': 'color(display-p3 0.973 0.980 0.988)', - '--monaco-inserted-text-bg': 'color(display-p3 0.145 0.282 0.176)', - '--monaco-inserted-line-bg': 'color(display-p3 0.106 0.165 0.118)', - '--monaco-inserted-text-border': 'color(display-p3 0.145 0.282 0.176)', - '--monaco-removed-text-bg': 'color(display-p3 0.380 0.086 0.137)', - '--monaco-removed-line-bg': 'color(display-p3 0.231 0.071 0.098)', - '--monaco-removed-text-border': 'color(display-p3 0.380 0.086 0.137)', - '--monaco-unchanged-region-bg': 'color(display-p3 0.165 0.165 0.165)', - '--monaco-diff-border': 'color(display-p3 0.227 0.227 0.227)', - '--monaco-diff-diagonal-fill': 'color(display-p3 0.165 0.165 0.165)', - }; - return vars[prop] ?? ''; - }, - } as unknown as CSSStyleDeclaration; - }); - - appendSpy = vi.spyOn(document.body, 'appendChild').mockReturnValue({} as Node); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('defines exactly two Monaco themes', () => { - const defineTheme = vi.fn(); - const monaco = { editor: { defineTheme } }; - - defineMonacoThemes(monaco as Parameters[0]); - - expect(defineTheme).toHaveBeenCalledTimes(2); - }); - - it('defines custom-dark with vs-dark base', () => { - const defineTheme = vi.fn(); - const monaco = { editor: { defineTheme } }; - - defineMonacoThemes(monaco as Parameters[0]); - - expect(defineTheme).toHaveBeenNthCalledWith( - 1, - 'custom-dark', - expect.objectContaining({ base: 'vs-dark', inherit: true }) - ); - }); - - it('defines custom-light with vs base', () => { - const defineTheme = vi.fn(); - const monaco = { editor: { defineTheme } }; - - defineMonacoThemes(monaco as Parameters[0]); - - expect(defineTheme).toHaveBeenNthCalledWith( - 2, - 'custom-light', - expect.objectContaining({ base: 'vs', inherit: true }) - ); - }); - - it('reads CSS vars and converts them to hex colors', () => { - const defineTheme = vi.fn(); - const monaco = { editor: { defineTheme } }; - - defineMonacoThemes(monaco as Parameters[0]); - - const darkCall = defineTheme.mock.calls[0][1]; - expect(darkCall.colors['editor.background']).toMatch(/^#[0-9a-f]{6}$/i); - expect(darkCall.colors['editor.foreground']).toMatch(/^#[0-9a-f]{6}$/i); - }); - - it('creates and appends a temp DOM element for each theme', () => { - const defineTheme = vi.fn(); - const monaco = { editor: { defineTheme } }; - - defineMonacoThemes(monaco as Parameters[0]); - - expect(appendSpy).toHaveBeenCalledTimes(2); - }); -}); - -describe('getMonacoTheme', () => { - it('maps emlight to custom-light', () => { - expect(getMonacoTheme('emlight')).toBe('custom-light'); - }); - - it('maps emdark to custom-dark', () => { - expect(getMonacoTheme('emdark')).toBe('custom-dark'); - }); - - it('defaults unknown themes to custom-dark', () => { - expect(getMonacoTheme('unknown')).toBe('custom-dark'); - }); -}); From 0d0a5e977db2b360fa90ba364b59f9331fb5d9c9 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 11:19:35 +0200 Subject: [PATCH 003/263] fix: ci consistency setup --- .eslintrc.json | 28 - .github/workflows/code-consistency-check.yml | 8 +- eslint.config.ts | 63 + package.json | 9 +- pnpm-lock.yaml | 1564 ++++------------- scripts/postinstall.ts | 4 +- src/main/core/fs/impl/local-fs.ts | 24 +- src/main/core/fs/impl/ssh-fs.ts | 20 +- src/main/core/ssh/controller.ts | 8 +- src/main/db/kv.ts | 9 +- .../add-project-modal/button-card.tsx | 6 +- .../features/projects/stores/project-view.ts | 6 +- .../features/tasks/stores/task-manager.ts | 4 +- src/shared/conversations.ts | 2 +- src/shared/tasks.ts | 4 +- 15 files changed, 428 insertions(+), 1331 deletions(-) create mode 100644 eslint.config.ts diff --git a/.eslintrc.json b/.eslintrc.json index 13ec039a5e..e69de29bb2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,28 +0,0 @@ -{ - "extends": ["plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "react-hooks", "import"], - "rules": { - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - } - ], - "@typescript-eslint/no-explicit-any": "warn", - - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-empty-function": "off", - "prefer-const": "warn", - "react-hooks/set-state-in-effect": "warn" - }, - "overrides": [], - "env": { - "browser": true, - "es2020": true, - "node": true - }, - "ignorePatterns": ["**/_*/**"] -} diff --git a/.github/workflows/code-consistency-check.yml b/.github/workflows/code-consistency-check.yml index 8126031c53..5b84940df8 100644 --- a/.github/workflows/code-consistency-check.yml +++ b/.github/workflows/code-consistency-check.yml @@ -43,13 +43,13 @@ jobs: - name: Check formatting run: pnpm run format:check - # TODO: add this once fixed across all files - # - name: Check linting - # run: pnpm run lint - - name: Type check run: pnpm run typecheck + - name: Check linting + run: pnpm run lint + + vitest: runs-on: ubuntu-latest diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000000..4584ee0751 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,63 @@ +import eslint from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import { globalIgnores } from 'eslint/config'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + globalIgnores(['dist/**', 'out/**', 'build/**', 'node_modules/**', '**/_*/**']), + + eslint.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + + // Non-type-aware rules for all TS/TSX files + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + globals: { ...globals.browser, ...globals.node, ...globals.es2020 }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + 'prefer-const': 'error', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-control-regex': 'off', + }, + }, + + // Type-aware rules scoped to src/ only (config files like vitest.config.ts are not in tsconfig) + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-floating-promises': 'warn', + // Allow async functions as React event handler attributes (onClick={asyncFn} is idiomatic) + '@typescript-eslint/no-misused-promises': [ + 'warn', + { checksVoidReturn: { attributes: false } }, + ], + '@typescript-eslint/await-thenable': 'warn', + }, + }, + + // Relax rules for test files + { + files: ['**/*.test.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + } +); diff --git a/package.json b/package.json index 39fb53662e..64a2ae76d4 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "run:docker-ssh": "docker compose up --build -d", "clean": "rm -rf node_modules dist", "reset": "pnpm run clean && pnpm install", - "lint": "eslint . --ext .ts,.tsx", + "lint": "eslint . --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit && tsc --noEmit -p scripts/release/tsconfig.json", @@ -59,8 +59,8 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/react-syntax-highlighter": "^15.5.13", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", + "@eslint/js": "^9.0.0", + "typescript-eslint": "^8.0.0", "@vitejs/plugin-react": "^4.7.0", "@vitest/browser": "^4.1.0", "@vitest/browser-playwright": "^4.1.0", @@ -70,8 +70,7 @@ "electron": "^40.7.0", "electron-builder": "^26.8.1", "electron-vite": "^5.0.0", - "eslint": "^8.55.0", - "eslint-plugin-import": "^2.32.0", + "eslint": "^9.0.0", "eslint-plugin-react-hooks": "^7.0.0", "husky": "^9.1.7", "jsdom": "^29.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81a1ef8aba..8065025661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,9 @@ importers: '@electron/rebuild': specifier: ^4.0.1 version: 4.0.3 + '@eslint/js': + specifier: ^9.0.0 + version: 9.39.4 '@ianvs/prettier-plugin-sort-imports': specifier: ^4.7.1 version: 4.7.1(prettier@3.6.2) @@ -294,12 +297,6 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 - '@typescript-eslint/eslint-plugin': - specifier: ^6.14.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/parser': - specifier: ^6.14.0 - version: 6.21.0(eslint@8.57.1)(typescript@6.0.2) '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) @@ -328,14 +325,11 @@ importers: specifier: ^5.0.0 version: 5.0.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) eslint: - specifier: ^8.55.0 - version: 8.57.1 - eslint-plugin-import: - specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1) + specifier: ^9.0.0 + version: 9.39.4(jiti@2.6.1) eslint-plugin-react-hooks: specifier: ^7.0.0 - version: 7.0.1(eslint@8.57.1) + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -363,6 +357,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + typescript-eslint: + specifier: ^8.0.0 + version: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) vite: specifier: ^6.4.1 version: 6.4.1(@types/node@20.19.32)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) @@ -1113,13 +1110,33 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} @@ -1180,18 +1197,25 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@ianvs/prettier-plugin-sort-imports@4.7.1': resolution: {integrity: sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw==} @@ -1282,18 +1306,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@npmcli/agent@3.0.0': resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2264,9 +2276,6 @@ packages: cpu: [x64] os: [win32] - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@shikijs/core@3.22.0': resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} @@ -2662,9 +2671,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} @@ -2686,63 +2692,64 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@6.21.0': - resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.59.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@6.21.0': - resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/parser@8.59.0': + resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@6.21.0': - resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@8.59.0': + resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@6.21.0': - resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@6.21.0': - resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.0': + resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@6.21.0': - resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@6.21.0': - resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/utils@8.59.0': + resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@6.21.0': - resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/visitor-keys@8.59.0': + resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2857,6 +2864,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + allotment@1.20.5: resolution: {integrity: sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==} peerDependencies: @@ -2937,34 +2947,6 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -2984,10 +2966,6 @@ packages: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2998,10 +2976,6 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - axios@1.14.0: resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} @@ -3125,10 +3099,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -3535,18 +3505,6 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -3557,14 +3515,6 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3646,10 +3596,6 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dmg-builder@26.8.1: resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} @@ -3659,14 +3605,6 @@ packages: os: [darwin] hasBin: true - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3874,10 +3812,6 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3897,14 +3831,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -3940,63 +3866,41 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint-plugin-react-hooks@7.0.1: resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} @@ -4056,19 +3960,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fault@1.0.4: resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} @@ -4087,9 +3984,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -4109,9 +4006,9 @@ packages: resolution: {integrity: sha512-g31GX207Tt+psI53ZSaB1egprYbEN0ZYl90aKcO22A2LmCNnFsSq3b5YpoKp3E/QEiWByTXGJOkFQG4S07Bc1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -4125,10 +4022,6 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4198,22 +4091,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4250,20 +4132,12 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -4285,18 +4159,14 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4312,9 +4182,6 @@ packages: resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==} engines: {node: '>=8.0.0'} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphql@16.13.1: resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -4322,10 +4189,6 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -4333,10 +4196,6 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -4489,6 +4348,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4517,10 +4380,6 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -4544,42 +4403,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-ci@3.0.1: resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} hasBin: true - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - is-decimal@1.0.4: resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} @@ -4590,10 +4417,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -4602,10 +4425,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -4623,26 +4442,10 @@ packages: is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -4650,56 +4453,17 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -5078,10 +4842,6 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - mermaid@11.12.2: resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} @@ -5245,14 +5005,13 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -5467,22 +5226,6 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5511,10 +5254,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -5572,9 +5311,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5583,10 +5319,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -5639,10 +5371,6 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -5794,9 +5522,6 @@ packages: query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -5904,10 +5629,6 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - refractor@3.6.0: resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} @@ -5920,10 +5641,6 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} @@ -5999,11 +5716,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} @@ -6019,10 +5731,6 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -6046,9 +5754,6 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -6059,24 +5764,12 @@ packages: resolution: {integrity: sha512-lA6p6JY0+lbcz/NJL8O/BKU8q96iA3f+wDO/QCg4QxOooEJwe0fomHZoeDJhs8TDDRTA40OVNY2E9wOQgAoAVw==} engines: {node: '>=20'} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6117,18 +5810,6 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6192,10 +5873,6 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -6281,10 +5958,6 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - stopword@3.1.5: resolution: {integrity: sha512-OgLYGVFCNa430WOrj9tYZhQge5yg6vd6JsKredveAqEhdLVQkfrpnQIGjx0L9lLqzL4Kq4J8yNTcfQR/MpBwhg==} @@ -6313,18 +5986,6 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -6382,10 +6043,6 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -6424,9 +6081,6 @@ packages: temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -6491,11 +6145,11 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.2.0' + typescript: '>=4.8.4' ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} @@ -6524,25 +6178,12 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + typescript-eslint@8.59.0: + resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -6557,10 +6198,6 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -6820,22 +6457,6 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7588,28 +7209,51 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 8.57.1 + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/eslintrc@2.1.4': + '@eslint/config-array@0.21.2': dependencies: - ajv: 6.12.6 + '@eslint/object-schema': 2.1.7 debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@8.57.1': {} + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 '@exodus/bytes@1.15.0': {} @@ -7672,17 +7316,21 @@ snapshots: dependencies: graphql: 16.13.1 - '@humanwhocodes/config-array@0.13.0': + '@humanfs/core@0.19.2': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.3': {} + '@humanwhocodes/retry@0.4.3': {} '@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.6.2)': dependencies: @@ -7785,18 +7433,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - '@npmcli/agent@3.0.0': dependencies: agent-base: 7.1.4 @@ -8710,8 +8346,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@rtsao/scc@1.1.0': {} - '@shikijs/core@3.22.0': dependencies: '@shikijs/types': 3.22.0 @@ -9137,8 +8771,6 @@ snapshots: dependencies: '@types/node': 20.19.32 - '@types/semver@7.7.1': {} - '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 @@ -9160,91 +8792,96 @@ snapshots: '@types/node': 20.19.32 optional: true - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 natural-compare: 1.4.0 - semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.0(typescript@6.0.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 debug: 4.4.3 - eslint: 8.57.1 - optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@6.21.0': + '@typescript-eslint/scope-manager@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + + '@typescript-eslint/tsconfig-utils@8.59.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + typescript: 6.0.2 - '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@6.21.0': {} + '@typescript-eslint/types@8.59.0': {} - '@typescript-eslint/typescript-estree@6.21.0(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.59.0(typescript@6.0.2)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/project-service': 8.59.0(typescript@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@6.0.2) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.3 + minimatch: 10.2.4 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@6.0.2) - optionalDependencies: + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@6.0.2)': + '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@6.0.2) - eslint: 8.57.1 - semver: 7.7.4 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@6.21.0': + '@typescript-eslint/visitor-keys@8.59.0': dependencies: - '@typescript-eslint/types': 6.21.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/types': 8.59.0 + eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} @@ -9396,6 +9033,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + allotment@1.20.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: classnames: 2.5.1 @@ -9559,58 +9203,6 @@ snapshots: dependencies: dequal: 2.0.3 - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array-union@2.1.0: {} - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -9625,18 +9217,12 @@ snapshots: async-exit-hook@2.0.1: {} - async-function@1.0.0: {} - async@3.2.6: {} asynckit@0.4.0: {} at-least-node@1.0.0: {} - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - axios@1.14.0: dependencies: follow-redirects: 1.15.11 @@ -9842,13 +9428,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10274,24 +9853,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - date-fns@2.30.0: dependencies: '@babel/runtime': 7.28.6 @@ -10300,10 +9861,6 @@ snapshots: dayjs@1.11.19: {} - debug@3.2.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -10335,12 +9892,14 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + optional: true define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 + optional: true delaunator@5.0.1: dependencies: @@ -10376,10 +9935,6 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - dmg-builder@26.8.1(electron-builder-squirrel-windows@24.13.3): dependencies: app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@24.13.3) @@ -10405,14 +9960,6 @@ snapshots: verror: 1.10.1 optional: true - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - dom-accessibility-api@0.5.16: {} dompurify@3.2.7: @@ -10581,63 +10128,6 @@ snapshots: err-code@2.0.3: {} - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -10655,16 +10145,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - es6-error@4.1.1: optional: true @@ -10761,119 +10241,74 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@6.0.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@6.0.2) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-react-hooks@7.0.1(eslint@8.57.1): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.0 - eslint: 8.57.1 + eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-scope@7.2.2: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint@8.57.1: + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 - ajv: 6.12.6 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 - doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@10.4.0: dependencies: acorn: 8.15.0 acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 3.4.3 + eslint-visitor-keys: 4.2.1 esquery@1.7.0: dependencies: @@ -10932,22 +10367,10 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - fault@1.0.4: dependencies: format: 0.2.2 @@ -10962,9 +10385,9 @@ snapshots: fflate@0.4.8: {} - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 file-uri-to-path@1.0.0: {} @@ -10985,20 +10408,15 @@ snapshots: dependencies: shell-path: 3.1.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.3.3: {} follow-redirects@1.15.11: {} - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -11068,17 +10486,6 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - gauge@4.0.4: dependencies: aproba: 2.1.0 @@ -11091,8 +10498,6 @@ snapshots: wide-align: 1.1.5 optional: true - generator-function@2.0.1: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -11127,22 +10532,12 @@ snapshots: get-stream@6.0.1: {} - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 github-from-package@0.0.0: {} - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -11181,23 +10576,13 @@ snapshots: serialize-error: 7.0.1 optional: true - globals@13.24.0: - dependencies: - type-fest: 0.20.2 + globals@14.0.0: {} globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + optional: true gopd@1.2.0: {} @@ -11219,23 +10604,16 @@ snapshots: grad-school@0.0.5: {} - graphemer@1.4.0: {} - graphql@16.13.1: {} hachure-fill@0.5.2: {} - has-bigints@1.1.0: {} - has-flag@4.0.0: {} has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 + optional: true has-symbols@1.1.0: {} @@ -11482,6 +10860,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -11506,12 +10886,6 @@ snapshots: inline-style-parser@0.2.7: {} - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - internmap@1.0.1: {} internmap@2.0.3: {} @@ -11532,74 +10906,22 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - is-ci@3.0.1: dependencies: ci-info: 3.9.0 - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-decimal@1.0.4: {} is-decimal@2.0.1: {} is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.4.0 - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -11613,70 +10935,18 @@ snapshots: is-lambda@1.0.1: optional: true - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-number@7.0.0: {} - is-path-inside@3.0.3: {} - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - is-stream@2.0.1: {} - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - is-unicode-supported@0.1.0: {} - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - isarray@1.0.0: {} - isarray@2.0.5: {} - isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -12166,8 +11436,6 @@ snapshots: merge-stream@2.0.0: {} - merge2@1.4.1: {} - mermaid@11.12.2: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -12457,11 +11725,11 @@ snapshots: dependencies: brace-expansion: 1.1.12 - minimatch@5.1.6: + minimatch@3.1.5: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.12 - minimatch@9.0.3: + minimatch@5.1.6: dependencies: brace-expansion: 2.0.2 @@ -12676,36 +11944,8 @@ snapshots: object-inspect@1.13.4: {} - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + object-keys@1.1.1: + optional: true obug@2.1.1: {} @@ -12750,12 +11990,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - p-cancelable@2.1.1: {} p-limit@3.1.0: @@ -12816,8 +12050,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -12828,8 +12060,6 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-type@4.0.0: {} - pathe@2.0.3: {} pe-library@0.4.1: {} @@ -12873,8 +12103,6 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - possible-typed-array-names@1.1.0: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -12990,8 +12218,6 @@ snapshots: query-selector-shadow-dom@1.0.1: {} - queue-microtask@1.2.3: {} - quick-lru@5.1.1: {} rate-limiter-flexible@8.3.0: {} @@ -13120,17 +12346,6 @@ snapshots: readdirp@5.0.0: {} - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - refractor@3.6.0: dependencies: hastscript: 6.0.0 @@ -13147,15 +12362,6 @@ snapshots: dependencies: regex-utilities: 2.3.0 - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - rehype-harden@1.1.7: dependencies: unist-util-visit: 5.1.0 @@ -13262,12 +12468,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - responselike@2.0.1: dependencies: lowercase-keys: 2.0.0 @@ -13284,13 +12484,12 @@ snapshots: retry@0.12.0: {} - reusify@1.1.0: {} - rfdc@1.4.1: {} rimraf@3.0.2: dependencies: glob: 7.2.3 + optional: true roarr@2.15.4: dependencies: @@ -13342,10 +12541,6 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - rw@1.3.3: {} rxjs@7.8.2: @@ -13354,29 +12549,10 @@ snapshots: s3mini@0.9.4: {} - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - safer-buffer@2.1.2: {} sanitize-filename@1.6.3: @@ -13408,28 +12584,6 @@ snapshots: set-blocking@2.0.0: optional: true - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13511,8 +12665,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slash@3.0.0: {} - slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -13612,11 +12764,6 @@ snapshots: std-env@4.0.0: {} - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - stopword@3.1.5: {} streamdown@1.6.11(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4): @@ -13676,29 +12823,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -13754,8 +12878,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} tabbable@6.4.0: {} @@ -13805,8 +12927,6 @@ snapshots: async-exit-hook: 2.0.1 fs-extra: 10.1.0 - text-table@0.2.0: {} - tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -13860,7 +12980,7 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@1.4.3(typescript@6.0.2): + ts-api-utils@2.5.0(typescript@6.0.2): dependencies: typescript: 6.0.2 @@ -13890,40 +13010,16 @@ snapshots: type-fest@0.13.1: optional: true - type-fest@0.20.2: {} - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: + typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2): dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 + '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.59.0(typescript@6.0.2) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + eslint: 9.39.4(jiti@2.6.1) + typescript: 6.0.2 + transitivePeerDependencies: + - supports-color typescript@5.9.3: {} @@ -13931,13 +13027,6 @@ snapshots: ufo@1.6.3: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -14171,47 +13260,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/scripts/postinstall.ts b/scripts/postinstall.ts index dcea87cf92..2953ef2d8b 100644 --- a/scripts/postinstall.ts +++ b/scripts/postinstall.ts @@ -28,7 +28,7 @@ function runElectronRebuild(onlyModules) { : spawnSync(electronRebuildBin, args, { stdio: 'inherit' }); if (result.error) { - // eslint-disable-next-line no-console + console.error('postinstall: failed to run electron-rebuild:', result.error); } @@ -39,7 +39,7 @@ function runElectronRebuild(onlyModules) { const disablePty = process.env.EMDASH_DISABLE_PTY === '1'; const disableNativeDb = process.env.EMDASH_DISABLE_NATIVE_DB === '1'; -const nativeModules = []; +const nativeModules: string[] = []; if (!disableNativeDb) nativeModules.push('better-sqlite3'); if (!disablePty) nativeModules.push('node-pty'); diff --git a/src/main/core/fs/impl/local-fs.ts b/src/main/core/fs/impl/local-fs.ts index dea86ccd89..d0bd47a6d6 100644 --- a/src/main/core/fs/impl/local-fs.ts +++ b/src/main/core/fs/impl/local-fs.ts @@ -5,20 +5,21 @@ import parcelWatcher from '@parcel/watcher'; import { glob } from 'glob'; import ignore from 'ignore'; import type { FileWatchEvent } from '@shared/fs'; +import { log } from '@main/lib/logger'; import { DEFAULT_EMDASH_CONFIG, - FileEntry, - FileListResult, + type FileEntry, + type FileListResult, FileSystemError, FileSystemErrorCodes, - FileSystemProvider, - FileWatcher, - ListOptions, - ReadResult, - SearchMatch, - SearchOptions, - SearchResult, - WriteResult, + type FileSystemProvider, + type FileWatcher, + type ListOptions, + type ReadResult, + type SearchMatch, + type SearchOptions, + type SearchResult, + type WriteResult, } from '../types'; // Binary file extensions to skip during search @@ -334,6 +335,7 @@ export class LocalFileSystem implements FileSystemProvider { try { stat = await fs.stat(fullPath); } catch (err) { + log.error('Failed to stat file', { path, error: err }); throw new FileSystemError(`File not found: ${path}`, FileSystemErrorCodes.NOT_FOUND, path); } @@ -378,6 +380,7 @@ export class LocalFileSystem implements FileSystemProvider { try { await fs.mkdir(dir, { recursive: true }); } catch (err) { + log.error('Failed to create directory', { dir, error: err }); throw new FileSystemError( `Failed to create directory: ${dir}`, FileSystemErrorCodes.PERMISSION_DENIED, @@ -388,6 +391,7 @@ export class LocalFileSystem implements FileSystemProvider { try { await fs.writeFile(fullPath, content, 'utf-8'); } catch (err) { + log.error('Failed to write file', { path, error: err }); throw new FileSystemError( `Failed to write file: ${path}`, FileSystemErrorCodes.PERMISSION_DENIED, diff --git a/src/main/core/fs/impl/ssh-fs.ts b/src/main/core/fs/impl/ssh-fs.ts index f4d81aa57d..d75faa5c1a 100644 --- a/src/main/core/fs/impl/ssh-fs.ts +++ b/src/main/core/fs/impl/ssh-fs.ts @@ -5,21 +5,22 @@ import type { SFTPWrapper } from 'ssh2'; import type { FileWatchEvent } from '@shared/fs'; +import { log } from '@main/lib/logger'; import { quoteShellArg } from '../../../utils/shellEscape'; import type { SshClientProxy } from '../../ssh/ssh-client-proxy'; import { DEFAULT_EMDASH_CONFIG, - FileEntry, - FileListResult, + type FileEntry, + type FileListResult, FileSystemError, FileSystemErrorCodes, - FileSystemProvider, - FileWatcher, - ListOptions, - ReadResult, - SearchOptions, - SearchResult, - WriteResult, + type FileSystemProvider, + type FileWatcher, + type ListOptions, + type ReadResult, + type SearchOptions, + type SearchResult, + type WriteResult, } from '../types'; const SFTP_STATUS = { @@ -612,6 +613,7 @@ export class SshFileSystem implements FileSystemProvider { filesSearched: seenFiles.size, }; } catch (error) { + log.error('Failed to search', { query, options, error }); // If command execution fails, return empty results return { matches: [], total: 0, filesSearched: 0 }; } diff --git a/src/main/core/ssh/controller.ts b/src/main/core/ssh/controller.ts index 19c765e46c..d8743fe015 100644 --- a/src/main/core/ssh/controller.ts +++ b/src/main/core/ssh/controller.ts @@ -103,7 +103,12 @@ export const sshController = createRPCController({ testConnection: async ( config: SshConfig & { password?: string; passphrase?: string } ): Promise => { - return new Promise(async (resolve) => { + let identityAgent: string | undefined; + if (config.authType === 'agent') { + identityAgent = await resolveIdentityAgent(config.host); + } + + return new Promise((resolve) => { const client = new Client(); const debugLogs: string[] = []; const startTime = Date.now(); @@ -137,7 +142,6 @@ export const sshController = createRPCController({ connectConfig.privateKey = readFileSync(keyPath); if (config.passphrase) connectConfig.passphrase = config.passphrase; } else if (config.authType === 'agent') { - const identityAgent = await resolveIdentityAgent(config.host); connectConfig.agent = identityAgent || process.env.SSH_AUTH_SOCK; } diff --git a/src/main/db/kv.ts b/src/main/db/kv.ts index 0647797b70..0c1ab1b846 100644 --- a/src/main/db/kv.ts +++ b/src/main/db/kv.ts @@ -1,4 +1,5 @@ import { eq, like } from 'drizzle-orm'; +import { log } from '@main/lib/logger'; import { db } from './client'; import { kv } from './schema'; @@ -35,8 +36,8 @@ export class KV> { .insert(kv) .values({ key: this.prefixed(key), value: serialised, updatedAt: now }) .onConflictDoUpdate({ target: kv.key, set: { value: serialised, updatedAt: now } }); - } catch (e) { - // kv table may not exist yet during the first-run migration window + } catch (_e) { + log.error('Failed to set KV', { key, value, error: _e }); } } @@ -44,7 +45,7 @@ export class KV> { try { await db.delete(kv).where(eq(kv.key, this.prefixed(key))); } catch { - // kv table may not exist yet during the first-run migration window + log.error('Failed to delete KV', { key }); } } @@ -52,7 +53,7 @@ export class KV> { try { await db.delete(kv).where(like(kv.key, `${this.namespace}:%`)); } catch { - // kv table may not exist yet during the first-run migration window + log.error('Failed to clear KV', { namespace: this.namespace }); } } } diff --git a/src/renderer/features/projects/components/add-project-modal/button-card.tsx b/src/renderer/features/projects/components/add-project-modal/button-card.tsx index 268fc5be22..f60dc93d03 100644 --- a/src/renderer/features/projects/components/add-project-modal/button-card.tsx +++ b/src/renderer/features/projects/components/add-project-modal/button-card.tsx @@ -1,9 +1,7 @@ -import { ButtonHTMLAttributes, forwardRef } from 'react'; +import { type ButtonHTMLAttributes, forwardRef } from 'react'; import { cn } from '@renderer/utils/utils'; -interface ButtonCardProps extends ButtonHTMLAttributes {} - -export const ButtonCard = forwardRef( +export const ButtonCard = forwardRef>( ({ children, className, ...props }, ref) => { return ( + ); +} + +export function ImportSourceSelector({ + sources, + v0Preview, + betaPreview, + selectedSources, + onToggle, +}: { + sources: LegacyImportSource[]; + v0Preview: LegacyPortPreviewSource; + betaPreview: LegacyPortPreviewSource; + selectedSources: LegacyImportSource[]; + onToggle: (source: LegacyImportSource) => void; +}) { + if (sources.length === 0) return null; + + return ( +
+ {sources.includes('v0') && ( + onToggle('v0')} + /> + )} + {sources.includes('v1-beta') && ( + onToggle('v1-beta')} + /> + )} +
+ ); +} diff --git a/src/renderer/features/onboarding/components/project-conflicts.tsx b/src/renderer/features/onboarding/components/project-conflicts.tsx new file mode 100644 index 0000000000..7dfe1d8f29 --- /dev/null +++ b/src/renderer/features/onboarding/components/project-conflicts.tsx @@ -0,0 +1,111 @@ +import { Check } from 'lucide-react'; +import { + type LegacyImportSource, + type LegacyPortProjectConflict, +} from '@renderer/lib/hooks/useLegacyPort'; +import { cn } from '@renderer/utils/utils'; +import { formatCount, sourceLabel } from './import-format'; + +function ConflictChoice({ + source, + conflict, + selected, + onSelect, +}: { + source: LegacyImportSource; + conflict: LegacyPortProjectConflict; + selected: boolean; + onSelect: () => void; +}) { + const details = source === 'v0' ? conflict.v0 : conflict.v1Beta; + + return ( + + ); +} + +function ConflictCard({ + conflict, + selectedSource, + onSelect, +}: { + conflict: LegacyPortProjectConflict; + selectedSource: LegacyImportSource; + onSelect: (source: LegacyImportSource) => void; +}) { + return ( +
+
+ {conflict.v1Beta.name} + + {conflict.kind === 'ssh' ? 'SSH' : 'Local'} + +
+
+ onSelect('v0')} + /> + onSelect('v1-beta')} + /> +
+
+ ); +} + +export function ProjectConflicts({ + conflicts, + choices, + onChoiceChange, +}: { + conflicts: LegacyPortProjectConflict[]; + choices: Record; + onChoiceChange: (identityKey: string, source: LegacyImportSource) => void; +}) { + if (conflicts.length === 0) return null; + + return ( +
+
+

Project conflicts

+

+ These projects exist in both selected versions. Choose which version to keep. +

+
+
+ {conflicts.map((conflict) => ( + onChoiceChange(conflict.identityKey, source)} + /> + ))} +
+
+ ); +} diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 6b6fab26f0..ad306e9f3d 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -1,141 +1,132 @@ -import { Import } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import { useLegacyPortImport, useLegacyPortPreview } from '@renderer/lib/hooks/useLegacyPort'; +import { useMemo, useState } from 'react'; +import { useImportProgress } from '@renderer/lib/hooks/useImportProgress'; +import { + useLegacyPortImport, + useLegacyPortPreview, + useLegacyPortSkip, + type LegacyImportSource, +} from '@renderer/lib/hooks/useLegacyPort'; import { Button } from '@renderer/lib/ui/button'; +import { ImportHeader } from './components/import-header'; +import { ImportProgress } from './components/import-progress'; +import { ImportSourceSelector } from './components/import-source-selector'; +import { ProjectConflicts } from './components/project-conflicts'; + +function availableSources(preview: ReturnType['data']) { + const sources: LegacyImportSource[] = []; + if (preview?.sources.v0.available) sources.push('v0'); + if (preview?.sources.v1Beta.available) sources.push('v1-beta'); + return sources; +} -const PROGRESS_DURATION_MS = 4000; -const COMPLETE_DELAY_MS = 1000; +function toggleSourceSelection( + sources: LegacyImportSource[], + source: LegacyImportSource +): LegacyImportSource[] { + if (sources.includes(source)) { + return sources.filter((candidate) => candidate !== source); + } + return [...sources, source]; +} export function ImportStep({ onComplete }: { onComplete: () => void }) { const { data: preview, isLoading: previewLoading } = useLegacyPortPreview(true); const importMutation = useLegacyPortImport(); - - const [isImporting, setIsImporting] = useState(false); - const [progress, setProgress] = useState(0); - const [importError, setImportError] = useState(null); - - const onCompleteRef = useRef(onComplete); - useEffect(() => { - onCompleteRef.current = onComplete; - }, [onComplete]); - - const animationRef = useRef(null); - const completeTimerRef = useRef | null>(null); - const importDoneRef = useRef(false); - const animationDoneRef = useRef(false); - - useEffect(() => { - return () => { - if (animationRef.current !== null) cancelAnimationFrame(animationRef.current); - if (completeTimerRef.current !== null) clearTimeout(completeTimerRef.current); - }; - }, []); - - const maybeScheduleComplete = () => { - if (importDoneRef.current && animationDoneRef.current && completeTimerRef.current === null) { - completeTimerRef.current = setTimeout(() => { - onCompleteRef.current(); - }, COMPLETE_DELAY_MS); - } + const skipMutation = useLegacyPortSkip(); + const importProgress = useImportProgress(); + + const sourceOptions = useMemo(() => availableSources(preview), [preview]); + const [selectedSourcesOverride, setSelectedSourcesOverride] = useState< + LegacyImportSource[] | null + >(null); + const [conflictChoiceOverrides, setConflictChoiceOverrides] = useState< + Record + >({}); + + const selectedSources = selectedSourcesOverride ?? sourceOptions; + const visibleConflicts = useMemo(() => { + if (!selectedSources.includes('v0') || !selectedSources.includes('v1-beta')) return []; + return preview?.conflicts ?? []; + }, [preview?.conflicts, selectedSources]); + + const v0Preview = preview?.sources.v0 ?? { available: false, projects: 0, tasks: 0 }; + const betaPreview = preview?.sources.v1Beta ?? { available: false, projects: 0, tasks: 0 }; + const canImport = selectedSources.length > 0 && !previewLoading; + + const toggleSource = (source: LegacyImportSource) => { + setSelectedSourcesOverride((current) => + toggleSourceSelection(current ?? selectedSources, source) + ); }; - const startAnimation = () => { - const startTime = performance.now(); - - const tick = (now: number) => { - const elapsed = now - startTime; - const pct = Math.min(elapsed / PROGRESS_DURATION_MS, 1); - setProgress(Math.round(pct * 100)); - - if (pct < 1) { - animationRef.current = requestAnimationFrame(tick); - } else { - animationRef.current = null; - animationDoneRef.current = true; - maybeScheduleComplete(); - } - }; - - animationRef.current = requestAnimationFrame(tick); + const updateConflictChoice = (identityKey: string, source: LegacyImportSource) => { + setConflictChoiceOverrides((current) => ({ + ...current, + [identityKey]: source, + })); }; const handleImport = async () => { - setImportError(null); - setIsImporting(true); - setProgress(0); - importDoneRef.current = false; - animationDoneRef.current = false; - completeTimerRef.current = null; - - startAnimation(); - - try { - const result = await importMutation.mutateAsync(); - if (!result.success) { - if (animationRef.current !== null) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; - } - setImportError(result.error ?? 'Import failed'); - setIsImporting(false); - setProgress(0); - return; - } - importDoneRef.current = true; - maybeScheduleComplete(); - } catch (err) { - if (animationRef.current !== null) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; - } - setImportError(err instanceof Error ? err.message : 'Import failed'); - setIsImporting(false); - setProgress(0); - } + const conflictChoices = Object.fromEntries( + visibleConflicts.map((conflict) => [ + conflict.identityKey, + conflictChoiceOverrides[conflict.identityKey] ?? 'v1-beta', + ]) + ) as Record; + + await importProgress.run( + () => + importMutation.mutateAsync({ + sources: selectedSources, + conflictChoices, + }), + { onComplete } + ); }; - const projectCount = preview?.projects ?? 0; - const taskCount = preview?.tasks ?? 0; + const handleSkip = async () => { + await importProgress.run(() => skipMutation.mutateAsync(), { onComplete }); + }; return ( -
-
-
- -
-

Import your Emdash v0 data

- {previewLoading ? ( -

- Scanning legacy database... -

- ) : ( -

- Found {projectCount}{' '} - {projectCount === 1 ? 'project' : 'projects'} and{' '} - {taskCount}{' '} - {taskCount === 1 ? 'task' : 'tasks'} from your previous Emdash installation -

- )} -
-
-
- - {isImporting && ( -
-
-
-
-

{progress}%

-
+
+ + + {!previewLoading && ( + )} - {importError &&

{importError}

} + + + {importProgress.isImporting && } + + {importProgress.error && ( +

{importProgress.error}

+ )} -
- -
diff --git a/src/renderer/features/onboarding/onboarding-shell.tsx b/src/renderer/features/onboarding/onboarding-shell.tsx index ee0feed647..0802cc7e72 100644 --- a/src/renderer/features/onboarding/onboarding-shell.tsx +++ b/src/renderer/features/onboarding/onboarding-shell.tsx @@ -65,7 +65,7 @@ export function OnboardingShell({ }; return ( -
+
{steps.map((step, index) => ( ))}
-
+
diff --git a/src/renderer/lib/hooks/useImportProgress.ts b/src/renderer/lib/hooks/useImportProgress.ts new file mode 100644 index 0000000000..e8d4828b22 --- /dev/null +++ b/src/renderer/lib/hooks/useImportProgress.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; + +const PROGRESS_DURATION_MS = 4000; +const COMPLETE_DELAY_MS = 1000; + +type ImportProgressActionResult = { success: boolean; error?: string }; + +type RunImportProgressOptions = { + onComplete: () => void; +}; + +export function useImportProgress() { + const [isImporting, setIsImporting] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const animationRef = useRef(null); + const completeTimerRef = useRef | null>(null); + const importDoneRef = useRef(false); + const animationDoneRef = useRef(false); + + useEffect(() => { + return () => { + if (animationRef.current !== null) cancelAnimationFrame(animationRef.current); + if (completeTimerRef.current !== null) clearTimeout(completeTimerRef.current); + }; + }, []); + + const cancelAnimation = () => { + if (animationRef.current !== null) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + }; + + const clearCompleteTimer = () => { + if (completeTimerRef.current !== null) { + clearTimeout(completeTimerRef.current); + completeTimerRef.current = null; + } + }; + + const resetRunState = () => { + cancelAnimation(); + clearCompleteTimer(); + setError(null); + setIsImporting(true); + setProgress(0); + importDoneRef.current = false; + animationDoneRef.current = false; + }; + + const maybeScheduleComplete = (onComplete: () => void) => { + if (importDoneRef.current && animationDoneRef.current && completeTimerRef.current === null) { + completeTimerRef.current = setTimeout(() => { + onComplete(); + }, COMPLETE_DELAY_MS); + } + }; + + const startAnimation = (onComplete: () => void) => { + let startTime: number | null = null; + + const tick = (now: number) => { + startTime ??= now; + const elapsed = now - startTime; + const pct = Math.min(elapsed / PROGRESS_DURATION_MS, 1); + setProgress(Math.round(pct * 100)); + + if (pct < 1) { + animationRef.current = requestAnimationFrame(tick); + return; + } + + animationRef.current = null; + animationDoneRef.current = true; + maybeScheduleComplete(onComplete); + }; + + animationRef.current = requestAnimationFrame(tick); + }; + + const run = async ( + action: () => Promise, + options: RunImportProgressOptions + ) => { + resetRunState(); + startAnimation(options.onComplete); + + try { + const result = await action(); + if (!result.success) { + cancelAnimation(); + setError(result.error ?? 'Import failed'); + setIsImporting(false); + setProgress(0); + return; + } + + importDoneRef.current = true; + maybeScheduleComplete(options.onComplete); + } catch (err) { + cancelAnimation(); + setError(err instanceof Error ? err.message : 'Import failed'); + setIsImporting(false); + setProgress(0); + } + }; + + return { error, isImporting, progress, run }; +} diff --git a/src/renderer/lib/hooks/useLegacyPort.ts b/src/renderer/lib/hooks/useLegacyPort.ts index a28b15a052..b2b46f9bec 100644 --- a/src/renderer/lib/hooks/useLegacyPort.ts +++ b/src/renderer/lib/hooks/useLegacyPort.ts @@ -1,6 +1,41 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { rpc } from '@renderer/lib/ipc'; +export type LegacyImportSource = 'v0' | 'v1-beta'; + +export type LegacyPortPreviewSource = { + available: boolean; + projects: number; + tasks: number; +}; + +export type LegacyPortProjectConflict = { + identityKey: string; + kind: 'local' | 'ssh'; + v0: { + name: string; + path: string; + taskCount: number; + updatedAt: string | null; + }; + v1Beta: { + name: string; + path: string; + taskCount: number; + updatedAt: string | null; + }; +}; + +export type LegacyPortPreview = { + sources: { + v0: LegacyPortPreviewSource; + v1Beta: LegacyPortPreviewSource; + }; + conflicts: LegacyPortProjectConflict[]; + projects: number; + tasks: number; +}; + export const LEGACY_PORT_STATUS_KEY = ['legacyPort:status'] as const; const LEGACY_PORT_PREVIEW_KEY = ['legacyPort:preview'] as const; @@ -15,7 +50,7 @@ export function useLegacyPortStatus() { export function useLegacyPortPreview(enabled: boolean) { return useQuery({ queryKey: LEGACY_PORT_PREVIEW_KEY, - queryFn: () => rpc.legacyPort.getPreview(), + queryFn: () => rpc.legacyPort.getPreview() as Promise, enabled, staleTime: Infinity, }); @@ -24,6 +59,20 @@ export function useLegacyPortPreview(enabled: boolean) { export function useLegacyPortImport() { const queryClient = useQueryClient(); return useMutation({ + mutationFn: (args: { + sources: LegacyImportSource[]; + conflictChoices?: Record; + }) => rpc.legacyPort.runImport(args), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [...LEGACY_PORT_STATUS_KEY] }); + }, + }); +} + +export function useLegacyPortSkip() { + const queryClient = useQueryClient(); + return useMutation({ + // run import without args skips import mutationFn: () => rpc.legacyPort.runImport(), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [...LEGACY_PORT_STATUS_KEY] }); From 9dff68cbb4b5bc09a0ba574a5ad346c96798f159 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 15:19:57 +0200 Subject: [PATCH 023/263] fix: skip legacy import without progress --- .../features/onboarding/import-step.tsx | 30 ++++++++++++------- src/renderer/lib/hooks/useImportProgress.ts | 2 +- src/renderer/lib/hooks/useLegacyPort.ts | 4 +-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index ad306e9f3d..2a918f7a9a 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -42,6 +42,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { const [conflictChoiceOverrides, setConflictChoiceOverrides] = useState< Record >({}); + const [skipError, setSkipError] = useState(null); const selectedSources = selectedSourcesOverride ?? sourceOptions; const visibleConflicts = useMemo(() => { @@ -67,6 +68,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { }; const handleImport = async () => { + setSkipError(null); const conflictChoices = Object.fromEntries( visibleConflicts.map((conflict) => [ conflict.identityKey, @@ -85,9 +87,22 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { }; const handleSkip = async () => { - await importProgress.run(() => skipMutation.mutateAsync(), { onComplete }); + setSkipError(null); + importProgress.clearError(); + try { + const result = await skipMutation.mutateAsync(); + if (!result.success) { + setSkipError(result.error ?? 'Skip failed'); + return; + } + onComplete(); + } catch (err) { + setSkipError(err instanceof Error ? err.message : 'Skip failed'); + } }; + const isBusy = importProgress.isImporting || skipMutation.isPending; + return (
@@ -113,20 +128,13 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { {importProgress.error && (

{importProgress.error}

)} + {skipError &&

{skipError}

}
- -
diff --git a/src/renderer/lib/hooks/useImportProgress.ts b/src/renderer/lib/hooks/useImportProgress.ts index e8d4828b22..3e43c2b281 100644 --- a/src/renderer/lib/hooks/useImportProgress.ts +++ b/src/renderer/lib/hooks/useImportProgress.ts @@ -107,5 +107,5 @@ export function useImportProgress() { } }; - return { error, isImporting, progress, run }; + return { clearError: () => setError(null), error, isImporting, progress, run }; } diff --git a/src/renderer/lib/hooks/useLegacyPort.ts b/src/renderer/lib/hooks/useLegacyPort.ts index b2b46f9bec..b304b85863 100644 --- a/src/renderer/lib/hooks/useLegacyPort.ts +++ b/src/renderer/lib/hooks/useLegacyPort.ts @@ -72,8 +72,8 @@ export function useLegacyPortImport() { export function useLegacyPortSkip() { const queryClient = useQueryClient(); return useMutation({ - // run import without args skips import - mutationFn: () => rpc.legacyPort.runImport(), + // An explicit empty source list means "skip import and start fresh". + mutationFn: () => rpc.legacyPort.runImport({ sources: [] }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [...LEGACY_PORT_STATUS_KEY] }); }, From a5ec620b3a16aeb34c3aab46b6c1529a41bde5da Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 15:36:04 +0200 Subject: [PATCH 024/263] refactor: reduce redundancy --- src/main/db/legacy-port/legacy-source/path.ts | 6 +++- src/main/db/legacy-port/service.ts | 34 ++++++------------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/main/db/legacy-port/legacy-source/path.ts b/src/main/db/legacy-port/legacy-source/path.ts index 1800f71e4c..bc8dd830da 100644 --- a/src/main/db/legacy-port/legacy-source/path.ts +++ b/src/main/db/legacy-port/legacy-source/path.ts @@ -10,6 +10,10 @@ export function hasLegacyDatabaseFile(userDataPath: string): boolean { return existsSync(resolveLegacyDatabasePath(userDataPath)); } +export function resolveBetaDatabasePath(userDataPath: string) { + return join(userDataPath, PREVIOUS_DB_FILENAME); +} + export function hasBetaDatabaseFile(userDataPath: string): boolean { - return existsSync(join(userDataPath, PREVIOUS_DB_FILENAME)); + return existsSync(resolveBetaDatabasePath(userDataPath)); } diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index 6a70917a05..6eefb5445a 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -1,9 +1,6 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; import type Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import type { StartupDataGateStatus } from '@shared/startup-data-gate'; -import { PREVIOUS_DB_FILENAME } from '@main/db/default-path'; import { log } from '../../lib/logger'; import * as schema from '../schema'; import { importBetaDatabaseIntoDestination } from './beta-import'; @@ -15,7 +12,12 @@ import { portTasks } from './importers/relational/tasks'; import type { PortSummary } from './importers/relational/types'; import { portLegacySettings } from './importers/settings/importer'; import { openLegacyReadOnly } from './legacy-source/open-readonly'; -import { hasLegacyDatabaseFile, resolveLegacyDatabasePath } from './legacy-source/path'; +import { + hasBetaDatabaseFile, + hasLegacyDatabaseFile, + resolveBetaDatabasePath, + resolveLegacyDatabasePath, +} from './legacy-source/path'; import { clearDestinationDataPreservingSignIn } from './reset'; import { buildLegacyProjectSelection } from './source-analysis'; import { createLegacyPortStateStore } from './state-store'; @@ -143,22 +145,6 @@ export async function createDefaultLegacyPortStateStore(): Promise Date: Mon, 27 Apr 2026 15:56:51 +0200 Subject: [PATCH 025/263] refactor(db): share legacy port import helper --- src/main/db/legacy-port/beta-import.ts | 62 ++++------ .../db/legacy-port/importers/auth/importer.ts | 12 +- .../importers/relational/conversations.ts | 58 ++++------ .../importers/relational/helpers.ts | 8 +- .../importers/relational/insert.ts | 34 ++++++ .../importers/relational/projects.ts | 107 ++++++++---------- .../importers/relational/ssh-connections.ts | 62 +++++----- .../legacy-port/importers/relational/tasks.ts | 63 +++++------ .../importers/settings/importer.ts | 10 +- .../legacy-source/project-identity.ts | 33 ++++++ src/main/db/legacy-port/reset.ts | 24 +--- src/main/db/legacy-port/source-analysis.ts | 52 ++------- src/main/db/legacy-port/sqlite-utils.ts | 56 +++++++++ 13 files changed, 289 insertions(+), 292 deletions(-) create mode 100644 src/main/db/legacy-port/importers/relational/insert.ts create mode 100644 src/main/db/legacy-port/legacy-source/project-identity.ts create mode 100644 src/main/db/legacy-port/sqlite-utils.ts diff --git a/src/main/db/legacy-port/beta-import.ts b/src/main/db/legacy-port/beta-import.ts index 3b5ae30bb4..08a061158e 100644 --- a/src/main/db/legacy-port/beta-import.ts +++ b/src/main/db/legacy-port/beta-import.ts @@ -1,5 +1,12 @@ import type Database from 'better-sqlite3'; -import { clearDestinationDataPreservingSignIn, quoteIdentifier, tableExists } from './reset'; +import { clearDestinationDataPreservingSignIn } from './reset'; +import { + columnsForTable, + quoteIdentifier, + quoteSqliteString, + tableExists, + withForeignKeysDisabled, +} from './sqlite-utils'; const COPY_TABLE_ORDER = [ 'app_settings', @@ -18,30 +25,8 @@ const COPY_TABLE_ORDER = [ 'editor_buffers', ] as const; -function quoteSqliteString(value: string): string { - return `'${value.split("'").join("''")}'`; -} - -function attachedTableExists(sqlite: Database.Database, tableName: string): boolean { - const row = sqlite - .prepare(`SELECT 1 FROM beta.sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName); - return Boolean(row); -} - -function columnsForTable( - sqlite: Database.Database, - schemaName: 'main' | 'beta', - tableName: string -): string[] { - const rows = sqlite - .prepare(`PRAGMA ${schemaName}.table_info(${quoteIdentifier(tableName)})`) - .all() as Array<{ name: string }>; - return rows.map((row) => row.name); -} - function copyTable(sqlite: Database.Database, tableName: string): void { - if (!tableExists(sqlite, tableName) || !attachedTableExists(sqlite, tableName)) return; + if (!tableExists(sqlite, tableName) || !tableExists(sqlite, tableName, 'beta')) return; const destinationColumns = new Set(columnsForTable(sqlite, 'main', tableName)); const sourceColumns = columnsForTable(sqlite, 'beta', tableName); @@ -61,20 +46,19 @@ export function importBetaDatabaseIntoDestination( sqlite: Database.Database, betaDatabasePath: string ): void { - const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; - sqlite.pragma('foreign_keys = OFF'); - sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); + withForeignKeysDisabled(sqlite, () => { + sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); - try { - const copy = sqlite.transaction(() => { - clearDestinationDataPreservingSignIn(sqlite); - for (const tableName of COPY_TABLE_ORDER) { - copyTable(sqlite, tableName); - } - }); - copy(); - } finally { - sqlite.exec('DETACH DATABASE beta'); - sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); - } + try { + const copy = sqlite.transaction(() => { + clearDestinationDataPreservingSignIn(sqlite); + for (const tableName of COPY_TABLE_ORDER) { + copyTable(sqlite, tableName); + } + }); + copy(); + } finally { + sqlite.exec('DETACH DATABASE beta'); + } + }); } diff --git a/src/main/db/legacy-port/importers/auth/importer.ts b/src/main/db/legacy-port/importers/auth/importer.ts index daf4923f58..3c169021cf 100644 --- a/src/main/db/legacy-port/importers/auth/importer.ts +++ b/src/main/db/legacy-port/importers/auth/importer.ts @@ -5,6 +5,7 @@ import { promisify } from 'node:util'; import type Database from 'better-sqlite3'; import { eq } from 'drizzle-orm'; import { appSecrets, kv } from '@main/db/schema'; +import { tableExists } from '../../sqlite-utils'; import type { RelationalImportDb } from '../relational/types'; const execFileAsync = promisify(execFile); @@ -105,13 +106,6 @@ const LEGACY_SECRET_SPECS: LegacySecretSpec[] = [ }, ]; -function hasTable(appSqlite: Database.Database, tableName: string): boolean { - const row = appSqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName) as { 1: number } | undefined; - return !!row; -} - async function hasStoredSecret(appDb: RelationalImportDb, key: string): Promise { const [row] = await appDb .select({ key: appSecrets.key }) @@ -305,8 +299,8 @@ export async function portLegacyAuthState( skipped: [], }; - const hasKvTable = hasTable(appSqlite, 'kv'); - const hasSecretsTable = hasTable(appSqlite, 'app_secrets'); + const hasKvTable = tableExists(appSqlite, 'kv'); + const hasSecretsTable = tableExists(appSqlite, 'app_secrets'); if (!hasKvTable && !hasSecretsTable) { summary.skipped.push('auth-port:missing-kv-and-app-secrets-tables'); diff --git a/src/main/db/legacy-port/importers/relational/conversations.ts b/src/main/db/legacy-port/importers/relational/conversations.ts index 0ccc516280..984aff7bcb 100644 --- a/src/main/db/legacy-port/importers/relational/conversations.ts +++ b/src/main/db/legacy-port/importers/relational/conversations.ts @@ -1,14 +1,9 @@ -import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { conversations, tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { - isUniqueConstraintError, - readLegacyRows, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; const LEGACY_PTY_SESSION_MAP_FILE = 'pty-session-map.json'; @@ -288,12 +283,8 @@ export async function portConversations({ claudeResumeTargets, }); - let nextConversationId = conversationIds.has(preferredConversationId) - ? randomUUID() - : preferredConversationId; - const insertValues = { - id: nextConversationId, + id: preferredConversationId, projectId: mappedProjectId, taskId: mappedTaskId, title: @@ -304,32 +295,29 @@ export async function portConversations({ updatedAt: toIsoTimestamp(row.updated_at, nowIso), }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextConversationId; - await appDb.insert(conversations).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'conversations.id')) { - nextConversationId = randomUUID(); - continue; - } + const insertResult = await insertWithRegeneratedId({ + initialId: preferredConversationId, + existingIds: conversationIds, + uniqueConstraintDetail: 'conversations.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(conversations).values(insertValues).execute(), + }); - summary.skippedError += 1; - log.warn('legacy-port: conversations: failed to insert row', { - legacyConversationId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: conversations: failed to insert row', { + legacyConversationId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; } - if (!inserted) continue; - - conversationIds.add(nextConversationId); + conversationIds.add(insertResult.id); summary.inserted += 1; } diff --git a/src/main/db/legacy-port/importers/relational/helpers.ts b/src/main/db/legacy-port/importers/relational/helpers.ts index 646a599864..b2f9383c40 100644 --- a/src/main/db/legacy-port/importers/relational/helpers.ts +++ b/src/main/db/legacy-port/importers/relational/helpers.ts @@ -1,11 +1,5 @@ import type Database from 'better-sqlite3'; - -function quoteIdentifier(identifier: string): string { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) { - throw new Error(`Invalid SQL identifier: ${identifier}`); - } - return `"${identifier}"`; -} +import { quoteIdentifier } from '../../sqlite-utils'; export function legacyTableExists(legacyDb: Database.Database, tableName: string): boolean { const row = legacyDb diff --git a/src/main/db/legacy-port/importers/relational/insert.ts b/src/main/db/legacy-port/importers/relational/insert.ts new file mode 100644 index 0000000000..317da8d81d --- /dev/null +++ b/src/main/db/legacy-port/importers/relational/insert.ts @@ -0,0 +1,34 @@ +import { randomUUID } from 'node:crypto'; +import { isUniqueConstraintError } from './helpers'; + +export type InsertWithRegeneratedIdResult = { + inserted: boolean; + id: string; + error?: unknown; +}; + +export async function insertWithRegeneratedId(args: { + initialId: string; + existingIds: ReadonlySet; + uniqueConstraintDetail: string; + setId: (id: string) => void; + insert: () => Promise; +}): Promise { + let nextId = args.existingIds.has(args.initialId) ? randomUUID() : args.initialId; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + args.setId(nextId); + await args.insert(); + return { inserted: true, id: nextId }; + } catch (error) { + if (attempt === 0 && isUniqueConstraintError(error, args.uniqueConstraintDetail)) { + nextId = randomUUID(); + continue; + } + return { inserted: false, id: nextId, error }; + } + } + + return { inserted: false, id: nextId }; +} diff --git a/src/main/db/legacy-port/importers/relational/projects.ts b/src/main/db/legacy-port/importers/relational/projects.ts index 2892e83636..1cfd526fb7 100644 --- a/src/main/db/legacy-port/importers/relational/projects.ts +++ b/src/main/db/legacy-port/importers/relational/projects.ts @@ -1,14 +1,16 @@ -import { randomUUID } from 'node:crypto'; import { basename } from 'node:path'; import { eq } from 'drizzle-orm'; import { projects, sshConnections } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { makeSshFingerprint, - normalizeLocalPath, normalizePort, normalizeRemotePath, } from '../../legacy-source/normalize'; +import { + localProjectIdentityKey, + sshProjectIdentityKey, +} from '../../legacy-source/project-identity'; import { isUniqueConstraintError, readLegacyRows, @@ -16,6 +18,7 @@ import { toIsoTimestamp, toTrimmedString, } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; type ExistingProjectRow = { @@ -35,14 +38,6 @@ type ConnectionFingerprintRow = { username: string; }; -function localProjectKey(projectPath: string): string { - return `local:${normalizeLocalPath(projectPath)}`; -} - -function sshProjectKey(fingerprint: string, projectPath: string): string { - return `ssh:${fingerprint}:${normalizeRemotePath(projectPath)}`; -} - function pickDefaultProjectName(projectPath: string, fallbackId: string): string { const derived = basename(projectPath.trim()); return derived.length > 0 ? derived : `Legacy Project ${fallbackId.slice(0, 8)}`; @@ -102,11 +97,11 @@ export async function portProjects({ if (row.workspaceProvider === 'ssh' && row.sshConnectionId && row.host && row.username) { const fingerprint = makeSshFingerprint(row.host, normalizePort(row.port), row.username); - sshKeyToProjectId.set(sshProjectKey(fingerprint, row.path), row.id); + sshKeyToProjectId.set(sshProjectIdentityKey(fingerprint, row.path), row.id); continue; } - localKeyToProjectId.set(localProjectKey(row.path), row.id); + localKeyToProjectId.set(localProjectIdentityKey(row.path), row.id); } const connectionFingerprintById = await loadConnectionFingerprintById(appDb); @@ -193,7 +188,7 @@ export async function portProjects({ } projectPath = remotePath; - dedupKey = sshProjectKey(fingerprint, normalizedRemotePath); + dedupKey = sshProjectIdentityKey(fingerprint, normalizedRemotePath); const existingProjectId = sshKeyToProjectId.get(dedupKey); if (existingProjectId) { @@ -212,7 +207,7 @@ export async function portProjects({ } projectPath = localPath; - dedupKey = localProjectKey(localPath); + dedupKey = localProjectIdentityKey(localPath); const existingProjectId = localKeyToProjectId.get(dedupKey); if (existingProjectId) { @@ -227,10 +222,8 @@ export async function portProjects({ continue; } - let nextProjectId = projectIds.has(legacyProjectId) ? randomUUID() : legacyProjectId; - const insertValues = { - id: nextProjectId, + id: legacyProjectId, name: toTrimmedString(row.name) ?? pickDefaultProjectName(projectPath, legacyProjectId), path: projectPath, workspaceProvider, @@ -240,54 +233,50 @@ export async function portProjects({ updatedAt, }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextProjectId; - await appDb.insert(projects).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'projects.id')) { - nextProjectId = randomUUID(); - continue; - } - - if (isUniqueConstraintError(error, 'projects.path')) { - const [existingByPath] = await appDb - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.path, projectPath)) - .limit(1) - .execute(); - - if (existingByPath) { - remap.projectId.set(legacyProjectId, existingByPath.id); - summary.skippedDedup += 1; - } else { - summary.skippedError += 1; - log.warn('legacy-port: projects: path conflict but no surviving row found', { - legacyProjectId, - projectPath, - }); - } - break; + const insertResult = await insertWithRegeneratedId({ + initialId: legacyProjectId, + existingIds: projectIds, + uniqueConstraintDetail: 'projects.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(projects).values(insertValues).execute(), + }); + + if (!insertResult.inserted) { + if (isUniqueConstraintError(insertResult.error, 'projects.path')) { + const [existingByPath] = await appDb + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.path, projectPath)) + .limit(1) + .execute(); + + if (existingByPath) { + remap.projectId.set(legacyProjectId, existingByPath.id); + summary.skippedDedup += 1; + } else { + summary.skippedError += 1; + log.warn('legacy-port: projects: path conflict but no surviving row found', { + legacyProjectId, + projectPath, + }); } - + } else { summary.skippedError += 1; log.warn('legacy-port: projects: failed to insert row', { legacyProjectId, - error: error instanceof Error ? error.message : String(error), + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), }); - break; } + continue; } - if (!inserted) continue; - - remap.projectId.set(legacyProjectId, nextProjectId); - projectIds.add(nextProjectId); + remap.projectId.set(legacyProjectId, insertResult.id); + projectIds.add(insertResult.id); summary.inserted += 1; if (workspaceProvider === 'ssh') { @@ -295,10 +284,10 @@ export async function portProjects({ ? connectionFingerprintById.get(mappedSshConnectionId) : undefined; if (fingerprint) { - sshKeyToProjectId.set(sshProjectKey(fingerprint, projectPath), nextProjectId); + sshKeyToProjectId.set(sshProjectIdentityKey(fingerprint, projectPath), insertResult.id); } } else { - localKeyToProjectId.set(localProjectKey(projectPath), nextProjectId); + localKeyToProjectId.set(localProjectIdentityKey(projectPath), insertResult.id); } } diff --git a/src/main/db/legacy-port/importers/relational/ssh-connections.ts b/src/main/db/legacy-port/importers/relational/ssh-connections.ts index 4c4162bb9b..c9d3a03d90 100644 --- a/src/main/db/legacy-port/importers/relational/ssh-connections.ts +++ b/src/main/db/legacy-port/importers/relational/ssh-connections.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { sshConnections } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { @@ -7,13 +6,8 @@ import { normalizePort, normalizeUsername, } from '../../legacy-source/normalize'; -import { - isUniqueConstraintError, - readLegacyRows, - toInteger, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toInteger, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; type ExistingSshConnection = { @@ -123,14 +117,12 @@ export async function portSshConnections({ continue; } - let nextConnectionId = existingConnectionIds.has(legacyId) ? randomUUID() : legacyId; - const preferredName = toTrimmedString(row.name) ?? `${normalizeUsername(username)}@${normalizeHost(host)}:${normalizedPort}`; const insertValues = { - id: nextConnectionId, + id: legacyId, name: pickUniqueConnectionName(preferredName, usedConnectionNames), host, port: normalizedPort, @@ -143,33 +135,31 @@ export async function portSshConnections({ updatedAt: toIsoTimestamp(row.updated_at, nowIso), }; - let inserted = false; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextConnectionId; - await appDb.insert(sshConnections).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'ssh_connections.id')) { - nextConnectionId = randomUUID(); - continue; - } - - summary.skippedError += 1; - log.warn('legacy-port: ssh_connections: failed to insert row', { - legacyId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } + const insertResult = await insertWithRegeneratedId({ + initialId: legacyId, + existingIds: existingConnectionIds, + uniqueConstraintDetail: 'ssh_connections.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(sshConnections).values(insertValues).execute(), + }); + + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: ssh_connections: failed to insert row', { + legacyId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; } - if (!inserted) continue; - - remap.sshConnectionId.set(legacyId, nextConnectionId); - fingerprintToConnectionId.set(fingerprint, nextConnectionId); - existingConnectionIds.add(nextConnectionId); + remap.sshConnectionId.set(legacyId, insertResult.id); + fingerprintToConnectionId.set(fingerprint, insertResult.id); + existingConnectionIds.add(insertResult.id); summary.inserted += 1; } diff --git a/src/main/db/legacy-port/importers/relational/tasks.ts b/src/main/db/legacy-port/importers/relational/tasks.ts index b841e80b91..b3f437c518 100644 --- a/src/main/db/legacy-port/importers/relational/tasks.ts +++ b/src/main/db/legacy-port/importers/relational/tasks.ts @@ -1,13 +1,7 @@ -import { randomUUID } from 'node:crypto'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; -import { - isUniqueConstraintError, - readLegacyRows, - toInteger, - toIsoTimestamp, - toTrimmedString, -} from './helpers'; +import { readLegacyRows, toInteger, toIsoTimestamp, toTrimmedString } from './helpers'; +import { insertWithRegeneratedId } from './insert'; import { createPortSummary, type PortContext, type PortSummary } from './types'; export type TaskPortResult = { @@ -145,13 +139,11 @@ export async function portTasks({ appDb, legacyDb, remap }: PortContext): Promis } } - let nextTaskId = existingTaskIds.has(legacyTaskId) ? randomUUID() : legacyTaskId; - const updatedAt = toIsoTimestamp(row.updated_at, nowIso); const createdAt = toIsoTimestamp(row.created_at, updatedAt); const insertValues = { - id: nextTaskId, + id: legacyTaskId, projectId: mappedProjectId, name: toTrimmedString(row.name) ?? branch ?? `Legacy Task ${legacyTaskId.slice(0, 8)}`, status: coerceTaskStatus(toTrimmedString(row.status)), @@ -165,37 +157,34 @@ export async function portTasks({ appDb, legacyDb, remap }: PortContext): Promis isPinned: 0, }; - let inserted = false; - - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - insertValues.id = nextTaskId; - await appDb.insert(tasks).values(insertValues).execute(); - inserted = true; - break; - } catch (error) { - if (attempt === 0 && isUniqueConstraintError(error, 'tasks.id')) { - nextTaskId = randomUUID(); - continue; - } - - summary.skippedError += 1; - log.warn('legacy-port: tasks: failed to insert row', { - legacyTaskId, - error: error instanceof Error ? error.message : String(error), - }); - break; - } - } + const insertResult = await insertWithRegeneratedId({ + initialId: legacyTaskId, + existingIds: existingTaskIds, + uniqueConstraintDetail: 'tasks.id', + setId: (id) => { + insertValues.id = id; + }, + insert: () => appDb.insert(tasks).values(insertValues).execute(), + }); - if (!inserted) continue; + if (!insertResult.inserted) { + summary.skippedError += 1; + log.warn('legacy-port: tasks: failed to insert row', { + legacyTaskId, + error: + insertResult.error instanceof Error + ? insertResult.error.message + : String(insertResult.error), + }); + continue; + } - remap.taskId.set(legacyTaskId, nextTaskId); - existingTaskIds.add(nextTaskId); + remap.taskId.set(legacyTaskId, insertResult.id); + existingTaskIds.add(insertResult.id); summary.inserted += 1; if (taskBranch) { - branchKeyToTaskId.set(`${mappedProjectId}::${taskBranch}`, nextTaskId); + branchKeyToTaskId.set(`${mappedProjectId}::${taskBranch}`, insertResult.id); } } diff --git a/src/main/db/legacy-port/importers/settings/importer.ts b/src/main/db/legacy-port/importers/settings/importer.ts index 64d2c8fab1..177ad0b68e 100644 --- a/src/main/db/legacy-port/importers/settings/importer.ts +++ b/src/main/db/legacy-port/importers/settings/importer.ts @@ -5,6 +5,7 @@ import { isValidProviderId } from '@shared/agent-provider-registry'; import type { AppSettings, AppSettingsKey } from '@shared/app-settings'; import { getDefaultForKey } from '@main/core/settings/settings-registry'; import { isPlainObject, mergeDeep } from '@main/core/settings/utils'; +import { tableExists } from '../../sqlite-utils'; import type { RelationalImportDb } from '../relational/types'; const LEGACY_SETTINGS_FILE = 'settings.json'; @@ -25,13 +26,6 @@ export type PortLegacySettingsOptions = { type LegacyTheme = 'light' | 'dark' | 'dark-black' | 'system'; -function hasTable(appSqlite: Database.Database, tableName: string): boolean { - const row = appSqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName) as { 1: number } | undefined; - return !!row; -} - function readJsonFile(filePath: string): unknown | null { if (!existsSync(filePath)) return null; try { @@ -97,7 +91,7 @@ export async function portLegacySettings( skipped: [], }; - if (!hasTable(appSqlite, 'app_settings')) { + if (!tableExists(appSqlite, 'app_settings')) { summary.skipped.push('settings:app_settings-table-missing'); return summary; } diff --git a/src/main/db/legacy-port/legacy-source/project-identity.ts b/src/main/db/legacy-port/legacy-source/project-identity.ts new file mode 100644 index 0000000000..24418132d3 --- /dev/null +++ b/src/main/db/legacy-port/legacy-source/project-identity.ts @@ -0,0 +1,33 @@ +import { normalizeGitHubUrl } from '@main/core/github/services/utils'; +import { normalizeLocalPath, normalizeRemotePath } from './normalize'; + +export function localProjectIdentityKey(projectPath: string): string { + return `local:${normalizeLocalPath(projectPath)}`; +} + +export function sshProjectIdentityKey(fingerprint: string, projectPath: string): string { + return `ssh:${fingerprint}:${normalizeRemotePath(projectPath)}`; +} + +function gitRemoteIdentityKey(remote: string): string | null { + const normalized = normalizeGitHubUrl(remote.trim()) + .replace(/\.git$/i, '') + .replace(/\/+$/g, ''); + if (!normalized) return null; + return `git:${normalized.toLowerCase()}`; +} + +function githubRepositoryIdentityKey(repository: string): string | null { + const normalized = repository + .trim() + .replace(/^\/+|\/+$/g, '') + .replace(/\.git$/i, ''); + if (!normalized.includes('/')) return null; + return gitRemoteIdentityKey(`https://github.com/${normalized}`); +} + +export function gitRemoteIdentityKeys(value: string): string[] { + return [gitRemoteIdentityKey(value), githubRepositoryIdentityKey(value)].filter( + (key, index, keys): key is string => Boolean(key) && keys.indexOf(key) === index + ); +} diff --git a/src/main/db/legacy-port/reset.ts b/src/main/db/legacy-port/reset.ts index 1cb34d73d3..c21424c6bd 100644 --- a/src/main/db/legacy-port/reset.ts +++ b/src/main/db/legacy-port/reset.ts @@ -1,26 +1,13 @@ import type Database from 'better-sqlite3'; +import { quoteIdentifier, tableExists, withForeignKeysDisabled } from './sqlite-utils'; export const PRESERVED_SECRET_KEYS = ['emdash-account-token', 'emdash-github-token'] as const; export const PRESERVED_KV_KEYS = ['account:profile', 'github:tokenSource'] as const; -export function quoteIdentifier(identifier: string): string { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) { - throw new Error(`Invalid SQL identifier: ${identifier}`); - } - return `"${identifier}"`; -} - function placeholders(values: readonly string[]): string { return values.map(() => '?').join(', '); } -export function tableExists(sqlite: Database.Database, tableName: string): boolean { - const row = sqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName); - return Boolean(row); -} - export function listUserTables(sqlite: Database.Database): string[] { const rows = sqlite .prepare( @@ -38,10 +25,7 @@ export function listUserTables(sqlite: Database.Database): string[] { } export function clearDestinationDataPreservingSignIn(sqlite: Database.Database): void { - const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; - sqlite.pragma('foreign_keys = OFF'); - - try { + withForeignKeysDisabled(sqlite, () => { const tables = listUserTables(sqlite); const clear = sqlite.transaction(() => { for (const table of tables) { @@ -68,7 +52,5 @@ export function clearDestinationDataPreservingSignIn(sqlite: Database.Database): }); clear(); - } finally { - sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); - } + }); } diff --git a/src/main/db/legacy-port/source-analysis.ts b/src/main/db/legacy-port/source-analysis.ts index 7d176a8c90..55cfed5a50 100644 --- a/src/main/db/legacy-port/source-analysis.ts +++ b/src/main/db/legacy-port/source-analysis.ts @@ -1,15 +1,14 @@ import type Database from 'better-sqlite3'; import { eq } from 'drizzle-orm'; -import { normalizeGitHubUrl } from '@main/core/github/services/utils'; import { projectRemotes, projects, sshConnections, tasks } from '@main/db/schema'; import { readLegacyRows, toInteger, toTrimmedString } from './importers/relational/helpers'; import type { RelationalImportDb } from './importers/relational/types'; +import { makeSshFingerprint, normalizePort } from './legacy-source/normalize'; import { - makeSshFingerprint, - normalizeLocalPath, - normalizePort, - normalizeRemotePath, -} from './legacy-source/normalize'; + gitRemoteIdentityKeys, + localProjectIdentityKey, + sshProjectIdentityKey, +} from './legacy-source/project-identity'; import type { LegacyImportSource } from './service'; export type ProjectIdentityKind = 'local' | 'ssh'; @@ -72,31 +71,6 @@ type AppProjectRemoteRow = { remoteUrl: string; }; -function localIdentityKey(projectPath: string): string { - return `local:${normalizeLocalPath(projectPath)}`; -} - -function sshIdentityKey(fingerprint: string, projectPath: string): string { - return `ssh:${fingerprint}:${normalizeRemotePath(projectPath)}`; -} - -function gitRemoteIdentityKey(remote: string): string | null { - const normalized = normalizeGitHubUrl(remote.trim()) - .replace(/\.git$/i, '') - .replace(/\/+$/g, ''); - if (!normalized) return null; - return `git:${normalized.toLowerCase()}`; -} - -function githubRepositoryIdentityKey(repository: string): string | null { - const normalized = repository - .trim() - .replace(/^\/+|\/+$/g, '') - .replace(/\.git$/i, ''); - if (!normalized.includes('/')) return null; - return gitRemoteIdentityKey(`https://github.com/${normalized}`); -} - async function readAppProjectRemoteRows(appDb: RelationalImportDb): Promise { try { return (await appDb @@ -174,9 +148,7 @@ export function readLegacyProjectInfos(legacyDb: Database.Database): SourceProje toTrimmedString(row.github_repository), ].flatMap((value) => { if (!value) return []; - const remoteKey = gitRemoteIdentityKey(value); - const repositoryKey = githubRepositoryIdentityKey(value); - return [...new Set([remoteKey, repositoryKey].filter((key): key is string => Boolean(key)))]; + return gitRemoteIdentityKeys(value); }); if (isRemote) { @@ -189,7 +161,7 @@ export function readLegacyProjectInfos(legacyDb: Database.Database): SourceProje result.push({ id, - identityKey: sshIdentityKey(fingerprint, remotePath), + identityKey: sshProjectIdentityKey(fingerprint, remotePath), kind: 'ssh', name, path: remotePath, @@ -206,7 +178,7 @@ export function readLegacyProjectInfos(legacyDb: Database.Database): SourceProje result.push({ id, - identityKey: localIdentityKey(localPath), + identityKey: localProjectIdentityKey(localPath), kind: 'local', name, path: localPath, @@ -230,10 +202,8 @@ export async function readAppProjectInfos(appDb: RelationalImportDb): Promise(); for (const row of remoteRows) { - const key = gitRemoteIdentityKey(row.remoteUrl); - if (!key) continue; const keys = gitRemoteKeysByProject.get(row.projectId) ?? []; - keys.push(key); + keys.push(...gitRemoteIdentityKeys(row.remoteUrl)); gitRemoteKeysByProject.set(row.projectId, [...new Set(keys)]); } @@ -259,7 +229,7 @@ export async function readAppProjectInfos(appDb: RelationalImportDb): Promise; + return rows.map((row) => row.name); +} + +export function withForeignKeysDisabled(sqlite: Database.Database, action: () => T): T { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = OFF'); + + try { + return action(); + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} + +export function withForeignKeysEnabled(sqlite: Database.Database, action: () => T): T { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = ON'); + + try { + return action(); + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} From fbb5052c149b3a3f78fbfb6136040a05c7e53d2d Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 15:58:30 +0200 Subject: [PATCH 026/263] refactor(db): extract legacy project cleanup --- .../db/legacy-port/destination-cleanup.ts | 17 +++++ src/main/db/legacy-port/service.ts | 66 +------------------ 2 files changed, 18 insertions(+), 65 deletions(-) create mode 100644 src/main/db/legacy-port/destination-cleanup.ts diff --git a/src/main/db/legacy-port/destination-cleanup.ts b/src/main/db/legacy-port/destination-cleanup.ts new file mode 100644 index 0000000000..5d377d0bbb --- /dev/null +++ b/src/main/db/legacy-port/destination-cleanup.ts @@ -0,0 +1,17 @@ +import type Database from 'better-sqlite3'; +import { tableExists, withForeignKeysEnabled } from './sqlite-utils'; + +export function deleteProjectsById( + sqlite: Database.Database, + projectIds: ReadonlySet +): void { + if (projectIds.size === 0) return; + if (!tableExists(sqlite, 'projects')) return; + + const ids = [...projectIds]; + const placeholders = ids.map(() => '?').join(', '); + + withForeignKeysEnabled(sqlite, () => { + sqlite.prepare(`DELETE FROM projects WHERE id IN (${placeholders})`).run(...ids); + }); +} diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index 6eefb5445a..42218185ce 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -4,6 +4,7 @@ import type { StartupDataGateStatus } from '@shared/startup-data-gate'; import { log } from '../../lib/logger'; import * as schema from '../schema'; import { importBetaDatabaseIntoDestination } from './beta-import'; +import { deleteProjectsById } from './destination-cleanup'; import { portConversations } from './importers/relational/conversations'; import { portProjects } from './importers/relational/projects'; import { createRemapTables } from './importers/relational/remap'; @@ -76,71 +77,6 @@ async function markStatus( } } -function deleteProjectsById(sqlite: Database.Database, projectIds: ReadonlySet): void { - if (projectIds.size === 0) return; - - const ids = [...projectIds]; - const placeholders = ids.map(() => '?').join(', '); - const tableExists = (tableName: string): boolean => - Boolean( - sqlite - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`) - .get(tableName) - ); - - const taskRows = tableExists('tasks') - ? (sqlite - .prepare(`SELECT id FROM tasks WHERE project_id IN (${placeholders})`) - .all(...ids) as Array<{ id: string }>) - : []; - const taskIds = taskRows.map((row) => row.id); - const taskPlaceholders = taskIds.map(() => '?').join(', '); - - if (tableExists('messages') && tableExists('conversations')) { - sqlite - .prepare( - `DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE project_id IN (${placeholders}))` - ) - .run(...ids); - } - - if (tableExists('conversations')) { - sqlite.prepare(`DELETE FROM conversations WHERE project_id IN (${placeholders})`).run(...ids); - } - - if (tableExists('terminals')) { - sqlite.prepare(`DELETE FROM terminals WHERE project_id IN (${placeholders})`).run(...ids); - } - - if (tableExists('editor_buffers')) { - sqlite.prepare(`DELETE FROM editor_buffers WHERE project_id IN (${placeholders})`).run(...ids); - } - - if (tableExists('project_remotes')) { - sqlite.prepare(`DELETE FROM project_remotes WHERE project_id IN (${placeholders})`).run(...ids); - } - - if (taskIds.length > 0 && tableExists('tasks_pull_requests')) { - sqlite - .prepare(`DELETE FROM tasks_pull_requests WHERE task_id IN (${taskPlaceholders})`) - .run(...taskIds); - } - - if (tableExists('projects_pull_requests')) { - sqlite - .prepare(`DELETE FROM projects_pull_requests WHERE project_id IN (${placeholders})`) - .run(...ids); - } - - if (tableExists('tasks')) { - sqlite.prepare(`DELETE FROM tasks WHERE project_id IN (${placeholders})`).run(...ids); - } - - if (tableExists('projects')) { - sqlite.prepare(`DELETE FROM projects WHERE id IN (${placeholders})`).run(...ids); - } -} - export async function createDefaultLegacyPortStateStore(): Promise { return createLegacyPortStateStore(); } From 98e1aebdc6b0a6edce9090f6f58e47c3bee3f19d Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 16:07:04 +0200 Subject: [PATCH 027/263] feat: add build variant to telemetry --- .github/actions/setup-build/action.yml | 6 ++++++ .github/workflows/release-canary.yml | 4 ++++ src/main/lib/env.ts | 1 + src/main/lib/telemetry.ts | 1 + 4 files changed, 12 insertions(+) diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml index db9113b989..2e6c8cae79 100644 --- a/.github/actions/setup-build/action.yml +++ b/.github/actions/setup-build/action.yml @@ -10,6 +10,10 @@ inputs: description: PostHog host required: false default: '' + build-variant: + description: Build variant to embed (canary | prod) + required: false + default: 'prod' windows-native: description: Set to true on Windows to pass MSVC/gyp flags to pnpm install required: false @@ -53,6 +57,8 @@ runs: env: PH_KEY: ${{ inputs.posthog-key }} PH_HOST: ${{ inputs.posthog-host }} + BUILD_VARIANT: ${{ inputs.build-variant }} run: | echo "VITE_POSTHOG_KEY=$PH_KEY" >> .env.production echo "VITE_POSTHOG_HOST=$PH_HOST" >> .env.production + echo "VITE_BUILD=$BUILD_VARIANT" >> .env.production diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 2b50adb0e8..2157aa2b57 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -20,6 +20,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary - run: pnpm run build @@ -49,6 +50,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary - run: pnpm run build @@ -80,6 +82,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary windows-native: 'true' - name: Export Python path for native modules shell: bash @@ -144,6 +147,7 @@ jobs: with: posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} posthog-host: ${{ secrets.POSTHOG_HOST }} + build-variant: canary - name: Import Apple signing certificate uses: apple-actions/import-codesign-certs@v2 diff --git a/src/main/lib/env.ts b/src/main/lib/env.ts index a1637160fb..f20ed78951 100644 --- a/src/main/lib/env.ts +++ b/src/main/lib/env.ts @@ -4,6 +4,7 @@ import { log } from './logger'; const buildSchema = z.object({ VITE_POSTHOG_KEY: z.string().optional(), VITE_POSTHOG_HOST: z.string().optional(), + VITE_BUILD: z.enum(['canary', 'prod']).default('prod'), }); // Dev-only overrides: read from process.env (supports non-VITE_ prefixed vars, diff --git a/src/main/lib/telemetry.ts b/src/main/lib/telemetry.ts index 92595159da..61b8fcbe53 100644 --- a/src/main/lib/telemetry.ts +++ b/src/main/lib/telemetry.ts @@ -61,6 +61,7 @@ function getBaseProps() { return { schema_version: 1, app_version: getVersionSafe(), + build_variant: appEnv.build.VITE_BUILD, source: 'desktop_app', electron_version: process.versions.electron, platform: process.platform, From 71ea1cff84d73f2095670ca4f6a5619d8d57c411 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:19:51 +0200 Subject: [PATCH 028/263] refactor(legacy-port): share import DTO types --- src/main/db/legacy-port/controller.ts | 7 +-- src/main/db/legacy-port/service.ts | 2 +- src/main/db/legacy-port/source-analysis.ts | 44 +++---------------- .../onboarding/components/import-format.ts | 2 +- .../components/import-source-selector.tsx | 5 +-- .../components/project-conflicts.tsx | 11 ++--- .../features/onboarding/import-step.tsx | 2 +- src/renderer/lib/hooks/useLegacyPort.ts | 40 ++--------------- src/shared/legacy-port.ts | 38 ++++++++++++++++ 9 files changed, 57 insertions(+), 94 deletions(-) create mode 100644 src/shared/legacy-port.ts diff --git a/src/main/db/legacy-port/controller.ts b/src/main/db/legacy-port/controller.ts index a47713059b..72ee83b8b9 100644 --- a/src/main/db/legacy-port/controller.ts +++ b/src/main/db/legacy-port/controller.ts @@ -3,6 +3,7 @@ import { count } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { app } from 'electron'; import { createRPCController } from '@shared/ipc/rpc'; +import type { LegacyImportSource } from '@shared/legacy-port'; import { db } from '@main/db/client'; import { PREVIOUS_DB_FILENAME } from '@main/db/default-path'; import * as schema from '@main/db/schema'; @@ -14,11 +15,7 @@ import { hasLegacyDatabaseFile, resolveLegacyDatabasePath, } from './legacy-source/path'; -import { - createDefaultLegacyPortStateStore, - runLegacyPort, - type LegacyImportSource, -} from './service'; +import { createDefaultLegacyPortStateStore, runLegacyPort } from './service'; import { createLegacyPortPreview } from './source-analysis'; export const legacyPortController = createRPCController({ diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index 42218185ce..4d411e5de5 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { LegacyImportSource } from '@shared/legacy-port'; import type { StartupDataGateStatus } from '@shared/startup-data-gate'; import { log } from '../../lib/logger'; import * as schema from '../schema'; @@ -31,7 +32,6 @@ type AppTarget = { }; export type LegacyPortStatus = StartupDataGateStatus; -export type LegacyImportSource = 'v0' | 'v1-beta'; export interface LegacyPortStateStore { getStatus(): Promise; diff --git a/src/main/db/legacy-port/source-analysis.ts b/src/main/db/legacy-port/source-analysis.ts index 55cfed5a50..06cf74005b 100644 --- a/src/main/db/legacy-port/source-analysis.ts +++ b/src/main/db/legacy-port/source-analysis.ts @@ -1,5 +1,11 @@ import type Database from 'better-sqlite3'; import { eq } from 'drizzle-orm'; +import type { + LegacyImportSource, + LegacyPortPreview, + LegacyProjectConflict, + SourceProjectInfo, +} from '@shared/legacy-port'; import { projectRemotes, projects, sshConnections, tasks } from '@main/db/schema'; import { readLegacyRows, toInteger, toTrimmedString } from './importers/relational/helpers'; import type { RelationalImportDb } from './importers/relational/types'; @@ -9,44 +15,6 @@ import { localProjectIdentityKey, sshProjectIdentityKey, } from './legacy-source/project-identity'; -import type { LegacyImportSource } from './service'; - -export type ProjectIdentityKind = 'local' | 'ssh'; - -export type SourceProjectInfo = { - id: string; - identityKey: string; - kind: ProjectIdentityKind; - name: string; - path: string; - taskCount: number; - updatedAt: string | null; - sshConnectionId: string | null; - gitRemoteKeys: string[]; -}; - -export type LegacyProjectConflict = { - identityKey: string; - kind: ProjectIdentityKind; - v0: SourceProjectInfo; - v1Beta: SourceProjectInfo; -}; - -export type LegacyPortPreviewSource = { - available: boolean; - projects: number; - tasks: number; -}; - -export type LegacyPortPreview = { - sources: { - v0: LegacyPortPreviewSource; - v1Beta: LegacyPortPreviewSource; - }; - conflicts: LegacyProjectConflict[]; - projects: number; - tasks: number; -}; export type LegacyProjectSelection = { skipLegacyProjectIds: Set; diff --git a/src/renderer/features/onboarding/components/import-format.ts b/src/renderer/features/onboarding/components/import-format.ts index be7467153e..cae8021134 100644 --- a/src/renderer/features/onboarding/components/import-format.ts +++ b/src/renderer/features/onboarding/components/import-format.ts @@ -1,4 +1,4 @@ -import type { LegacyImportSource } from '@renderer/lib/hooks/useLegacyPort'; +import type { LegacyImportSource } from '@shared/legacy-port'; export function sourceLabel(source: LegacyImportSource): string { return source === 'v0' ? 'v0' : 'v1-beta'; diff --git a/src/renderer/features/onboarding/components/import-source-selector.tsx b/src/renderer/features/onboarding/components/import-source-selector.tsx index 100fb9b915..40262d53c5 100644 --- a/src/renderer/features/onboarding/components/import-source-selector.tsx +++ b/src/renderer/features/onboarding/components/import-source-selector.tsx @@ -1,8 +1,5 @@ import { Check } from 'lucide-react'; -import { - type LegacyImportSource, - type LegacyPortPreviewSource, -} from '@renderer/lib/hooks/useLegacyPort'; +import { type LegacyImportSource, type LegacyPortPreviewSource } from '@shared/legacy-port'; import { cn } from '@renderer/utils/utils'; import { formatCount, sourceLabel } from './import-format'; diff --git a/src/renderer/features/onboarding/components/project-conflicts.tsx b/src/renderer/features/onboarding/components/project-conflicts.tsx index 7dfe1d8f29..f0a91dd9f3 100644 --- a/src/renderer/features/onboarding/components/project-conflicts.tsx +++ b/src/renderer/features/onboarding/components/project-conflicts.tsx @@ -1,8 +1,5 @@ import { Check } from 'lucide-react'; -import { - type LegacyImportSource, - type LegacyPortProjectConflict, -} from '@renderer/lib/hooks/useLegacyPort'; +import { type LegacyImportSource, type LegacyProjectConflict } from '@shared/legacy-port'; import { cn } from '@renderer/utils/utils'; import { formatCount, sourceLabel } from './import-format'; @@ -13,7 +10,7 @@ function ConflictChoice({ onSelect, }: { source: LegacyImportSource; - conflict: LegacyPortProjectConflict; + conflict: LegacyProjectConflict; selected: boolean; onSelect: () => void; }) { @@ -47,7 +44,7 @@ function ConflictCard({ selectedSource, onSelect, }: { - conflict: LegacyPortProjectConflict; + conflict: LegacyProjectConflict; selectedSource: LegacyImportSource; onSelect: (source: LegacyImportSource) => void; }) { @@ -82,7 +79,7 @@ export function ProjectConflicts({ choices, onChoiceChange, }: { - conflicts: LegacyPortProjectConflict[]; + conflicts: LegacyProjectConflict[]; choices: Record; onChoiceChange: (identityKey: string, source: LegacyImportSource) => void; }) { diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 2a918f7a9a..7505804e3d 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; +import type { LegacyImportSource } from '@shared/legacy-port'; import { useImportProgress } from '@renderer/lib/hooks/useImportProgress'; import { useLegacyPortImport, useLegacyPortPreview, useLegacyPortSkip, - type LegacyImportSource, } from '@renderer/lib/hooks/useLegacyPort'; import { Button } from '@renderer/lib/ui/button'; import { ImportHeader } from './components/import-header'; diff --git a/src/renderer/lib/hooks/useLegacyPort.ts b/src/renderer/lib/hooks/useLegacyPort.ts index b304b85863..82507189f5 100644 --- a/src/renderer/lib/hooks/useLegacyPort.ts +++ b/src/renderer/lib/hooks/useLegacyPort.ts @@ -1,41 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { LegacyImportSource, LegacyPortPreview } from '@shared/legacy-port'; import { rpc } from '@renderer/lib/ipc'; -export type LegacyImportSource = 'v0' | 'v1-beta'; - -export type LegacyPortPreviewSource = { - available: boolean; - projects: number; - tasks: number; -}; - -export type LegacyPortProjectConflict = { - identityKey: string; - kind: 'local' | 'ssh'; - v0: { - name: string; - path: string; - taskCount: number; - updatedAt: string | null; - }; - v1Beta: { - name: string; - path: string; - taskCount: number; - updatedAt: string | null; - }; -}; - -export type LegacyPortPreview = { - sources: { - v0: LegacyPortPreviewSource; - v1Beta: LegacyPortPreviewSource; - }; - conflicts: LegacyPortProjectConflict[]; - projects: number; - tasks: number; -}; - export const LEGACY_PORT_STATUS_KEY = ['legacyPort:status'] as const; const LEGACY_PORT_PREVIEW_KEY = ['legacyPort:preview'] as const; @@ -48,9 +14,9 @@ export function useLegacyPortStatus() { } export function useLegacyPortPreview(enabled: boolean) { - return useQuery({ + return useQuery({ queryKey: LEGACY_PORT_PREVIEW_KEY, - queryFn: () => rpc.legacyPort.getPreview() as Promise, + queryFn: () => rpc.legacyPort.getPreview(), enabled, staleTime: Infinity, }); diff --git a/src/shared/legacy-port.ts b/src/shared/legacy-port.ts new file mode 100644 index 0000000000..bee68c0a77 --- /dev/null +++ b/src/shared/legacy-port.ts @@ -0,0 +1,38 @@ +export type LegacyImportSource = 'v0' | 'v1-beta'; + +export type ProjectIdentityKind = 'local' | 'ssh'; + +export type LegacyPortPreviewSource = { + available: boolean; + projects: number; + tasks: number; +}; + +export type SourceProjectInfo = { + id: string; + identityKey: string; + kind: ProjectIdentityKind; + name: string; + path: string; + taskCount: number; + updatedAt: string | null; + sshConnectionId: string | null; + gitRemoteKeys: string[]; +}; + +export type LegacyProjectConflict = { + identityKey: string; + kind: ProjectIdentityKind; + v0: SourceProjectInfo; + v1Beta: SourceProjectInfo; +}; + +export type LegacyPortPreview = { + sources: { + v0: LegacyPortPreviewSource; + v1Beta: LegacyPortPreviewSource; + }; + conflicts: LegacyProjectConflict[]; + projects: number; + tasks: number; +}; From 90aa59034c80b854462ccb631ac55f25398416f1 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:20:40 +0200 Subject: [PATCH 029/263] fix(legacy-port): roll back fatal imports --- src/main/db/legacy-port/service.test.ts | 59 +++++++++++++++ src/main/db/legacy-port/service.ts | 99 ++++++++++++++++--------- 2 files changed, 122 insertions(+), 36 deletions(-) diff --git a/src/main/db/legacy-port/service.test.ts b/src/main/db/legacy-port/service.test.ts index 17ce1cb228..4808784db9 100644 --- a/src/main/db/legacy-port/service.test.ts +++ b/src/main/db/legacy-port/service.test.ts @@ -227,4 +227,63 @@ describe('runLegacyPort', () => { }; expect(projectsAfterSecondRun.count).toBe(1); }); + + it('rolls back destination changes when a fatal import error happens', async () => { + const appDb = new Database(':memory:'); + openDbs.push(appDb); + appDb.pragma('foreign_keys = ON'); + appDb.exec(` + CREATE TABLE ssh_connections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'agent', + private_key_path TEXT, + use_agent INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + workspace_provider TEXT NOT NULL DEFAULT 'local', + base_ref TEXT, + ssh_connection_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + appDb + .prepare( + `INSERT INTO projects (id, name, path, workspace_provider, base_ref) VALUES (?, ?, ?, ?, ?)` + ) + .run('existing-project', 'Existing Project', '/existing/repo', 'local', 'main'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-port-rollback-')); + tempDirs.push(tmpDir); + + seedLegacyDb(path.join(tmpDir, 'emdash.db')); + + const stateStore = new InMemoryLegacyPortStateStore(); + + await runLegacyPort(tmpDir, { appDb, stateStore }); + + const projects = appDb + .prepare(`SELECT id, name, path FROM projects ORDER BY id ASC`) + .all() as Array<{ id: string; name: string; path: string }>; + + expect(await stateStore.getStatus()).toBeNull(); + expect(projects).toEqual([ + { + id: 'existing-project', + name: 'Existing Project', + path: '/existing/repo', + }, + ]); + }); }); diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index 4d411e5de5..9ec541854b 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -77,6 +77,28 @@ async function markStatus( } } +async function withAtomicDestinationImport( + sqlite: Database.Database, + action: () => Promise +): Promise { + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }) as number; + sqlite.pragma('foreign_keys = OFF'); + sqlite.exec('BEGIN IMMEDIATE'); + + try { + const result = await action(); + sqlite.exec('COMMIT'); + return result; + } catch (error) { + if (sqlite.inTransaction) { + sqlite.exec('ROLLBACK'); + } + throw error; + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} + export async function createDefaultLegacyPortStateStore(): Promise { return createLegacyPortStateStore(); } @@ -142,42 +164,47 @@ export async function runLegacyPort( const start = Date.now(); try { - const remap = createRemapTables(); - if (!selectedSources.has('v1-beta')) { - clearDestinationDataPreservingSignIn(appTarget.sqlite); - } - - const selection = await buildLegacyProjectSelection({ - appDb: appTarget.db, - legacyDb, - selectedSources, - conflictChoices: options.conflictChoices ?? {}, - }); - - if (selectedSources.has('v1-beta')) { - deleteProjectsById(appTarget.sqlite, selection.replaceAppProjectIds); - } - - const sshSummary = await portSshConnections({ - appDb: appTarget.db, - legacyDb, - remap, - allowedLegacyConnectionIds: selection.allowedLegacySshConnectionIds, - }); - const projectsSummary = await portProjects({ - appDb: appTarget.db, - legacyDb, - remap, - skipLegacyProjectIds: selection.skipLegacyProjectIds, - }); - const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); - const conversationsSummary = await portConversations({ - appDb: appTarget.db, - legacyDb, - remap, - mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, - userDataPath, - }); + const { sshSummary, projectsSummary, taskResult, conversationsSummary } = + await withAtomicDestinationImport(appTarget.sqlite, async () => { + const remap = createRemapTables(); + if (!selectedSources.has('v1-beta')) { + clearDestinationDataPreservingSignIn(appTarget.sqlite); + } + + const selection = await buildLegacyProjectSelection({ + appDb: appTarget.db, + legacyDb, + selectedSources, + conflictChoices: options.conflictChoices ?? {}, + }); + + if (selectedSources.has('v1-beta')) { + deleteProjectsById(appTarget.sqlite, selection.replaceAppProjectIds); + } + + const sshSummary = await portSshConnections({ + appDb: appTarget.db, + legacyDb, + remap, + allowedLegacyConnectionIds: selection.allowedLegacySshConnectionIds, + }); + const projectsSummary = await portProjects({ + appDb: appTarget.db, + legacyDb, + remap, + skipLegacyProjectIds: selection.skipLegacyProjectIds, + }); + const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); + const conversationsSummary = await portConversations({ + appDb: appTarget.db, + legacyDb, + remap, + mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, + userDataPath, + }); + + return { sshSummary, projectsSummary, taskResult, conversationsSummary }; + }); logSummary(sshSummary); logSummary(projectsSummary); From ca877550044c6ef3686f9d0f70a67e370cd73c8c Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:20:47 +0200 Subject: [PATCH 030/263] chore(legacy-port): remove unused auth importer --- .../importers/auth/importer.test.ts | 273 ---------- .../db/legacy-port/importers/auth/importer.ts | 482 ------------------ 2 files changed, 755 deletions(-) delete mode 100644 src/main/db/legacy-port/importers/auth/importer.test.ts delete mode 100644 src/main/db/legacy-port/importers/auth/importer.ts diff --git a/src/main/db/legacy-port/importers/auth/importer.test.ts b/src/main/db/legacy-port/importers/auth/importer.test.ts deleted file mode 100644 index b5fb36fb51..0000000000 --- a/src/main/db/legacy-port/importers/auth/importer.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import Database from 'better-sqlite3'; -import { afterEach, describe, expect, it } from 'vitest'; -import { createDrizzleClient } from '../../../drizzleClient'; -import { portLegacyAuthState } from './importer'; - -function createAppDbWithConfigTables(): { - appSqlite: Database.Database; - appDb: ReturnType['db']; -} { - const appSqlite = new Database(':memory:'); - appSqlite.exec(` - CREATE TABLE kv ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE app_secrets ( - key TEXT PRIMARY KEY, - secret TEXT NOT NULL - ); - `); - return { - appSqlite, - appDb: createDrizzleClient({ database: appSqlite }).db, - }; -} - -function readKv(appSqlite: Database.Database, fullKey: string): T | null { - const row = appSqlite.prepare('SELECT value FROM kv WHERE key = ?').get(fullKey) as - | { value: string } - | undefined; - if (!row) return null; - return JSON.parse(row.value) as T; -} - -function readSecret(appSqlite: Database.Database, key: string): string | null { - const row = appSqlite.prepare('SELECT secret FROM app_secrets WHERE key = ?').get(key) as - | { secret: string } - | undefined; - return row?.secret ?? null; -} - -function upsertKv( - appSqlite: Database.Database, - namespace: string, - key: string, - value: unknown -): void { - appSqlite - .prepare( - ` - INSERT INTO kv (key, value, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE - SET value = excluded.value, updated_at = excluded.updated_at - ` - ) - .run(`${namespace}:${key}`, JSON.stringify(value), Date.now()); -} - -function upsertSecret(appSqlite: Database.Database, key: string, secret: string): void { - appSqlite - .prepare( - ` - INSERT INTO app_secrets (key, secret) - VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET secret = excluded.secret - ` - ) - .run(key, secret); -} - -describe('portLegacyAuthState', () => { - const tempDirs: string[] = []; - const openDbs: Database.Database[] = []; - - afterEach(() => { - for (const db of openDbs.splice(0)) db.close(); - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it('ports keychain secrets + legacy JSON files into app_secrets and kv', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-')); - tempDirs.push(userDataDir); - - fs.writeFileSync( - path.join(userDataDir, 'jira.json'), - JSON.stringify({ siteUrl: 'https://jira.example.com', email: 'me@example.com' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'forgejo.json'), - JSON.stringify({ siteUrl: 'https://forgejo.example.com/' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'gitlab.json'), - JSON.stringify({ siteUrl: 'https://gitlab.example.com/' }), - 'utf8' - ); - fs.writeFileSync( - path.join(userDataDir, 'emdash-account.json'), - JSON.stringify({ - hasAccount: true, - userId: 'user-1', - username: 'jona', - avatarUrl: 'https://example.com/avatar.png', - email: 'jona@example.com', - lastValidated: '2026-04-23T12:00:00.000Z', - }), - 'utf8' - ); - - const secretMap = new Map([ - ['emdash-github:github-token', 'gh_123'], - ['emdash-linear:api-token', 'lin_123'], - ['emdash-jira:api-token', 'jira_123'], - ['emdash-plain:api-token', 'plain_123'], - ['emdash-forgejo:forgejo-token', 'forgejo_123'], - ['emdash-gitlab:gitlab-token', 'gitlab_123'], - ['emdash-account:session-token', 'session_123'], - ['emdash-ssh:legacy-ssh-1:password', 'ssh_pwd_123'], - ]); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async (service, account) => secretMap.get(`${service}:${account}`) ?? null, - encryptSecret: (secret) => Buffer.from(`enc:${secret}`, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSecrets).toEqual([ - 'github', - 'linear', - 'jira', - 'plain', - 'forgejo', - 'gitlab', - 'account', - ]); - expect(summary.importedSshPasswords).toBe(1); - - expect(readSecret(appSqlite, 'emdash-github-token')).toBe( - Buffer.from('enc:gh_123', 'utf8').toString('base64') - ); - expect(readSecret(appSqlite, 'emdash-account-token')).toBe( - Buffer.from('enc:session_123', 'utf8').toString('base64') - ); - expect(readSecret(appSqlite, 'ssh:ssh-app-1:password')).toBe( - Buffer.from('enc:ssh_pwd_123', 'utf8').toString('base64') - ); - - expect(readKv(appSqlite, 'github:tokenSource')).toBe('secure_storage'); - expect(readKv<{ siteUrl: string; email: string }>(appSqlite, 'jira:creds')).toEqual({ - siteUrl: 'https://jira.example.com', - email: 'me@example.com', - }); - expect(readKv<{ instanceUrl: string }>(appSqlite, 'forgejo:connection')).toEqual({ - instanceUrl: 'https://forgejo.example.com', - }); - expect(readKv<{ instanceUrl: string }>(appSqlite, 'gitlab:connection')).toEqual({ - instanceUrl: 'https://gitlab.example.com', - }); - expect( - readKv<{ userId: string; username: string }>(appSqlite, 'account:profile') - ).toMatchObject({ - userId: 'user-1', - username: 'jona', - }); - }); - - it('skips malformed legacy config without throwing', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-invalid-')); - tempDirs.push(userDataDir); - - fs.writeFileSync(path.join(userDataDir, 'jira.json'), '{bad-json', 'utf8'); - fs.writeFileSync( - path.join(userDataDir, 'emdash-account.json'), - JSON.stringify({ hasAccount: true, userId: '', username: '' }), - 'utf8' - ); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async () => null, - encryptSecret: (secret) => Buffer.from(secret, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSecrets).toEqual([]); - expect(summary.importedKv).toEqual([]); - expect(summary.importedSshPasswords).toBe(0); - expect(summary.skipped.length).toBeGreaterThan(0); - - const secretCount = ( - appSqlite.prepare('SELECT COUNT(*) AS count FROM app_secrets').get() as { - count: number; - } - ).count; - const kvCount = ( - appSqlite.prepare('SELECT COUNT(*) AS count FROM kv').get() as { count: number } - ).count; - - expect(secretCount).toBe(0); - expect(kvCount).toBe(0); - }); - - it('does not overwrite an existing app ssh password on dedup remap', async () => { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-auth-port-ssh-dedup-')); - tempDirs.push(userDataDir); - - const { appSqlite, appDb } = createAppDbWithConfigTables(); - openDbs.push(appSqlite); - - appSqlite - .prepare('INSERT INTO app_secrets (key, secret) VALUES (?, ?)') - .run('ssh:ssh-app-1:password', Buffer.from('enc:existing_pwd', 'utf8').toString('base64')); - - const secretMap = new Map([['emdash-ssh:legacy-ssh-1:password', 'legacy_pwd']]); - - const summary = await portLegacyAuthState(userDataDir, { - appDb, - appSqlite, - readLegacySecret: async (service, account) => secretMap.get(`${service}:${account}`) ?? null, - encryptSecret: (secret) => Buffer.from(`enc:${secret}`, 'utf8').toString('base64'), - legacyToAppSshConnectionId: new Map([['legacy-ssh-1', 'ssh-app-1']]), - writeKv: async (namespace, key, value) => { - upsertKv(appSqlite, namespace, key, value); - }, - secretsStore: { - async setEncryptedSecret(key, encryptedSecret) { - upsertSecret(appSqlite, key, encryptedSecret); - }, - }, - }); - - expect(summary.importedSshPasswords).toBe(0); - expect(summary.skipped).toContain('ssh.password:ssh-app-1:already-present'); - expect(readSecret(appSqlite, 'ssh:ssh-app-1:password')).toBe( - Buffer.from('enc:existing_pwd', 'utf8').toString('base64') - ); - }); -}); diff --git a/src/main/db/legacy-port/importers/auth/importer.ts b/src/main/db/legacy-port/importers/auth/importer.ts deleted file mode 100644 index 3c169021cf..0000000000 --- a/src/main/db/legacy-port/importers/auth/importer.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { execFile } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import type Database from 'better-sqlite3'; -import { eq } from 'drizzle-orm'; -import { appSecrets, kv } from '@main/db/schema'; -import { tableExists } from '../../sqlite-utils'; -import type { RelationalImportDb } from '../relational/types'; - -const execFileAsync = promisify(execFile); - -type LegacySecretSpec = { - label: string; - legacyService: string; - legacyAccount: string; - appSecretKey: string; -}; - -type KvWrite = { - namespace: string; - key: string; - value: unknown; - label: string; -}; - -type SecretWrite = { - key: string; - encryptedSecret: string; - label: string; -}; - -type LegacyAccountProfile = { - hasAccount: boolean; - userId: string; - username: string; - avatarUrl: string; - email: string; - lastValidated: string; -}; - -export type LegacySecretReader = (service: string, account: string) => Promise; -export type LegacySecretEncryptor = (secret: string) => string | null | Promise; - -export type PortLegacyAuthStateOptions = { - appDb: RelationalImportDb; - appSqlite: Database.Database; - legacyToAppSshConnectionId?: ReadonlyMap; - readLegacySecret?: LegacySecretReader; - encryptSecret?: LegacySecretEncryptor; - writeKv?: (namespace: string, key: string, value: unknown) => Promise; - secretsStore?: { - setEncryptedSecret(key: string, encryptedSecret: string): Promise; - }; -}; - -export type LegacyAuthPortSummary = { - importedSecrets: string[]; - importedKv: string[]; - importedSshPasswords: number; - skipped: string[]; -}; - -const LEGACY_SECRET_SPECS: LegacySecretSpec[] = [ - { - label: 'github', - legacyService: 'emdash-github', - legacyAccount: 'github-token', - appSecretKey: 'emdash-github-token', - }, - { - label: 'linear', - legacyService: 'emdash-linear', - legacyAccount: 'api-token', - appSecretKey: 'emdash-linear-token', - }, - { - label: 'jira', - legacyService: 'emdash-jira', - legacyAccount: 'api-token', - appSecretKey: 'emdash-jira-token', - }, - { - label: 'plain', - legacyService: 'emdash-plain', - legacyAccount: 'api-token', - appSecretKey: 'emdash-plain-token', - }, - { - label: 'forgejo', - legacyService: 'emdash-forgejo', - legacyAccount: 'forgejo-token', - appSecretKey: 'emdash-forgejo-token', - }, - { - label: 'gitlab', - legacyService: 'emdash-gitlab', - legacyAccount: 'gitlab-token', - appSecretKey: 'emdash-gitlab-token', - }, - { - label: 'account', - legacyService: 'emdash-account', - legacyAccount: 'session-token', - appSecretKey: 'emdash-account-token', - }, -]; - -async function hasStoredSecret(appDb: RelationalImportDb, key: string): Promise { - const [row] = await appDb - .select({ key: appSecrets.key }) - .from(appSecrets) - .where(eq(appSecrets.key, key)) - .limit(1) - .execute(); - return Boolean(row); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -type JsonReadResult = { kind: 'missing' } | { kind: 'invalid' } | { kind: 'ok'; value: unknown }; - -function readJsonFile(filePath: string): JsonReadResult { - try { - if (!existsSync(filePath)) return { kind: 'missing' }; - return { kind: 'ok', value: JSON.parse(readFileSync(filePath, 'utf8')) as unknown }; - } catch { - return { kind: 'invalid' }; - } -} - -function normalizeHostedInstanceUrl(instanceUrl: string): string | null { - const trimmed = instanceUrl.trim(); - if (!trimmed) return null; - - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return null; - } - if (parsed.search || parsed.hash) { - return null; - } - - const pathname = parsed.pathname.replace(/\/+$/, ''); - return pathname && pathname !== '/' - ? `${parsed.protocol}//${parsed.host}${pathname}` - : `${parsed.protocol}//${parsed.host}`; - } catch { - return null; - } -} - -function parseJiraCreds(raw: unknown): { siteUrl: string; email: string } | null { - if (!isRecord(raw)) return null; - const siteUrl = readTrimmedString(raw.siteUrl); - const email = readTrimmedString(raw.email); - if (!siteUrl || !email) return null; - return { siteUrl, email }; -} - -function parseHostedConnection(raw: unknown): { instanceUrl: string } | null { - if (!isRecord(raw)) return null; - - const siteUrl = - readTrimmedString(raw.instanceUrl) ?? - readTrimmedString(raw.siteUrl) ?? - readTrimmedString(raw.url); - if (!siteUrl) return null; - - const instanceUrl = normalizeHostedInstanceUrl(siteUrl); - if (!instanceUrl) return null; - - return { instanceUrl }; -} - -function parseLegacyAccountProfile(raw: unknown): LegacyAccountProfile | null { - if (!isRecord(raw)) return null; - if (typeof raw.hasAccount !== 'boolean') return null; - - const userId = readTrimmedString(raw.userId); - const username = readTrimmedString(raw.username); - const avatarUrl = readTrimmedString(raw.avatarUrl); - const email = readTrimmedString(raw.email); - const lastValidated = readTrimmedString(raw.lastValidated) ?? new Date().toISOString(); - - if (!userId || !username || !avatarUrl || !email) return null; - - return { - hasAccount: raw.hasAccount, - userId, - username, - avatarUrl, - email, - lastValidated, - }; -} - -async function defaultReadLegacySecret(service: string, account: string): Promise { - if (process.platform === 'darwin') { - try { - const { stdout } = await execFileAsync('security', [ - 'find-generic-password', - '-s', - service, - '-a', - account, - '-w', - ]); - const secret = stdout.trim(); - return secret.length > 0 ? secret : null; - } catch { - return null; - } - } - - if (process.platform === 'linux') { - try { - const { stdout } = await execFileAsync('secret-tool', [ - 'lookup', - 'service', - service, - 'account', - account, - ]); - const secret = stdout.trim(); - return secret.length > 0 ? secret : null; - } catch { - return null; - } - } - - return null; -} - -type SafeStorageLike = { - isEncryptionAvailable: () => boolean; - encryptString: (secret: string) => Buffer; - getSelectedStorageBackend?: () => string; -}; - -async function createDefaultEncryptor(): Promise { - if (!process.versions.electron) { - return null; - } - - try { - const electron = (await import('electron')) as { safeStorage?: SafeStorageLike }; - const safeStorage = electron.safeStorage; - if (!safeStorage || !safeStorage.isEncryptionAvailable()) { - return null; - } - - if ( - process.platform === 'linux' && - typeof safeStorage.getSelectedStorageBackend === 'function' && - safeStorage.getSelectedStorageBackend() === 'basic_text' - ) { - return null; - } - - return (secret: string) => safeStorage.encryptString(secret).toString('base64'); - } catch { - return null; - } -} - -function addKvWrite( - kvWrites: KvWrite[], - summary: LegacyAuthPortSummary, - hasKvTable: boolean, - write: KvWrite -): void { - if (!hasKvTable) { - summary.skipped.push(`${write.label}:kv-table-missing`); - return; - } - kvWrites.push(write); - summary.importedKv.push(write.label); -} - -export async function portLegacyAuthState( - userDataPath: string, - options: PortLegacyAuthStateOptions -): Promise { - const { appDb, appSqlite, legacyToAppSshConnectionId } = options; - const summary: LegacyAuthPortSummary = { - importedSecrets: [], - importedKv: [], - importedSshPasswords: 0, - skipped: [], - }; - - const hasKvTable = tableExists(appSqlite, 'kv'); - const hasSecretsTable = tableExists(appSqlite, 'app_secrets'); - - if (!hasKvTable && !hasSecretsTable) { - summary.skipped.push('auth-port:missing-kv-and-app-secrets-tables'); - return summary; - } - - const readLegacySecret = options.readLegacySecret ?? defaultReadLegacySecret; - const encryptSecret = options.encryptSecret ?? (await createDefaultEncryptor()); - const writeKv = - options.writeKv ?? - (async (namespace: string, key: string, value: unknown) => { - const namespaceKey = `${namespace}:${key}`; - const serialized = JSON.stringify(value); - const now = Date.now(); - - await appDb - .insert(kv) - .values({ key: namespaceKey, value: serialized, updatedAt: now }) - .onConflictDoUpdate({ - target: kv.key, - set: { value: serialized, updatedAt: now }, - }) - .execute(); - }); - - const secretWrites: SecretWrite[] = []; - const kvWrites: KvWrite[] = []; - - if (hasSecretsTable && encryptSecret) { - for (const spec of LEGACY_SECRET_SPECS) { - const rawSecret = await readLegacySecret(spec.legacyService, spec.legacyAccount); - const secret = readTrimmedString(rawSecret); - - if (!secret) { - continue; - } - - const encryptedSecret = await encryptSecret(secret); - if (!encryptedSecret) { - summary.skipped.push(`${spec.label}:secret-encryption-failed`); - continue; - } - - secretWrites.push({ key: spec.appSecretKey, encryptedSecret, label: spec.label }); - summary.importedSecrets.push(spec.label); - } - - if (legacyToAppSshConnectionId && legacyToAppSshConnectionId.size > 0) { - const migratedTargetIds = new Set(); - - for (const [legacyConnectionId, appConnectionId] of legacyToAppSshConnectionId.entries()) { - if (migratedTargetIds.has(appConnectionId)) { - summary.skipped.push(`ssh.password:${appConnectionId}:duplicate-target`); - continue; - } - - const targetKey = `ssh:${appConnectionId}:password`; - if (await hasStoredSecret(appDb, targetKey)) { - summary.skipped.push(`ssh.password:${appConnectionId}:already-present`); - continue; - } - - const rawPassword = await readLegacySecret('emdash-ssh', `${legacyConnectionId}:password`); - const password = readTrimmedString(rawPassword); - if (!password) { - continue; - } - - const encryptedSecret = await encryptSecret(password); - if (!encryptedSecret) { - summary.skipped.push(`ssh.password:${appConnectionId}:secret-encryption-failed`); - continue; - } - - secretWrites.push({ - key: targetKey, - encryptedSecret, - label: `ssh.password:${appConnectionId}`, - }); - migratedTargetIds.add(appConnectionId); - summary.importedSshPasswords += 1; - } - } - } else if (!hasSecretsTable) { - summary.skipped.push('auth-port:app-secrets-table-missing'); - } else { - summary.skipped.push('auth-port:secret-encryption-unavailable'); - } - - if (summary.importedSecrets.includes('github')) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'github', - key: 'tokenSource', - value: 'secure_storage', - label: 'github.tokenSource', - }); - } - - const jiraResult = readJsonFile(join(userDataPath, 'jira.json')); - if (jiraResult.kind === 'invalid') { - summary.skipped.push('jira.creds:invalid-json'); - } - const jiraCreds = jiraResult.kind === 'ok' ? parseJiraCreds(jiraResult.value) : null; - if (jiraCreds) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'jira', - key: 'creds', - value: jiraCreds, - label: 'jira.creds', - }); - } - - const forgejoResult = readJsonFile(join(userDataPath, 'forgejo.json')); - if (forgejoResult.kind === 'invalid') { - summary.skipped.push('forgejo.connection:invalid-json'); - } - const forgejoConnection = - forgejoResult.kind === 'ok' ? parseHostedConnection(forgejoResult.value) : null; - if (forgejoConnection) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'forgejo', - key: 'connection', - value: forgejoConnection, - label: 'forgejo.connection', - }); - } - - const gitlabResult = readJsonFile(join(userDataPath, 'gitlab.json')); - if (gitlabResult.kind === 'invalid') { - summary.skipped.push('gitlab.connection:invalid-json'); - } - const gitlabConnection = - gitlabResult.kind === 'ok' ? parseHostedConnection(gitlabResult.value) : null; - if (gitlabConnection) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'gitlab', - key: 'connection', - value: gitlabConnection, - label: 'gitlab.connection', - }); - } - - const accountResult = readJsonFile(join(userDataPath, 'emdash-account.json')); - if (accountResult.kind === 'invalid') { - summary.skipped.push('account.profile:invalid-json'); - } - const accountProfile = - accountResult.kind === 'ok' ? parseLegacyAccountProfile(accountResult.value) : null; - if (accountProfile) { - addKvWrite(kvWrites, summary, hasKvTable, { - namespace: 'account', - key: 'profile', - value: accountProfile, - label: 'account.profile', - }); - } - - if (secretWrites.length === 0 && kvWrites.length === 0) { - return summary; - } - - const secretsStore = - options.secretsStore ?? - (hasSecretsTable - ? new ( - await import('@main/core/secrets/encrypted-app-secrets-store') - ).EncryptedAppSecretsStore(appDb) - : null); - - for (const row of secretWrites) { - await secretsStore?.setEncryptedSecret(row.key, row.encryptedSecret); - } - - for (const row of kvWrites) { - await writeKv(row.namespace, row.key, row.value); - } - - return summary; -} From ffc936761a0233523d1e217ec1dcff6362386f17 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:21:01 +0200 Subject: [PATCH 031/263] fix(onboarding): clarify start fresh flow --- .../features/onboarding/import-step.tsx | 26 +++++++++---------- .../features/onboarding/onboarding-shell.tsx | 13 ++++------ src/renderer/lib/hooks/useLegacyPort.ts | 4 +-- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 7505804e3d..2207faab6f 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -4,7 +4,7 @@ import { useImportProgress } from '@renderer/lib/hooks/useImportProgress'; import { useLegacyPortImport, useLegacyPortPreview, - useLegacyPortSkip, + useLegacyPortStartFresh, } from '@renderer/lib/hooks/useLegacyPort'; import { Button } from '@renderer/lib/ui/button'; import { ImportHeader } from './components/import-header'; @@ -32,7 +32,7 @@ function toggleSourceSelection( export function ImportStep({ onComplete }: { onComplete: () => void }) { const { data: preview, isLoading: previewLoading } = useLegacyPortPreview(true); const importMutation = useLegacyPortImport(); - const skipMutation = useLegacyPortSkip(); + const startFreshMutation = useLegacyPortStartFresh(); const importProgress = useImportProgress(); const sourceOptions = useMemo(() => availableSources(preview), [preview]); @@ -42,7 +42,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { const [conflictChoiceOverrides, setConflictChoiceOverrides] = useState< Record >({}); - const [skipError, setSkipError] = useState(null); + const [startFreshError, setStartFreshError] = useState(null); const selectedSources = selectedSourcesOverride ?? sourceOptions; const visibleConflicts = useMemo(() => { @@ -68,7 +68,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { }; const handleImport = async () => { - setSkipError(null); + setStartFreshError(null); const conflictChoices = Object.fromEntries( visibleConflicts.map((conflict) => [ conflict.identityKey, @@ -86,22 +86,22 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { ); }; - const handleSkip = async () => { - setSkipError(null); + const handleStartFresh = async () => { + setStartFreshError(null); importProgress.clearError(); try { - const result = await skipMutation.mutateAsync(); + const result = await startFreshMutation.mutateAsync(); if (!result.success) { - setSkipError(result.error ?? 'Skip failed'); + setStartFreshError(result.error ?? 'Start fresh failed'); return; } onComplete(); } catch (err) { - setSkipError(err instanceof Error ? err.message : 'Skip failed'); + setStartFreshError(err instanceof Error ? err.message : 'Start fresh failed'); } }; - const isBusy = importProgress.isImporting || skipMutation.isPending; + const isBusy = importProgress.isImporting || startFreshMutation.isPending; return (
@@ -128,14 +128,14 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { {importProgress.error && (

{importProgress.error}

)} - {skipError &&

{skipError}

} + {startFreshError &&

{startFreshError}

}
-
diff --git a/src/renderer/features/onboarding/onboarding-shell.tsx b/src/renderer/features/onboarding/onboarding-shell.tsx index 0802cc7e72..4b37f841ea 100644 --- a/src/renderer/features/onboarding/onboarding-shell.tsx +++ b/src/renderer/features/onboarding/onboarding-shell.tsx @@ -22,25 +22,23 @@ const stepConfig: Record< function StepHeader({ label, isActive, - onClick, isLast, }: { label: string; isActive: boolean; - onClick: () => void; isLast: boolean; }) { return ( - +
); } @@ -73,7 +71,6 @@ export function OnboardingShell({ label={stepConfig[step].label} isLast={index === steps.length - 1} isActive={step === activeStep} - onClick={() => setActiveIndex(index)} /> ))}
diff --git a/src/renderer/lib/hooks/useLegacyPort.ts b/src/renderer/lib/hooks/useLegacyPort.ts index 82507189f5..3a07dd6619 100644 --- a/src/renderer/lib/hooks/useLegacyPort.ts +++ b/src/renderer/lib/hooks/useLegacyPort.ts @@ -35,10 +35,10 @@ export function useLegacyPortImport() { }); } -export function useLegacyPortSkip() { +export function useLegacyPortStartFresh() { const queryClient = useQueryClient(); return useMutation({ - // An explicit empty source list means "skip import and start fresh". + // An explicit empty source list means "start fresh". mutationFn: () => rpc.legacyPort.runImport({ sources: [] }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: [...LEGACY_PORT_STATUS_KEY] }); From fd2fad9d8af56414b7f30e82b2820809d03c668e Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:26:23 +0200 Subject: [PATCH 032/263] fix: disable buttons while import in progress --- .../components/import-source-selector.tsx | 10 +++++++++- .../onboarding/components/project-conflicts.tsx | 13 ++++++++++++- src/renderer/features/onboarding/import-step.tsx | 2 ++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/renderer/features/onboarding/components/import-source-selector.tsx b/src/renderer/features/onboarding/components/import-source-selector.tsx index 40262d53c5..06102c7a1b 100644 --- a/src/renderer/features/onboarding/components/import-source-selector.tsx +++ b/src/renderer/features/onboarding/components/import-source-selector.tsx @@ -7,23 +7,27 @@ function SourceCard({ source, preview, selected, + disabled, onToggle, }: { source: LegacyImportSource; preview: LegacyPortPreviewSource; selected: boolean; + disabled: boolean; onToggle: () => void; }) { return (
@@ -77,10 +85,12 @@ function ConflictCard({ export function ProjectConflicts({ conflicts, choices, + disabled = false, onChoiceChange, }: { conflicts: LegacyProjectConflict[]; choices: Record; + disabled?: boolean; onChoiceChange: (identityKey: string, source: LegacyImportSource) => void; }) { if (conflicts.length === 0) return null; @@ -99,6 +109,7 @@ export function ProjectConflicts({ key={conflict.identityKey} conflict={conflict} selectedSource={choices[conflict.identityKey] ?? 'v1-beta'} + disabled={disabled} onSelect={(source) => onChoiceChange(conflict.identityKey, source)} /> ))} diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 2207faab6f..53fbb7ed55 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -113,6 +113,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { v0Preview={v0Preview} betaPreview={betaPreview} selectedSources={selectedSources} + disabled={importProgress.isImporting} onToggle={toggleSource} /> )} @@ -120,6 +121,7 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { From 94e9df1dc146957f87bf6d1e5305e37683fe6da2 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 16:37:14 +0200 Subject: [PATCH 033/263] fix: single db source import screen --- .../onboarding/components/import-header.tsx | 40 +++++++++++++++---- .../features/onboarding/import-state.ts | 25 ++++++++++++ .../features/onboarding/import-step.tsx | 38 +++++++++++++----- 3 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 src/renderer/features/onboarding/import-state.ts diff --git a/src/renderer/features/onboarding/components/import-header.tsx b/src/renderer/features/onboarding/components/import-header.tsx index 14c7863b0b..b5437d5dd3 100644 --- a/src/renderer/features/onboarding/components/import-header.tsx +++ b/src/renderer/features/onboarding/components/import-header.tsx @@ -1,22 +1,48 @@ import { Import } from 'lucide-react'; +import type { LegacyImportSource, LegacyPortPreviewSource } from '@shared/legacy-port'; +import { formatCount, sourceLabel } from './import-format'; + +export type SingleSourceImport = { + source: LegacyImportSource; + preview: LegacyPortPreviewSource; +}; + +export type ImportHeaderProps = { + isLoading: boolean; + singleSource?: SingleSourceImport | null; +}; + +function singleSourceTitle(source: LegacyImportSource): string { + return `Import your Emdash ${sourceLabel(source)} data`; +} + +function singleSourceDescription(preview: LegacyPortPreviewSource): string { + return `Found ${formatCount(preview.projects, 'project')} and ${formatCount( + preview.tasks, + 'task' + )} from your previous Emdash installation`; +} + +export function ImportHeader({ isLoading, singleSource = null }: ImportHeaderProps) { + const title = singleSource + ? singleSourceTitle(singleSource.source) + : 'Do you want to import projects and tasks from other Emdash versions?'; + const description = singleSource + ? singleSourceDescription(singleSource.preview) + : 'Select one or more sources.'; -export function ImportHeader({ isLoading }: { isLoading: boolean }) { return (
-

- Do you want to import projects and tasks from other Emdash versions? -

+

{title}

{isLoading ? (

Scanning existing Emdash data...

) : ( -

- Select one or more sources. Conflicting projects can be resolved before import. -

+

{description}

)}
diff --git a/src/renderer/features/onboarding/import-state.ts b/src/renderer/features/onboarding/import-state.ts new file mode 100644 index 0000000000..a4b2a0fd5a --- /dev/null +++ b/src/renderer/features/onboarding/import-state.ts @@ -0,0 +1,25 @@ +import type { LegacyImportSource, LegacyPortPreview } from '@shared/legacy-port'; + +export type ImportStepPreview = LegacyPortPreview; + +export function availableSources(preview: ImportStepPreview | undefined): LegacyImportSource[] { + const sources: LegacyImportSource[] = []; + if (preview?.sources.v0.available) sources.push('v0'); + if (preview?.sources.v1Beta.available) sources.push('v1-beta'); + return sources; +} + +export function shouldShowSourceSelector(preview: ImportStepPreview | undefined): boolean { + return availableSources(preview).length > 1; +} + +export function singleAvailableSource( + preview: ImportStepPreview | undefined +): LegacyImportSource | null { + const sources = availableSources(preview); + return sources.length === 1 ? sources[0] : null; +} + +export function shouldCenterImportContent(preview: ImportStepPreview | undefined): boolean { + return singleAvailableSource(preview) !== null; +} diff --git a/src/renderer/features/onboarding/import-step.tsx b/src/renderer/features/onboarding/import-step.tsx index 53fbb7ed55..e5f0ec18c5 100644 --- a/src/renderer/features/onboarding/import-step.tsx +++ b/src/renderer/features/onboarding/import-step.tsx @@ -7,17 +7,17 @@ import { useLegacyPortStartFresh, } from '@renderer/lib/hooks/useLegacyPort'; import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; import { ImportHeader } from './components/import-header'; import { ImportProgress } from './components/import-progress'; import { ImportSourceSelector } from './components/import-source-selector'; import { ProjectConflicts } from './components/project-conflicts'; - -function availableSources(preview: ReturnType['data']) { - const sources: LegacyImportSource[] = []; - if (preview?.sources.v0.available) sources.push('v0'); - if (preview?.sources.v1Beta.available) sources.push('v1-beta'); - return sources; -} +import { + availableSources, + shouldCenterImportContent, + shouldShowSourceSelector, + singleAvailableSource, +} from './import-state'; function toggleSourceSelection( sources: LegacyImportSource[], @@ -53,6 +53,9 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { const v0Preview = preview?.sources.v0 ?? { available: false, projects: 0, tasks: 0 }; const betaPreview = preview?.sources.v1Beta ?? { available: false, projects: 0, tasks: 0 }; const canImport = selectedSources.length > 0 && !previewLoading; + const singleSource = singleAvailableSource(preview); + const showSourceSelector = shouldShowSourceSelector(preview); + const centerContent = shouldCenterImportContent(preview); const toggleSource = (source: LegacyImportSource) => { setSelectedSourcesOverride((current) => @@ -104,10 +107,25 @@ export function ImportStep({ onComplete }: { onComplete: () => void }) { const isBusy = importProgress.isImporting || startFreshMutation.isPending; return ( -
- +
+ - {!previewLoading && ( + {!previewLoading && showSourceSelector && ( Date: Mon, 27 Apr 2026 16:33:30 +0200 Subject: [PATCH 034/263] Fix PR merge line status text --- .../lib/components/pr-merge-line.test.ts | 16 ++++++++++++++++ src/renderer/lib/components/pr-merge-line.tsx | 14 +++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/renderer/lib/components/pr-merge-line.test.ts diff --git a/src/renderer/lib/components/pr-merge-line.test.ts b/src/renderer/lib/components/pr-merge-line.test.ts new file mode 100644 index 0000000000..125185cd25 --- /dev/null +++ b/src/renderer/lib/components/pr-merge-line.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getPrMergeLineActionText } from './pr-merge-line'; + +describe('getPrMergeLineActionText', () => { + it('uses open PR wording while the PR can still be merged', () => { + expect(getPrMergeLineActionText('open')).toBe('wants to merge into'); + }); + + it('uses merged wording after the PR is merged', () => { + expect(getPrMergeLineActionText('merged')).toBe('merged into'); + }); + + it('distinguishes closed unmerged PRs from merged PRs', () => { + expect(getPrMergeLineActionText('closed')).toBe('was closed without merging into'); + }); +}); diff --git a/src/renderer/lib/components/pr-merge-line.tsx b/src/renderer/lib/components/pr-merge-line.tsx index 6b39b27617..505a5d3b09 100644 --- a/src/renderer/lib/components/pr-merge-line.tsx +++ b/src/renderer/lib/components/pr-merge-line.tsx @@ -13,12 +13,13 @@ export function PrMergeLine({ pr, className }: { pr: PullRequest; className?: st const baseBranch = pr.baseRefName; const headOwner = ownerFromUrl(pr.headRepositoryUrl) ?? author; const headBranch = pr.headRefName; + const actionText = getPrMergeLineActionText(pr.status); return (

{author && {author}} {author && ' '} - wants to merge into + {actionText} from @@ -26,6 +27,17 @@ export function PrMergeLine({ pr, className }: { pr: PullRequest; className?: st ); } +export function getPrMergeLineActionText(status: PullRequest['status']) { + switch (status) { + case 'merged': + return 'merged into'; + case 'closed': + return 'was closed without merging into'; + case 'open': + return 'wants to merge into'; + } +} + function PrBranchBadge({ owner, branch }: { owner?: string; branch: string }) { return ( From e9342c0d85fe626b33cee6af2a1093c28a00b27a Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 17:19:53 +0200 Subject: [PATCH 035/263] fix: ci release --- .github/workflows/release-canary.yml | 25 -------------------- .github/workflows/release-prod.yml | 23 ------------------- scripts/release/verify-mac.ts | 34 ++++++++++++---------------- 3 files changed, 14 insertions(+), 68 deletions(-) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 2157aa2b57..ae19471d7c 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -12,31 +12,6 @@ permissions: contents: read jobs: - build-mac: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-build - with: - posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - posthog-host: ${{ secrets.POSTHOG_HOST }} - build-variant: canary - - - run: pnpm run build - - - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Build unsigned DMGs - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: > - node --experimental-strip-types scripts/release/build.ts - --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg - --config electron-builder.canary.config.ts - - - name: Verify macOS bundle - run: node --experimental-strip-types scripts/release/verify-mac.ts - release-linux: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index e0044e98d8..97ef1f8da2 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -12,29 +12,6 @@ permissions: contents: read jobs: - build-mac: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-build - with: - posthog-key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} - posthog-host: ${{ secrets.POSTHOG_HOST }} - - - run: pnpm run build - - - run: echo "NODE_OPTIONS=${NODE_OPTIONS:-not set}" && node --version - - - name: Build unsigned DMGs - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: > - node --experimental-strip-types scripts/release/build.ts - --platform mac --arch ${{ github.event.inputs.arch || 'both' }} --targets dmg - - - name: Verify macOS bundle - run: node --experimental-strip-types scripts/release/verify-mac.ts - release-linux: if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest diff --git a/scripts/release/verify-mac.ts b/scripts/release/verify-mac.ts index 26a92b43a5..1d8290a508 100644 --- a/scripts/release/verify-mac.ts +++ b/scripts/release/verify-mac.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { parseArgs } from 'node:util'; -import { APP_BUNDLE, APP_ID, PRODUCT_NAME, RELEASE_DIR } from './lib/config.ts'; +import { RELEASE_DIR } from './lib/config.ts'; import { exec, execOrNull } from './lib/exec.ts'; import { fail, info, step, warn } from './lib/log.ts'; @@ -12,34 +12,39 @@ if (process.platform !== 'darwin') { const { values } = parseArgs({ options: { - 'smoke-test': { type: 'boolean', default: false }, 'expected-team-id': { type: 'string' }, }, strict: true, }); -const smokeTest = values['smoke-test'] ?? false; const expectedTeamId = values['expected-team-id']; -const macDirs = readdirSync(RELEASE_DIR) +const appBundles = readdirSync(RELEASE_DIR) .filter((d) => d.startsWith('mac')) - .map((d) => join(RELEASE_DIR, d, APP_BUNDLE)) + .flatMap((d) => { + const dir = join(RELEASE_DIR, d); + return readdirSync(dir) + .filter((f) => f.endsWith('.app')) + .map((f) => join(dir, f)); + }) .filter((p) => existsSync(p)); -if (macDirs.length === 0) { +if (appBundles.length === 0) { fail('No app bundles found to verify'); } let verified = 0; -for (const appDir of macDirs) { +for (const appDir of appBundles) { const archDir = appDir.split('/').at(-2)!; const expectedArch = archDir === 'mac-arm64' ? 'arm64' : archDir.startsWith('mac') ? 'x86_64' : null; + const productName = basename(appDir, '.app'); + step(`Verifying ${appDir} (expected: ${expectedArch ?? 'unknown'})`); - const electronBin = join(appDir, 'Contents', 'MacOS', PRODUCT_NAME); + const electronBin = join(appDir, 'Contents', 'MacOS', productName); const sqliteNode = join( appDir, 'Contents', @@ -70,14 +75,6 @@ for (const appDir of macDirs) { } } - if (smokeTest && archDir === 'mac-arm64') { - step('Smoke test sqlite3 (arm64)'); - exec( - `ELECTRON_RUN_AS_NODE=1 NODE_PATH="${appDir}/Contents/Resources/app.asar.unpacked/node_modules" "${electronBin}" -e "require('sqlite3'); console.log('sqlite3 OK')"`, - { echo: true } - ); - } - const plist = join(appDir, 'Contents', 'Info.plist'); if (existsSync(plist)) { const bid = @@ -86,9 +83,6 @@ for (const appDir of macDirs) { `plutil -extract CFBundleIdentifier xml1 -o - "${plist}" | sed -n 's/.*\\(.*\\)<\\/string>.*/\\1/p' | head -n1` ); info(`CFBundleIdentifier: ${bid}`); - if (bid !== APP_ID) { - fail(`CFBundleIdentifier mismatch (got '${bid}', expected '${APP_ID}')`); - } } exec(`codesign --verify --deep --strict --verbose=2 "${appDir}"`, { echo: true }); From 907e0bea35e14feb8c51f4cbb38840ee73ee79e1 Mon Sep 17 00:00:00 2001 From: David <78656334+Davidknp@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:22:06 +0200 Subject: [PATCH 036/263] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/renderer/lib/components/pr-merge-line.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/lib/components/pr-merge-line.tsx b/src/renderer/lib/components/pr-merge-line.tsx index 505a5d3b09..a495f2c9e4 100644 --- a/src/renderer/lib/components/pr-merge-line.tsx +++ b/src/renderer/lib/components/pr-merge-line.tsx @@ -32,7 +32,7 @@ export function getPrMergeLineActionText(status: PullRequest['status']) { case 'merged': return 'merged into'; case 'closed': - return 'was closed without merging into'; + return 'closed without merging into'; case 'open': return 'wants to merge into'; } From 50d4078680a38aff3051ea05214fb8e1a4009b82 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 18:03:39 +0200 Subject: [PATCH 037/263] fix: ci notarization script --- .github/workflows/release-canary.yml | 2 +- .github/workflows/release-prod.yml | 2 +- scripts/release/notarize-mac.ts | 22 ++++++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index ae19471d7c..d255285983 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -155,7 +155,7 @@ jobs: APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: node --experimental-strip-types scripts/release/notarize-mac.ts + run: node --experimental-strip-types scripts/release/notarize-mac.ts --app-bundle "Emdash Canary.app" - name: Upload to R2 uses: ./.github/actions/upload-r2 diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 97ef1f8da2..5e6bee27d6 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -148,7 +148,7 @@ jobs: APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: node --experimental-strip-types scripts/release/notarize-mac.ts + run: node --experimental-strip-types scripts/release/notarize-mac.ts --app-bundle "Emdash.app" - name: Upload to R2 uses: ./.github/actions/upload-r2 diff --git a/scripts/release/notarize-mac.ts b/scripts/release/notarize-mac.ts index 8c868be954..a439842b65 100644 --- a/scripts/release/notarize-mac.ts +++ b/scripts/release/notarize-mac.ts @@ -1,7 +1,8 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { APP_BUNDLE, RELEASE_DIR } from './lib/config.ts'; +import { parseArgs } from 'node:util'; +import { RELEASE_DIR } from './lib/config.ts'; import { exec } from './lib/exec.ts'; import { fail, info, step, warn } from './lib/log.ts'; @@ -10,6 +11,19 @@ if (process.platform !== 'darwin') { process.exit(0); } +const { values } = parseArgs({ + options: { + 'app-bundle': { type: 'string' }, + }, + strict: true, +}); + +if (!values['app-bundle']) { + fail('--app-bundle is required (e.g. --app-bundle "Emdash.app")'); +} + +const appBundle = values['app-bundle']; + const apiKeyPath = process.env.APPLE_API_KEY ?? process.env.APPLE_API_KEY_CONTENT; const apiKeyId = process.env.APPLE_API_KEY_ID; const apiIssuer = process.env.APPLE_API_ISSUER; @@ -50,7 +64,7 @@ for (const dmg of dmgs) { step('Staple app bundles'); const macDirs = readdirSync(RELEASE_DIR) .filter((d) => d.startsWith('mac')) - .map((d) => join(RELEASE_DIR, d, APP_BUNDLE)) + .map((d) => join(RELEASE_DIR, d, appBundle)) .filter((p) => existsSync(p)); for (const appDir of macDirs) { @@ -68,9 +82,9 @@ for (const dmg of dmgs) { const mnt = mkdtempSync(join(tmpdir(), 'dmg-')); try { exec(`hdiutil attach "${dmg}" -mountpoint "${mnt}" -nobrowse -quiet`, { echo: true }); - const appPath = join(mnt, APP_BUNDLE); + const appPath = join(mnt, appBundle); if (!existsSync(appPath)) { - fail(`No ${APP_BUNDLE} found inside ${dmg}`); + fail(`No ${appBundle} found inside ${dmg}`); } exec(`spctl -a -vv --type execute "${appPath}"`, { echo: true }); info(`Gatekeeper passed for ${dmg}`); From d950225211a5a54ee8ac9ece0d909cb86bd0196f Mon Sep 17 00:00:00 2001 From: David Konopka Date: Mon, 27 Apr 2026 18:04:34 +0200 Subject: [PATCH 038/263] chore: cleanup unused vars --- scripts/release/lib/config.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/release/lib/config.ts b/scripts/release/lib/config.ts index 958d87624f..806d8ebb05 100644 --- a/scripts/release/lib/config.ts +++ b/scripts/release/lib/config.ts @@ -1,5 +1,3 @@ -import { PRODUCT_NAME } from '../../../src/shared/app-identity.ts'; - export { APP_ID, APP_NAME_LOWER, @@ -9,8 +7,6 @@ export { UPDATE_CHANNEL, } from '../../../src/shared/app-identity.ts'; -export const APP_BUNDLE = `${PRODUCT_NAME}.app`; -export const APP_BINARY = PRODUCT_NAME; export const RELEASE_DIR = 'release'; export const NATIVE_MODULES = ['better-sqlite3', 'node-pty', '@parcel/watcher']; From 117a6a76b4b780695e1d29f7835dbc7b4ce168d6 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Mon, 27 Apr 2026 19:58:48 +0200 Subject: [PATCH 039/263] fix: review remarks --- src/main/db/legacy-port/beta-import.ts | 35 ++++-- .../db/legacy-port/destination-cleanup.ts | 47 +++++++- src/main/db/legacy-port/service.ts | 101 +++++++++++------- src/main/db/legacy-port/source-analysis.ts | 14 ++- 4 files changed, 145 insertions(+), 52 deletions(-) diff --git a/src/main/db/legacy-port/beta-import.ts b/src/main/db/legacy-port/beta-import.ts index 08a061158e..5a25f3f5e0 100644 --- a/src/main/db/legacy-port/beta-import.ts +++ b/src/main/db/legacy-port/beta-import.ts @@ -42,6 +42,31 @@ function copyTable(sqlite: Database.Database, tableName: string): void { .run(); } +function copyAttachedBetaTables(sqlite: Database.Database): void { + clearDestinationDataPreservingSignIn(sqlite); + for (const tableName of COPY_TABLE_ORDER) { + copyTable(sqlite, tableName); + } +} + +export function copyAttachedBetaDatabaseIntoDestination(sqlite: Database.Database): void { + copyAttachedBetaTables(sqlite); +} + +export async function withBetaDatabaseAttached( + sqlite: Database.Database, + betaDatabasePath: string, + action: () => Promise +): Promise { + sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); + + try { + return await action(); + } finally { + sqlite.exec('DETACH DATABASE beta'); + } +} + export function importBetaDatabaseIntoDestination( sqlite: Database.Database, betaDatabasePath: string @@ -50,13 +75,9 @@ export function importBetaDatabaseIntoDestination( sqlite.exec(`ATTACH DATABASE ${quoteSqliteString(betaDatabasePath)} AS beta`); try { - const copy = sqlite.transaction(() => { - clearDestinationDataPreservingSignIn(sqlite); - for (const tableName of COPY_TABLE_ORDER) { - copyTable(sqlite, tableName); - } - }); - copy(); + sqlite.transaction(() => { + copyAttachedBetaTables(sqlite); + })(); } finally { sqlite.exec('DETACH DATABASE beta'); } diff --git a/src/main/db/legacy-port/destination-cleanup.ts b/src/main/db/legacy-port/destination-cleanup.ts index 5d377d0bbb..ff0e3f80e1 100644 --- a/src/main/db/legacy-port/destination-cleanup.ts +++ b/src/main/db/legacy-port/destination-cleanup.ts @@ -1,5 +1,10 @@ import type Database from 'better-sqlite3'; -import { tableExists, withForeignKeysEnabled } from './sqlite-utils'; +import { tableExists } from './sqlite-utils'; + +function runDelete(sqlite: Database.Database, tableName: string, sql: string, ids: string[]): void { + if (!tableExists(sqlite, tableName)) return; + sqlite.prepare(sql).run(...ids); +} export function deleteProjectsById( sqlite: Database.Database, @@ -11,7 +16,41 @@ export function deleteProjectsById( const ids = [...projectIds]; const placeholders = ids.map(() => '?').join(', '); - withForeignKeysEnabled(sqlite, () => { - sqlite.prepare(`DELETE FROM projects WHERE id IN (${placeholders})`).run(...ids); - }); + if (tableExists(sqlite, 'conversations')) { + runDelete( + sqlite, + 'messages', + `DELETE FROM messages WHERE conversation_id IN ( + SELECT id FROM conversations WHERE project_id IN (${placeholders}) + )`, + ids + ); + } + + runDelete( + sqlite, + 'terminals', + `DELETE FROM terminals WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'conversations', + `DELETE FROM conversations WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'editor_buffers', + `DELETE FROM editor_buffers WHERE project_id IN (${placeholders})`, + ids + ); + runDelete( + sqlite, + 'project_remotes', + `DELETE FROM project_remotes WHERE project_id IN (${placeholders})`, + ids + ); + runDelete(sqlite, 'tasks', `DELETE FROM tasks WHERE project_id IN (${placeholders})`, ids); + sqlite.prepare(`DELETE FROM projects WHERE id IN (${placeholders})`).run(...ids); } diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index 9ec541854b..f58e86c53d 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -4,7 +4,11 @@ import type { LegacyImportSource } from '@shared/legacy-port'; import type { StartupDataGateStatus } from '@shared/startup-data-gate'; import { log } from '../../lib/logger'; import * as schema from '../schema'; -import { importBetaDatabaseIntoDestination } from './beta-import'; +import { + copyAttachedBetaDatabaseIntoDestination, + importBetaDatabaseIntoDestination, + withBetaDatabaseAttached, +} from './beta-import'; import { deleteProjectsById } from './destination-cleanup'; import { portConversations } from './importers/relational/conversations'; import { portProjects } from './importers/relational/projects'; @@ -128,7 +132,7 @@ export async function runLegacyPort( return; } - if (selectedSources.has('v1-beta')) { + if (selectedSources.has('v1-beta') && !selectedSources.has('v0')) { const betaPath = resolveBetaDatabasePath(userDataPath); if (hasBetaDatabaseFile(userDataPath)) { importBetaDatabaseIntoDestination(appTarget.sqlite, betaPath); @@ -163,49 +167,68 @@ export async function runLegacyPort( const start = Date.now(); - try { - const { sshSummary, projectsSummary, taskResult, conversationsSummary } = - await withAtomicDestinationImport(appTarget.sqlite, async () => { - const remap = createRemapTables(); - if (!selectedSources.has('v1-beta')) { - clearDestinationDataPreservingSignIn(appTarget.sqlite); + const betaPath = resolveBetaDatabasePath(userDataPath); + const shouldCopyBeta = selectedSources.has('v1-beta') && hasBetaDatabaseFile(userDataPath); + const runImport = async (): Promise<{ + sshSummary: PortSummary; + projectsSummary: PortSummary; + taskResult: Awaited>; + conversationsSummary: PortSummary; + }> => + await withAtomicDestinationImport(appTarget.sqlite, async () => { + const remap = createRemapTables(); + if (selectedSources.has('v1-beta')) { + if (shouldCopyBeta) { + copyAttachedBetaDatabaseIntoDestination(appTarget.sqlite); + } else { + log.warn('legacy-port: v1-beta source selected but emdash3.db was not found', { + betaPath, + }); } + } else { + clearDestinationDataPreservingSignIn(appTarget.sqlite); + } - const selection = await buildLegacyProjectSelection({ - appDb: appTarget.db, - legacyDb, - selectedSources, - conflictChoices: options.conflictChoices ?? {}, - }); + const selection = await buildLegacyProjectSelection({ + appDb: appTarget.db, + legacyDb, + selectedSources, + conflictChoices: options.conflictChoices ?? {}, + }); - if (selectedSources.has('v1-beta')) { - deleteProjectsById(appTarget.sqlite, selection.replaceAppProjectIds); - } + if (selectedSources.has('v1-beta')) { + deleteProjectsById(appTarget.sqlite, selection.replaceAppProjectIds); + } - const sshSummary = await portSshConnections({ - appDb: appTarget.db, - legacyDb, - remap, - allowedLegacyConnectionIds: selection.allowedLegacySshConnectionIds, - }); - const projectsSummary = await portProjects({ - appDb: appTarget.db, - legacyDb, - remap, - skipLegacyProjectIds: selection.skipLegacyProjectIds, - }); - const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); - const conversationsSummary = await portConversations({ - appDb: appTarget.db, - legacyDb, - remap, - mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, - userDataPath, - }); - - return { sshSummary, projectsSummary, taskResult, conversationsSummary }; + const sshSummary = await portSshConnections({ + appDb: appTarget.db, + legacyDb, + remap, + allowedLegacyConnectionIds: selection.allowedLegacySshConnectionIds, + }); + const projectsSummary = await portProjects({ + appDb: appTarget.db, + legacyDb, + remap, + skipLegacyProjectIds: selection.skipLegacyProjectIds, + }); + const taskResult = await portTasks({ appDb: appTarget.db, legacyDb, remap }); + const conversationsSummary = await portConversations({ + appDb: appTarget.db, + legacyDb, + remap, + mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, + userDataPath, }); + return { sshSummary, projectsSummary, taskResult, conversationsSummary }; + }); + + try { + const { sshSummary, projectsSummary, taskResult, conversationsSummary } = shouldCopyBeta + ? await withBetaDatabaseAttached(appTarget.sqlite, betaPath, runImport) + : await runImport(); + logSummary(sshSummary); logSummary(projectsSummary); logSummary(taskResult.summary); diff --git a/src/main/db/legacy-port/source-analysis.ts b/src/main/db/legacy-port/source-analysis.ts index 06cf74005b..fe0efa6021 100644 --- a/src/main/db/legacy-port/source-analysis.ts +++ b/src/main/db/legacy-port/source-analysis.ts @@ -7,7 +7,12 @@ import type { SourceProjectInfo, } from '@shared/legacy-port'; import { projectRemotes, projects, sshConnections, tasks } from '@main/db/schema'; -import { readLegacyRows, toInteger, toTrimmedString } from './importers/relational/helpers'; +import { + legacyTableExists, + readLegacyRows, + toInteger, + toTrimmedString, +} from './importers/relational/helpers'; import type { RelationalImportDb } from './importers/relational/types'; import { makeSshFingerprint, normalizePort } from './legacy-source/normalize'; import { @@ -15,6 +20,7 @@ import { localProjectIdentityKey, sshProjectIdentityKey, } from './legacy-source/project-identity'; +import { quoteIdentifier } from './sqlite-utils'; export type LegacyProjectSelection = { skipLegacyProjectIds: Set; @@ -70,7 +76,11 @@ function countLegacyTasksByProject(legacyDb: Database.Database): Map { From 97c099b59dfad9bbc3da1d57ca48232368a67437 Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Mon, 27 Apr 2026 15:14:04 -0700 Subject: [PATCH 040/263] Allow first click to focus terminal panes --- src/main/app/window.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/app/window.ts b/src/main/app/window.ts index a9c525a083..ed8f65a380 100644 --- a/src/main/app/window.ts +++ b/src/main/app/window.ts @@ -29,7 +29,11 @@ export function createMainWindow(): BrowserWindow { preload: join(__dirname, '../preload/index.mjs'), }, ...(process.platform === 'darwin' - ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 10, y: 10 } } + ? { + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 10, y: 10 }, + acceptFirstMouse: true, + } : {}), show: false, }); From 1266620d2dfd03c15237aa6e41b1b077b81391eb Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 09:54:18 +0200 Subject: [PATCH 041/263] fix(pty): resolve local spawns by platform --- .../conversations/impl/local-conversation.ts | 41 ++-- src/main/core/pty/local-pty.ts | 39 +--- src/main/core/pty/pty-spawn-platform.test.ts | 192 ++++++++++++++++++ src/main/core/pty/pty-spawn-platform.ts | 187 +++++++++++++++++ src/main/core/pty/spawn-utils.test.ts | 185 ++--------------- src/main/core/pty/spawn-utils.ts | 85 ++------ src/main/core/pty/tmux-session-name.ts | 9 + .../terminals/impl/local-terminal-provider.ts | 70 ++++--- 8 files changed, 497 insertions(+), 311 deletions(-) create mode 100644 src/main/core/pty/pty-spawn-platform.test.ts create mode 100644 src/main/core/pty/pty-spawn-platform.ts diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 715b2c6652..d304ce5571 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os'; import { getProvider } from '@shared/agent-provider-registry'; -import type { AgentSessionConfig } from '@shared/agent-session'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtyId } from '@shared/ptyId'; import { makePtySessionId } from '@shared/ptySessionId'; @@ -12,10 +11,10 @@ import { HookConfigWriter } from '@main/core/agent-hooks/hook-config'; import type { ConversationProvider } from '@main/core/conversations/types'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { buildAgentEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; -import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; +import { logLocalPtySpawnWarnings, resolveLocalPtySpawn } from '@main/core/pty/pty-spawn-platform'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { appSettingsService } from '@main/core/settings/settings-service'; import type { ExecFn } from '@main/core/utils/exec'; @@ -100,29 +99,31 @@ export class LocalConversationProvider implements ConversationProvider { const tmuxSessionName = this.tmux ? makeTmuxSessionName(sessionId) : undefined; - const cfg: AgentSessionConfig = { - taskId: this.taskId, - conversationId: conversation.id, - providerId: conversation.providerId, - command, - args, - cwd: this.taskPath, - shellSetup: this.shellSetup, - tmuxSessionName, - autoApprove: conversation.autoApprove ?? false, - resume: isResuming, - }; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent: { + kind: 'run-command', + cwd: this.taskPath, + command: { kind: 'argv', command, args }, + shellSetup: this.shellSetup, + tmuxSessionName, + }, + }); - const spawnParams = resolveSpawnParams('agent', cfg); + logLocalPtySpawnWarnings('LocalConversationProvider', resolved.warnings, { + conversationId: conversation.id, + sessionId, + }); const ptyId = makePtyId(conversation.providerId, conversation.id); const port = agentHookService.getPort(); const token = agentHookService.getToken(); const pty = spawnLocalPty({ id: sessionId, - command: spawnParams.command, - args: spawnParams.args, - cwd: this.taskPath, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: { ...buildAgentEnv({ hook: port > 0 ? { port, ptyId, token } : undefined, diff --git a/src/main/core/pty/local-pty.ts b/src/main/core/pty/local-pty.ts index f1b69af416..78cafcde1e 100644 --- a/src/main/core/pty/local-pty.ts +++ b/src/main/core/pty/local-pty.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import * as nodePty from 'node-pty'; import type { IPty } from 'node-pty'; import { log } from '@main/lib/logger'; @@ -18,19 +17,18 @@ const MIN_ROWS = 1; export function spawnLocalPty(options: LocalSpawnOptions): LocalPtySession { const { id, command, args, cwd, env, cols, rows } = options; - const spawnSpec = resolveWindowsPtySpawn(command, args); log.info('LocalPtySession:spawn', { id, - command: spawnSpec.command, - args: spawnSpec.args, + command, + args, cwd, cols, rows, }); try { - const proc = nodePty.spawn(spawnSpec.command, spawnSpec.args, { + const proc = nodePty.spawn(command, args, { name: 'xterm-256color', cols, rows, @@ -86,34 +84,3 @@ export class LocalPtySession implements Pty { }); } } - -function resolveWindowsPtySpawn( - command: string, - args: string[] -): { command: string; args: string[] } { - if (process.platform !== 'win32') return { command, args }; - - const quoteForCmdExe = (input: string): string => { - if (input.length === 0) return '""'; - if (!/[\s"^&|<>()%!]/.test(input)) return input; - return `"${input - .replace(/%/g, '%%') - .replace(/!/g, '^!') - .replace(/(["^&|<>()])/g, '^$1')}"`; - }; - - const ext = path.extname(command).toLowerCase(); - if (ext === '.cmd' || ext === '.bat') { - const comspec = process.env.ComSpec || String.raw`C:\\Windows\\System32\\cmd.exe`; - const fullCommandString = [command, ...args].map(quoteForCmdExe).join(' '); - return { command: comspec, args: ['/d', '/s', '/c', fullCommandString] }; - } - if (ext === '.ps1') { - return { - command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command, ...args], - }; - } - - return { command, args }; -} diff --git a/src/main/core/pty/pty-spawn-platform.test.ts b/src/main/core/pty/pty-spawn-platform.test.ts new file mode 100644 index 0000000000..c4944c8d7c --- /dev/null +++ b/src/main/core/pty/pty-spawn-platform.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { resolveLocalPtySpawn } from './pty-spawn-platform'; + +const winEnv = { + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD;.PS1', +} satisfies NodeJS.ProcessEnv; + +const posixEnv = { + SHELL: '/bin/bash', +} satisfies NodeJS.ProcessEnv; + +describe('resolveLocalPtySpawn - Windows', () => { + it('uses ComSpec for interactive shells without POSIX flags', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { kind: 'interactive-shell', cwd: 'C:\\repo' }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: [], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('direct-spawns argv commands when no Windows-unsupported shell features are present', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'node.exe', args: ['--version'] }, + }, + }); + + expect(result).toEqual({ + command: 'node.exe', + args: ['--version'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('wraps cmd and bat argv commands through cmd.exe', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'pnpm.cmd', args: ['run', 'dev'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'pnpm.cmd run dev'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('quotes cmd wrapper arguments that contain Windows metacharacters', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'tool.cmd', args: ['hello world', 'A&B'] }, + }, + }); + + expect(result.args).toEqual(['/d', '/s', '/c', 'tool.cmd "hello world" "A^&B"']); + }); + + it('wraps PowerShell scripts through powershell.exe -File', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'scripts\\setup.ps1', args: ['-Verbose'] }, + }, + }); + + expect(result).toEqual({ + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'scripts\\setup.ps1', '-Verbose'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('runs shell-line commands through cmd.exe /d /s /c', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'shell-line', commandLine: 'pnpm run dev' }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'pnpm run dev'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('returns warnings for ignored shellSetup and tmux on Windows', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: winEnv, + intent: { + kind: 'interactive-shell', + cwd: 'C:\\repo', + shellSetup: 'source ~/.nvm/nvm.sh', + tmuxSessionName: 'session-1', + }, + }); + + expect(result.warnings).toEqual([ + 'shell_setup_ignored_on_windows', + 'tmux_unsupported_on_windows', + ]); + }); +}); + +describe('resolveLocalPtySpawn - POSIX', () => { + it('uses SHELL -il for interactive shells', () => { + const result = resolveLocalPtySpawn({ + platform: 'darwin', + env: posixEnv, + intent: { kind: 'interactive-shell', cwd: '/repo' }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-il'], + cwd: '/repo', + warnings: [], + }); + }); + + it('quotes argv commands before shell wrapping', () => { + const result = resolveLocalPtySpawn({ + platform: 'linux', + env: posixEnv, + intent: { + kind: 'run-command', + cwd: '/repo', + command: { kind: 'argv', command: 'node', args: ['script name.js', "it's ok"] }, + }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-c', "node 'script name.js' 'it'\\''s ok'"], + cwd: '/repo', + warnings: [], + }); + }); + + it('prepends shellSetup to shell-line commands', () => { + const result = resolveLocalPtySpawn({ + platform: 'linux', + env: posixEnv, + intent: { + kind: 'run-command', + cwd: '/repo', + shellSetup: 'source ~/.nvm/nvm.sh', + command: { kind: 'shell-line', commandLine: 'pnpm run dev' }, + }, + }); + + expect(result).toEqual({ + command: '/bin/bash', + args: ['-c', 'source ~/.nvm/nvm.sh && pnpm run dev'], + cwd: '/repo', + warnings: [], + }); + }); +}); diff --git a/src/main/core/pty/pty-spawn-platform.ts b/src/main/core/pty/pty-spawn-platform.ts new file mode 100644 index 0000000000..0212a6bf36 --- /dev/null +++ b/src/main/core/pty/pty-spawn-platform.ts @@ -0,0 +1,187 @@ +import path from 'node:path'; +import { log } from '@main/lib/logger'; +import { buildTmuxShellLine } from './tmux-session-name'; + +export type PtyCommandSpec = + | { kind: 'argv'; command: string; args: string[] } + | { kind: 'shell-line'; commandLine: string }; + +export type PtySpawnIntent = + | { + kind: 'interactive-shell'; + cwd: string; + shellSetup?: string; + tmuxSessionName?: string; + } + | { + kind: 'run-command'; + cwd: string; + command: PtyCommandSpec; + shellSetup?: string; + tmuxSessionName?: string; + }; + +export type LocalPtySpawnWarning = 'shell_setup_ignored_on_windows' | 'tmux_unsupported_on_windows'; + +export type ResolvedLocalPtySpawn = { + command: string; + args: string[]; + cwd: string; + warnings: LocalPtySpawnWarning[]; +}; + +function getPosixShell(env: NodeJS.ProcessEnv): string { + return env.SHELL || '/bin/sh'; +} + +function getWindowsShell(env: NodeJS.ProcessEnv): string { + return env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'; +} + +function isWindows(platform: NodeJS.Platform): boolean { + return platform === 'win32'; +} + +function quotePosixArg(input: string): string { + if (input.length === 0) return "''"; + if (!/[\s'"\\$`\n\r\t;&|<>(){}[\]*?!]/.test(input)) return input; + return `'${input.replace(/'/g, "'\\''")}'`; +} + +function argvToPosixShellLine(command: string, args: string[]): string { + return [command, ...args].map(quotePosixArg).join(' '); +} + +function quoteForCmdExe(input: string): string { + if (input.length === 0) return '""'; + if (!/[\s"^&|<>()%!]/.test(input)) return input; + return `"${input + .replace(/%/g, '%%') + .replace(/!/g, '^!') + .replace(/(["^&|<>()])/g, '^$1')}"`; +} + +function windowsWarnings(intent: PtySpawnIntent): LocalPtySpawnWarning[] { + const warnings: LocalPtySpawnWarning[] = []; + if (intent.shellSetup) warnings.push('shell_setup_ignored_on_windows'); + if (intent.tmuxSessionName) warnings.push('tmux_unsupported_on_windows'); + return warnings; +} + +function resolveWindowsSpawn( + intent: PtySpawnIntent, + env: NodeJS.ProcessEnv +): ResolvedLocalPtySpawn { + const warnings = windowsWarnings(intent); + const shell = getWindowsShell(env); + + if (intent.kind === 'interactive-shell') { + return { command: shell, args: [], cwd: intent.cwd, warnings }; + } + + if (intent.command.kind === 'shell-line') { + return { + command: shell, + args: ['/d', '/s', '/c', intent.command.commandLine], + cwd: intent.cwd, + warnings, + }; + } + + const { command, args } = intent.command; + const ext = path.extname(command).toLowerCase(); + + if (ext === '.cmd' || ext === '.bat') { + return { + command: shell, + args: ['/d', '/s', '/c', [command, ...args].map(quoteForCmdExe).join(' ')], + cwd: intent.cwd, + warnings, + }; + } + + if (ext === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command, ...args], + cwd: intent.cwd, + warnings, + }; + } + + return { command, args, cwd: intent.cwd, warnings }; +} + +function resolvePosixSpawn(intent: PtySpawnIntent, env: NodeJS.ProcessEnv): ResolvedLocalPtySpawn { + const shell = getPosixShell(env); + + if (intent.kind === 'interactive-shell') { + if (intent.tmuxSessionName) { + const commandLine = intent.shellSetup + ? `${intent.shellSetup} && exec ${quotePosixArg(shell)} -il` + : `exec ${quotePosixArg(shell)} -il`; + return { + command: shell, + args: ['-c', buildTmuxShellLine(intent.tmuxSessionName, commandLine)], + cwd: intent.cwd, + warnings: [], + }; + } + + if (intent.shellSetup) { + return { + command: shell, + args: ['-c', `${intent.shellSetup} && exec ${quotePosixArg(shell)} -il`], + cwd: intent.cwd, + warnings: [], + }; + } + + return { command: shell, args: ['-il'], cwd: intent.cwd, warnings: [] }; + } + + const commandLine = + intent.command.kind === 'shell-line' + ? intent.command.commandLine + : argvToPosixShellLine(intent.command.command, intent.command.args); + const fullCommandLine = intent.shellSetup + ? `${intent.shellSetup} && ${commandLine}` + : commandLine; + + if (intent.tmuxSessionName) { + return { + command: shell, + args: ['-c', buildTmuxShellLine(intent.tmuxSessionName, fullCommandLine)], + cwd: intent.cwd, + warnings: [], + }; + } + + return { + command: shell, + args: ['-c', fullCommandLine], + cwd: intent.cwd, + warnings: [], + }; +} + +export function resolveLocalPtySpawn({ + intent, + platform, + env, +}: { + intent: PtySpawnIntent; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; +}): ResolvedLocalPtySpawn { + return isWindows(platform) ? resolveWindowsSpawn(intent, env) : resolvePosixSpawn(intent, env); +} + +export function logLocalPtySpawnWarnings( + source: string, + warnings: LocalPtySpawnWarning[], + context: Record +): void { + if (warnings.length === 0) return; + log.warn(`${source}: local PTY platform warning`, { ...context, warnings }); +} diff --git a/src/main/core/pty/spawn-utils.test.ts b/src/main/core/pty/spawn-utils.test.ts index 13c0eb22b4..cfd5a7d612 100644 --- a/src/main/core/pty/spawn-utils.test.ts +++ b/src/main/core/pty/spawn-utils.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { AgentSessionConfig } from '@shared/agent-session'; -import type { GeneralSessionConfig } from '@shared/general-session'; -import { buildTmuxParams, resolveSpawnParams, resolveSshCommand } from './spawn-utils'; - -const SHELL = '/bin/bash'; +import { resolveSshCommand } from './spawn-utils'; function makeAgentConfig(overrides: Partial = {}): AgentSessionConfig { return { @@ -19,172 +16,34 @@ function makeAgentConfig(overrides: Partial = {}): AgentSess }; } -function makeGeneralConfig(overrides: Partial = {}): GeneralSessionConfig { - return { - cwd: '/workspace', - ...overrides, - }; -} - -describe('resolveSpawnParams – agent type', () => { - it('no tmux, no shellSetup → shell -c with command joined', () => { - const config = makeAgentConfig(); - const result = resolveSpawnParams('agent', config); - - expect(result.cwd).toBe('/workspace'); - expect(result.command).toBe(process.env.SHELL ?? '/bin/sh'); - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('claude --resume conv-1'); - }); - - it('with shellSetup → shellSetup prepended with &&', () => { - const config = makeAgentConfig({ shellSetup: 'source ~/.nvm/nvm.sh' }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('source ~/.nvm/nvm.sh && claude --resume conv-1'); - }); - - it('with tmuxSessionName → tmux command contains has-session and session name', () => { - const config = makeAgentConfig({ tmuxSessionName: 'my-session' }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"my-session"'); - expect(cmd).toContain('tmux attach-session'); - }); - - it('with both shellSetup and tmuxSessionName → tmux command contains shellSetup', () => { - const config = makeAgentConfig({ - shellSetup: 'export NVM_DIR="$HOME/.nvm"', - tmuxSessionName: 'agent-session', - }); - const result = resolveSpawnParams('agent', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"agent-session"'); - expect(cmd).toContain('export NVM_DIR=\\"$HOME/.nvm\\"'); - expect(cmd).toContain('claude --resume conv-1'); - }); -}); - -describe('resolveSpawnParams – general type', () => { - it('no command, no shellSetup → shell -c exec shell -il', () => { - const config = makeGeneralConfig(); - const result = resolveSpawnParams('general', config); - - const shell = process.env.SHELL ?? '/bin/sh'; - expect(result.command).toBe(shell); - expect(result.args[0]).toBe('-il'); - expect(result.cwd).toBe('/workspace'); - }); - - it('with shellSetup → shell -c with shellSetup && exec shell -il', () => { - const config = makeGeneralConfig({ shellSetup: 'source /opt/homebrew/bin/brew shellenv' }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('source /opt/homebrew/bin/brew shellenv'); - expect(cmd).toContain('exec'); - expect(cmd).toContain('-il'); - }); - - it('with tmuxSessionName → tmux wrapping', () => { - const config = makeGeneralConfig({ tmuxSessionName: 'general-session' }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"general-session"'); - expect(cmd).toContain('tmux attach-session'); - }); - - it('with both shellSetup and tmuxSessionName → tmux command contains shellSetup', () => { - const config = makeGeneralConfig({ - shellSetup: 'eval "$(rbenv init -)"', - tmuxSessionName: 'ruby-session', - }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"ruby-session"'); - expect(cmd).toContain('rbenv init'); - }); - - it('with command → shell -c with the command instead of interactive shell', () => { - const config = makeGeneralConfig({ command: 'npm', args: ['install'] }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('npm install'); - }); - - it('with command and shellSetup → shellSetup prepended to command', () => { - const config = makeGeneralConfig({ command: 'npm', args: ['install'], shellSetup: 'nvm use' }); - const result = resolveSpawnParams('general', config); +describe('resolveSshCommand', () => { + it('runs remote commands through a login shell so PATH matches install/probe', () => { + const result = resolveSshCommand('agent', makeAgentConfig()); - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe('nvm use && npm install'); + expect(result).toBe(`bash -l -c 'cd "/workspace" && claude --resume conv-1'`); }); - it('with command and tmuxSessionName → tmux wrapping around the command', () => { - const config = makeGeneralConfig({ - command: 'npm', - args: ['install'], - tmuxSessionName: 'setup-session', + it('adds SSH env exports before the remote command', () => { + const result = resolveSshCommand('agent', makeAgentConfig(), { + FOO: 'bar', }); - const result = resolveSpawnParams('general', config); - - expect(result.args[0]).toBe('-c'); - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session'); - expect(cmd).toContain('"setup-session"'); - expect(cmd).toContain('npm install'); - }); -}); - -describe('buildTmuxParams', () => { - it('produces attach-or-create command with has-session, new-session -d, and attach-session', () => { - const result = buildTmuxParams(SHELL, 'my-tmux-session', 'claude --resume conv-42', '/tmp'); - - expect(result.command).toBe(SHELL); - expect(result.cwd).toBe('/tmp'); - expect(result.args[0]).toBe('-c'); - - const cmd = result.args[1]; - expect(cmd).toContain('tmux has-session -t "my-tmux-session"'); - expect(cmd).toContain('tmux new-session -d -s "my-tmux-session"'); - expect(cmd).toContain('tmux attach-session -t "my-tmux-session"'); - const attachCount = (cmd.match(/tmux attach-session/g) ?? []).length; - expect(attachCount).toBe(2); - }); - - it('JSON-encodes the session name and command', () => { - const result = buildTmuxParams(SHELL, 'session with spaces', 'echo hello', '/home/user'); - const cmd = result.args[1]; - expect(cmd).toContain('"session with spaces"'); - expect(cmd).toContain('"echo hello"'); + expect(result).toBe( + `bash -l -c 'cd "/workspace" && export FOO='\\''bar'\\''; claude --resume conv-1'` + ); }); - it('uses the provided cwd', () => { - const result = buildTmuxParams(SHELL, 'sess', 'cmd', '/custom/path'); - expect(result.cwd).toBe('/custom/path'); - }); -}); + it('preserves remote tmux wrapping for SSH commands', () => { + const result = resolveSshCommand( + 'agent', + makeAgentConfig({ + tmuxSessionName: 'agent-session', + }) + ); -describe('resolveSshCommand', () => { - it('runs remote commands through a login shell so PATH matches install/probe', () => { - const result = resolveSshCommand('agent', makeAgentConfig()); - - expect(result).toBe(`bash -l -c 'cd "/workspace" && claude --resume conv-1'`); + expect(result).toContain('tmux has-session -t "agent-session"'); + expect(result).toContain('tmux new-session -d -s "agent-session"'); + expect(result).toContain('tmux attach-session -t "agent-session"'); + expect(result).toContain('claude --resume conv-1'); }); }); diff --git a/src/main/core/pty/spawn-utils.ts b/src/main/core/pty/spawn-utils.ts index 9e12d39902..e1c48a8489 100644 --- a/src/main/core/pty/spawn-utils.ts +++ b/src/main/core/pty/spawn-utils.ts @@ -1,93 +1,43 @@ import type { AgentSessionConfig } from '@shared/agent-session'; import type { GeneralSessionConfig } from '@shared/general-session'; import { quoteShellArg } from '@main/utils/shellEscape'; +import { buildTmuxShellLine } from './tmux-session-name'; -export type SessionType = 'agent' | 'general' | 'lifecycle'; +export type SessionType = 'agent' | 'general'; export type SessionConfig = AgentSessionConfig | GeneralSessionConfig; -export interface SpawnParams { - command: string; - args: string[]; - cwd: string; -} - -/** - * Derive the executable, arguments, and working directory from a session config. - * Applies shellSetup and tmux wrapping where relevant. - */ -export function resolveSpawnParams(type: SessionType, config: SessionConfig): SpawnParams { +function posixShellLineForSsh( + type: SessionType, + config: SessionConfig +): { cwd: string; line: string } { const shell = process.env.SHELL ?? '/bin/sh'; switch (type) { case 'agent': { const cfg = config as AgentSessionConfig; const baseCmd = [cfg.command, ...cfg.args].join(' '); - const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; - - if (cfg.tmuxSessionName) { - return buildTmuxParams(shell, cfg.tmuxSessionName, fullCmd, cfg.cwd); - } - + const line = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; return { - command: shell, - args: ['-c', fullCmd], cwd: cfg.cwd, + line: cfg.tmuxSessionName ? buildTmuxShellLine(cfg.tmuxSessionName, line) : line, }; } - case 'general': { const cfg = config as GeneralSessionConfig; const baseCmd = cfg.command ? [cfg.command, ...(cfg.args ?? [])].join(' ') : `exec ${shell} -il`; - const fullCmd = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; - - if (cfg.tmuxSessionName) { - return buildTmuxParams(shell, cfg.tmuxSessionName, fullCmd, cfg.cwd); - } - - if (cfg.command || cfg.shellSetup) { - return { command: shell, args: ['-c', fullCmd], cwd: cfg.cwd }; - } - - return { command: shell, args: ['-il'], cwd: cfg.cwd }; + const line = cfg.shellSetup ? `${cfg.shellSetup} && ${baseCmd}` : baseCmd; + return { + cwd: cfg.cwd, + line: cfg.tmuxSessionName ? buildTmuxShellLine(cfg.tmuxSessionName, line) : line, + }; } - - default: { + default: throw new Error(`Unsupported session type: ${type}`); - } } } -/** - * Build spawn params that wrap a command in a tmux session for persistence. - * - * Behaviour: - * - If a tmux session named `sessionName` already exists → attach to it. - * - Otherwise → create a detached session running `cmd`, then attach. - */ -export function buildTmuxParams( - shell: string, - sessionName: string, - cmd: string, - cwd: string -): SpawnParams { - const quotedName = JSON.stringify(sessionName); - const quotedCmd = JSON.stringify(cmd); - - const checkExists = `tmux has-session -t ${quotedName} 2>/dev/null`; - const newSession = `tmux new-session -d -s ${quotedName} ${quotedCmd}`; - const attach = `tmux attach-session -t ${quotedName}`; - - const tmuxCmd = `(${checkExists} && ${attach}) || (${newSession} && ${attach})`; - - return { - command: shell, - args: ['-c', tmuxCmd], - cwd, - }; -} - /** * Build a single command string for SSH remote execution. */ @@ -96,12 +46,9 @@ export function resolveSshCommand( config: SessionConfig, envVars?: Record ): string { - const { command, args, cwd } = resolveSpawnParams(type, config); - const shell = process.env.SHELL ?? '/bin/sh'; - - const innerCmd = command === shell && args[0] === '-c' ? args[1] : [command, ...args].join(' '); + const { cwd, line } = posixShellLineForSsh(type, config); const envPrefix = envVars ? buildSshEnvPrefix(envVars) : ''; - const commandString = `cd ${JSON.stringify(cwd)} && ${envPrefix}${innerCmd}`; + const commandString = `cd ${JSON.stringify(cwd)} && ${envPrefix}${line}`; return `bash -l -c ${quoteShellArg(commandString)}`; } diff --git a/src/main/core/pty/tmux-session-name.ts b/src/main/core/pty/tmux-session-name.ts index ac93fa4f90..1e5932db72 100644 --- a/src/main/core/pty/tmux-session-name.ts +++ b/src/main/core/pty/tmux-session-name.ts @@ -3,6 +3,15 @@ import { log } from '@main/lib/logger'; const TMUX_SESSION_PREFIX = 'emdash-'; +export function buildTmuxShellLine(sessionName: string, commandLine: string): string { + const quotedName = JSON.stringify(sessionName); + const quotedCmd = JSON.stringify(commandLine); + const checkExists = `tmux has-session -t ${quotedName} 2>/dev/null`; + const newSession = `tmux new-session -d -s ${quotedName} ${quotedCmd}`; + const attach = `tmux attach-session -t ${quotedName}`; + return `(${checkExists} && ${attach}) || (${newSession} && ${attach})`; +} + export function makeTmuxSessionName(sessionId: string): string { const encoded = Buffer.from(sessionId, 'utf8').toString('base64url'); return `${TMUX_SESSION_PREFIX}${encoded}`; diff --git a/src/main/core/terminals/impl/local-terminal-provider.ts b/src/main/core/terminals/impl/local-terminal-provider.ts index 028af29044..533b4b2381 100644 --- a/src/main/core/terminals/impl/local-terminal-provider.ts +++ b/src/main/core/terminals/impl/local-terminal-provider.ts @@ -1,11 +1,15 @@ -import type { GeneralSessionConfig } from '@shared/general-session'; import { makePtySessionId } from '@shared/ptySessionId'; -import { Terminal } from '@shared/terminals'; +import type { Terminal } from '@shared/terminals'; import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { buildTerminalEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; -import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; +import { + logLocalPtySpawnWarnings, + resolveLocalPtySpawn, + type PtyCommandSpec, + type PtySpawnIntent, +} from '@main/core/pty/pty-spawn-platform'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; @@ -65,11 +69,16 @@ export class LocalTerminalProvider implements TerminalProvider { initialSize: { cols: number; rows: number } = { cols: DEFAULT_COLS, rows: DEFAULT_ROWS }, command?: { command: string; args: string[] } ): Promise { - return this.spawnWithPolicy(terminal, initialSize, command, { - respawnOnExit: true, - preserveBufferOnExit: false, - watchDevServer: true, - }); + return this.spawnWithPolicy( + terminal, + initialSize, + command ? { kind: 'argv', command: command.command, args: command.args } : undefined, + { + respawnOnExit: true, + preserveBufferOnExit: false, + watchDevServer: true, + } + ); } async spawnLifecycleScript({ @@ -83,7 +92,7 @@ export class LocalTerminalProvider implements TerminalProvider { return this.spawnWithPolicy( terminal, initialSize, - { command, args: [] }, + { kind: 'shell-line', commandLine: command }, { respawnOnExit, preserveBufferOnExit, @@ -95,28 +104,43 @@ export class LocalTerminalProvider implements TerminalProvider { private async spawnWithPolicy( terminal: Terminal, initialSize: { cols: number; rows: number }, - command: { command: string; args: string[] } | undefined, + command: PtyCommandSpec | undefined, policy: SpawnPolicy ): Promise { const sessionId = makePtySessionId(terminal.projectId, terminal.taskId, terminal.id); this.knownSessionIds.add(sessionId); if (this.sessions.has(sessionId)) return; - const cfg: GeneralSessionConfig = { - taskId: this.scopeId, - cwd: this.taskPath, - shellSetup: this.shellSetup, - tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, - command: command?.command, - args: command?.args, - }; - const params = resolveSpawnParams('general', cfg); + const intent: PtySpawnIntent = command + ? { + kind: 'run-command', + cwd: this.taskPath, + command, + shellSetup: this.shellSetup, + tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, + } + : { + kind: 'interactive-shell', + cwd: this.taskPath, + shellSetup: this.shellSetup, + tmuxSessionName: this.tmux ? makeTmuxSessionName(sessionId) : undefined, + }; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent, + }); + + logLocalPtySpawnWarnings('LocalTerminalProvider', resolved.warnings, { + terminalId: terminal.id, + sessionId, + }); const pty = spawnLocalPty({ id: sessionId, - command: params.command, - args: params.args, - cwd: this.taskPath, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: { ...buildTerminalEnv(), ...this.taskEnvVars }, cols: initialSize.cols, rows: initialSize.rows, From 02217369d1553ff2ff1eca69ca4e34ea70d6c197 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 09:55:49 +0200 Subject: [PATCH 042/263] fix(pty): avoid posix shell fallback on windows --- src/main/core/pty/pty-env.test.ts | 60 +++++++++++++++++++++++++++++++ src/main/core/pty/pty-env.ts | 18 +++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 src/main/core/pty/pty-env.test.ts diff --git a/src/main/core/pty/pty-env.test.ts b/src/main/core/pty/pty-env.test.ts new file mode 100644 index 0000000000..49d1902c37 --- /dev/null +++ b/src/main/core/pty/pty-env.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalEnv = { ...process.env }; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +async function loadPtyEnv() { + vi.resetModules(); + return import('./pty-env'); +} + +afterEach(() => { + process.env = { ...originalEnv }; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + vi.resetModules(); +}); + +describe('pty env Windows shell handling', () => { + it('does not synthesize /bin/bash as SHELL for Windows terminals', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const { buildTerminalEnv } = await loadPtyEnv(); + const env = buildTerminalEnv(); + + expect(env.SHELL).toBeUndefined(); + expect(env.ComSpec).toBe('C:\\Windows\\System32\\cmd.exe'); + }); + + it('does not synthesize /bin/bash when includeShellVar is true on Windows', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const { buildAgentEnv } = await loadPtyEnv(); + const env = buildAgentEnv({ includeShellVar: true, agentApiVars: false }); + + expect(env.SHELL).toBeUndefined(); + expect(env.ComSpec).toBe('C:\\Windows\\System32\\cmd.exe'); + }); + + it('keeps POSIX shell fallback for non-Windows terminal envs', async () => { + setPlatform('linux'); + delete process.env.SHELL; + + const { buildTerminalEnv } = await loadPtyEnv(); + const env = buildTerminalEnv(); + + expect(env.SHELL).toBe('/bin/bash'); + }); +}); diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index bb544d565e..91626a79ca 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -120,8 +120,9 @@ export interface AgentEnvOptions { * feels identical to one opened in Ghostty or Terminal.app — the user's * EDITOR, MANPATH, JAVA_HOME, custom vars, etc. are all present. * - * TERM, COLORTERM, TERM_PROGRAM, and SHELL are always set or overridden so - * the shell and programs inside it report the correct terminal identity. + * TERM, COLORTERM, and TERM_PROGRAM are always set or overridden so programs + * inside the terminal report the correct terminal identity. SHELL is only + * synthesized on POSIX platforms. * SSH_AUTH_SOCK is injected via the same cached detector used for agents, * since GUI-launched apps often don't inherit it from the user's login shell. */ @@ -137,8 +138,13 @@ export function buildTerminalEnv(): Record { env.COLORTERM = 'truecolor'; env.TERM_PROGRAM = 'emdash'; - // Ensure SHELL reflects the user's configured shell (may be absent in GUI). - env.SHELL = process.env.SHELL ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + // Ensure SHELL reflects the user's configured shell on POSIX. Native Windows + // shells are selected via ComSpec by the spawn resolver, not SHELL. + if (process.platform !== 'win32') { + env.SHELL = process.env.SHELL ?? (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + } else if (process.env.SHELL) { + env.SHELL = process.env.SHELL; + } // SSH_AUTH_SOCK is normally set by resolveUserEnv() at startup. The // detectSshAuthSock() fallback covers cases where that failed (timeout, @@ -181,8 +187,10 @@ export function buildAgentEnv(options: AgentEnvOptions = {}): Record Date: Tue, 28 Apr 2026 10:00:17 +0200 Subject: [PATCH 043/263] chore: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 64a2ae76d4..c54ac98ab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "emdash", - "version": "1.1.3", + "version": "1.1.4", "description": "A cross-platform Electron app that orchestrates multiple coding agents in parallel", "type": "module", "main": "./out/main/index.js", From 693f7ec431c5463d6308879305e39bb43184d4b2 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:17:33 +0200 Subject: [PATCH 044/263] fix pending project error wrapping --- .../projects/components/main-panel/pending-project.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/features/projects/components/main-panel/pending-project.tsx b/src/renderer/features/projects/components/main-panel/pending-project.tsx index 0ca6fbe41e..7c5b1fcb3e 100644 --- a/src/renderer/features/projects/components/main-panel/pending-project.tsx +++ b/src/renderer/features/projects/components/main-panel/pending-project.tsx @@ -35,8 +35,8 @@ export const PendingProjectStatus = observer(function PendingProjectStatus({ }; return ( -

-
+
+

{project.name}

{stages.map((stage, i) => { @@ -69,10 +69,10 @@ export const PendingProjectStatus = observer(function PendingProjectStatus({ })} {isError && ( -
-
+
+
- + {project.error ?? 'An error occurred'}
From 78e766806056455342dd74b91f58ee1a37744307 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 10:35:44 +0200 Subject: [PATCH 045/263] fix: type lint --- .../core/projects/impl/local-project-provider.ts | 10 ++++------ src/main/core/projects/impl/ssh-project-provider.ts | 13 ++++++++----- src/main/core/projects/project-provider.ts | 12 ++++++------ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index dc578568dc..f325092a28 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -1,14 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import { LocalProject } from '@shared/projects'; +import type { LocalProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; +import { type Task, type TaskBootstrapStatus } from '@shared/tasks'; import { type Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; @@ -388,9 +388,7 @@ export class LocalProjectProvider implements ProjectProvider { return promise; } - getWorkspace( - workspaceId: string - ): import('@main/core/workspaces/workspace').Workspace | undefined { + getWorkspace(workspaceId: string): Workspace | undefined { return this.workspaceRegistry.get(workspaceId); } diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 5867370266..756f2538ce 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -1,15 +1,15 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { SFTPWrapper } from 'ssh2'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { Terminal } from '@shared/terminals'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; @@ -20,8 +20,11 @@ import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import { SshConnectionEvent, sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index c42929285f..4fbdc0937b 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -1,14 +1,14 @@ -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import type { Branch, FetchError } from '@shared/git'; import type { Result } from '@shared/result'; -import { Task, TaskBootstrapStatus } from '@shared/tasks'; -import { Terminal } from '@shared/terminals'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; import type { FileSystemProvider } from '@main/core/fs/types'; -import { ConversationProvider } from '../conversations/types'; +import type { ConversationProvider } from '../conversations/types'; import type { GitRepositoryService } from '../git/repository-service'; -import { TerminalProvider } from '../terminals/terminal-provider'; +import type { TerminalProvider } from '../terminals/terminal-provider'; import type { Workspace } from '../workspaces/workspace'; -import { ProjectSettingsProvider } from './settings/schema'; +import type { ProjectSettingsProvider } from './settings/schema'; export type BaseTaskProvisionArgs = { taskId: string; From 58e3095f96f2280618a36ae4fc382a836f027614 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 10:44:18 +0200 Subject: [PATCH 046/263] fix: cleanup project providers --- src/main/core/git/repository-service.ts | 12 ++++ .../projects/impl/local-project-provider.ts | 65 +++---------------- .../projects/impl/ssh-project-provider.ts | 65 +++---------------- src/main/core/projects/project-provider.ts | 6 +- .../core/projects/provision-task-error.ts | 17 ++++- src/main/core/projects/worktrees/utils.ts | 26 +++++++- src/shared/projects.ts | 5 ++ 7 files changed, 77 insertions(+), 119 deletions(-) diff --git a/src/main/core/git/repository-service.ts b/src/main/core/git/repository-service.ts index ea90f27886..3bd09a78a9 100644 --- a/src/main/core/git/repository-service.ts +++ b/src/main/core/git/repository-service.ts @@ -13,6 +13,7 @@ import type { RenameBranchError, } from '@shared/git'; import { computeDefaultBranch, selectPreferredRemote } from '@shared/git-utils'; +import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema'; import type { RepositoryGitProvider } from './repository-git-provider'; @@ -160,4 +161,15 @@ export class GitRepositoryService { const gitDefaultBranch = await this.git.getDefaultBranch(remote); return { remoteBranches, remotes, gitDefaultBranch }; } + + async getRemoteState(): Promise { + try { + const remotes = await this.getRemotes(); + const remoteName = await this.getConfiguredRemote(); + const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; + return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; + } catch { + return { hasRemote: false, selectedRemoteUrl: null }; + } + } } diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index f325092a28..aa82606394 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -4,7 +4,7 @@ import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import type { LocalProject } from '@shared/projects'; +import type { LocalProject, ProjectRemoteState } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; @@ -31,36 +31,24 @@ import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { type ProjectProvider, - type ProjectRemoteState, type ProvisionTaskError, type TaskProvider, type TeardownTaskError, } from '../project-provider'; import { formatProvisionTaskError, - isProvisionTaskError, - mapWorktreeErrorToProvisionError, + TASK_TIMEOUT_MS, + TEARDOWN_SCRIPT_WAIT_MS, + toProvisionError, + toTeardownError, } from '../provision-task-error'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { getEffectiveTaskSettings } from '../settings/task-settings'; import { TimeoutSignal, withTimeout } from '../utils'; +import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; -const TASK_TIMEOUT_MS = 60_000; -const TEARDOWN_SCRIPT_WAIT_MS = 10_000; - -function toProvisionError(e: unknown): ProvisionTaskError { - if (isProvisionTaskError(e)) return e; - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -function toTeardownError(e: unknown): TeardownTaskError { - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - export async function createLocalProvider( project: LocalProject, rootFs: FileSystemProvider @@ -183,7 +171,7 @@ export class LocalProjectProvider implements ProjectProvider { const workspaceId = workspaceKey(task.taskBranch); const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await this.resolveTaskWorkDir(task); + const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); const exec = getGitLocalExec(() => githubConnectionService.getToken()); const workspaceFs = new LocalFileSystem(workDir); @@ -480,42 +468,7 @@ export class LocalProjectProvider implements ProjectProvider { } } - private async resolveTaskWorkDir(task: Task): Promise { - if (!task.taskBranch) { - return this.project.path; - } - - const existing = await this.worktreeService.getWorktree(task.taskBranch); - if (existing) { - return existing; - } - - if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { - const result = await this.worktreeService.checkoutExistingBranch(task.taskBranch); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - const result = await this.worktreeService.checkoutBranchWorktree( - task.sourceBranch, - task.taskBranch - ); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - async getRemoteState(): Promise { - try { - const remotes = await this.repository.getRemotes(); - const remoteName = await this.repository.getConfiguredRemote(); - const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; - return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; - } catch { - return { hasRemote: false, selectedRemoteUrl: null }; - } + getRemoteState(): Promise { + return this.repository.getRemoteState(); } } diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 756f2538ce..3b11398dec 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -4,7 +4,7 @@ import type { SFTPWrapper } from 'ssh2'; import type { Conversation } from '@shared/conversations'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import type { SshProject } from '@shared/projects'; +import type { ProjectRemoteState, SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; @@ -34,36 +34,24 @@ import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { log } from '@main/lib/logger'; import { type ProjectProvider, - type ProjectRemoteState, type ProvisionTaskError, type TaskProvider, type TeardownTaskError, } from '../project-provider'; import { formatProvisionTaskError, - isProvisionTaskError, - mapWorktreeErrorToProvisionError, + TASK_TIMEOUT_MS, + TEARDOWN_SCRIPT_WAIT_MS, + toProvisionError, + toTeardownError, } from '../provision-task-error'; import { SshProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { getEffectiveTaskSettings } from '../settings/task-settings'; import { TimeoutSignal, withTimeout } from '../utils'; +import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; -const TASK_TIMEOUT_MS = 60_000; -const TEARDOWN_SCRIPT_WAIT_MS = 10_000; - -function toProvisionError(e: unknown): ProvisionTaskError { - if (isProvisionTaskError(e)) return e; - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - -function toTeardownError(e: unknown): TeardownTaskError { - if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; - return { type: 'error', message: e instanceof Error ? e.message : String(e) }; -} - export async function createSshProvider( project: SshProject, rootFs: FileSystemProvider, @@ -224,7 +212,7 @@ export class SshProjectProvider implements ProjectProvider { const workspaceId = workspaceKey(task.taskBranch); const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await this.resolveTaskWorkDir(task); + const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); const workspaceFs = new SshFileSystem(this.proxy, workDir); const projectSettings = await this.settings.get(); const defaultBranch = await this.settings.getDefaultBranch(); @@ -560,42 +548,7 @@ export class SshProjectProvider implements ProjectProvider { ); } - async getRemoteState(): Promise { - try { - const remotes = await this.repository.getRemotes(); - const remoteName = await this.repository.getConfiguredRemote(); - const remoteUrl = remotes.find((r) => r.name === remoteName)?.url; - return { hasRemote: remotes.length > 0, selectedRemoteUrl: remoteUrl ?? null }; - } catch { - return { hasRemote: false, selectedRemoteUrl: null }; - } - } - - private async resolveTaskWorkDir(task: Task): Promise { - if (!task.taskBranch) { - return this.project.path; - } - - const existing = await this.worktreeService.getWorktree(task.taskBranch); - if (existing) { - return existing; - } - - if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { - const result = await this.worktreeService.checkoutExistingBranch(task.taskBranch); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; - } - - const result = await this.worktreeService.checkoutBranchWorktree( - task.sourceBranch, - task.taskBranch - ); - if (!result.success) { - throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); - } - return result.data; + getRemoteState(): Promise { + return this.repository.getRemoteState(); } } diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 4fbdc0937b..889abd5f0a 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -1,5 +1,6 @@ import type { Conversation } from '@shared/conversations'; import type { Branch, FetchError } from '@shared/git'; +import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; @@ -26,11 +27,6 @@ export type TeardownTaskError = | { type: 'timeout'; message: string; timeout: number } | { type: 'error'; message: string }; -export type ProjectRemoteState = { - hasRemote: boolean; - selectedRemoteUrl: string | null; -}; - export interface TaskProvider { readonly taskId: string; readonly taskBranch: string | undefined; diff --git a/src/main/core/projects/provision-task-error.ts b/src/main/core/projects/provision-task-error.ts index ca22f7aaea..7af73e56a4 100644 --- a/src/main/core/projects/provision-task-error.ts +++ b/src/main/core/projects/provision-task-error.ts @@ -1,6 +1,21 @@ -import type { ProvisionTaskError } from './project-provider'; +import type { ProvisionTaskError, TeardownTaskError } from './project-provider'; +import { TimeoutSignal } from './utils'; import type { ServeWorktreeError } from './worktrees/worktree-service'; +export const TASK_TIMEOUT_MS = 60_000; +export const TEARDOWN_SCRIPT_WAIT_MS = 10_000; + +export function toProvisionError(e: unknown): ProvisionTaskError { + if (isProvisionTaskError(e)) return e; + if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; + return { type: 'error', message: e instanceof Error ? e.message : String(e) }; +} + +export function toTeardownError(e: unknown): TeardownTaskError { + if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; + return { type: 'error', message: e instanceof Error ? e.message : String(e) }; +} + export function mapWorktreeErrorToProvisionError( branch: string, error: ServeWorktreeError diff --git a/src/main/core/projects/worktrees/utils.ts b/src/main/core/projects/worktrees/utils.ts index 0bf67a1492..6f83514cb1 100644 --- a/src/main/core/projects/worktrees/utils.ts +++ b/src/main/core/projects/worktrees/utils.ts @@ -1,6 +1,9 @@ import fs from 'fs'; import path from 'path'; -import { FileSystemProvider } from '@main/core/fs/types'; +import type { Task } from '@shared/tasks'; +import type { FileSystemProvider } from '@main/core/fs/types'; +import { mapWorktreeErrorToProvisionError } from '../provision-task-error'; +import type { WorktreeService } from './worktree-service'; export const ensureLocalWorktreeDirectory = ({ directory, @@ -33,3 +36,24 @@ export const ensureSshWorktreeDirectory = async ({ } return directory; }; + +export async function resolveTaskWorkDir( + task: Pick, + projectPath: string, + worktreeService: WorktreeService +): Promise { + if (!task.taskBranch) return projectPath; + + const existing = await worktreeService.getWorktree(task.taskBranch); + if (existing) return existing; + + if (!task.sourceBranch || task.taskBranch === task.sourceBranch.branch) { + const result = await worktreeService.checkoutExistingBranch(task.taskBranch); + if (!result.success) throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); + return result.data; + } + + const result = await worktreeService.checkoutBranchWorktree(task.sourceBranch, task.taskBranch); + if (!result.success) throw mapWorktreeErrorToProvisionError(task.taskBranch, result.error); + return result.data; +} diff --git a/src/shared/projects.ts b/src/shared/projects.ts index 964cce34e7..c302beecf0 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -42,3 +42,8 @@ export type UpdateProjectSettingsError = | { type: 'invalid-settings' } | { type: 'invalid-worktree-directory' } | { type: 'error' }; + +export type ProjectRemoteState = { + hasRemote: boolean; + selectedRemoteUrl: string | null; +}; From 56de4a3428ec8909cc9705da7534cd716ace25ac Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:07:03 +0200 Subject: [PATCH 047/263] Change sidebar update label --- src/renderer/features/sidebar/update-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/sidebar/update-section.tsx b/src/renderer/features/sidebar/update-section.tsx index d49a6f3793..f0949caea7 100644 --- a/src/renderer/features/sidebar/update-section.tsx +++ b/src/renderer/features/sidebar/update-section.tsx @@ -19,7 +19,7 @@ export const UpdateSection = observer(function UpdateSection() { }) } > - Upgrade + Update ); } From 07b879526b96ea88b89b4ed5d855c0981f5dd543 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 11:32:08 +0200 Subject: [PATCH 048/263] fix: workspace lifecycle handling --- .../conversations/impl/local-conversation.ts | 4 +- .../projects/impl/local-project-provider.ts | 122 ++++++++--------- .../projects/impl/ssh-project-provider.ts | 104 +++++++------- .../workspace-lifecycle-service.test.ts | 4 +- .../workspaces/workspace-lifecycle-service.ts | 2 +- .../workspaces/workspace-registry.test.ts | 127 ++++++++++++++++-- .../core/workspaces/workspace-registry.ts | 25 +++- src/main/core/workspaces/workspace.ts | 4 +- 8 files changed, 252 insertions(+), 140 deletions(-) diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 715b2c6652..79cd957761 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os'; import { getProvider } from '@shared/agent-provider-registry'; import type { AgentSessionConfig } from '@shared/agent-session'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtyId } from '@shared/ptyId'; import { makePtySessionId } from '@shared/ptySessionId'; @@ -12,7 +12,7 @@ import { HookConfigWriter } from '@main/core/agent-hooks/hook-config'; import type { ConversationProvider } from '@main/core/conversations/types'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { Pty } from '@main/core/pty/pty'; +import type { Pty } from '@main/core/pty/pty'; import { buildAgentEnv } from '@main/core/pty/pty-env'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSpawnParams } from '@main/core/pty/spawn-utils'; diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index aa82606394..082edbc9fc 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -25,7 +25,7 @@ import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; import type { Workspace } from '@main/core/workspaces/workspace'; -import { WorkspaceLifecycleService } from '@main/core/workspaces/workspace-lifecycle-service'; +import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; @@ -203,7 +203,7 @@ export class LocalProjectProvider implements ProjectProvider { exec, taskEnvVars: bootstrapTaskEnvVars, }); - const lifecycleService = new WorkspaceLifecycleService({ + const lifecycleService = new LifecycleScriptService({ projectId: this.project.id, workspaceId, terminals: workspaceTerminals, @@ -218,36 +218,62 @@ export class LocalProjectProvider implements ProjectProvider { lifecycleService, }; - if (scripts?.setup) { - void lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - - if (scripts?.run) { - void lifecycleService.prepareLifecycleScript({ - type: 'run', - script: scripts.run, - }); - } - - if (scripts?.teardown) { - void lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } + return { + workspace: createdWorkspace, - return createdWorkspace; + onCreateSideEffect: (ws) => { + if (scripts?.setup) { + void ws.lifecycleService.prepareAndRunLifecycleScript({ + type: 'setup', + script: scripts.setup, + }); + } + if (scripts?.run) { + void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); + } + if (scripts?.teardown) { + void ws.lifecycleService.prepareLifecycleScript({ + type: 'teardown', + script: scripts.teardown, + }); + } + }, + + onCreate: async (ws) => { + const mainDotGitAbs = path.resolve(this.project.path, '.git'); + const relativeGitDir = await ws.git.getWorktreeGitDir(mainDotGitAbs); + this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); + }, + + onDestroy: async (ws) => { + if (scripts?.teardown) { + try { + await withTimeout( + ws.lifecycleService.runLifecycleScript( + { type: 'teardown', script: scripts.teardown }, + { waitForExit: true, exit: true } + ), + TEARDOWN_SCRIPT_WAIT_MS + ); + } catch (error) { + if (error instanceof TimeoutSignal) { + log.debug('LocalProjectProvider: teardown script wait timed out', { + workspaceId, + timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, + }); + } else { + log.warn('LocalProjectProvider: teardown script failed (continuing cleanup)', { + workspaceId, + error: String(error), + }); + } + } + } + this._gitWatcher.unregisterWorktree(workspaceId); + }, + }; }); - // Register the workspace with the git watcher so that index/HEAD changes - // in its worktree git dir are emitted as granular workspace events. - const mainDotGitAbs = path.resolve(this.project.path, '.git'); - const relativeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); - this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); - let provisionSucceeded = false; try { const exec = getGitLocalExec(() => githubConnectionService.getToken()); @@ -381,45 +407,9 @@ export class LocalProjectProvider implements ProjectProvider { } private async doTeardownTask(task: TaskProvider): Promise { - const wsId = workspaceKey(task.taskBranch); - const workspace = this.workspaceRegistry.get(wsId); - - if (workspace) { - const settings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const scripts = settings.scripts; - - if (scripts?.teardown && this.workspaceRegistry.refCount(wsId) === 1) { - try { - const runTeardown = workspace.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ); - await withTimeout(runTeardown, TEARDOWN_SCRIPT_WAIT_MS); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('LocalProjectProvider: teardown script wait timed out', { - taskId: task.taskId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('LocalProjectProvider: teardown script failed (continuing cleanup)', { - taskId: task.taskId, - error: String(error), - }); - } - } - } - } - await task.conversations.destroyAll(); await task.terminals.destroyAll(); - if (this.workspaceRegistry.refCount(wsId) <= 1) { - this._gitWatcher.unregisterWorktree(wsId); - } - await this.workspaceRegistry.release(wsId); + await this.workspaceRegistry.release(workspaceKey(task.taskBranch)); } private async cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 3b11398dec..035331b144 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -29,7 +29,7 @@ import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; import type { Workspace } from '@main/core/workspaces/workspace'; -import { WorkspaceLifecycleService } from '@main/core/workspaces/workspace-lifecycle-service'; +import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { log } from '@main/lib/logger'; import { @@ -243,7 +243,7 @@ export class SshProjectProvider implements ProjectProvider { proxy, taskEnvVars: bootstrapTaskEnvVars, }); - const lifecycleService = new WorkspaceLifecycleService({ + const lifecycleService = new LifecycleScriptService({ projectId: this.project.id, workspaceId, terminals: workspaceTerminals, @@ -258,26 +258,53 @@ export class SshProjectProvider implements ProjectProvider { lifecycleService, }; - if (scripts?.setup) { - void lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - if (scripts?.run) { - void lifecycleService.prepareLifecycleScript({ - type: 'run', - script: scripts.run, - }); - } - if (scripts?.teardown) { - void lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } + return { + workspace: createdWorkspace, - return createdWorkspace; + onCreateSideEffect: (ws) => { + if (scripts?.setup) { + void ws.lifecycleService.prepareAndRunLifecycleScript({ + type: 'setup', + script: scripts.setup, + }); + } + if (scripts?.run) { + void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); + } + if (scripts?.teardown) { + void ws.lifecycleService.prepareLifecycleScript({ + type: 'teardown', + script: scripts.teardown, + }); + } + }, + + onDestroy: async (ws) => { + if (scripts?.teardown) { + try { + await withTimeout( + ws.lifecycleService.runLifecycleScript( + { type: 'teardown', script: scripts.teardown }, + { waitForExit: true, exit: true } + ), + TEARDOWN_SCRIPT_WAIT_MS + ); + } catch (error) { + if (error instanceof TimeoutSignal) { + log.debug('SshProjectProvider: teardown script wait timed out', { + workspaceId, + timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, + }); + } else { + log.warn('SshProjectProvider: teardown script failed (continuing cleanup)', { + workspaceId, + error: String(error), + }); + } + } + } + }, + }; }); let provisionSucceeded = false; @@ -422,42 +449,9 @@ export class SshProjectProvider implements ProjectProvider { } private async doTeardownTask(task: TaskProvider): Promise { - const wsId = workspaceKey(task.taskBranch); - const workspace = this.workspaceRegistry.get(wsId); - - if (workspace) { - const settings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const scripts = settings.scripts; - - if (scripts?.teardown && this.workspaceRegistry.refCount(wsId) === 1) { - try { - const runTeardown = workspace.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ); - await withTimeout(runTeardown, TEARDOWN_SCRIPT_WAIT_MS); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('SshProjectProvider: teardown script wait timed out', { - taskId: task.taskId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('SshProjectProvider: teardown script failed (continuing cleanup)', { - taskId: task.taskId, - error: String(error), - }); - } - } - } - } - await task.conversations.destroyAll(); await task.terminals.destroyAll(); - await this.workspaceRegistry.release(wsId); + await this.workspaceRegistry.release(workspaceKey(task.taskBranch)); } private async cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/workspaces/workspace-lifecycle-service.test.ts b/src/main/core/workspaces/workspace-lifecycle-service.test.ts index 21fb5d84c6..3cbd482f34 100644 --- a/src/main/core/workspaces/workspace-lifecycle-service.test.ts +++ b/src/main/core/workspaces/workspace-lifecycle-service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import type { Pty, PtyExitInfo } from '../pty/pty'; import type { TerminalProvider } from '../terminals/terminal-provider'; -import { WorkspaceLifecycleService } from './workspace-lifecycle-service'; +import { LifecycleScriptService } from './workspace-lifecycle-service'; vi.mock('@main/lib/events', () => ({ events: { @@ -62,7 +62,7 @@ function makeTerminalProvider(): { describe('WorkspaceLifecycleService', () => { it('respawns an interactive lifecycle shell after an exit-backed script finishes', async () => { const { provider, spawned } = makeTerminalProvider(); - const service = new WorkspaceLifecycleService({ + const service = new LifecycleScriptService({ projectId: 'project-1', workspaceId: 'branch:feature', terminals: provider, diff --git a/src/main/core/workspaces/workspace-lifecycle-service.ts b/src/main/core/workspaces/workspace-lifecycle-service.ts index 26d9da025e..4c0e754451 100644 --- a/src/main/core/workspaces/workspace-lifecycle-service.ts +++ b/src/main/core/workspaces/workspace-lifecycle-service.ts @@ -13,7 +13,7 @@ type LifecycleScript = { script: string; }; -export class WorkspaceLifecycleService { +export class LifecycleScriptService { private readonly projectId: string; private readonly workspaceId: string; private readonly terminals: TerminalProvider; diff --git a/src/main/core/workspaces/workspace-registry.test.ts b/src/main/core/workspaces/workspace-registry.test.ts index 66266c0333..ebed0c759e 100644 --- a/src/main/core/workspaces/workspace-registry.test.ts +++ b/src/main/core/workspaces/workspace-registry.test.ts @@ -30,7 +30,7 @@ describe('WorkspaceRegistry', () => { it('creates once and increments ref count on repeated acquire', async () => { const registry = new WorkspaceRegistry(); const { workspace } = makeWorkspace('branch:main'); - const factory = vi.fn(async () => workspace); + const factory = vi.fn(async () => ({ workspace })); const first = await registry.acquire('branch:main', factory); const second = await registry.acquire('branch:main', factory); @@ -45,10 +45,10 @@ describe('WorkspaceRegistry', () => { it('coalesces concurrent acquires for the same key', async () => { const registry = new WorkspaceRegistry(); const { workspace } = makeWorkspace('branch:main'); - let resolveFactory: ((value: Workspace) => void) | undefined; + let resolveFactory: ((value: { workspace: Workspace }) => void) | undefined; const factory = vi.fn( () => - new Promise((resolve) => { + new Promise<{ workspace: Workspace }>((resolve) => { resolveFactory = resolve; }) ); @@ -57,7 +57,7 @@ describe('WorkspaceRegistry', () => { const second = registry.acquire('branch:main', factory); expect(factory).toHaveBeenCalledTimes(1); - resolveFactory?.(workspace); + resolveFactory?.({ workspace }); await expect(first).resolves.toBe(workspace); await expect(second).resolves.toBe(workspace); @@ -67,7 +67,7 @@ describe('WorkspaceRegistry', () => { it('disposes workspace resources when ref count reaches zero', async () => { const registry = new WorkspaceRegistry(); const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); - const factory = vi.fn(async () => workspace); + const factory = vi.fn(async () => ({ workspace })); await registry.acquire('branch:main', factory); await registry.acquire('branch:main', factory); @@ -89,9 +89,9 @@ describe('WorkspaceRegistry', () => { const first = makeWorkspace('branch:main'); const second = makeWorkspace('root:'); - await registry.acquire('branch:main', async () => first.workspace); - await registry.acquire('branch:main', async () => first.workspace); - await registry.acquire('root:', async () => second.workspace); + await registry.acquire('branch:main', async () => ({ workspace: first.workspace })); + await registry.acquire('branch:main', async () => ({ workspace: first.workspace })); + await registry.acquire('root:', async () => ({ workspace: second.workspace })); await registry.releaseAll(); @@ -107,4 +107,115 @@ describe('WorkspaceRegistry', () => { const registry = new WorkspaceRegistry(); await expect(registry.release('missing')).resolves.toBeUndefined(); }); + + it('calls onCreateSideEffect once on first acquire and not on re-acquire', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onCreateSideEffect = vi.fn(); + const factory = vi.fn(async () => ({ workspace, onCreateSideEffect })); + + await registry.acquire('branch:main', factory); + expect(onCreateSideEffect).toHaveBeenCalledTimes(1); + expect(onCreateSideEffect).toHaveBeenCalledWith(workspace); + + await registry.acquire('branch:main', factory); + expect(onCreateSideEffect).toHaveBeenCalledTimes(1); + }); + + it('awaits onCreate before acquire resolves', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const order: string[] = []; + + const onCreate = vi.fn(async () => { + order.push('onCreate'); + }); + const factory = vi.fn(async () => ({ workspace, onCreate })); + + const acquired = registry.acquire('branch:main', factory).then((ws) => { + order.push('acquired'); + return ws; + }); + + await acquired; + + expect(order).toEqual(['onCreate', 'acquired']); + expect(onCreate).toHaveBeenCalledWith(workspace); + }); + + it('does not call onCreate on re-acquire', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onCreate = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onCreate })); + + await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', factory); + + expect(onCreate).toHaveBeenCalledTimes(1); + }); + + it('calls onDestroy once at final release, not on earlier releases', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy })); + + await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', factory); + + await registry.release('branch:main'); + expect(onDestroy).not.toHaveBeenCalled(); + + await registry.release('branch:main'); + expect(onDestroy).toHaveBeenCalledTimes(1); + expect(onDestroy).toHaveBeenCalledWith(workspace); + }); + + it('calls onDestroy before git.dispose and lifecycleService.dispose', async () => { + const registry = new WorkspaceRegistry(); + const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const order: string[] = []; + + dispose.mockImplementation(async () => { + order.push('lifecycleDispose'); + }); + gitDispose.mockImplementation(() => { + order.push('gitDispose'); + }); + + const onDestroy = vi.fn(async () => { + order.push('onDestroy'); + }); + const factory = vi.fn(async () => ({ workspace, onDestroy })); + + await registry.acquire('branch:main', factory); + await registry.release('branch:main'); + + expect(order).toEqual(['onDestroy', 'gitDispose', 'lifecycleDispose']); + }); + + it('calls onDestroy for each entry in releaseAll', async () => { + const registry = new WorkspaceRegistry(); + const first = makeWorkspace('branch:main'); + const second = makeWorkspace('root:'); + const onDestroyFirst = vi.fn(async () => {}); + const onDestroySecond = vi.fn(async () => {}); + + await registry.acquire('branch:main', async () => ({ + workspace: first.workspace, + onDestroy: onDestroyFirst, + })); + await registry.acquire('root:', async () => ({ + workspace: second.workspace, + onDestroy: onDestroySecond, + })); + + await registry.releaseAll(); + + expect(onDestroyFirst).toHaveBeenCalledTimes(1); + expect(onDestroyFirst).toHaveBeenCalledWith(first.workspace); + expect(onDestroySecond).toHaveBeenCalledTimes(1); + expect(onDestroySecond).toHaveBeenCalledWith(second.workspace); + }); }); diff --git a/src/main/core/workspaces/workspace-registry.ts b/src/main/core/workspaces/workspace-registry.ts index 30d259ed5f..6bb4231c25 100644 --- a/src/main/core/workspaces/workspace-registry.ts +++ b/src/main/core/workspaces/workspace-registry.ts @@ -1,15 +1,24 @@ import type { Workspace } from './workspace'; +type WorkspaceHooks = { + onCreate?: (workspace: Workspace) => Promise; + onCreateSideEffect?: (workspace: Workspace) => void; + onDestroy?: (workspace: Workspace) => Promise; +}; + +export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; + type WorkspaceEntry = { workspace: Workspace; refCount: number; + onDestroy?: (workspace: Workspace) => Promise; }; export class WorkspaceRegistry { private entries = new Map(); private acquiring = new Map>(); - async acquire(key: string, factory: () => Promise): Promise { + async acquire(key: string, factory: () => Promise): Promise { const existing = this.entries.get(key); if (existing) { existing.refCount += 1; @@ -25,9 +34,15 @@ export class WorkspaceRegistry { } const pending = factory() - .then((workspace) => { - this.entries.set(key, { workspace, refCount: 1 }); - return workspace; + .then(async (result) => { + this.entries.set(key, { + workspace: result.workspace, + refCount: 1, + onDestroy: result.onDestroy, + }); + result.onCreateSideEffect?.(result.workspace); + await result.onCreate?.(result.workspace); + return result.workspace; }) .finally(() => { this.acquiring.delete(key); @@ -54,6 +69,7 @@ export class WorkspaceRegistry { } this.entries.delete(key); + await entry.onDestroy?.(entry.workspace); entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); } @@ -71,6 +87,7 @@ export class WorkspaceRegistry { this.entries.clear(); await Promise.all( entries.map(async (entry) => { + await entry.onDestroy?.(entry.workspace); entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); }) diff --git a/src/main/core/workspaces/workspace.ts b/src/main/core/workspaces/workspace.ts index 4e08902c05..11df345a0d 100644 --- a/src/main/core/workspaces/workspace.ts +++ b/src/main/core/workspaces/workspace.ts @@ -1,7 +1,7 @@ import type { FileSystemProvider } from '@main/core/fs/types'; import type { WorkspaceGitProvider } from '@main/core/git/workspace-git-provider'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema'; -import type { WorkspaceLifecycleService } from './workspace-lifecycle-service'; +import type { LifecycleScriptService } from './workspace-lifecycle-service'; export interface Workspace { readonly id: string; @@ -9,5 +9,5 @@ export interface Workspace { readonly fs: FileSystemProvider; readonly git: WorkspaceGitProvider; readonly settings: ProjectSettingsProvider; - readonly lifecycleService: WorkspaceLifecycleService; + readonly lifecycleService: LifecycleScriptService; } From 0ed7135ce1d46f4033580b0701622865ec1a6ee2 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 11:48:21 +0200 Subject: [PATCH 049/263] feat: create workspace factory --- .../projects/impl/local-project-provider.ts | 193 +++--------- .../projects/impl/ssh-project-provider.ts | 187 +++--------- src/main/core/projects/workspace-factory.ts | 282 ++++++++++++++++++ src/main/core/terminals/terminal-provider.ts | 4 +- 4 files changed, 368 insertions(+), 298 deletions(-) create mode 100644 src/main/core/projects/workspace-factory.ts diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 082edbc9fc..7d9875fed7 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -7,11 +7,9 @@ import { bareRefName } from '@shared/git-utils'; import type { LocalProject, ProjectRemoteState } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; -import { getTaskEnvVars } from '@shared/task/envVars'; import { type Task, type TaskBootstrapStatus } from '@shared/tasks'; import { type Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; -import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { GitFetchService } from '@main/core/git/git-fetch-service'; @@ -22,10 +20,8 @@ import { githubConnectionService } from '@main/core/github/services/github-conne import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; -import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; import type { Workspace } from '@main/core/workspaces/workspace'; -import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; @@ -38,15 +34,13 @@ import { import { formatProvisionTaskError, TASK_TIMEOUT_MS, - TEARDOWN_SCRIPT_WAIT_MS, toProvisionError, toTeardownError, } from '../provision-task-error'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; -import { getEffectiveTaskSettings } from '../settings/task-settings'; -import { TimeoutSignal, withTimeout } from '../utils'; -import { resolveTaskWorkDir } from '../worktrees/utils'; +import { withTimeout } from '../utils'; +import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createLocalProvider( @@ -170,149 +164,50 @@ export class LocalProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); - const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); - const exec = getGitLocalExec(() => githubConnectionService.getToken()); - const workspaceFs = new LocalFileSystem(workDir); - - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const bootstrapTaskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workDir, - projectPath: this.project.path, - defaultBranch, - portSeed: workDir, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspaceFs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const scripts = taskLevelSettings.scripts; - - const workspaceTerminals = new LocalTerminalProvider({ - projectId: this.project.id, - scopeId: workspaceId, - taskPath: workDir, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars: bootstrapTaskEnvVars, - }); - const lifecycleService = new LifecycleScriptService({ - projectId: this.project.id, + const workspace = await this.workspaceRegistry.acquire( + workspaceId, + createWorkspaceFactory( workspaceId, - terminals: workspaceTerminals, - }); - - const createdWorkspace: Workspace = { - id: workspaceId, - path: workDir, - fs: workspaceFs, - git: new GitService(workDir, exec, workspaceFs), - settings: this.settings, - lifecycleService, - }; - - return { - workspace: createdWorkspace, - - onCreateSideEffect: (ws) => { - if (scripts?.setup) { - void ws.lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - if (scripts?.run) { - void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); - } - if (scripts?.teardown) { - void ws.lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } - }, - - onCreate: async (ws) => { - const mainDotGitAbs = path.resolve(this.project.path, '.git'); - const relativeGitDir = await ws.git.getWorktreeGitDir(mainDotGitAbs); - this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); - }, - - onDestroy: async (ws) => { - if (scripts?.teardown) { - try { - await withTimeout( - ws.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ), - TEARDOWN_SCRIPT_WAIT_MS - ); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('LocalProjectProvider: teardown script wait timed out', { - workspaceId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('LocalProjectProvider: teardown script failed (continuing cleanup)', { - workspaceId, - error: String(error), - }); - } - } - } - this._gitWatcher.unregisterWorktree(workspaceId); - }, - }; - }); + { kind: 'local' }, + { + task, + projectId: this.project.id, + projectPath: this.project.path, + settings: this.settings, + worktreeService: this.worktreeService, + logPrefix: 'LocalProjectProvider', + extraHooks: { + onCreate: async (ws) => { + const mainDotGitAbs = path.resolve(this.project.path, '.git'); + const relativeGitDir = await ws.git.getWorktreeGitDir(mainDotGitAbs); + this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); + }, + onDestroy: async () => this._gitWatcher.unregisterWorktree(workspaceId), + }, + } + ) + ); let provisionSucceeded = false; try { - const exec = getGitLocalExec(() => githubConnectionService.getToken()); - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const taskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workspace.path, - projectPath: this.project.path, - defaultBranch, - portSeed: workspace.path, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - - const conversationProvider = new LocalConversationProvider({ - projectId: this.project.id, - taskPath: workspace.path, - taskId: task.id, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars, - }); - - const terminalProvider = new LocalTerminalProvider({ - projectId: this.project.id, - scopeId: task.id, - taskPath: workspace.path, - tmux: tmuxEnabled, - shellSetup, - exec, - taskEnvVars, - }); + const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + task, + workspace, + this.project.path, + this.settings + ); + const { conversations: conversationProvider, terminals: terminalProvider } = + buildTaskProviders( + { kind: 'local' }, + { + projectId: this.project.id, + taskId: task.id, + taskPath: workspace.path, + tmuxEnabled, + shellSetup, + taskEnvVars, + } + ); const taskEnv: TaskProvider = { taskId: task.id, @@ -323,7 +218,7 @@ export class LocalProjectProvider implements ProjectProvider { terminals: terminalProvider, }; - Promise.all( + void Promise.all( terminals.map((term) => terminalProvider.spawnTerminal(term).catch((e) => { log.error('LocalEnvironmentProvider: failed to hydrate terminal', { @@ -334,7 +229,7 @@ export class LocalProjectProvider implements ProjectProvider { ) ); - Promise.all( + void Promise.all( conversations.map((conv) => conversationProvider.startSession(conv, undefined, true).catch((e) => { log.error('LocalEnvironmentProvider: failed to hydrate conversation', { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 035331b144..c6a5a58e75 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -7,11 +7,10 @@ import { bareRefName } from '@shared/git-utils'; import type { ProjectRemoteState, SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; -import { getTaskEnvVars } from '@shared/task/envVars'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; -import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; +import type { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { GitFetchService } from '@main/core/git/git-fetch-service'; @@ -26,10 +25,8 @@ import { type SshConnectionEvent, } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; -import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; +import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; -import type { Workspace } from '@main/core/workspaces/workspace'; -import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { log } from '@main/lib/logger'; import { @@ -41,15 +38,13 @@ import { import { formatProvisionTaskError, TASK_TIMEOUT_MS, - TEARDOWN_SCRIPT_WAIT_MS, toProvisionError, toTeardownError, } from '../provision-task-error'; import { SshProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; -import { getEffectiveTaskSettings } from '../settings/task-settings'; -import { TimeoutSignal, withTimeout } from '../utils'; -import { resolveTaskWorkDir } from '../worktrees/utils'; +import { withTimeout } from '../utils'; +import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createSshProvider( @@ -211,144 +206,42 @@ export class SshProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); - const workspace = await this.workspaceRegistry.acquire(workspaceId, async () => { - const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); - const workspaceFs = new SshFileSystem(this.proxy, workDir); - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const bootstrapTaskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workDir, - projectPath: this.project.path, - defaultBranch, - portSeed: workDir, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspaceFs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const scripts = taskLevelSettings.scripts; - const proxy = this.proxy; - const exec = getSshExec(proxy); - const workspaceTerminals = new SshTerminalProvider({ - projectId: this.project.id, - scopeId: workspaceId, - taskPath: workDir, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars: bootstrapTaskEnvVars, - }); - const lifecycleService = new LifecycleScriptService({ - projectId: this.project.id, + const workspace = await this.workspaceRegistry.acquire( + workspaceId, + createWorkspaceFactory( workspaceId, - terminals: workspaceTerminals, - }); - const workspaceGitExec = getGitSshExec(proxy, () => githubConnectionService.getToken()); - const createdWorkspace: Workspace = { - id: workspaceId, - path: workDir, - fs: workspaceFs, - git: new GitService(workDir, workspaceGitExec, workspaceFs, false), - settings: this.settings, - lifecycleService, - }; - - return { - workspace: createdWorkspace, - - onCreateSideEffect: (ws) => { - if (scripts?.setup) { - void ws.lifecycleService.prepareAndRunLifecycleScript({ - type: 'setup', - script: scripts.setup, - }); - } - if (scripts?.run) { - void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); - } - if (scripts?.teardown) { - void ws.lifecycleService.prepareLifecycleScript({ - type: 'teardown', - script: scripts.teardown, - }); - } - }, - - onDestroy: async (ws) => { - if (scripts?.teardown) { - try { - await withTimeout( - ws.lifecycleService.runLifecycleScript( - { type: 'teardown', script: scripts.teardown }, - { waitForExit: true, exit: true } - ), - TEARDOWN_SCRIPT_WAIT_MS - ); - } catch (error) { - if (error instanceof TimeoutSignal) { - log.debug('SshProjectProvider: teardown script wait timed out', { - workspaceId, - timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, - }); - } else { - log.warn('SshProjectProvider: teardown script failed (continuing cleanup)', { - workspaceId, - error: String(error), - }); - } - } - } - }, - }; - }); + { kind: 'ssh', proxy: this.proxy }, + { + task, + projectId: this.project.id, + projectPath: this.project.path, + settings: this.settings, + worktreeService: this.worktreeService, + logPrefix: 'SshProjectProvider', + } + ) + ); let provisionSucceeded = false; try { - const projectSettings = await this.settings.get(); - const defaultBranch = await this.settings.getDefaultBranch(); - const taskEnvVars = getTaskEnvVars({ - taskId: task.id, - taskName: task.name, - taskPath: workspace.path, - projectPath: this.project.path, - defaultBranch, - portSeed: workspace.path, - }); - const tmuxEnabled = projectSettings.tmux ?? false; - const taskLevelSettings = await getEffectiveTaskSettings({ - projectSettings: this.settings, - taskFs: workspace.fs, - }); - const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; - const proxy = this.proxy; - const exec = getSshExec(proxy); - - const conversationProvider = new SshConversationProvider({ - projectId: this.project.id, - taskPath: workspace.path, - taskId: task.id, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars, - }); - - const terminalProvider = new SshTerminalProvider({ - projectId: this.project.id, - scopeId: task.id, - taskPath: workspace.path, - tmux: tmuxEnabled, - shellSetup, - exec, - proxy, - taskEnvVars, - }); + const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + task, + workspace, + this.project.path, + this.settings + ); + const { conversations: conversationProvider, terminals: terminalProvider } = + buildTaskProviders( + { kind: 'ssh', proxy: this.proxy }, + { + projectId: this.project.id, + taskId: task.id, + taskPath: workspace.path, + tmuxEnabled, + shellSetup, + taskEnvVars, + } + ); const taskEnv: TaskProvider = { taskId: task.id, @@ -359,7 +252,7 @@ export class SshProjectProvider implements ProjectProvider { terminals: terminalProvider, }; - Promise.all( + void Promise.all( terminals.map((term) => terminalProvider.spawnTerminal(term).catch((e) => { log.error('SshEnvironmentProvider: failed to hydrate terminal', { @@ -370,7 +263,7 @@ export class SshProjectProvider implements ProjectProvider { ) ); - Promise.all( + void Promise.all( conversations.map((conv) => conversationProvider.startSession(conv, undefined, true).catch((e) => { log.error('SshEnvironmentProvider: failed to hydrate conversation', { @@ -381,8 +274,8 @@ export class SshProjectProvider implements ProjectProvider { ) ); - this.terminalProviders.set(task.id, terminalProvider); - this.conversationProviders.set(task.id, conversationProvider); + this.terminalProviders.set(task.id, terminalProvider as SshTerminalProvider); + this.conversationProviders.set(task.id, conversationProvider as SshConversationProvider); log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id, }); diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/projects/workspace-factory.ts new file mode 100644 index 0000000000..5931586a64 --- /dev/null +++ b/src/main/core/projects/workspace-factory.ts @@ -0,0 +1,282 @@ +import { getTaskEnvVars } from '@shared/task/envVars'; +import type { Task } from '@shared/tasks'; +import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; +import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; +import type { ConversationProvider } from '@main/core/conversations/types'; +import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { GitService } from '@main/core/git/impl/git-service'; +import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; +import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; +import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; +import { getGitLocalExec, getGitSshExec, getSshExec } from '@main/core/utils/exec'; +import type { Workspace } from '@main/core/workspaces/workspace'; +import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; +import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-registry'; +import { log } from '@main/lib/logger'; +import { TEARDOWN_SCRIPT_WAIT_MS } from './provision-task-error'; +import type { ProjectSettingsProvider } from './settings/schema'; +import { getEffectiveTaskSettings } from './settings/task-settings'; +import { TimeoutSignal, withTimeout } from './utils'; +import { resolveTaskWorkDir } from './worktrees/utils'; +import type { WorktreeService } from './worktrees/worktree-service'; + +export type WorkspaceType = { kind: 'local' } | { kind: 'ssh'; proxy: SshClientProxy }; + +type WorkspaceFactoryContext = { + task: Pick; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + worktreeService: WorktreeService; + logPrefix: string; + extraHooks?: { + onCreate?: (ws: Workspace) => Promise; + onDestroy?: (ws: Workspace) => Promise; + }; +}; + +/** + * Returns a factory function suitable for passing to `WorkspaceRegistry.acquire`. + * Handles all transport-specific construction (local vs SSH) and wires lifecycle + * script hooks. Provider-specific hooks (e.g. git watcher) are passed via `extraHooks`. + */ +export function createWorkspaceFactory( + workspaceId: string, + type: WorkspaceType, + context: WorkspaceFactoryContext +): () => Promise { + return async () => { + const workDir = await resolveTaskWorkDir( + context.task, + context.projectPath, + context.worktreeService + ); + + // Transport-specific FS, exec, and git exec + const workspaceFs = + type.kind === 'ssh' ? new SshFileSystem(type.proxy, workDir) : new LocalFileSystem(workDir); + + const exec = + type.kind === 'ssh' + ? getSshExec(type.proxy) + : getGitLocalExec(() => githubConnectionService.getToken()); + + const gitExec = + type.kind === 'ssh' + ? getGitSshExec(type.proxy, () => githubConnectionService.getToken()) + : exec; + + // Settings (shared) + const projectSettings = await context.settings.get(); + const defaultBranch = await context.settings.getDefaultBranch(); + const bootstrapTaskEnvVars = getTaskEnvVars({ + taskId: context.task.id, + taskName: context.task.name, + taskPath: workDir, + projectPath: context.projectPath, + defaultBranch, + portSeed: workDir, + }); + const tmuxEnabled = projectSettings.tmux ?? false; + const taskLevelSettings = await getEffectiveTaskSettings({ + projectSettings: context.settings, + taskFs: workspaceFs, + }); + const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; + const scripts = taskLevelSettings.scripts; + + // Transport-specific workspace terminal provider (used only by lifecycle scripts) + const workspaceTerminals = + type.kind === 'ssh' + ? new SshTerminalProvider({ + projectId: context.projectId, + scopeId: workspaceId, + taskPath: workDir, + tmux: tmuxEnabled, + shellSetup, + exec, + proxy: type.proxy, + taskEnvVars: bootstrapTaskEnvVars, + }) + : new LocalTerminalProvider({ + projectId: context.projectId, + scopeId: workspaceId, + taskPath: workDir, + tmux: tmuxEnabled, + shellSetup, + exec, + taskEnvVars: bootstrapTaskEnvVars, + }); + + const lifecycleService = new LifecycleScriptService({ + projectId: context.projectId, + workspaceId, + terminals: workspaceTerminals, + }); + + const workspace: Workspace = { + id: workspaceId, + path: workDir, + fs: workspaceFs, + git: new GitService(workDir, gitExec, workspaceFs, type.kind === 'ssh' ? false : undefined), + settings: context.settings, + lifecycleService, + }; + + const { logPrefix } = context; + + return { + workspace, + + onCreateSideEffect: (ws) => { + if (scripts?.setup) { + void ws.lifecycleService.prepareAndRunLifecycleScript({ + type: 'setup', + script: scripts.setup, + }); + } + if (scripts?.run) { + void ws.lifecycleService.prepareLifecycleScript({ type: 'run', script: scripts.run }); + } + if (scripts?.teardown) { + void ws.lifecycleService.prepareLifecycleScript({ + type: 'teardown', + script: scripts.teardown, + }); + } + }, + + onCreate: context.extraHooks?.onCreate, + + onDestroy: async (ws) => { + if (scripts?.teardown) { + try { + await withTimeout( + ws.lifecycleService.runLifecycleScript( + { type: 'teardown', script: scripts.teardown }, + { waitForExit: true, exit: true } + ), + TEARDOWN_SCRIPT_WAIT_MS + ); + } catch (error) { + if (error instanceof TimeoutSignal) { + log.debug(`${logPrefix}: teardown script wait timed out`, { + workspaceId, + timeoutMs: TEARDOWN_SCRIPT_WAIT_MS, + }); + } else { + log.warn(`${logPrefix}: teardown script failed (continuing cleanup)`, { + workspaceId, + error: String(error), + }); + } + } + } + await context.extraHooks?.onDestroy?.(ws); + }, + }; + }; +} + +type TaskProviderOpts = { + projectId: string; + taskId: string; + taskPath: string; + tmuxEnabled: boolean; + shellSetup?: string; + taskEnvVars: Record; +}; + +/** + * Creates task-scoped conversation and terminal providers for the given transport type. + * The exec function is derived internally from the WorkspaceType. + */ +export function buildTaskProviders( + type: WorkspaceType, + opts: TaskProviderOpts +): { conversations: ConversationProvider; terminals: TerminalProvider } { + if (type.kind === 'ssh') { + const exec = getSshExec(type.proxy); + return { + conversations: new SshConversationProvider({ + projectId: opts.projectId, + taskPath: opts.taskPath, + taskId: opts.taskId, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + exec, + proxy: type.proxy, + taskEnvVars: opts.taskEnvVars, + }), + terminals: new SshTerminalProvider({ + projectId: opts.projectId, + scopeId: opts.taskId, + taskPath: opts.taskPath, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + exec, + proxy: type.proxy, + taskEnvVars: opts.taskEnvVars, + }), + }; + } + + const exec = getGitLocalExec(() => githubConnectionService.getToken()); + return { + conversations: new LocalConversationProvider({ + projectId: opts.projectId, + taskPath: opts.taskPath, + taskId: opts.taskId, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + exec, + taskEnvVars: opts.taskEnvVars, + }), + terminals: new LocalTerminalProvider({ + projectId: opts.projectId, + scopeId: opts.taskId, + taskPath: opts.taskPath, + tmux: opts.tmuxEnabled, + shellSetup: opts.shellSetup, + exec, + taskEnvVars: opts.taskEnvVars, + }), + }; +} + +/** + * Resolves the task-level environment variables and settings from an already-acquired workspace. + * Used by providers after `workspaceRegistry.acquire` to avoid duplicating settings reads. + */ +export async function resolveTaskEnv( + task: Pick, + workspace: Pick, + projectPath: string, + settings: ProjectSettingsProvider +): Promise<{ + taskEnvVars: Record; + tmuxEnabled: boolean; + shellSetup?: string; +}> { + const projectSettings = await settings.get(); + const defaultBranch = await settings.getDefaultBranch(); + const taskLevelSettings = await getEffectiveTaskSettings({ + projectSettings: settings, + taskFs: workspace.fs, + }); + return { + taskEnvVars: getTaskEnvVars({ + taskId: task.id, + taskName: task.name, + taskPath: workspace.path, + projectPath, + defaultBranch, + portSeed: workspace.path, + }), + tmuxEnabled: projectSettings.tmux ?? false, + shellSetup: taskLevelSettings.shellSetup ?? projectSettings.shellSetup, + }; +} diff --git a/src/main/core/terminals/terminal-provider.ts b/src/main/core/terminals/terminal-provider.ts index f656ff6b84..e7a048246c 100644 --- a/src/main/core/terminals/terminal-provider.ts +++ b/src/main/core/terminals/terminal-provider.ts @@ -1,4 +1,4 @@ -import { Terminal } from '@shared/terminals'; +import type { Terminal } from '@shared/terminals'; export type LifecycleScriptSpawnRequest = { terminal: Terminal; @@ -12,7 +12,7 @@ export type LifecycleScriptSpawnRequest = { export interface TerminalProvider { spawnTerminal( terminal: Terminal, - initialSize: { cols: number; rows: number }, + initialSize?: { cols: number; rows: number }, command?: { command: string; args: string[] } ): Promise; spawnLifecycleScript(request: LifecycleScriptSpawnRequest): Promise; From c3d92742f337454218aa886953913166d8f37936 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 12:26:05 +0200 Subject: [PATCH 050/263] fix: separate workspace from worktrees --- .../core/projects/impl/local-project-provider.ts | 4 +++- src/main/core/projects/impl/ssh-project-provider.ts | 4 +++- src/main/core/projects/workspace-factory.ts | 12 +++--------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 7d9875fed7..b6d0248558 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -41,6 +41,7 @@ import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { withTimeout } from '../utils'; import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; +import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createLocalProvider( @@ -164,6 +165,7 @@ export class LocalProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); + const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); const workspace = await this.workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( @@ -171,10 +173,10 @@ export class LocalProjectProvider implements ProjectProvider { { kind: 'local' }, { task, + workDir, projectId: this.project.id, projectPath: this.project.path, settings: this.settings, - worktreeService: this.worktreeService, logPrefix: 'LocalProjectProvider', extraHooks: { onCreate: async (ws) => { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index c6a5a58e75..a2713095c5 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -45,6 +45,7 @@ import { SshProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { withTimeout } from '../utils'; import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; +import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createSshProvider( @@ -206,6 +207,7 @@ export class SshProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); + const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); const workspace = await this.workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( @@ -213,10 +215,10 @@ export class SshProjectProvider implements ProjectProvider { { kind: 'ssh', proxy: this.proxy }, { task, + workDir, projectId: this.project.id, projectPath: this.project.path, settings: this.settings, - worktreeService: this.worktreeService, logPrefix: 'SshProjectProvider', } ) diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/projects/workspace-factory.ts index 5931586a64..b93d4a8206 100644 --- a/src/main/core/projects/workspace-factory.ts +++ b/src/main/core/projects/workspace-factory.ts @@ -20,17 +20,15 @@ import { TEARDOWN_SCRIPT_WAIT_MS } from './provision-task-error'; import type { ProjectSettingsProvider } from './settings/schema'; import { getEffectiveTaskSettings } from './settings/task-settings'; import { TimeoutSignal, withTimeout } from './utils'; -import { resolveTaskWorkDir } from './worktrees/utils'; -import type { WorktreeService } from './worktrees/worktree-service'; export type WorkspaceType = { kind: 'local' } | { kind: 'ssh'; proxy: SshClientProxy }; type WorkspaceFactoryContext = { - task: Pick; + task: Pick; + workDir: string; projectId: string; projectPath: string; settings: ProjectSettingsProvider; - worktreeService: WorktreeService; logPrefix: string; extraHooks?: { onCreate?: (ws: Workspace) => Promise; @@ -49,11 +47,7 @@ export function createWorkspaceFactory( context: WorkspaceFactoryContext ): () => Promise { return async () => { - const workDir = await resolveTaskWorkDir( - context.task, - context.projectPath, - context.worktreeService - ); + const workDir = context.workDir; // Transport-specific FS, exec, and git exec const workspaceFs = From 7c6e05285bb10fabf3c08b3631ff1c0ee32f28bc Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 12:36:18 +0200 Subject: [PATCH 051/263] feat: enable remote tasks --- .../projects/impl/local-project-provider.ts | 2 + .../projects/impl/ssh-project-provider.ts | 2 + src/main/core/projects/workspace-factory.ts | 35 ++++++++++++- src/main/core/repository/controller.ts | 49 +++++++++++++++---- .../workspaces/workspace-registry.test.ts | 2 + src/main/core/workspaces/workspace.ts | 4 ++ .../features/projects/stores/project.ts | 2 +- .../projects/stores/repository-store.ts | 16 +++--- .../features/tasks/stores/task-manager.ts | 15 +++++- src/renderer/features/tasks/stores/task.ts | 16 +++--- .../tasks/stores/workspace-registry.ts | 7 +-- .../features/tasks/stores/workspace.ts | 13 +++-- src/shared/events/gitEvents.ts | 3 ++ 13 files changed, 133 insertions(+), 33 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index b6d0248558..9101a9092e 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -178,6 +178,8 @@ export class LocalProjectProvider implements ProjectProvider { projectPath: this.project.path, settings: this.settings, logPrefix: 'LocalProjectProvider', + repository: this.repository, + fetchService: this._gitFetchService, extraHooks: { onCreate: async (ws) => { const mainDotGitAbs = path.resolve(this.project.path, '.git'); diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index a2713095c5..8d40d90309 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -220,6 +220,8 @@ export class SshProjectProvider implements ProjectProvider { projectPath: this.project.path, settings: this.settings, logPrefix: 'SshProjectProvider', + repository: this.repository, + fetchService: this._gitFetchService, } ) ); diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/projects/workspace-factory.ts index b93d4a8206..db8931e7bf 100644 --- a/src/main/core/projects/workspace-factory.ts +++ b/src/main/core/projects/workspace-factory.ts @@ -5,7 +5,9 @@ import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conve import type { ConversationProvider } from '@main/core/conversations/types'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { GitFetchService } from '@main/core/git/git-fetch-service'; import { GitService } from '@main/core/git/impl/git-service'; +import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import { LocalTerminalProvider } from '@main/core/terminals/impl/local-terminal-provider'; @@ -30,6 +32,12 @@ type WorkspaceFactoryContext = { projectPath: string; settings: ProjectSettingsProvider; logPrefix: string; + /** Inject an existing repository service (e.g. the project-level singleton). + * When absent, the factory creates a fresh instance from the workspace's GitService. */ + repository?: GitRepositoryService; + /** Inject an existing fetch service. When absent, the factory creates and manages one. + * Lifecycle (start/stop) is only managed by the factory when it creates the instance. */ + fetchService?: GitFetchService; extraHooks?: { onCreate?: (ws: Workspace) => Promise; onDestroy?: (ws: Workspace) => Promise; @@ -111,13 +119,32 @@ export function createWorkspaceFactory( terminals: workspaceTerminals, }); + const gitService = new GitService( + workDir, + gitExec, + workspaceFs, + type.kind === 'ssh' ? false : undefined + ); + + const repository = context.repository ?? new GitRepositoryService(gitService, context.settings); + + const ownsFetchService = !context.fetchService; + const fetchService = + context.fetchService ?? + new GitFetchService( + gitService, + async () => (await githubConnectionService.getToken()) !== null + ); + const workspace: Workspace = { id: workspaceId, path: workDir, fs: workspaceFs, - git: new GitService(workDir, gitExec, workspaceFs, type.kind === 'ssh' ? false : undefined), + git: gitService, settings: context.settings, lifecycleService, + repository, + fetchService, }; const { logPrefix } = context; @@ -126,6 +153,9 @@ export function createWorkspaceFactory( workspace, onCreateSideEffect: (ws) => { + if (ownsFetchService) { + fetchService.start(); + } if (scripts?.setup) { void ws.lifecycleService.prepareAndRunLifecycleScript({ type: 'setup', @@ -146,6 +176,9 @@ export function createWorkspaceFactory( onCreate: context.extraHooks?.onCreate, onDestroy: async (ws) => { + if (ownsFetchService) { + fetchService.stop(); + } if (scripts?.teardown) { try { await withTimeout( diff --git a/src/main/core/repository/controller.ts b/src/main/core/repository/controller.ts index 3be6fb47ef..beba4f2fbf 100644 --- a/src/main/core/repository/controller.ts +++ b/src/main/core/repository/controller.ts @@ -1,9 +1,22 @@ +import { gitRefChangedChannel } from '@shared/events/gitEvents'; import type { BranchesPayload, LocalBranchesPayload, RemoteBranchesPayload } from '@shared/git'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; +import { events } from '@main/lib/events'; import { capture } from '@main/lib/telemetry'; +import type { GitRepositoryService } from '../git/repository-service'; import { projectManager } from '../projects/project-manager'; +function resolveRepository(projectId: string, workspaceId?: string): GitRepositoryService { + const project = projectManager.getProject(projectId); + if (!project) throw new Error('Project not found'); + if (workspaceId) { + const ws = project.getWorkspace(workspaceId); + if (ws) return ws.repository; + } + return project.repository; +} + export const repositoryController = createRPCController({ getBranches: async (projectId: string): Promise => { const project = projectManager.getProject(projectId); @@ -13,16 +26,18 @@ export const repositoryController = createRPCController({ return project.repository.getBranchesPayload(); }, - getLocalBranches: async (projectId: string): Promise => { - const project = projectManager.getProject(projectId); - if (!project) throw new Error('Project not found'); - return project.repository.getLocalBranchesPayload(); + getLocalBranches: async ( + projectId: string, + workspaceId?: string + ): Promise => { + return resolveRepository(projectId, workspaceId).getLocalBranchesPayload(); }, - getRemoteBranches: async (projectId: string): Promise => { - const project = projectManager.getProject(projectId); - if (!project) throw new Error('Project not found'); - return project.repository.getRemoteBranchesPayload(); + getRemoteBranches: async ( + projectId: string, + workspaceId?: string + ): Promise => { + return resolveRepository(projectId, workspaceId).getRemoteBranchesPayload(); }, getRemotes: async (projectId: string) => { @@ -52,16 +67,30 @@ export const repositoryController = createRPCController({ return ok({ remotePushed: result.data.remotePushed }); }, - fetch: async (projectId: string) => { + fetch: async (projectId: string, workspaceId?: string) => { const project = projectManager.getProject(projectId); if (!project) return err({ type: 'not_found' as const }); - const result = await project.fetch(); + + let result; + if (workspaceId) { + const ws = project.getWorkspace(workspaceId); + result = ws ? await ws.fetchService.fetch() : await project.fetch(); + } else { + result = await project.fetch(); + } + capture('vcs_fetch', { success: result.success, project_id: projectId, ...(result.success ? {} : { error_type: result.error.type }), }); + if (!result.success) return err(result.error); + + if (workspaceId) { + events.emit(gitRefChangedChannel, { projectId, workspaceId, kind: 'remote-refs' }); + } + return ok(); }, diff --git a/src/main/core/workspaces/workspace-registry.test.ts b/src/main/core/workspaces/workspace-registry.test.ts index ebed0c759e..5cdd05336c 100644 --- a/src/main/core/workspaces/workspace-registry.test.ts +++ b/src/main/core/workspaces/workspace-registry.test.ts @@ -20,6 +20,8 @@ function makeWorkspace(id: string): { lifecycleService: { dispose, } as unknown as Workspace['lifecycleService'], + repository: {} as Workspace['repository'], + fetchService: {} as Workspace['fetchService'], }, dispose, gitDispose, diff --git a/src/main/core/workspaces/workspace.ts b/src/main/core/workspaces/workspace.ts index 11df345a0d..3b1495b043 100644 --- a/src/main/core/workspaces/workspace.ts +++ b/src/main/core/workspaces/workspace.ts @@ -1,4 +1,6 @@ import type { FileSystemProvider } from '@main/core/fs/types'; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; import type { WorkspaceGitProvider } from '@main/core/git/workspace-git-provider'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/schema'; import type { LifecycleScriptService } from './workspace-lifecycle-service'; @@ -10,4 +12,6 @@ export interface Workspace { readonly git: WorkspaceGitProvider; readonly settings: ProjectSettingsProvider; readonly lifecycleService: LifecycleScriptService; + readonly repository: GitRepositoryService; + readonly fetchService: GitFetchService; } diff --git a/src/renderer/features/projects/stores/project.ts b/src/renderer/features/projects/stores/project.ts index fcc1f58d26..207901bfe5 100644 --- a/src/renderer/features/projects/stores/project.ts +++ b/src/renderer/features/projects/stores/project.ts @@ -45,7 +45,7 @@ export class MountedProject { this.settings = new ProjectSettingsStore(data.id); this.repository = new RepositoryStore(data.id, this.settings, data.baseRef); this.prSync = new PrSyncStore(data.id); - this.taskManager = new TaskManagerStore(data.id, this.repository); + this.taskManager = new TaskManagerStore(data.id, this.repository, this.settings, data.baseRef); if (savedSnapshot) this.view.restoreSnapshot(savedSnapshot); diff --git a/src/renderer/features/projects/stores/repository-store.ts b/src/renderer/features/projects/stores/repository-store.ts index 9f480176bc..c81da8da3c 100644 --- a/src/renderer/features/projects/stores/repository-store.ts +++ b/src/renderer/features/projects/stores/repository-store.ts @@ -22,17 +22,20 @@ export class RepositoryStore { constructor( private readonly projectId: string, private readonly settingsStore: ProjectSettingsStore, - private readonly baseRef: string + private readonly baseRef: string, + private readonly workspaceId?: string ) { this.localData = new Resource( - () => rpc.repository.getLocalBranches(projectId), + () => rpc.repository.getLocalBranches(projectId, workspaceId), [ { kind: 'demand' }, { kind: 'event', subscribe: (handler) => events.on(gitRefChangedChannel, (p) => { - if (p.projectId === projectId && p.kind === 'local-refs') handler(p); + if (p.projectId !== projectId) return; + if (workspaceId ? p.workspaceId !== workspaceId : p.workspaceId !== undefined) return; + if (p.kind === 'local-refs') handler(p); }), onEvent: 'reload', debounceMs: 200, @@ -41,15 +44,16 @@ export class RepositoryStore { ); this.remoteData = new Resource( - () => rpc.repository.getRemoteBranches(projectId), + () => rpc.repository.getRemoteBranches(projectId, workspaceId), [ { kind: 'demand' }, { kind: 'event', subscribe: (handler) => events.on(gitRefChangedChannel, (p) => { - if (p.projectId === projectId && (p.kind === 'remote-refs' || p.kind === 'config')) - handler(p); + if (p.projectId !== projectId) return; + if (workspaceId ? p.workspaceId !== workspaceId : p.workspaceId !== undefined) return; + if (p.kind === 'remote-refs' || p.kind === 'config') handler(p); }), onEvent: 'reload', debounceMs: 300, diff --git a/src/renderer/features/tasks/stores/task-manager.ts b/src/renderer/features/tasks/stores/task-manager.ts index 4a284f3a58..b178052906 100644 --- a/src/renderer/features/tasks/stores/task-manager.ts +++ b/src/renderer/features/tasks/stores/task-manager.ts @@ -11,6 +11,7 @@ import type { } from '@shared/tasks'; import type { TaskViewSnapshot } from '@shared/view-state'; import { getProjectManagerStore } from '@renderer/features/projects/stores/project-selectors'; +import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; import { events, rpc } from '@renderer/lib/ipc'; import { @@ -71,6 +72,8 @@ function formatCreateTaskWarning(warning: CreateTaskWarning): string { export class TaskManagerStore { private readonly projectId: string; private readonly _repository: RepositoryStore; + private readonly _settingsStore: ProjectSettingsStore; + private readonly _baseRef: string; private _loadPromise: Promise | null = null; private _teardownPromises = new Map>(); private _provisionPromises = new Map>(); @@ -81,9 +84,16 @@ export class TaskManagerStore { tasks = observable.map(); - constructor(projectId: string, repository: RepositoryStore) { + constructor( + projectId: string, + repository: RepositoryStore, + settingsStore: ProjectSettingsStore, + baseRef: string + ) { this.projectId = projectId; this._repository = repository; + this._settingsStore = settingsStore; + this._baseRef = baseRef; makeObservable(this, { tasks: observable }); events.on(taskStatusUpdatedChannel, ({ taskId, projectId: evtProjectId, status }) => { @@ -257,7 +267,8 @@ export class TaskManagerStore { current.transitionToProvisioned( { ...current.data, lastInteractedAt: new Date().toISOString() }, result.path, - this._repository, + this._settingsStore, + this._baseRef, savedSnapshot as TaskViewSnapshot | undefined ); current.activate(); diff --git a/src/renderer/features/tasks/stores/task.ts b/src/renderer/features/tasks/stores/task.ts index d452c072a7..9022b57562 100644 --- a/src/renderer/features/tasks/stores/task.ts +++ b/src/renderer/features/tasks/stores/task.ts @@ -1,7 +1,8 @@ import { makeAutoObservable, observable, runInAction } from 'mobx'; -import { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; +import type { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; import type { TaskViewSnapshot } from '@shared/view-state'; import { workspaceKey } from '@shared/workspace-key'; +import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; import { ConversationManagerStore } from '@renderer/features/tasks/conversations/conversation-manager'; import { DraftCommentsStore } from '@renderer/features/tasks/diff-view/stores/draft-comments-store'; @@ -60,7 +61,8 @@ export class ProvisionedTask { constructor( taskStore: TaskStore, path: string, - repositoryStore: RepositoryStore, + settingsStore: ProjectSettingsStore, + baseRef: string, savedSnapshot?: TaskViewSnapshot ) { this._taskStore = taskStore; @@ -68,14 +70,15 @@ export class ProvisionedTask { this._taskData = taskData; this.path = path; this.workspaceId = workspaceKey(taskData.taskBranch); - this.repositoryStore = repositoryStore; this.workspace = workspaceRegistry.acquire( taskData.projectId, this.workspaceId, taskStore, - repositoryStore + settingsStore, + baseRef ); + this.repositoryStore = this.workspace.repository; this.devServers = new DevServerStore(taskData.id, this.workspaceId); this.conversations = new ConversationManagerStore(taskData.projectId, taskData.id); this.terminals = new TerminalManagerStore(taskData.projectId, taskData.id); @@ -161,11 +164,12 @@ export class TaskStore { transitionToProvisioned( data: Task, path: string, - repositoryStore: RepositoryStore, + settingsStore: ProjectSettingsStore, + baseRef: string, savedSnapshot?: TaskViewSnapshot ): void { this.data = data; - this.provisionedTask = new ProvisionedTask(this, path, repositoryStore, savedSnapshot); + this.provisionedTask = new ProvisionedTask(this, path, settingsStore, baseRef, savedSnapshot); this.state = 'provisioned'; this.phase = null; this.errorMessage = undefined; diff --git a/src/renderer/features/tasks/stores/workspace-registry.ts b/src/renderer/features/tasks/stores/workspace-registry.ts index bebd95b608..d188b389cd 100644 --- a/src/renderer/features/tasks/stores/workspace-registry.ts +++ b/src/renderer/features/tasks/stores/workspace-registry.ts @@ -1,4 +1,4 @@ -import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; +import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; import type { TaskStore } from './task'; import { WorkspaceStore } from './workspace'; @@ -19,7 +19,8 @@ export class WorkspaceRegistryStore { projectId: string, workspaceId: string, taskStore: TaskStore, - repositoryStore: RepositoryStore + settingsStore: ProjectSettingsStore, + baseRef: string ): WorkspaceStore { const key = makeKey(projectId, workspaceId); const existing = this.entries.get(key); @@ -29,7 +30,7 @@ export class WorkspaceRegistryStore { return existing.store; } - const store = new WorkspaceStore(projectId, workspaceId, [taskStore], repositoryStore); + const store = new WorkspaceStore(projectId, workspaceId, [taskStore], settingsStore, baseRef); this.entries.set(key, { store, refCount: 1, activated: false }); return store; } diff --git a/src/renderer/features/tasks/stores/workspace.ts b/src/renderer/features/tasks/stores/workspace.ts index bc6ee279e5..66c933f161 100644 --- a/src/renderer/features/tasks/stores/workspace.ts +++ b/src/renderer/features/tasks/stores/workspace.ts @@ -1,5 +1,6 @@ import { observable } from 'mobx'; -import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; +import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; +import { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; import { GitStore } from '../diff-view/stores/git-store'; import { FilesStore } from '../editor/stores/files-store'; import { LifecycleScriptsStore } from './lifecycle-scripts'; @@ -8,6 +9,7 @@ import type { TaskStore } from './task'; export class WorkspaceStore { readonly tasks = observable.array(); + readonly repository: RepositoryStore; git: GitStore; files: FilesStore; lifecycleScripts: LifecycleScriptsStore; @@ -17,13 +19,15 @@ export class WorkspaceStore { projectId: string, workspaceId: string, initialTasks: TaskStore[], - repositoryStore: RepositoryStore + settingsStore: ProjectSettingsStore, + baseRef: string ) { this.tasks.replace(initialTasks); - this.git = new GitStore(projectId, workspaceId, repositoryStore); + this.repository = new RepositoryStore(projectId, settingsStore, baseRef, workspaceId); + this.git = new GitStore(projectId, workspaceId, this.repository); this.files = new FilesStore(projectId, workspaceId); this.lifecycleScripts = new LifecycleScriptsStore(projectId, workspaceId); - this.pr = new PrStore(projectId, workspaceId, repositoryStore, this.tasks); + this.pr = new PrStore(projectId, workspaceId, this.repository, this.tasks); } addTask(task: TaskStore): void { @@ -41,6 +45,7 @@ export class WorkspaceStore { } dispose(): void { + this.repository.dispose(); this.git.dispose(); this.files.dispose(); this.lifecycleScripts.dispose(); diff --git a/src/shared/events/gitEvents.ts b/src/shared/events/gitEvents.ts index 5dca03dd67..08566a31d8 100644 --- a/src/shared/events/gitEvents.ts +++ b/src/shared/events/gitEvents.ts @@ -3,6 +3,9 @@ import { defineEvent } from '@shared/ipc/events'; export type GitRefChange = { projectId: string; + /** Present when the change is scoped to a specific workspace (e.g. after a workspace-level fetch). + * Absent for project-level watcher events. */ + workspaceId?: string; kind: 'local-refs' | 'remote-refs' | 'config'; /** Specific structured refs that changed, when derivable from the FS path. * Absent for packed-refs (ambiguous) and bare HEAD pointer changes. */ From 3c44511082e4650fded5cae48328892478d28d78 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 13:02:31 +0200 Subject: [PATCH 052/263] fix(worktrees): guard local worktree filesystem access --- .../projects/impl/local-project-provider.ts | 50 ++--- .../projects/impl/ssh-project-provider.ts | 22 ++- src/main/core/projects/project-manager.ts | 4 +- .../settings/project-settings.test.ts | 32 +--- .../projects/settings/project-settings.ts | 13 +- .../hosts/local-worktree-host.test.ts | 109 +++++++++++ .../worktrees/hosts/local-worktree-host.ts | 177 ++++++++++++++++++ .../worktrees/hosts/ssh-worktree-host.test.ts | 46 +++++ .../worktrees/hosts/ssh-worktree-host.ts | 62 ++++++ .../projects/worktrees/hosts/worktree-host.ts | 14 ++ .../worktrees/worktree-service.test.ts | 9 +- .../projects/worktrees/worktree-service.ts | 44 ++--- 12 files changed, 497 insertions(+), 85 deletions(-) create mode 100644 src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts create mode 100644 src/main/core/projects/worktrees/hosts/local-worktree-host.ts create mode 100644 src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts create mode 100644 src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts create mode 100644 src/main/core/projects/worktrees/hosts/worktree-host.ts diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index cbf4da7651..31d1ffa160 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -1,15 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; -import { Conversation } from '@shared/conversations'; +import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import { LocalProject } from '@shared/projects'; +import type { LocalProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { type Terminal } from '@shared/terminals'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import { LocalConversationProvider } from '@main/core/conversations/impl/local-conversation'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; @@ -45,6 +45,8 @@ import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { getEffectiveTaskSettings } from '../settings/task-settings'; import { TimeoutSignal, withTimeout } from '../utils'; +import { LocalWorktreeHost } from '../worktrees/hosts/local-worktree-host'; +import type { WorktreeHost } from '../worktrees/hosts/worktree-host'; import { WorktreeService } from '../worktrees/worktree-service'; const TASK_TIMEOUT_MS = 60_000; @@ -61,20 +63,25 @@ function toTeardownError(e: unknown): TeardownTaskError { return { type: 'error', message: e instanceof Error ? e.message : String(e) }; } -export async function createLocalProvider( - project: LocalProject, - rootFs: FileSystemProvider -): Promise { - const settings = new LocalProjectSettingsProvider( - project.path, - bareRefName(project.baseRef), - rootFs - ); - const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); - - await fs.promises.mkdir(worktreePoolPath, { recursive: true }); - - return new LocalProjectProvider(project, rootFs, { settings, worktreePoolPath }); +export async function createLocalProvider(project: LocalProject): Promise { + const settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); + const worktreeDirectory = await settings.getWorktreeDirectory(); + await fs.promises.mkdir(worktreeDirectory, { recursive: true }); + + const projectFs = new LocalFileSystem(project.path); + const worktreeHost = await LocalWorktreeHost.create({ + allowedRoots: [project.path, worktreeDirectory], + }); + const worktreePoolPath = path.join(worktreeDirectory, project.name); + + await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); + + return new LocalProjectProvider(project, { + projectFs, + worktreeHost, + settings, + worktreePoolPath, + }); } export class LocalProjectProvider implements ProjectProvider { @@ -96,14 +103,15 @@ export class LocalProjectProvider implements ProjectProvider { constructor( private readonly project: LocalProject, - readonly rootFs: FileSystemProvider, options: { + projectFs: FileSystemProvider; + worktreeHost: WorktreeHost; settings: ProjectSettingsProvider; worktreePoolPath: string; } ) { this.settings = options.settings; - this.fs = new LocalFileSystem(project.path); + this.fs = options.projectFs; const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); const repoGit = new GitService(project.path, gitExec, this.fs); this.repository = new GitRepositoryService(repoGit, this.settings); @@ -112,7 +120,7 @@ export class LocalProjectProvider implements ProjectProvider { repoPath: project.path, projectSettings: this.settings, exec: gitExec, - rootFs: rootFs, + host: options.worktreeHost, }); this._gitWatcher = new GitWatcherService(project.id, project.path); void this._gitWatcher.start(); diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 0ec6bcf386..c94fb865ba 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -1,15 +1,15 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { SFTPWrapper } from 'ssh2'; -import { Conversation } from '@shared/conversations'; +import { type Conversation } from '@shared/conversations'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import { err, ok, type Result } from '@shared/result'; import { getTaskEnvVars } from '@shared/task/envVars'; -import { Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { Terminal } from '@shared/terminals'; +import { type Task, type TaskBootstrapStatus } from '@shared/tasks'; +import { type Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; @@ -20,8 +20,8 @@ import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import { SshConnectionEvent, sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import { type SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { type SshConnectionEvent, sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; @@ -45,6 +45,8 @@ import { SshProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; import { getEffectiveTaskSettings } from '../settings/task-settings'; import { TimeoutSignal, withTimeout } from '../utils'; +import { SshWorktreeHost } from '../worktrees/hosts/ssh-worktree-host'; +import type { WorktreeHost } from '../worktrees/hosts/worktree-host'; import { WorktreeService } from '../worktrees/worktree-service'; const TASK_TIMEOUT_MS = 60_000; @@ -78,10 +80,12 @@ export async function createSshProvider( exec ); const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); - await rootFs.mkdir(worktreePoolPath, { recursive: true }); + const worktreeHost = new SshWorktreeHost(rootFs); + await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); - return new SshProjectProvider(project, rootFs, proxy, { + return new SshProjectProvider(project, proxy, { fs: projectFs, + worktreeHost, settings, worktreePoolPath, }); @@ -113,10 +117,10 @@ export class SshProjectProvider implements ProjectProvider { constructor( private readonly project: SshProject, - rootFs: FileSystemProvider, private readonly proxy: SshClientProxy, options: { fs: SshFileSystem; + worktreeHost: WorktreeHost; settings: ProjectSettingsProvider; worktreePoolPath: string; } @@ -131,7 +135,7 @@ export class SshProjectProvider implements ProjectProvider { repoPath: project.path, projectSettings: this.settings, exec: gitExec, - rootFs: rootFs, + host: options.worktreeHost, }); this._gitFetchService = new GitFetchService(repoGit); this._gitFetchService.start(); diff --git a/src/main/core/projects/project-manager.ts b/src/main/core/projects/project-manager.ts index 71abe61889..b25c00edcc 100644 --- a/src/main/core/projects/project-manager.ts +++ b/src/main/core/projects/project-manager.ts @@ -6,7 +6,6 @@ import type { } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; import { log } from '@main/lib/logger'; -import { LocalFileSystem } from '../fs/impl/local-fs'; import { SshFileSystem } from '../fs/impl/ssh-fs'; import { checkIsValidDirectory } from '../git/impl/detectGitInfo'; import { getProjectById, getProjects } from '../projects/operations/getProjects'; @@ -172,8 +171,7 @@ async function createProvider(project: LocalProject | SshProject): Promise { it('normalizes and canonicalizes local worktreeDirectory on update', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/worktrees'), - }; - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); expect(result.success).toBe(true); - expect(rootFs.mkdir).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees'), { - recursive: true, - }); - expect(rootFs.realPath).toHaveBeenCalledWith(path.resolve(projectPath, 'worktrees')); + const expectedPath = path.resolve(projectPath, 'worktrees'); + expect(fs.existsSync(expectedPath)).toBe(true); const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); - expect(persisted.worktreeDirectory).toBe('/canonical/worktrees'); + expect(persisted.worktreeDirectory).toBe(fs.realpathSync(expectedPath)); }); it('surfaces local worktreeDirectory validation errors', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockRejectedValue(new Error('EACCES')), - realPath: vi.fn(), - }; + fs.writeFileSync(path.join(projectPath, 'not-a-directory'), 'file'); - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], - worktreeDirectory: '/restricted', + worktreeDirectory: path.join(projectPath, 'not-a-directory', 'worktrees'), }); expect(result).toEqual({ success: false, @@ -72,16 +63,11 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('clears blank local worktreeDirectory values', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/unused'), - }; - const provider = new LocalProjectSettingsProvider(projectPath, 'main', rootFs); + const provider = new LocalProjectSettingsProvider(projectPath, 'main'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); expect(result.success).toBe(true); - expect(rootFs.mkdir).not.toHaveBeenCalled(); const persisted = JSON.parse(fs.readFileSync(path.join(projectPath, '.emdash.json'), 'utf8')); expect(persisted.worktreeDirectory).toBeUndefined(); }); diff --git a/src/main/core/projects/settings/project-settings.ts b/src/main/core/projects/settings/project-settings.ts index f89aaf2711..b3c77e2439 100644 --- a/src/main/core/projects/settings/project-settings.ts +++ b/src/main/core/projects/settings/project-settings.ts @@ -3,14 +3,18 @@ import os from 'node:os'; import path from 'node:path'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { getDefaultSshWorktreeDirectory } from '@main/core/settings/worktree-defaults'; import { resolveRemoteHome } from '@main/core/ssh/utils'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { ProjectSettings, ProjectSettingsProvider, projectSettingsSchema } from './schema'; +import { + projectSettingsSchema, + type ProjectSettings, + type ProjectSettingsProvider, +} from './schema'; import { defaultLocalWorktreeFs, normalizeWorktreeDirectory, @@ -31,8 +35,7 @@ function parseSettingsOrDefault(raw: string, source: string): ProjectSettings { export class LocalProjectSettingsProvider implements ProjectSettingsProvider { constructor( private readonly projectPath: string, - private readonly defaultBranchFallback: string = 'main', - private readonly rootFs?: Pick + private readonly defaultBranchFallback: string = 'main' ) {} async get(): Promise { @@ -54,7 +57,7 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { { projectPath: this.projectPath, pathApi: path, - fs: this.rootFs ?? defaultLocalWorktreeFs, + fs: defaultLocalWorktreeFs, homeDirectory: os.homedir(), } ); diff --git a/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts b/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts new file mode 100644 index 0000000000..e443d7ef34 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FileSystemErrorCodes } from '@main/core/fs/types'; +import { isPathInsideRoot, LocalWorktreeHost } from './local-worktree-host'; + +describe('LocalWorktreeHost', () => { + let repoDir: string; + let worktreeDir: string; + let outsideDir: string; + + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-repo-')); + worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-worktrees-')); + outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-outside-')); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(worktreeDir, { recursive: true, force: true }); + fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + async function makeHost(): Promise { + return LocalWorktreeHost.create({ + allowedRoots: [repoDir, worktreeDir], + }); + } + + it('copies files between separate allowed roots using absolute paths', async () => { + const host = await makeHost(); + const src = path.join(repoDir, '.env'); + const dest = path.join(worktreeDir, 'task-1', '.env'); + fs.writeFileSync(src, 'SECRET=abc'); + + await host.mkdirAbsolute(path.dirname(dest), { recursive: true }); + await host.copyFileAbsolute(src, dest); + + expect(fs.readFileSync(dest, 'utf8')).toBe('SECRET=abc'); + }); + + it('rejects relative paths', async () => { + const host = await makeHost(); + + await expect(host.mkdirAbsolute('relative/path', { recursive: true })).rejects.toMatchObject({ + code: FileSystemErrorCodes.INVALID_PATH, + }); + }); + + it('rejects paths outside the allowed roots', async () => { + const host = await makeHost(); + const src = path.join(outsideDir, 'secret.txt'); + const dest = path.join(worktreeDir, 'secret.txt'); + fs.writeFileSync(src, 'outside'); + + await expect(host.copyFileAbsolute(src, dest)).rejects.toMatchObject({ + code: FileSystemErrorCodes.PATH_ESCAPE, + }); + }); + + it('rejects symlink escapes outside the allowed roots', async () => { + if (process.platform === 'win32') { + return; + } + + const host = await makeHost(); + const secret = path.join(outsideDir, 'passwords.txt'); + const escape = path.join(worktreeDir, 'escape'); + fs.writeFileSync(secret, 'outside'); + fs.symlinkSync(outsideDir, escape); + + await expect(host.realPathAbsolute(path.join(escape, 'passwords.txt'))).rejects.toMatchObject({ + code: FileSystemErrorCodes.PATH_ESCAPE, + }); + }); + + it('returns false/null for out-of-scope existence checks', async () => { + const host = await makeHost(); + const outside = path.join(outsideDir, 'file.txt'); + fs.writeFileSync(outside, 'outside'); + + await expect(host.existsAbsolute(outside)).resolves.toBe(false); + await expect(host.statAbsolute(outside)).resolves.toBeNull(); + }); + + it('matches Windows paths by drive-aware containment rules', () => { + expect( + isPathInsideRoot(String.raw`C:\repo\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(true); + expect( + isPathInsideRoot(String.raw`C:\repo2\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(false); + expect( + isPathInsideRoot(String.raw`D:\repo\.env`, String.raw`C:\repo`, { + pathApi: path.win32, + }) + ).toBe(false); + expect( + isPathInsideRoot(String.raw`c:\repo\.env`, String.raw`C:\Repo`, { + pathApi: path.win32, + }) + ).toBe(true); + }); +}); diff --git a/src/main/core/projects/worktrees/hosts/local-worktree-host.ts b/src/main/core/projects/worktrees/hosts/local-worktree-host.ts new file mode 100644 index 0000000000..9c5a88bce7 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/local-worktree-host.ts @@ -0,0 +1,177 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { glob } from 'glob'; +import { FileSystemError, FileSystemErrorCodes, type FileEntry } from '@main/core/fs/types'; +import type { WorktreeHost } from './worktree-host'; + +type PathApi = Pick; + +export function isPathInsideRoot( + child: string, + parent: string, + options: { pathApi?: PathApi } = {} +): boolean { + const pathApi = options.pathApi ?? path; + const rel = pathApi.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !pathApi.isAbsolute(rel)); +} + +function isNotFound(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +export class LocalWorktreeHost implements WorktreeHost { + private constructor(private readonly roots: string[]) {} + + static async create(args: { allowedRoots: string[] }): Promise { + if (args.allowedRoots.length === 0) { + throw new FileSystemError( + 'At least one allowed root is required', + FileSystemErrorCodes.INVALID_PATH + ); + } + + const roots = await Promise.all( + args.allowedRoots.map(async (root) => { + const resolved = path.resolve(root); + if (!path.isAbsolute(resolved)) { + throw new FileSystemError( + `Expected absolute allowed root: ${root}`, + FileSystemErrorCodes.INVALID_PATH, + root + ); + } + return fs.realpath(resolved); + }) + ); + + return new LocalWorktreeHost(roots); + } + + private assertAbsolute(input: string): string { + const resolved = path.resolve(input); + if (!path.isAbsolute(input)) { + throw new FileSystemError( + `Expected absolute path: ${input}`, + FileSystemErrorCodes.INVALID_PATH, + input + ); + } + return resolved; + } + + private assertInsideAllowedRoots(resolved: string, originalPath: string): void { + if (!this.roots.some((root) => isPathInsideRoot(resolved, root))) { + throw new FileSystemError( + `Path outside allowed roots: ${originalPath}`, + FileSystemErrorCodes.PATH_ESCAPE, + originalPath + ); + } + } + + private async validateExisting(input: string): Promise { + const resolved = this.assertAbsolute(input); + const real = await fs.realpath(resolved); + this.assertInsideAllowedRoots(real, input); + return real; + } + + private async nearestExistingPath(resolved: string): Promise<{ + realAncestor: string; + unresolvedSegments: string[]; + }> { + const unresolvedSegments: string[] = []; + let current = resolved; + + while (true) { + try { + return { + realAncestor: await fs.realpath(current), + unresolvedSegments: unresolvedSegments.reverse(), + }; + } catch (error) { + if (!isNotFound(error)) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + unresolvedSegments.push(path.basename(current)); + current = parent; + } + } + } + + private async validateTarget(input: string): Promise { + const resolved = this.assertAbsolute(input); + try { + return await this.validateExisting(resolved); + } catch (error) { + if (!isNotFound(error)) throw error; + } + + const { realAncestor, unresolvedSegments } = await this.nearestExistingPath(resolved); + this.assertInsideAllowedRoots(realAncestor, input); + const target = path.join(realAncestor, ...unresolvedSegments); + this.assertInsideAllowedRoots(target, input); + return target; + } + + async existsAbsolute(filePath: string): Promise { + try { + await this.validateExisting(filePath); + return true; + } catch { + return false; + } + } + + async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { + const target = await this.validateTarget(dirPath); + await fs.mkdir(target, { recursive: options?.recursive ?? false }); + } + + async removeAbsolute( + filePath: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }> { + try { + const target = await this.validateExisting(filePath); + await fs.rm(target, { recursive: options?.recursive ?? false, force: false }); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + async realPathAbsolute(filePath: string): Promise { + return this.validateExisting(filePath); + } + + async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { + const cwd = await this.validateExisting(options.cwd); + return glob(pattern, { cwd, dot: options.dot ?? false, absolute: false }); + } + + async copyFileAbsolute(src: string, dest: string): Promise { + const safeSrc = await this.validateExisting(src); + const safeDest = await this.validateTarget(dest); + await fs.copyFile(safeSrc, safeDest); + } + + async statAbsolute(filePath: string): Promise { + try { + const fullPath = await this.validateExisting(filePath); + const stat = await fs.stat(fullPath); + return { + path: fullPath, + type: stat.isDirectory() ? 'dir' : 'file', + size: stat.size, + mtime: stat.mtime, + ctime: stat.ctime, + mode: stat.mode, + }; + } catch { + return null; + } + } +} diff --git a/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts new file mode 100644 index 0000000000..082c43d890 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FileSystemErrorCodes, type FileSystemProvider } from '@main/core/fs/types'; +import { SshWorktreeHost } from './ssh-worktree-host'; + +function makeFs(): Pick< + FileSystemProvider, + 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'copyFile' | 'stat' +> { + return { + exists: vi.fn().mockResolvedValue(true), + mkdir: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue({ success: true }), + realPath: vi.fn().mockResolvedValue('/real/path'), + glob: vi.fn().mockResolvedValue(['.env']), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue(null), + }; +} + +describe('SshWorktreeHost', () => { + it('delegates absolute POSIX paths to the wrapped filesystem', async () => { + const fs = makeFs(); + const host = new SshWorktreeHost(fs); + + await host.mkdirAbsolute('/remote/worktrees/project', { recursive: true }); + await host.copyFileAbsolute('/remote/repo/.env', '/remote/worktrees/project/task/.env'); + await host.globAbsolute('.env', { cwd: '/remote/repo', dot: true }); + + expect(fs.mkdir).toHaveBeenCalledWith('/remote/worktrees/project', { recursive: true }); + expect(fs.copyFile).toHaveBeenCalledWith( + '/remote/repo/.env', + '/remote/worktrees/project/task/.env' + ); + expect(fs.glob).toHaveBeenCalledWith('.env', { cwd: '/remote/repo', dot: true }); + }); + + it('rejects relative paths before delegating', async () => { + const fs = makeFs(); + const host = new SshWorktreeHost(fs); + + await expect(host.existsAbsolute('relative/path')).rejects.toMatchObject({ + code: FileSystemErrorCodes.INVALID_PATH, + }); + expect(fs.exists).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts new file mode 100644 index 0000000000..68704910de --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import { + FileSystemError, + FileSystemErrorCodes, + type FileEntry, + type FileSystemProvider, +} from '@main/core/fs/types'; +import type { WorktreeHost } from './worktree-host'; + +type SshWorktreeFs = Pick< + FileSystemProvider, + 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'copyFile' | 'stat' +>; + +export class SshWorktreeHost implements WorktreeHost { + constructor(private readonly fs: SshWorktreeFs) {} + + private validateAbsolute(input: string): string { + if (!path.posix.isAbsolute(input)) { + throw new FileSystemError( + `Expected absolute POSIX path: ${input}`, + FileSystemErrorCodes.INVALID_PATH, + input + ); + } + return input; + } + + async existsAbsolute(filePath: string): Promise { + return this.fs.exists(this.validateAbsolute(filePath)); + } + + async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { + return this.fs.mkdir(this.validateAbsolute(dirPath), options); + } + + async removeAbsolute( + filePath: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }> { + return this.fs.remove(this.validateAbsolute(filePath), options); + } + + async realPathAbsolute(filePath: string): Promise { + return this.fs.realPath(this.validateAbsolute(filePath)); + } + + async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { + return this.fs.glob(pattern, { + ...options, + cwd: this.validateAbsolute(options.cwd), + }); + } + + async copyFileAbsolute(src: string, dest: string): Promise { + return this.fs.copyFile(this.validateAbsolute(src), this.validateAbsolute(dest)); + } + + async statAbsolute(filePath: string): Promise { + return this.fs.stat(this.validateAbsolute(filePath)); + } +} diff --git a/src/main/core/projects/worktrees/hosts/worktree-host.ts b/src/main/core/projects/worktrees/hosts/worktree-host.ts new file mode 100644 index 0000000000..fc5e8d97c7 --- /dev/null +++ b/src/main/core/projects/worktrees/hosts/worktree-host.ts @@ -0,0 +1,14 @@ +import type { FileEntry } from '@main/core/fs/types'; + +export interface WorktreeHost { + existsAbsolute(path: string): Promise; + mkdirAbsolute(path: string, options?: { recursive?: boolean }): Promise; + removeAbsolute( + path: string, + options?: { recursive?: boolean } + ): Promise<{ success: boolean; error?: string }>; + realPathAbsolute(path: string): Promise; + globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise; + copyFileAbsolute(src: string, dest: string): Promise; + statAbsolute(path: string): Promise; +} diff --git a/src/main/core/projects/worktrees/worktree-service.test.ts b/src/main/core/projects/worktrees/worktree-service.test.ts index dc2c9e8a39..95be697d7d 100644 --- a/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/src/main/core/projects/worktrees/worktree-service.test.ts @@ -4,9 +4,10 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { Remote } from '@shared/git'; import { ok } from '@shared/result'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { getLocalExec, type ExecFn } from '@main/core/utils/exec'; import type { ProjectSettingsProvider } from '../settings/schema'; +import { LocalWorktreeHost } from './hosts/local-worktree-host'; +import type { WorktreeHost } from './hosts/worktree-host'; import { WorktreeService } from './worktree-service'; async function initRepo(dir: string, exec: ExecFn): Promise { @@ -34,12 +35,16 @@ describe('WorktreeService', () => { let repoDir: string; let poolDir: string; let exec: ExecFn; + let host: WorktreeHost; beforeEach(async () => { repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-repo-')); poolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-pool-')); exec = getLocalExec(); await initRepo(repoDir, exec); + host = await LocalWorktreeHost.create({ + allowedRoots: [repoDir, poolDir], + }); }); afterEach(() => { @@ -59,7 +64,7 @@ describe('WorktreeService', () => { worktreePoolPath: poolDir, repoPath: repoDir, exec, - rootFs: new LocalFileSystem('/'), + host, projectSettings: makeSettings(), ...overrides, }); diff --git a/src/main/core/projects/worktrees/worktree-service.ts b/src/main/core/projects/worktrees/worktree-service.ts index 82f42479e8..3c8131e4ec 100644 --- a/src/main/core/projects/worktrees/worktree-service.ts +++ b/src/main/core/projects/worktrees/worktree-service.ts @@ -1,11 +1,11 @@ import path from 'node:path'; import type { Branch } from '@shared/git'; import { DEFAULT_REMOTE_NAME } from '@shared/git-utils'; -import { err, ok, Result } from '@shared/result'; -import { FileSystemProvider } from '@main/core/fs/types'; -import { ExecFn } from '@main/core/utils/exec'; +import { err, ok, type Result } from '@shared/result'; +import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; -import { ProjectSettingsProvider } from '../settings/schema'; +import type { ProjectSettingsProvider } from '../settings/schema'; +import type { WorktreeHost } from './hosts/worktree-host'; export type ServeWorktreeError = | { type: 'worktree-setup-failed'; cause: unknown } @@ -16,21 +16,21 @@ export class WorktreeService { private readonly worktreePoolPath: string; private readonly repoPath: string; private readonly exec: ExecFn; - private readonly rootFs: FileSystemProvider; + private readonly host: WorktreeHost; private readonly projectSettings: ProjectSettingsProvider; constructor(args: { worktreePoolPath: string; repoPath: string; exec: ExecFn; - rootFs: FileSystemProvider; + host: WorktreeHost; projectSettings: ProjectSettingsProvider; }) { this.worktreePoolPath = args.worktreePoolPath; this.repoPath = args.repoPath; this.projectSettings = args.projectSettings; this.exec = args.exec; - this.rootFs = args.rootFs; + this.host = args.host; this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); } @@ -51,7 +51,7 @@ export class WorktreeService { } private async ensureWorktreePoolDirExists(): Promise { - await this.rootFs.mkdir(this.worktreePoolPath, { recursive: true }); + await this.host.mkdirAbsolute(this.worktreePoolPath, { recursive: true }); } private async getRemoteCandidates(): Promise { @@ -112,13 +112,13 @@ export class WorktreeService { async getWorktree(branchName: string): Promise { const worktreePath = path.join(this.worktreePoolPath, branchName); - if (await this.rootFs.exists(worktreePath)) { + if (await this.host.existsAbsolute(worktreePath)) { if (await this.isValidWorktree(worktreePath)) return worktreePath; - await this.rootFs.remove(worktreePath, { recursive: true }).catch(() => {}); + await this.host.removeAbsolute(worktreePath, { recursive: true }).catch(() => {}); } try { - const realPoolPath = await this.rootFs.realPath(this.worktreePoolPath); + const realPoolPath = await this.host.realPathAbsolute(this.worktreePoolPath); const { stdout } = await this.exec('git', ['worktree', 'list', '--porcelain'], { cwd: this.repoPath, }); @@ -151,9 +151,9 @@ export class WorktreeService { } const targetPath = path.join(this.worktreePoolPath, branchName); - if (await this.rootFs.exists(targetPath)) { + if (await this.host.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); - await this.rootFs.remove(targetPath, { recursive: true }).catch(() => {}); + await this.host.removeAbsolute(targetPath, { recursive: true }).catch(() => {}); await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); } @@ -176,7 +176,7 @@ export class WorktreeService { }); } - await this.rootFs.mkdir(path.dirname(targetPath), { recursive: true }); + await this.host.mkdirAbsolute(path.dirname(targetPath), { recursive: true }); await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); await this.exec('git', ['worktree', 'add', targetPath, branchName], { cwd: this.repoPath, @@ -211,14 +211,14 @@ export class WorktreeService { const targetPath = path.join(this.worktreePoolPath, branchName); const remoteCandidates = await this.getRemoteCandidates(); - if (await this.rootFs.exists(targetPath)) { + if (await this.host.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); - await this.rootFs.remove(targetPath, { recursive: true }); + await this.host.removeAbsolute(targetPath, { recursive: true }); await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); } try { - await this.rootFs.mkdir(path.dirname(targetPath), { recursive: true }); + await this.host.mkdirAbsolute(path.dirname(targetPath), { recursive: true }); for (const remoteName of remoteCandidates) { await this.exec('git', ['fetch', remoteName], { cwd: this.repoPath }).catch(() => {}); } @@ -280,7 +280,7 @@ export class WorktreeService { } async removeWorktree(worktreePath: string): Promise { - await this.rootFs.remove(worktreePath, { recursive: true }).catch(() => {}); + await this.host.removeAbsolute(worktreePath, { recursive: true }).catch(() => {}); await this.exec('git', ['worktree', 'prune'], { cwd: this.repoPath }).catch(() => {}); } @@ -288,17 +288,17 @@ export class WorktreeService { const settings = await this.projectSettings.get(); const patterns = settings.preservePatterns ?? []; for (const pattern of patterns) { - const matches = await this.rootFs.glob(pattern, { + const matches = await this.host.globAbsolute(pattern, { cwd: this.repoPath, dot: true, }); for (const relPath of matches) { const src = path.join(this.repoPath, relPath); - const stat = await this.rootFs.stat(src).catch(() => null); + const stat = await this.host.statAbsolute(src).catch(() => null); if (!stat || stat.type !== 'file') continue; const dest = path.join(targetPath, relPath); - await this.rootFs.mkdir(path.dirname(dest), { recursive: true }); - await this.rootFs.copyFile(src, dest); + await this.host.mkdirAbsolute(path.dirname(dest), { recursive: true }); + await this.host.copyFileAbsolute(src, dest); } } } From 31de45e13917fcea85f595cb92ab6f1d04e63c03 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 13:15:29 +0200 Subject: [PATCH 053/263] refactor(projects): inline worktree directory fs contract --- .../core/projects/impl/ssh-project-provider.ts | 5 ++++- .../core/projects/settings/project-settings.ts | 8 ++++++-- .../core/projects/settings/worktree-directory.ts | 16 +++------------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index c94fb865ba..853440e2d2 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -21,7 +21,10 @@ import { githubConnectionService } from '@main/core/github/services/github-conne import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { type SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import { type SshConnectionEvent, sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; diff --git a/src/main/core/projects/settings/project-settings.ts b/src/main/core/projects/settings/project-settings.ts index b3c77e2439..5532d9c604 100644 --- a/src/main/core/projects/settings/project-settings.ts +++ b/src/main/core/projects/settings/project-settings.ts @@ -16,7 +16,6 @@ import { type ProjectSettingsProvider, } from './schema'; import { - defaultLocalWorktreeFs, normalizeWorktreeDirectory, resolveAndValidateWorktreeDirectory, } from './worktree-directory'; @@ -57,7 +56,12 @@ export class LocalProjectSettingsProvider implements ProjectSettingsProvider { { projectPath: this.projectPath, pathApi: path, - fs: defaultLocalWorktreeFs, + fs: { + mkdir: async (p, options) => { + await fs.promises.mkdir(p, options); + }, + realPath: async (p) => fs.promises.realpath(p), + }, homeDirectory: os.homedir(), } ); diff --git a/src/main/core/projects/settings/worktree-directory.ts b/src/main/core/projects/settings/worktree-directory.ts index 37c425b1c5..aba0017f88 100644 --- a/src/main/core/projects/settings/worktree-directory.ts +++ b/src/main/core/projects/settings/worktree-directory.ts @@ -1,11 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import type path from 'node:path'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; -export type WorktreeDirectoryFs = Pick; - type PathApi = Pick; export async function normalizeWorktreeDirectory( @@ -44,7 +41,7 @@ export async function normalizeWorktreeDirectory( export async function canonicalizeWorktreeDirectory( directory: string, - fs: WorktreeDirectoryFs + fs: Pick ): Promise> { try { await fs.mkdir(directory, { recursive: true }); @@ -54,19 +51,12 @@ export async function canonicalizeWorktreeDirectory( } } -export const defaultLocalWorktreeFs: WorktreeDirectoryFs = { - mkdir: async (p, options) => { - await fs.promises.mkdir(p, options); - }, - realPath: async (p) => fs.promises.realpath(p), -}; - export async function resolveAndValidateWorktreeDirectory( input: string | undefined, options: { projectPath: string; pathApi: Pick; - fs: WorktreeDirectoryFs; + fs: Pick; homeDirectory?: string; resolveHomeDirectory?: () => Promise; } From a3591068343a1a4bf867dc40773571e28630d788 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 14:06:09 +0200 Subject: [PATCH 054/263] feat: add BYOI task provision support --- drizzle/0007_jittery_johnny_blaze.sql | 1 + drizzle/meta/0007_snapshot.json | 1278 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../projects/impl/local-project-provider.ts | 208 ++- .../projects/impl/ssh-project-provider.ts | 87 +- src/main/core/projects/provision-output.ts | 41 + src/main/core/projects/settings/schema.ts | 7 + src/main/core/projects/task-builder.ts | 86 ++ .../core/pull-requests/pr-query-service.ts | 8 - src/main/core/ssh/ssh-connection-manager.ts | 17 + src/main/core/tasks/core.ts | 7 +- src/main/core/tasks/createTask.ts | 3 +- src/main/db/schema.ts | 1 + src/renderer/features/tasks/main-panel.tsx | 6 +- .../features/tasks/stores/task-manager.ts | 18 +- src/renderer/features/tasks/stores/task.ts | 3 + src/shared/events/taskEvents.ts | 13 + src/shared/tasks.ts | 2 + 18 files changed, 1674 insertions(+), 119 deletions(-) create mode 100644 drizzle/0007_jittery_johnny_blaze.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 src/main/core/projects/provision-output.ts create mode 100644 src/main/core/projects/task-builder.ts diff --git a/drizzle/0007_jittery_johnny_blaze.sql b/drizzle/0007_jittery_johnny_blaze.sql new file mode 100644 index 0000000000..1fd5dd4dc3 --- /dev/null +++ b/drizzle/0007_jittery_johnny_blaze.sql @@ -0,0 +1 @@ +ALTER TABLE `tasks` ADD `workspace_provider` text DEFAULT 'local' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..92379b4525 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1278 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c679c995-005e-4295-9241-bccec75aadd9", + "prevId": "79109ea2-737d-48d0-b5c6-ec2ceb4b0d3c", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e9a35926ba..d4500369d9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1776959681864, "tag": "0006_bumpy_gamma_corps", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1777376101478, + "tag": "0007_jittery_johnny_blaze", + "breakpoints": true } ] } diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 9101a9092e..407d5faf4c 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; import type { LocalProject, ProjectRemoteState } from '@shared/projects'; @@ -19,18 +20,21 @@ import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; +import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; import type { Workspace } from '@main/core/workspaces/workspace'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; import { type ProjectProvider, type ProvisionTaskError, type TaskProvider, type TeardownTaskError, } from '../project-provider'; +import { parseProvisionOutput } from '../provision-output'; import { formatProvisionTaskError, TASK_TIMEOUT_MS, @@ -38,9 +42,10 @@ import { toTeardownError, } from '../provision-task-error'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettingsProvider } from '../settings/schema'; +import type { ProjectSettings, ProjectSettingsProvider } from '../settings/schema'; +import { buildTaskFromWorkspace } from '../task-builder'; import { withTimeout } from '../utils'; -import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; +import { createWorkspaceFactory } from '../workspace-factory'; import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; @@ -76,6 +81,13 @@ export class LocalProjectProvider implements ProjectProvider { private readonly _gitWatcher: GitWatcherService; private readonly _gitFetchService: GitFetchService; private _configChangeUnsubscribe: (() => void) | undefined; + private _remoteHandles = new Map< + string, + { + terminationId: string | undefined; + terminateCommand: string; + } + >(); constructor( private readonly project: LocalProject, @@ -152,9 +164,12 @@ export class LocalProjectProvider implements ProjectProvider { conversations: Conversation[], terminals: Terminal[] ): Promise { - log.debug('LocalProjectProvider: doProvisionTask START', { - taskId: task.id, - }); + log.debug('LocalProjectProvider: doProvisionTask START', { taskId: task.id }); + + const settings = await this.settings.get(); + if (task.workspaceProvider === 'ssh' && settings.workspaceProvider?.type === 'script') { + return this.doProvisionRemoteTask(task, conversations, terminals, settings.workspaceProvider); + } // Refresh remote-tracking refs in the background so they are as fresh as // possible during the lifetime of this task. Non-blocking — provision @@ -165,7 +180,21 @@ export class LocalProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'resolving-worktree', + message: 'Resolving worktree…', + }); const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'initialising-workspace', + message: 'Initialising workspace…', + }); const workspace = await this.workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( @@ -194,61 +223,127 @@ export class LocalProjectProvider implements ProjectProvider { let provisionSucceeded = false; try { - const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const { taskProvider } = await buildTaskFromWorkspace( task, workspace, + { kind: 'local' }, + this.project.id, this.project.path, - this.settings - ); - const { conversations: conversationProvider, terminals: terminalProvider } = - buildTaskProviders( - { kind: 'local' }, - { - projectId: this.project.id, - taskId: task.id, - taskPath: workspace.path, - tmuxEnabled, - shellSetup, - taskEnvVars, - } - ); - - const taskEnv: TaskProvider = { - taskId: task.id, - taskBranch: task.taskBranch, - sourceBranch: task.sourceBranch, - taskEnvVars, - conversations: conversationProvider, - terminals: terminalProvider, - }; - - void Promise.all( - terminals.map((term) => - terminalProvider.spawnTerminal(term).catch((e) => { - log.error('LocalEnvironmentProvider: failed to hydrate terminal', { - terminalId: term.id, - error: String(e), - }); - }) - ) + this.settings, + { conversations, terminals }, + 'LocalProjectProvider' ); + log.debug('LocalProjectProvider: doProvisionTask DONE', { taskId: task.id }); + provisionSucceeded = true; + return taskProvider; + } finally { + if (!provisionSucceeded) { + await this.workspaceRegistry.release(workspace.id).catch(() => {}); + } + } + } - void Promise.all( - conversations.map((conv) => - conversationProvider.startSession(conv, undefined, true).catch((e) => { - log.error('LocalEnvironmentProvider: failed to hydrate conversation', { - conversationId: conv.id, - error: String(e), - }); - }) - ) - ); + private async doProvisionRemoteTask( + task: Task, + conversations: Conversation[], + terminals: Terminal[], + wpConfig: NonNullable + ): Promise { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'running-provision-script', + message: 'Running provision script…', + }); + + const { stdout } = await this.localExec('/bin/sh', ['-c', wpConfig.provisionCommand], { + cwd: this.project.path, + }); + + const parseResult = parseProvisionOutput(stdout); + if (!parseResult.success) { + throw new Error(parseResult.error.message); + } + const output = parseResult.data; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'connecting', + message: `Connecting to ${output.host}…`, + }); + + const connectionId = `task:${task.id}`; + const proxy = await sshConnectionManager.connectFromConfig(connectionId, { + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env['USER'], + agent: process.env['SSH_AUTH_SOCK'], + }); + + this._remoteHandles.set(task.id, { + terminationId: output.id, + terminateCommand: wpConfig.terminateCommand, + }); - log.debug('LocalProjectProvider: doProvisionTask DONE', { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'setting-up-workspace', + message: 'Setting up workspace…', + }); + + const workDir = output.worktreePath ?? this.project.path; + const workspaceId = workspaceKey(task.taskBranch); + + const workspace = await this.workspaceRegistry.acquire( + workspaceId, + createWorkspaceFactory( + workspaceId, + { kind: 'ssh', proxy }, + { + task, + workDir, + projectId: this.project.id, + projectPath: this.project.path, + settings: this.settings, + logPrefix: 'LocalProjectProvider[remote]', + extraHooks: { + onDestroy: async () => { + await sshConnectionManager.disconnect(connectionId); + }, + }, + } + ) + ); + + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { taskId: task.id, + projectId: this.project.id, + step: 'starting-sessions', + message: 'Starting sessions…', }); + const { taskProvider } = await buildTaskFromWorkspace( + task, + workspace, + { kind: 'ssh', proxy }, + this.project.id, + this.project.path, + this.settings, + { conversations, terminals }, + 'LocalProjectProvider[remote]' + ); + log.debug('LocalProjectProvider: doProvisionRemoteTask DONE', { taskId: task.id }); provisionSucceeded = true; - return taskEnv; + return taskProvider; } finally { if (!provisionSucceeded) { await this.workspaceRegistry.release(workspace.id).catch(() => {}); @@ -309,6 +404,17 @@ export class LocalProjectProvider implements ProjectProvider { await task.conversations.destroyAll(); await task.terminals.destroyAll(); await this.workspaceRegistry.release(workspaceKey(task.taskBranch)); + + const handle = this._remoteHandles.get(task.taskId); + if (handle) { + const cmd = handle.terminationId + ? `REMOTE_WORKSPACE_ID=${quoteShellArg(handle.terminationId)} ${handle.terminateCommand}` + : handle.terminateCommand; + await this.localExec('/bin/sh', ['-c', cmd], { cwd: this.project.path }).catch((e) => { + log.warn('LocalProjectProvider: terminate command failed', { error: String(e) }); + }); + this._remoteHandles.delete(task.taskId); + } } private async cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 8d40d90309..0121024c15 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import type { SFTPWrapper } from 'ssh2'; import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; import type { ProjectRemoteState, SshProject } from '@shared/projects'; @@ -27,7 +28,9 @@ import { import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; +import type { Workspace } from '@main/core/workspaces/workspace'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { type ProjectProvider, @@ -43,8 +46,9 @@ import { } from '../provision-task-error'; import { SshProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettingsProvider } from '../settings/schema'; +import { buildTaskFromWorkspace } from '../task-builder'; import { withTimeout } from '../utils'; -import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv } from '../workspace-factory'; +import { createWorkspaceFactory } from '../workspace-factory'; import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; @@ -207,7 +211,21 @@ export class SshProjectProvider implements ProjectProvider { void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'resolving-worktree', + message: 'Resolving worktree…', + }); const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'initialising-workspace', + message: 'Initialising workspace…', + }); const workspace = await this.workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( @@ -228,63 +246,28 @@ export class SshProjectProvider implements ProjectProvider { let provisionSucceeded = false; try { - const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: this.project.id, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const { taskProvider, conversationProvider, terminalProvider } = await buildTaskFromWorkspace( task, workspace, + { kind: 'ssh', proxy: this.proxy }, + this.project.id, this.project.path, - this.settings - ); - const { conversations: conversationProvider, terminals: terminalProvider } = - buildTaskProviders( - { kind: 'ssh', proxy: this.proxy }, - { - projectId: this.project.id, - taskId: task.id, - taskPath: workspace.path, - tmuxEnabled, - shellSetup, - taskEnvVars, - } - ); - - const taskEnv: TaskProvider = { - taskId: task.id, - taskBranch: task.taskBranch, - sourceBranch: task.sourceBranch, - taskEnvVars, - conversations: conversationProvider, - terminals: terminalProvider, - }; - - void Promise.all( - terminals.map((term) => - terminalProvider.spawnTerminal(term).catch((e) => { - log.error('SshEnvironmentProvider: failed to hydrate terminal', { - terminalId: term.id, - error: String(e), - }); - }) - ) - ); - - void Promise.all( - conversations.map((conv) => - conversationProvider.startSession(conv, undefined, true).catch((e) => { - log.error('SshEnvironmentProvider: failed to hydrate conversation', { - conversationId: conv.id, - error: String(e), - }); - }) - ) + this.settings, + { conversations, terminals }, + 'SshProjectProvider' ); this.terminalProviders.set(task.id, terminalProvider as SshTerminalProvider); this.conversationProviders.set(task.id, conversationProvider as SshConversationProvider); - log.debug('SshProjectProvider: doProvisionTask DONE', { - taskId: task.id, - }); + log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); provisionSucceeded = true; - return taskEnv; + return taskProvider; } finally { if (!provisionSucceeded) { await this.workspaceRegistry.release(workspace.id).catch(() => {}); @@ -339,9 +322,7 @@ export class SshProjectProvider implements ProjectProvider { return promise; } - getWorkspace( - workspaceId: string - ): import('@main/core/workspaces/workspace').Workspace | undefined { + getWorkspace(workspaceId: string): Workspace | undefined { return this.workspaceRegistry.get(workspaceId); } diff --git a/src/main/core/projects/provision-output.ts b/src/main/core/projects/provision-output.ts new file mode 100644 index 0000000000..da5c957491 --- /dev/null +++ b/src/main/core/projects/provision-output.ts @@ -0,0 +1,41 @@ +import z from 'zod'; +import { err, ok, type Result } from '@shared/result'; + +const provisionOutputSchema = z.object({ + host: z.string().min(1, 'Provisioner output must contain a non-empty "host" field').trim(), + id: z.string().optional(), + port: z.number().optional(), + username: z.string().optional(), + worktreePath: z.string().optional(), +}); + +export type ProvisionOutput = z.infer; + +export type ParseError = { type: 'parse-error'; message: string }; + +export function parseProvisionOutput(stdout: string): Result { + const trimmed = stdout.trim(); + if (!trimmed) { + return err({ type: 'parse-error', message: 'Provisioner returned empty output' }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return err({ + type: 'parse-error', + message: `Could not parse provisioner output as JSON: ${trimmed.slice(0, 200)}`, + }); + } + + const result = provisionOutputSchema.safeParse(parsed); + if (!result.success) { + return err({ + type: 'parse-error', + message: result.error.message, + }); + } + + return ok(result.data); +} diff --git a/src/main/core/projects/settings/schema.ts b/src/main/core/projects/settings/schema.ts index 4cb837e347..0b26a0734b 100644 --- a/src/main/core/projects/settings/schema.ts +++ b/src/main/core/projects/settings/schema.ts @@ -34,6 +34,13 @@ export const projectSettingsSchema = z.object({ worktreeDirectory: z.string().trim().optional(), defaultBranch: defaultBranchSettingSchema.optional(), remote: z.string().optional(), + workspaceProvider: z + .object({ + type: z.literal('script'), + provisionCommand: z.string().min(1), + terminateCommand: z.string().min(1), + }) + .optional(), }); export type ProjectSettings = z.infer; diff --git a/src/main/core/projects/task-builder.ts b/src/main/core/projects/task-builder.ts new file mode 100644 index 0000000000..bbbcacb81a --- /dev/null +++ b/src/main/core/projects/task-builder.ts @@ -0,0 +1,86 @@ +import type { Conversation } from '@shared/conversations'; +import type { Task } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import type { ConversationProvider } from '@main/core/conversations/types'; +import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; +import type { Workspace } from '@main/core/workspaces/workspace'; +import { log } from '@main/lib/logger'; +import type { TaskProvider } from './project-provider'; +import type { ProjectSettingsProvider } from './settings/schema'; +import { buildTaskProviders, resolveTaskEnv, type WorkspaceType } from './workspace-factory'; + +export type BuildTaskResult = { + taskProvider: TaskProvider; + conversationProvider: ConversationProvider; + terminalProvider: TerminalProvider; +}; + +/** + * Shared tail of doProvisionTask — builds and hydrates a TaskProvider from + * an already-acquired workspace. Works for both local and SSH transports. + * + * Returns all three provider objects so callers (e.g. SshProjectProvider) + * can keep references for reconnect rehydration. + */ +export async function buildTaskFromWorkspace( + task: Task, + workspace: Workspace, + type: WorkspaceType, + projectId: string, + projectPath: string, + settings: ProjectSettingsProvider, + hydrate: { conversations: Conversation[]; terminals: Terminal[] }, + logPrefix: string +): Promise { + const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( + task, + workspace, + projectPath, + settings + ); + + const { conversations: conversationProvider, terminals: terminalProvider } = buildTaskProviders( + type, + { + projectId, + taskId: task.id, + taskPath: workspace.path, + tmuxEnabled, + shellSetup, + taskEnvVars, + } + ); + + const taskProvider: TaskProvider = { + taskId: task.id, + taskBranch: task.taskBranch, + sourceBranch: task.sourceBranch, + taskEnvVars, + conversations: conversationProvider, + terminals: terminalProvider, + }; + + void Promise.all( + hydrate.terminals.map((term) => + terminalProvider.spawnTerminal(term).catch((e) => { + log.error(`${logPrefix}: failed to hydrate terminal`, { + terminalId: term.id, + error: String(e), + }); + }) + ) + ); + + void Promise.all( + hydrate.conversations.map((conv) => + conversationProvider.startSession(conv, undefined, true).catch((e) => { + log.error(`${logPrefix}: failed to hydrate conversation`, { + conversationId: conv.id, + error: String(e), + }); + }) + ) + ); + + return { taskProvider, conversationProvider, terminalProvider }; +} diff --git a/src/main/core/pull-requests/pr-query-service.ts b/src/main/core/pull-requests/pr-query-service.ts index 300afafdb5..e215b552d1 100644 --- a/src/main/core/pull-requests/pr-query-service.ts +++ b/src/main/core/pull-requests/pr-query-service.ts @@ -19,10 +19,6 @@ export type ProjectRemoteCapability = | { status: 'no_remote' } | { status: 'unsupported_remote' }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - async function fetchRelated(rows: PrRow[]): Promise { if (rows.length === 0) return []; @@ -83,10 +79,6 @@ async function fetchRelated(rows: PrRow[]): Promise { ); } -// --------------------------------------------------------------------------- -// PrQueryService -// --------------------------------------------------------------------------- - export class PrQueryService { async listPullRequests(projectId: string, options: ListPrOptions = {}): Promise { let repositoryUrls: string[]; diff --git a/src/main/core/ssh/ssh-connection-manager.ts b/src/main/core/ssh/ssh-connection-manager.ts index 756809f527..8321fc4d3a 100644 --- a/src/main/core/ssh/ssh-connection-manager.ts +++ b/src/main/core/ssh/ssh-connection-manager.ts @@ -186,6 +186,23 @@ export class SshConnectionManager extends EventEmitter { await Promise.all(ids.map((id) => this.disconnect(id))); } + /** + * Establish an ephemeral connection from a caller-supplied config. + * The connection is marked intentional from the start so the close handler + * never schedules a reconnect — callers are responsible for teardown via + * `disconnect(id)`. + */ + async connectFromConfig(id: string, config: ConnectConfig): Promise { + this.intentionalDisconnects.add(id); + const connectionPromise = this.createConnection(id, config); + this.pendingConnections.set(id, connectionPromise); + try { + return await connectionPromise; + } finally { + this.pendingConnections.delete(id); + } + } + // ─── Private ───────────────────────────────────────────────────────────── private createConnection(id: string, config: ConnectConfig): Promise { diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/core.ts index 7f143e0778..836836a4cd 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/core.ts @@ -1,6 +1,6 @@ -import { PullRequest } from '@shared/pull-requests'; -import { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; -import { TaskRow } from '@main/db/schema'; +import type { PullRequest } from '@shared/pull-requests'; +import type { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; +import type { TaskRow } from '@main/db/schema'; import { fromStoredBranch } from './stored-branch'; export function mapTaskRowToTask( @@ -25,5 +25,6 @@ export function mapTaskRowToTask( updatedAt: row.updatedAt, statusChangedAt: row.statusChangedAt, isPinned: row.isPinned === 1, + workspaceProvider: (row.workspaceProvider as 'local' | 'ssh') ?? 'local', }; } diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/createTask.ts index f12f9697d8..b1fc9fdeca 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/createTask.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm'; import { resolveAgentAutoApprove } from '@shared/agent-auto-approve-defaults'; -import { err, ok, Result } from '@shared/result'; +import { err, ok, type Result } from '@shared/result'; import type { CreateTaskError, CreateTaskParams, @@ -190,6 +190,7 @@ export async function createTask( status: initialStatus, sourceBranch: toStoredBranch(dbSourceBranch), linkedIssue: params.linkedIssue ? JSON.stringify(params.linkedIssue) : null, + workspaceProvider: params.workspaceProvider ?? 'local', updatedAt: sql`CURRENT_TIMESTAMP`, statusChangedAt: sql`CURRENT_TIMESTAMP`, lastInteractedAt: sql`CURRENT_TIMESTAMP`, diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index fb57be8235..174e1a04b6 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -110,6 +110,7 @@ export const tasks = sqliteTable( .notNull() .default(sql`CURRENT_TIMESTAMP`), isPinned: integer('is_pinned').notNull().default(0), // boolean, 0=false, 1=true + workspaceProvider: text('workspace_provider').notNull().default('local'), // 'local' | 'ssh' }, (table) => ({ projectIdIdx: index('idx_tasks_project_id').on(table.projectId), diff --git a/src/renderer/features/tasks/main-panel.tsx b/src/renderer/features/tasks/main-panel.tsx index b1f4fa39ad..1dbc76eac9 100644 --- a/src/renderer/features/tasks/main-panel.tsx +++ b/src/renderer/features/tasks/main-panel.tsx @@ -39,10 +39,11 @@ export const TaskMainPanel = observer(function TaskMainPanel() { } if (kind === 'project-mounting' || kind === 'provisioning') { + const progressMessage = taskStore?.provisionProgressMessage ?? 'Setting up workspace…'; return (
-

Setting up workspace…

+

{progressMessage}

); } @@ -61,10 +62,11 @@ export const TaskMainPanel = observer(function TaskMainPanel() { } if (kind === 'idle' || kind === 'teardown') { + const progressMessage = taskStore?.provisionProgressMessage ?? 'Setting up workspace…'; return (
-

Setting up workspace…

+

{progressMessage}

); } diff --git a/src/renderer/features/tasks/stores/task-manager.ts b/src/renderer/features/tasks/stores/task-manager.ts index b178052906..d8461d8e72 100644 --- a/src/renderer/features/tasks/stores/task-manager.ts +++ b/src/renderer/features/tasks/stores/task-manager.ts @@ -1,7 +1,7 @@ import { makeObservable, observable, reaction, runInAction, toJS } from 'mobx'; import { toast } from 'sonner'; import { prSyncProgressChannel, prUpdatedChannel } from '@shared/events/prEvents'; -import { taskStatusUpdatedChannel } from '@shared/events/taskEvents'; +import { taskProvisionProgressChannel, taskStatusUpdatedChannel } from '@shared/events/taskEvents'; import type { CreateTaskError, CreateTaskParams, @@ -80,6 +80,7 @@ export class TaskManagerStore { private _unsubPrUpdated: (() => void) | null = null; private _unsubPrSyncProgress: (() => void) | null = null; + private _unsubProvisionProgress: (() => void) | null = null; private _disposeRepositoryReaction: (() => void) | null = null; tasks = observable.map(); @@ -106,6 +107,19 @@ export class TaskManagerStore { } }); + this._unsubProvisionProgress = events.on( + taskProvisionProgressChannel, + ({ taskId, projectId: evtProjectId, message }) => { + if (evtProjectId !== this.projectId) return; + const store = this.tasks.get(taskId); + if (store?.isBootstrapping) { + runInAction(() => { + store.provisionProgressMessage = message; + }); + } + } + ); + this._unsubPrUpdated = events.on(prUpdatedChannel, ({ prs }) => { const repoUrl = this._repository.repositoryUrl; if (!repoUrl) return; @@ -416,6 +430,8 @@ export class TaskManagerStore { this._unsubPrUpdated = null; this._unsubPrSyncProgress?.(); this._unsubPrSyncProgress = null; + this._unsubProvisionProgress?.(); + this._unsubProvisionProgress = null; this._disposeRepositoryReaction?.(); this._disposeRepositoryReaction = null; } diff --git a/src/renderer/features/tasks/stores/task.ts b/src/renderer/features/tasks/stores/task.ts index 9022b57562..64de518619 100644 --- a/src/renderer/features/tasks/stores/task.ts +++ b/src/renderer/features/tasks/stores/task.ts @@ -133,6 +133,7 @@ export class TaskStore { phase: UnregisteredTaskPhase | UnprovisionedTaskPhase | null; errorMessage: string | undefined = undefined; provisionedTask: ProvisionedTask | null = null; + provisionProgressMessage: string | null = null; get displayName(): string { return this.data.name; @@ -173,6 +174,7 @@ export class TaskStore { this.state = 'provisioned'; this.phase = null; this.errorMessage = undefined; + this.provisionProgressMessage = null; } transitionToUnprovisioned(data: Task, phase: UnprovisionedTaskPhase = 'idle'): void { @@ -182,6 +184,7 @@ export class TaskStore { this.state = 'unprovisioned'; this.phase = phase; this.errorMessage = undefined; + this.provisionProgressMessage = null; } transitionToUnregistered(data: UnregisteredTaskData): void { diff --git a/src/shared/events/taskEvents.ts b/src/shared/events/taskEvents.ts index f9293d70a7..cd11206156 100644 --- a/src/shared/events/taskEvents.ts +++ b/src/shared/events/taskEvents.ts @@ -13,3 +13,16 @@ export const taskPrUpdatedChannel = defineEvent<{ workspaceId: string; prs: PullRequest[]; }>('task:pr-updated'); + +export const taskProvisionProgressChannel = defineEvent<{ + taskId: string; + projectId: string; + step: + | 'resolving-worktree' + | 'initialising-workspace' + | 'running-provision-script' + | 'connecting' + | 'setting-up-workspace' + | 'starting-sessions'; + message: string; +}>('task:provision-progress'); diff --git a/src/shared/tasks.ts b/src/shared/tasks.ts index b725d279f8..98e90e1fbf 100644 --- a/src/shared/tasks.ts +++ b/src/shared/tasks.ts @@ -35,6 +35,7 @@ export type Task = { isPinned: boolean; prs: PullRequest[]; conversations: Record; + workspaceProvider?: 'local' | 'ssh'; }; export type TaskBootstrapStatus = @@ -71,6 +72,7 @@ export type CreateTaskParams = { /** */ initialConversation?: CreateConversationParams; initialStatus?: TaskLifecycleStatus; + workspaceProvider?: 'local' | 'ssh'; }; export type CreateTaskError = From b9d16f69d7f10ee9890e8bda6b512d1b45847935 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:11:09 +0200 Subject: [PATCH 055/263] Add Devin agent provider --- src/assets/images/devin.png | Bin 0 -> 38611 bytes .../core/agent-hooks/classifiers/devin.ts | 50 ++++++++++++++++++ .../core/agent-hooks/classifiers/index.ts | 2 + .../tasks/editor/stores/files-store-utils.ts | 1 + src/renderer/lib/providers/meta.ts | 2 + src/renderer/utils/agentConfig.ts | 2 + src/shared/agent-provider-registry.test.ts | 23 ++++++++ src/shared/agent-provider-registry.ts | 19 +++++++ 8 files changed, 99 insertions(+) create mode 100644 src/assets/images/devin.png create mode 100644 src/main/core/agent-hooks/classifiers/devin.ts create mode 100644 src/shared/agent-provider-registry.test.ts diff --git a/src/assets/images/devin.png b/src/assets/images/devin.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd3f3457752b67f234eee30c5fd16c78b8a6999 GIT binary patch literal 38611 zcmeFZWmuGL*EW38NC;d~3W|VANGj4Lq98G(64D4#(n!Ooh)9a0fPf+ljdX{E0n**g zz|hh$%y*o4UDtCz-}e4|f4o0#woTb)&hw}h`&w&XhrUrqC{U7JAcG)?^1h=2Y3`yYhK0+)=ue(0XP&|1L9dip; zg%F=K9@MYC5#8xCf=cQf)A~yE-V6`Qt|*p&%cZ`7fOJ#X8OWe7)DY(nnlmnVUu+?h zXx2_zJd*;t()V&k!ccfdP+P#UHk8B*b$#xRG=Rn^puc$M$?K-b`OfjZ<4TyZyic!u zmn-W=+4;$&r|6kRQxZ~_n*lf8KM;*b>4+j58p~BVqgKN><OI zsHnW`NG=oSwNLeNWA~=_rOlr=1NyNFE9<|DY#`Bd(9nX68F?$7{vf{9*de=R36rcC zOKSRFh@Wn-=lz(USMr60D=RAp*jOj^Sr^y$Q_kQZt&xY69 zKM1+z8gJ-Q5~&j=no4NP-s+RPDAZ_b^~&7wLdVyu6%<%UCSp^doSMO>m0P*P@*3nS=;?xwftg+m}aPB;Vuzh+VvV z$oQcFN;Bh8VJU7DWn>_4`s>SMmEgPvvO>*VV%z4CLcSGl;UKB66H}z3#NlDZjg`~* zj>S$sV{&6;WA_Y*)}VZ%!8g^xOpRf)(o;(wPiwAD(6IzXG@+(kHi`B%kEMNXp77MM z$h`2sf0bGM0XRn?!J#;;s*6(uVgs)YaGKgG!@Q>vg7sm5mVj zg}7M7s9xdj-CjhueNS@llIyuD4yosSX%3$TyF0tqyN$04M(4)h#}F!AkKvBdkIv_m zdT{MJ({*pIPA#KP2H8IHl{yD+$n;v|U*$Av>3!79a@L-&Y7OAN(~r2Na!boC&88MZ z)c@K>{?@r$#FyZ4MG;gH+tjm{&I+y`sL(y zYi9`~hzaSt-XD=eRU{C5=K9;1uiTawEh)cDAiCer+`21j(G>lSODChICq8>Ri?xrl z&#Gr}&?D#Pz;?gN;Mk|(Y)>6>l*LE7k1;u=%FDR}QCYvT?6nTG$WfP)y_A{y%eun* zjIwKWsvmjFDd>DRR`{fq?{W|QhEI7=OGMQ@aqY~wpj6<5id5u;aBY-V#1O`g{~2+0 z==je|gM$N9U2)6n-Rs>cLIStqpT2$4_1JOnt=SJj72z?HFe+uUoDVOai2&r@$+_m(cDUUBZ7+^yV4c_|}| z!&ipv3YUrwOqQ(@O&{5Mk0RRne(-hXkFV)?lY7_sreklBRJ}}TQhF`?`bjS@iX63` z;MWv6&4{V!C~VrSjVg}n%j&lNHT*;DXJzYw`0Jl%&~|f4?q91ts&ie%7Jh&0|M_6S zYiNf-hZ&FXXcK>o%1yRkA|ZcVD$TYR}Qq zof`Zu1wsWcjnrzN)}~LiPZU(-kIj#1NykUjt9?|9C`eehnErE>kM6Ahx{-V} zx&H#O>2uRZO>2RPf&3IW=@-;Amo(0AUhd&@)_bf&%WmVR?c3L8wZ1sBI9l9p+4jV} zne+RVZ+$H_!8>nw-&_nWe)H&(JD1UQBihMWS(TvsLvaFv-`zWQ#n|73zae@<;G&A= zOYk;L9;gZR{EoifiEfv97oZlbnyu#boqV=`;~G67U#7l52l}`B1`D|)(I}z0oV)28 zN1tv%wPd@?1eHi_vm@*+or;}LJ5o%{!qE=< z>YSW4x{n`e>u=k$`_Hx%tkiKIVP-StRHa%FE!D#3ghNEA>^6s-Q{_t^-~YKW(3QtK zIy6dFUOOc1fAp$nDZO8UQ({hnxJ|Bgq&2KnB(6S3F>kS8JKrw5Cx1kJ#|CABjxkKG zzL4XV9g;n{M!#BD_}yPkIqbgbl2PMCQZ1E@xlMK5f{$Ujnyqnn-QH2l5eb$2rNvyA z{G6P@e13abra4M;l5C*RPCW%qKvNvY;$e7fVP=(pHiet+^T^I7RJrvR7Fn-a6@ z*$L;{U-2ro&BSgF^bLG1+b^q2yR9HGGCq=6Uw-0;6U3UweD9B7NJ&llA+xzT_b%!j z>Iy132_YsmCRjha`@Z7lpg6m@cL#Bblz+$i*n7T${3!HzCrkB|*Yx7uj+OysM4W!r z#p*(7a9-?Z%Ja6uZ4AXv4$UjLTndwnl}z^0+$hF$K2U2<_S*|V^N-Iu&9ZlX?vy^M z+N@mI!t!U-FUFImRP5c@nxD&Q984&ns+q1?@?tuA=6YlA;-=xS_txGj%L|#YgZP8Z zS%XF{jm(HQh9 z;;%7=K|=VB->FfiHiRt~pBFL_&jwbWtLUakHtIxn`z@Pjc0L%CcrEi+61%UoZNC!l zV5iZ29)q5^T7R?=$WkF_Dsj#C=rvdrf~eI`f88L|hCd-G@aW**m+#0D!M}g}_c1Jn z|BB_m<^bElf4$_t=m5;%zo_`%U;)hF|6-7=&?;_YSukIb3KCTD#m?3mnHTIYf8E_a zFvf$X_?nAAj9x@l#_WZ#HnYf)Zm}M_2w~4Dum;pp_atKT6sj$nAgGHUpBsV*MCuPW zzyAFB(?)4ik97KW#e4hErGw_?=7oucCi`Il2pS7Ol0gkm1A0rl{N>;g9I5z_0CjQO zYc(?pCSOq}{ftCStBZD>E76qrP{V^WnGi%V^-1^7)k!?3SHmw3o(-_+LP zNbf3R0WU271rm^owd7v5q>k5KLm`4C6SeVT+)RWJvH)J$V5#Z^rG{b>4CMG2s7WTV zw;oY$uuK9?6~XiI&2RSZEz^y z@=L3+;6MUZ@W49jXP))vXNPhwtcum<*G6~5o-IR*Tf78$*d=&4t`NPwzYq4>XT!*w z7!@`9^hg5_+E0K*C-W=MN0*W{kMe7B?=mKkdi3Z~Z{q1tVtA-P^|7h+w{08Jy(A+Y zj`RxGFH*HS!lY1x<2i8D#>{oG+Hx8>eV)XNs!k&5oxJeG_u|0B0-wAQY6!$bt>6|n zU!~^wk#pzHCGMu-|JjmB^DjOY={JIQp&RhB8MOK&qc2RL@ZFo~_8PtnLH$>O`Sta! z{C<7F*n8a@alIf)(*PLJ#C4#`R4@1l3GWRj9KMM@4AAmMEL-rW7pK2mtwfv-W`ze^ zbuMjgF6on+mfFQCjV2{AJB)(kt5d;CQvOjE-DY2M?{pU>YuR3ThDaMRu(?O@4G76ErPdVAs?sdb@UtSL2Y)wb(9jG9{8O_FvoOi{*si%+ii z-n6QiJ#bALFhB8Q`}rmtQtS3)E#h1zIlFZS zRR`FO9Nl2?rL^<;$43a(7NMgb4czDAyJ)Ajemb@1>C<0)>mugN*Bi5>P-E%;>15Hc zD;$0~^|6ZVdVOK?aw`{S=bBT?Y$pZ66cMQQ*}wAb3)mN3|9?nvUl^9hA&{b@_yb{b zI>*0_{d7MKF?&g;SQOhXx6?cxEnN;nPX#RasdxW6SH4ze@!z0$s<(OA#@bfJcpI&Y z?dWYjM`>EY_OpS8zn7VG(GTc7DALO;{6}97@aF5B3~{}v-Y~|ds3JW^dZ4hQz3-3m zcL<>4oA7eiBU0*&)wra|StU6WUzv_a9Imyb?G)XHkQ%U=1W+$LoZt_n7FiK{}QXaQh0{CNN9;L8O$?inC-N6&F;GjD%euVP>?ChfSEb*BR~vwcPn+F!&m?_#bHd;2Mx)skGbSQt#5eQ+RXK znOU2gtG!C~9v{Lef&JR}Sm3rx4N>ak3IM{%*}!}XVIY_yvuV^mKFbI$7@n~$2ck)} z0f$e0)~uCzm4*(=yb89J88bZJG-IkU!`0W@x4DU)v1No(h2htPVa<7&(DoVXCjn9G z3ll-=beGtOpg!yMrFB-qLH~~@Ra_sWB;h~^JZ0<_2T-a0{`QjV9m!W8H!pwlZW$DU zWaq`6lR?@OE5H42ek+{W<`R(C3??TT?e(^BfgtyvXTVa#-v(N*ArIdON?uO{h-Hfa zCw3e1J@ju~Kzb=Htw>aZnYmr?!FEg3QVkEqwIqn1!}$Nc}DNn}lR)?j=r(5v>t0tTEWSj1Q<%>Q(o*I{93KMehPMw{`> z={Zhg^?%Fg|BDIZ|GUK~lG|Hd7;H~K5q4h-Eeith;pG)zx!~uqkTZ?|YB2OV-tJn` z0z^~?fGpXoJ6_!8i%7X7?hBf-+ArW|hywJXGz6(r=5G){54P`|zWEezS;*Emf;_S9VBi?B z&OY#+H5p%E_YmaB1kbOj8N6k26Fe<%q4R@M&3gQ!RpEE(VRr}}p2u2}eyjjngw)T# zGskpW2_;9L&UOv>_z&qHsKF34)o*JdFSG=gckR#_)@K*O`h54y7BW``kvU3SRdrMi z4MN5^Vcd>)qRh6LDA|)Rd(ypdadPGH6@I8eF&Ky|H1O|CpD$m{c$Zb$TQEa}wb}4? zOk%^2l8p-8XA=DmMa#9EQPFlx+jhfJo~@dVk}mLIg#OuOq9ww>}}-G7*uiP&Tx zNSNKuZ)f&pR8&-dh?alL+RsEz1WGz3siwTR{GjV6Wxn0BgN+BFU+Mf9Rq?rfdwsB& z+i6Zk33A30ldf8d5+4V>C-iieqA3Xkk_fet-Dgpqa-`_N?W0VPHcEpK!1dWV=CwkL z=&tuLvp@-<_ThO1$>zzm&u@kAIDA?SmT6erq~=1>+${fXy0gE(y%#+*a#`Adw%&5n zmW3t7FUe5aHQ}xD<_0HZ|LI_NK59r58{gP4F&V%(V`G#> zWK-!3=iBh74@sZqiH}{?pSAd*vimyho_gm<*Ti)H5Sieh!ZE5@$@GU zz+a1@gajyb($=2_yrpj%=WJX(2FY3n_Gp%O@bEyVMf?r*Cl9iQ{`}?dyfE3WA0)V!_F~~l0>>fDPvFwb8C@fpN3?)XXJts$`I$QhuZY>|C z?t;vpB5QnE)ovt$Odx4RmWWZVWv#nlH|gQe1HZn?n5Iee!->iMH@e*Jhb7`HNW9E^ zSdI1GTfg^iuYrnWW__v6uP^SI7Xbw;Fs(p`&ZT>#rrdo^Mxh^f)>?jP3}0@lGBV2* z^(C7kEX2){ZnKKJC9%Y_RZwt~(2(mT=_UwW0Fz=aM5xE^D;6e?zf;F;>bDa6~uqoPLE zlUlp8jOX#Wai0%%%lqRs=kqbfcSHiW$NaRBULbj~sX+^UQ}_8|6*%4VCnK!h6&|As zKNJlpFq}Fh)b6+i?4N~csh|49U!J3-Yt09@1t;_U?5X&Dzh+UdwYXlHby^$lA`P|1 zRF_+F6kE4<4rbWx%VRun+Y2L z9_FKlFw`*aeY}l51ogyv8oe4hS4-(kO0BM*y0G$2{TXWWqCHxqOM5wgO7pkBUkKdt z->t7ZaI3YWM^>W=xn+0MmMLd1eOVS);woEF$OC12(_mA*7k&l62nB~u>%tC;Y`bUj zuA2OOZuEY?Pl!`L0jDBM_@~AxTw(%sE^AWhszTl%7iLDt2{=^|VPE&KOVz`qI#4&> zM;~c)#)^|)pO}QSLy=_awWuhD$e#qyTHVZ5I>E3yGptHT4fgi@-@3eDbhCRq8iE39ThF5UC3~Jj{k6*0 zPFt2Jy|7$S_+ntVCO?n_5nORKd;r${r~I9_`KLTIe&a@aS8^u|Z|VkNpJ`P-iyBCa zR}11B?fE~H!ULJWz>5Lt0Kgtl^_@TV>vQM7lovn9HbH zJ(xID!a_Io48UGFPT<1~fL!|Wv~zMcs}52lyiA{Enzg192>UMB)?wWQGjwa}tz+AJ z7{+Pu`yUcgYz_@b-aUb)4rP&`0c0uX32h$9x?yFenzYS z{A>jlWj5;H+R8co+<^a{K8d}oY~GuN(aDGA;`I+^JCALazP(Kbg^2(oJ~t@5@@kAJ zzFzurK@^zT*)o)&clCkMqz=d(uM+kJ+t!^tl@`|AJo%OoYqR`0ULQn*LfpmF2w-U^ zMy7nyxKN>R1kF? z@5uEy1__pOT)QT`s!+QvLLRJzLa^dt*-{@mc3Pjt44(XDt>wQx%02^ll+Am6 z+1|;P-a}=se7gF$mYD6LVGaLw6s>eH3Tn#v8}kh%^Xh4v4%RIStIL2lNdn-@$8{ym z5GG5Znydlbm$B^N694!~%|c!#MEvzkU-pXFMP*yogJM5Lw|`w~Di|E}I(6hdWhGON zfZ|=wbp_ss+DS=Cx%3>aZI*aIwzWr6DBBk6mLk1C3mMF?<`Ywk$CHzgK@2$N&mdPH zAAQ0rgDLmaM80kN(C($tFH@LNv@B2Dw*{$4U-h`lAy2?Ys$KH*JGl{x+BX1LAy7vm z+wOj*WZ}7IB$D0*IE>*lX@#FYd9;tZkbxXxC-&ClNfo0O&q?l{Y7yWD1WUvxagy8X z4v@W^EYT0@$RCTJX-5FVwSIQ=4TutZA=7PKSFUp-N3W$$yjxg~({kdm8**u_7geeq z0?}w{6s+j*uR~dU{$RYizT+j~HW8_xi-HS|?gze8;b$_-oeX7??ZL>DH(V>bJ$iuW zK>CC7$zbCPb3XrCVAs*;$w1n;`Z?J9cf6+c0C?PczC+t!cut*UvB1I4upAwo6U18T zUHe8A#D_W%S|oX1pRC;<4->>bp)D(KLDr?dX$mUs+poP{rvAxAeoQ4uJX? znXSqUMDRyY-XoD@p%o!gsJo)2B%yd8R45(HRJ>tE;+2H10!^dt=E%v{`&y~3W-6CT~v6tHCgMB?H_+j1Lf$wo|cL4VOCU(<){jLcUp6=D9z2mp`|C<*uRw5 zZ!v{U_<)LpoRyAB#$|>y^Ucu6WWoeb;wPU@YpbN(7HXX~%js-0GKpHD3FkZ(#=H33 z0?wCYeCw0nNttNh+k6{{AT4AvU#~ye{~CRvMI2dxZ)+obVTt8-$zX{8n@!2L^aZnQ zvG{!tm7JzcyhoPGn5^o253Xct;nrFFoXnURxxmaf{*?P6RP=~>JlR(( z4(M60wiX5bTUDYlWE3( zP3&oNVc53C_giDretlgSp5w9dQhDfsYk?h<+To3eh)^5UB-=&e$y&7FcoL^l_L77z zaO7|`mud@Byo_!1u?vW37pb=ooR9~6fr;*|R$>Ta1%dzu4tK0UD3Gsn`934s=k`W0 zp;$sZU|uH6!%^=~4wkq#(V*`9iiMH;l&n@MSP>&aVwAa7nC&`wV%o#1wxm$z)_J0< zI)Inyx?fw-r?>y}fY4Lq;$yZ`3*qsvL%Z^}Y<#w(&#q+I zvCAs{(!<3>lqrr!mx)d8g%j3j0}BGA*PCj z1oIO%wjhtaA8Oh}LJnnquvdCQMOB7QQaNI1FR$nOMjNsxGC(7!QpFYc5Pg*54p@zB zMOh*YprrRr1Ob->X=~W6!t|DE*nc?NTZuo;mdOM*HJse*&BXc{fCeZ=490Q#TRS-< zVIDAhmIgFGs{91dxzDSp)t`&6ij|U3K$ul4XJ;Q%hpmr>LFmC9|3_h5blzjN^`^9L z=87Ox6Jcb*764beZ(>Tt2=yg;czrxLEI$}hEU2k}?(%{54$xF;E1`DkyGt@sNlHt{ zcp9*Rv2Rf5Y1f?d-@h3RyO2#%HyMeO=XEr8Dp;gJ<*Kqo-0pwqDc~It#jEH>ILXb^ z2uGoTW?_q%c}Tgz@vre`{sqn!`dw|Z03a1~C++W86i74RbM*WEv2)J1Bc_TOzdqc_ zbR6KYkfWDV@!jq1=dos_g?~0q@iVIzFqR~vE+1$BV^&lDo;#7KNSrGJh}!rW;9H47 zyI}3t-(&|KY@`j2L&{ubRAs2WM$+|D%cgBI8|HD0!Dc>D{_rX$5}QHcSWc)th1fl1 zyCfPO0^~;OFaDvv*^g*fK|-ysKuGW}1UEcAr~dpAVE#>S1!y;GvG719q<{11?AW)N z{2-{qHt==IO)GS@V^1b*+s^{d6dLmWuwvZ#%SR@7&>hybjP1%>zr7LPd-N5-`xh5K z8yNt16DqoX2RRDz)N(*w0($u8B>{->tO8fdfYrK~?@lU>8bCHc;6DEv{148Wyd~5& zF)TA@%e84U$$#kjVb9b$K9CpVDLz%S*(yY?@9V?O0iY<>3QQy_XCiUy%eG=ZBpYX9g zoJ`;Xb160?3N^z8_34Ow>zkNNI6z2ZKY?@kf-n2pUJfxHd2dp_1vbzIjJ&R5$P?29 zm@jQ5(&s2d@r7M;FWWp$-HGr%OnV>dXeY2L`RrpgLkj)APtH;P5U9t zG-!Q+piWPvBs4Rm&&`By!x8?$|Jmk~%Z|K&nH#)OT{9Wly+i?JR@f_joXlUzZu`b*MQ@k_a&+h0P4eSNHZs~;ZR42XqA0xA^n`Ir z-(Z>~-?q*UTqq}pbF@4xuQ#F~yPI6*iu<;SRSUImAYOjUD8W?gf{u)|sI)Y1emS_T z-3`jEVHYpo$y!gnTn?MqcD$V!uUTAm@s~c{M1p-l_BF&@WJ&pTbV4pb7#@s$+hT04 zg4>H=QOWWcLd@n+Jc5(WWnSZ60kUtwoi{*> zfMUuPpWD$wFfeOwvqaX{RW3+DaQ7r<(01k3My1P_@%o&0IG;$5PB1>P3zY$FmKF6m z`lkp)c=?_%i@2(rmU&p#6qb^r_MSKnXN^Z!0br=2D0&9RorqU)=R>rd)2jnj(kI^7 z(DcdQYb`2H?{*fKsmzT(VGfc@S>zN${7J^TrKswn`a@P5I{TiZ3g=t zonSY|#n*fR`8#}ub>&Y3#G78uV@8|X|62MN7tmnI2`|_azaQ3BLO=)YpHfR9ptW#h z;$1c5pzqC4t@mgvhh>z51K@NEZiCP<@Zc6`py|MLn%kX=k$ea``oW?>7C2SWt!l+Cb^Q4l*i`>pttW_6DB%(Sr<9 zzXqm7xTd%_XYxrUTM6Bf#vRfVss2gJO>eGb(9wGdi0KByk+I1H>ln&SwIhv3 z#cfBO=FHx7a`F5}V%iROTUxxpL2W?Z46VIbZWD2&nWE?0O`~Tm)!TKc(E1QP^G*r{ zK|3(kAL1a|7QFfa;vN+86XYkr%;odFv9sdvcPiD7G#- ziCbD^i0y~+o8B77$sjL+c0gqhIX()%#_^qDSNKV=R5FF745kOL6`fjSOuK0l?`s+F zw4JeqkkfE`r<_r(lbwaY)v3ze^iP;1%>pll|Bb(J&jINLhzjKGb871icYlof0@A@t zY4bfclb|v!}pn|KgYK}nNiBkgc*CKlt)L8(6-bN#CRK+PnUv|`)L;{xOG(l>+}bND+Xj9 z?7G}*)=;WD4|)T@fwtA2IRBo|9H#Dfr6FVKfV=F|$G5f8s#r=vMsjoyP_h1BfYU6J zQ-xhPdsmcaH!YC*p5WzVTyY4{kxx6^GvTKi^4yz=e0`7dO$hY_U{(Atfi^#NqYtA! zY{Q1RfZs*>vjHWExNV<$F)Iem#>r=}qQEO>bCUu@8mFJhfQ>!@%nCH7xS9H)=saeX zO~7w&f9Yr^dZw6lC}ITYh~WgZWGDcSg0uxWuxPD_c<_|&3MsGO0y7%Lw1eL^IgGeIpRZRI4T# zFG}s8sm0dxR7(McrJ!Z$__}VzO3HTKq1~<35)Vp+k81FsT+~fhxOq`*NSZ2E8RYmU z^yb{VX^2;&@Qnh`FF;FJ*qwAHeD#H!tg1=lsrX@!+qun{YwyIZKSTnVPy?yW{286)Mp>Z?05FR3+)LCXr%Z;0Ly9M%OrGYu|(IC228e@au+`7rt` z^#X8@8}M?`pn>b&W(kZe5bjqR$ZZ2Y8ZxCrw zoxF<>B>U(LIH9xB!C#QR8ax=tl>%%R{4@xHb>W}y)L?;ix{ttT zA$Wlr9gSxHLAYU}BJqEE6+B?R^Ph9cezqScg}M-6SqOOrtTj;}y6_$BME_$o)~5{Y z(Tbl5tR>rq6*U&YgG%Bk!JDb$;?w)mkRuPgMwiW9QgDb_a1tPekfilKu>NW~yd|r0 zMPc}Sr;i3O_A*(GT40DHkHS89cNasu<;D)lRu98x9J&4uh;-^Dd@@CcRSQ<2*|E$K{+T{=Q1gjLv>mB!#W)g!Z`}1^n>DZ&(9^7%6 z`3vaeTXuErtrX4VygFmrdAXgZ@3$^fZe30vV&TbO>BnAh)EArStvU}OgMo&SIaDek z`$A~R6t*z@-sqV{+}7f#0oL4U-`u63OtaORL!pMd?N~FlwIyIT>yQPK<%K1o(<7pH z@qGskRknZbU)^cHwjZ}4|rD$y+(_sr5j@tQ#JP=)&V>J#$HpfFmU0YR}ucBvHa z(w)n#V@y1LWm1IbeDdx`_sf5LDE%Rr|FrACpn13bLmq<##UM$@ z9*LXG^JikYQ9sJm@Q_+GK-`YErBIbs@lRH!wdY(#&>ipmwEceWazTy{#lga&pq;X; zydoy$AcL{RW@@{Fe&Z7hU=m2JWqXHqG$Pvz!Jt{}rhv_?ew6Y}V23#5x;I#3$@~at znhF*sN+LgnN;up6H&@SGP|4en?ZYoDZW(A@|LV|7#qc(6ItQ>o$a9rIL`??F+X~Z5 z0T*`C=t-~`->gInao6$`UX|bG^bn&AY`{M1?t4p`;Ra7P+LG``9)KDH#8f=uo$#w`4p>{zv5ZFa1X)UA=p1aHUM^m+up z@P|xPU~4^A?zHBf$e)ka;mAgB8-QTqx7Xj>u5DyI7$JUFVNWZ!za0jrVELB`cR zCGX+Ik7*kv7TJ51UXwWyx5s(f{b?GyeW9sOL%?`VO7cX=lGsN4#yo`;>_QJU+Wys7 zG8>DeyDJM+!3h+Gj0M$eWigFdEXMMYr8GmKugjBVi}Ov%etkbRo>D7#ND@E{3c$F! zde}RFag|nu)luF;J)slhogdSy0%G&hGn(v+5XVdGqhWG$0V0QSB-Ns-`jLB1ekZqN zUgEs^PNDdU!g$pqkv0$4^78DwX@}(n2tu?GgUd;YWn1Ef568ugMcTaE7Zf|u&&s94 zOpOL3MoN^^&Sr18mf6!oQzfvBRu{z#Lzvy8whqw@Syhg<(+eE~AvXe-4>g!*lI?XS z^Ad?5QF5U7hV8v1Y0^Yb20rI1@4kpbMH$RcQ}02Q9)v9J0TL8=RaLDP=j^m`n2FZ* z@ba{bIMguKVcDuOwmh@*|a(vwx;j__?dOIr{|abVFXyyV|+ZX zT1>OllMHf9hsAL&qZ<1;QQ`xdORjV2@!|qo8P$=->$aCIM=z^V8f2F1ys9GE^9F#~ z7!bokA>2vIm|4|Fcs}S{`n;R=>~LI1O+?Gn*H8v?#+Zjwd{VrK3JQ%n3#{Ty*SK$) zzMD;Wqt-S39kE>LxPEkreOdcimaPwijarb&m>oE~P(9j}{Vr)_Mw?yukSIoBbx8sH zK9~$jwT8DxNf{YDHXOvP>{hH&a*)lk1GLq4Be1q?FKxnSXWR7i@#VV-u*xd9VM7eu zNuqBV?Z@msyMjM1N~xGq0) z=e7-SIEE}97FA~jn+Ix<;v~vqJ^D)BJMkpYwO8;Qp>gx_&U$A{EnQl3vKDh;f0kF8 zq_8ijQ9Snv1P0@|?aRNvzH9pkA@9RsutJWnZQ6j1cP^TK!RYDJIYs_9wi6e}qlvb@ zhAttxO>2Z_)}caM2Gno`gs3Nlaqp6)k-fx9l~KamNzS(|*-_PEzsD?_oO>A{ONXG)a-a6UV{vBx#<5r}KS z$Lo>3$D9IT-J^J2R(F+fAkCOE#MhqcW4p(c+0b|7WX7Lk9lW+y+|Nv6>xHN^EAG#5 z`CRihr|U#OJI~MOt@DMs8>=X0+)l6K`w*h>Un079W<>U>D^=x&gOF^kzJ$!;7 zx(e#>-%ZUfuDJ2p!vhm`X8`5okWh5mQ3H`v080h6i2X00cbX$Pi3ACp^|Z++D+@U& zDi^0NfPW40qao9aVq^`K0OI(5HDZ>BaoEY1b$+t(oZes@&l<>E)p!TTeXF9`9kfqD z{K8Y2*7ucPXPlSo>+tvj!TO=prQ#hj#vR8avbfalYPl_-Rwl7Zy7ruP!IkIs{l8U3vH1`v4X`sl0q6EmhT>4_)@R&OjN>eseX_71&|R@yiW*r0UO=cd9$fo0V95a#3DPSD^kCrA-Oj?aCy=nyO}yKh2p*gndr}zoNJmcO4c$l zAk382I@B=4;G>HfBE7*&-H1d$%a7@WB&`hqOg(2xZDBOvLKX^25v$Ni+-3n4`)`8p zPp7qa*)lLj1OqcG8JswtLLr!w8teeUKp<1H?(JiO)9)~RgIi#L6;SH3rcx!D=1!ViKLNdGCD@J(4KTz$M)~lGsomN* zx0lc?n=H|_8C~3Hxr5G5T94Tg*Zj_UvQLSg$Ph7zZ}G{=y+t*3OBK>xu0U#L$7@^b zRa|ZZ+i5*F5`od?mRF*;4Mp6`>iR)Aj+$^0H(=qJaB#awBOpeY+0cA2j`lbHA(m~9 zE$fW8l%Q#vFWXD1%c(xHyY+ps9^`z#SMI*|e%aEgZ>&J5LXXESPzamJ+Ja=2RcoPN z#?Q;?i@tkv@?bp>925r3PmYh7;e<@^;XuW>yPbsJ3(7$ia~aGQw?kIqE8?LyKVXB21os*E!ts%B z1cPqyFN+an0Mv_D`p1_l_$hA%DK0;tygPmwwG0vGKgbSG}NODF9AdS?sxt=4eT}@4h!|6P_vj9q-!L%L;U{ z-~3}N3Zr@=UWQ1HjEzqu$WZ9@`po%ov}8^+lQ$Z?VP3j>{wByliwYc&1E9#r+pX@z z)xCAeT~e5Tu_8|qx07U)lvt1~9U7g3yN8!xC8OY_N5Ayo!dZ8|8$42mnF$QRH`e44 zeB-n4ybxQ5CYv|i7UE+!@T0`=yB1H9Td4ZNFoaIC6Djx(zyJ@tY-5yJUxA8-iX;2HZX{C{nkzUHT;@Se>~gRE|V zGn3hXIgJA|)oY0Z9|?z85_!H6U; zF9e>n3b=t%<)>Z#yu8z|V{?(;|75kP>Na08h%0P)H9V*hMc&}@obcUpEkq%J_^HPr zCq}I)tV$N#=&#btNJ9&yPap0X`{$C_fda6>;vCyWx6cb5`g5-3_OaH(C71YsYl(`1 zQw@VtUG6U|Un)5lIarT7Ov{pK90ss7)k7M2a@YN6SZ=L3LB<_5aJjW+EHIc183MQ> z(bo~{INw|8nc+JUyTTX$jwHR=>`AKNDc{$BBb&*iC4~>-m$o1@3@DB@FbaCke*0yh z^poN9IX<**y_5Mu>;|n#_UhgD^hEbr9=d(KC+3oqEs~j-J`AEu^i3Cdv^fFY)QjVj z2>-+gMW#ay4prDy)jF+j!eL)2eEU=Ad!xi^L}zzI70B-U+j~lagZ}|;b&$0>ewD7n z4W>ss(|s1-9kBDJsYIbmhJj@61|`MjBS@B>0h+D-eFQXzz%fs5vaF5HCdkyuNbi2IsU zE)(I@HUJ}ocOW2_Q?Un@v%L$8TVQk>AXCHBHx=(J1Bn80WAEm6n)KdId(3%x#Ilz!UVb<*%3v5n ze2;clZRGP`X!Css#+I3_k;(KKmJQb#weO2?uEoCOU#}w80vF@kj3gEM_}WN85QHY( zTUWA?Tv)LDppyX|CrnV(@Zw&j>a{ z%v$crT7Du&x?_V@ zj0jrWjk1rFuszg>h&&A+*_==U;5xd%)kSVax0Tf{guD^%WI19RSsDNm`-cb#?CA9n zH*+C1*y;GT>v4_1<#N%qsg=OF&rSWwsgF}zu$f0RSvvj9zyu9}mIQYd6p|R?E}ls- zIj!OK$8mKjT9QR~DZ6|C(RHl>0M+R02QrxH?wx017_ko^R&PXcZgDjB{?u{vd3bwY zL&hd&PBCrI(1T3iJUE|9Jjl5mwwx;R1m_=l9#}L-0GM30B%t${B$9Y3vFGLHqViY< zb8oIBI-?FPv}52yET9MP5%VW9I;fb+U_i#ZES3%up$WlEd)IPb@f~bt?+{Kph#BM< z-u%nNcvhEy!4f{w^_2qlH`>p&q%f|=Rp7rjm)2f&djxR6$C}Ze-FGEcVY(oJeIR3c zIWZeu!c*Wtc7k^en1Kn|Kbey=!oBzLNR5-ZrSFriSPuH*b9mevQOry8iLYYRFbnaQ z##hnBSsvA~T1KkpMqKp2Ir@I_Fuq9B5CBSJmB>lolf73-3ZoaI{t{h14Rp@T@C2N% zU0!7F?xrmu&Q3)-I1sZUs1 z5}OwSPs-H9@>xoMkT3}@oa|*MmWP_E)e<(ifGj}RP8YRKE?Egcn(HqBof@lJyLR_k?4_M$>+yQP zp)NxDohDVTdRTh?^C?U6x%B#?!gxf|AW6B5)^)rtHeh%93;!fx>FsSRVzqy89=XNY z)-%bTq}VTYVg6U1G9d17m4GE9>k7ZwB9Xz zPPQ7uUh7f(rN}^pWPMpd^#jZLLJ$eVmq_s+Ty?Hz{Yz$7pk4GFyr0a747JxQ2)^b%mXfPi}ewF?Q! zJ%;Dsf?04V;ZqI?5Xl92_AcfRcI4m#?db=`zdJQKFi{63Jgx&=>LU?C$ngOzblGCp zeBS;r@DJN#Y{B_T`esiuU*;>$%eZyNWDTA7MTb;2Ip9sbm=7Z1N^h?c6U}QQa^&;fhhF?*sp_nV3dUInpZiVw@Ul-)r~GWjfIx4Gf#|iXOBTK^S_*+b*X=9SL%-wSe0^M?FD&QK1Z)- zL|gVAP-r%b%ty3XV%k>+DVy$-s(-9?{>NtIpZX-5T zL8`#a!*_H@oC>qaPfNN}cr9B?qkr2}c^q-gL|%>w0S@^r#98zYoAEN`Meiq%>I+;l z6-b7_$x;x&{`!nx5~chZ($Coc{kq@m+F=}R@&cEgdwwK_Czjn_JD}lLLx@-L4>Z6`x5OF zQvy1D2S5giG5Is2V3x(*>?wu64#lTmZC+k@BfupDR+G1@D|tkm=hB59Zhn&F^a|f` zxqz>I8Kvv*f}Uuq64UoEkbyHC^Ks1%nEzSHN`T@)a}{X~E@eN=fB1b#%Y}J2&2Q6I zEkKM0kK2Si#(f+x)YDgYl9I144uZ@4wIEGY`IznP+UGKoyRnV->*Jr?mWVhqp9mr{ zkqhM`&So+@gwq*7t744JI+xyK$0Dk34TR}Je6K0NO1~@WL76r$be_pO4V1G+h&?p3 z_iQaVx6B)7QFOekgj;!vs|#xD|7-8dktX=bHMJoMan_NLXf zGo@zd789L9#+bM>?-;q1=@U-7M^4FA?@ZqW>K8nki^|>vkrtk7Czn!~0!yJ*&aGP- z#ld!qd{!Hr$L@HF@Ca@BiR6_Z5J)qdz^$tQ3jdE(!*KF-p_YC&KntGty{PoRAE6+&lvlwYdh-d(8tWQLL&7A)i}vt zz}@H;l2PFEoM3^TFNXSJPX$TpSm|$W&7o1=0q-7`6zOA8U`!l3UCFYozF!5E`w4-p z>B`H7czYFi(RtSh{~~WfaQ_#3k6>T>unNECC$U6(a@ARISPuEU8;}s+arC3}h3n>3 zUN5|RRIFHx`BI**mW6@N+QOR;e_T_5t_{xTubbbcoSa;$Gu4v-5bKa1=(DKLuKrIy z7`F3VGx51H2vM)O%$xk8`>8Q2Umm_II=qWPGG`Ug+v@V(f~Dt3+qGutotaJSsjIlI z{zZ*yD=b$H{+)ye8zw3MNJ~#=p>2TkOK(D3>X(|Lb`yvuZjFTCr>`$h!y#CrVHPF* zd4%cs$tzf&?oxcl^hiDskl6}2{>CrbxZxmo{1ohF^D%3LSG0}JC*nS=6KO_|aQFcH1)@mN5_yIhEQqoqt_ zo3Tp=gbIJzT>YVDEEreWy(a?h;`xy&H132y|3ORjZ@)+uf*s1RkhHJsEcMg3yC6Iy zEk|7;9mc4RTLMG-O>PM=S2kvlzz>@rEYrV_R;HtM+4BC3$mYN z&yBeH%eXm?ayb;29C6db+h-&H_?N+)G`Z*6$3wSx$iWc1?G;UxtxGjDyso^# z&61`b8I!?QkHnfB{F;M{mUR6`_}o47Crs3+Z<_4JwADs^YI!K&^pdl$cUtXrQ-vsK zjO6o}z+zyDWsq)>K5xAWJ`YcOdJ|N#hUr}?#{e@F*nyv6Z2{-?EEi$mbXoMBDGR#Y z`M1F@4b#T8#Ux-0+ued#fWNj6OVw*b0Z>cc<*oyU<-vRy6+s(b5VO;1pQ6sS>un3I zmhF#$0qf?4YyN87ZFaqS`oK0Z9y%)Ge z?|`}U1F8AjR<$K*B7^-v*tDM7nsK61zbX@Ve{fu6PKs*DuvEpA5Q*OLA<(3E%y=Z^ zq=8*;$!?;EP%_Av(9ZJ@qWV7~NVzG`H3|g`4LooW;+}qXa@Cy<6)<00;EC&2bUQPr z!4JQ}{bt2`1%fP@b-1}}cL5;W9x_MmtF1Y$KCH@AIvDgSglQ-Zklp)C;KJTGSRX$~ z6@FoHragQU!4Yp98M<7Vp*YTq69J(6yQMp?4ZP*gS+qwdTC=sDtbB*_(tq6ai7q3a z%W*qKwgT&Dxs$|H60tqDS4xg5H!SM&>LS9dS#Kq-KGsaU{-k>O%=U`riHWA?&ua0v zk}SU)RyFr3+*Q5=>v7Ge6AD5mf@Mv4lmNZBI_b)J;wJU{M!u6EK~AA2;4%c87eP_J z7~XkdnD@{|URRTPiZ;i?k9KKP(c0K59kg=@x#E7|;fkIu8*j z-kf@Ux#GAQ)#4_HLULZ9f}p5}BqN9qW?fL^YjE+Ap>iyeG^?k2@SKZl**SDB2kAOt z{gq|b=|TTc$-1PS#+y@qcy^eR{BNYz!CiblQZiJbU6reE-EW4Up!mIG8b=>P8fAfBbF)MLEqhED`~+Pe zM66!9PJ-#WZ@m|-vr;5jgR}A68rfPw>)9Rrx-`H%E`BcrBHq0Agw=JOfx%*v+PCBN zroO_DW17EeSMqShzjmvpLH2;DoHvLPT}cq%?p}X!T=h?Z5!WQAi~O3s(eB;{F#wQw zmdJ@Gpm^dI*}RXz9J;8iI_5cZD0Uoo<;c6>>G)AxtAFvtT=p%Cx7WHvq4&36bKf)o zKP#RW`$3geK}{|)jh>C4eA+i>_%>lW*b1K};_myN~#biJNc z>j?3O2bv+BB7TRd^D|;7{l&uFa>-d7+f(m1*en8!sjv7ga(6Bo07LbhzA`bUyB<1m{V@GIppP zEL=z}VkK~g{{ky*Kp*4L_Owgk7_shf*7$@G5BX;b!h9{a4DuSdF1meLw{pT!SmPH6 zJ82r_JMA#Pc=n_v!KyZQqdwQgAQUb`2cdCTDQSY2Z@5R z?X#Gx{!pSYpKuL~#kFXhVX+cW>Q&X(~SWF7Hk< z#66pyi+UZ0Zaccg96*a_pK%Lo!9?!CL=JCfDCk|91`R$%wemMmY}=t|rRKX+2LJW) zcbKkxbaMGE8741Ac*ax}ihc82by#GO93(i%ot7y<_-s3Rm#TzYga+jNZ z_;pJ)y&gD^9p{*?rxvk(34;bjj}Pf5onJG{apVD|Hv*JjLtH)!Cu!$DmLZ0hRrbzR z2ZJAAeEvYoJ3)^bNNTu82*)S^0DON&Tdi88#Q#|GiZuQC63OX>+R62N!22KUQXr4( z>g~NV{THXQV<50L50DYXtp1YWmZYU>Ag)A@f#oJB2U$b(Jzp* zv*E(JaKHSWC(E2RKR&fI)8(!sX*#oADwCWGBne8t1nE>DHLR zi*1QeyTeug0g@?Z71-JDbeH`-tjkY`M69}!(wze5|FTIlN>Lt!Ma*8?6=&8P+b+>^ zIgp)(5cN430x53fDwAu|7Daff^k>nvq=I|0FB(F1wr@5d1Xv_-A_mI**sqTWFWD^w zZ~CY_ zTq4F2<;}-4K0D$4!-Dc1!16EhD%iawLoE@VO0Bh%+cI-8vtXK};eDj?_9i5dBho#B zpIMv=TUDFhYm`geqWedJa3Ih`3W%kqnz7a9`&f9!si9w<%zDMPeLVcy($i-=y7RP% z%%3aAWG#UE4ouVJ@5LPb<2lvMJ-~xi7UR!vtn$BPByQvQuf zDoN1(;I%cVUxa&4z1`{|`TMJmZ}giJ&a&QpTs83YaZq2&g{ra^yZR?3d*AoH%?N`r z9o_rc+Y4pQg-!Etk|{a_m9R!d@RJhaU-rrE{47Saq6_2H0x7A7S_Z_h$4Xi#HCuSH zF2+Y7LA1NtwIGX6B2z*}AwjgB1=%qQYO20gkJ&btuHYcKw8uD5vG#CK@s;DBjhB^UvjTS`5mvV#j(eNC{yh)WWJhQc%R*bFb$~} zWA_|O^j*w}GE_VN+D!&rD~!Vp8e89?iZ{6-}N2&8d%e zyWQx&ipLs^&~UfKNa{@O7@Ct=MzpQ6QQSC_Wa&FW?JHO@lQkXhF?*uoJr(aU370#r zT|Sq9-2R`ci?6#!;Zv1Z~{zxx61bXp(bK*sxKByy+0&uN7QQTGi zvo_J!dH9>1MsYMIDQRTZ@uZw+auy(dCjh@Y)XqsJ6-kOXloc75vPH*mCCR&jjY_We zW^KhRWY7>s?ppP=L#LFFi9BOl7{!G%O(1)d01)R(sxVXmKq?DHmO1NT4@+Sda-^#< zR2lkUxYO7cqqq`L<1N2L9BtW?3fjEj`?V_>4I-?B;e``;=EGe^5;GOCO+v4`f2O^$ z`waG5OD5V}Xz%#F~C|jM4C9Er1 zmB$024q*e#T4R&46r^CzJOf_|0ujM?)Tr|1UG)f_i|Cu*9?hAlTNmnY_ z^xw+ZpTwZFa8ICgrdNa$hi=2 z0{$aO*$jz{*=JA*5SHGu1rS+Ih_agVngJ4+8`0=!Pvv~@D&g;A^4g^zv3W#Nf2_Bj zcF9DTYOh3UZzhv{Pw>U2?`ZRjnGhOK7K_vwV!o8e$RtE4U*-Cmdh1IpTfp>>T44;6 zI#RmZn7*wfMq>_z!G^43&nIqBOB4rFRgS<-A4 z2-Y8AQ85X56zE;8ukQS)&?E17b;5M=SC+b%N7<2gqCcJ$qxp}ni$;34a43Qfx8va$9k#F~coHodsZ)E0wW*z+xn^pPCj>c?Yes^;Xe zL%guZ7=Z{h=J_c!>XAtIVO(IRtI~C`ggLJcp_raQbFTMqcK~tjK$DVPZk>U+#(AIy(`_bSLcIx- zX`QKllSP+0YW}$>Tf$@F`z3vU2p~3*s!t=doUpbyLbD0=wOaKh%sY*XTT-oP>|-p7 zThhViQ(z}xn4jAhi5?y(2yhd0Gl}+(?)vg#(daD|H?k;b_hZZnf(owR*laiwECSbA z+7YelQ=t&cLpLIxz|x!bF?Q&8?t;lls^@*n_Ezi1n-8m=s!%;8Gq)khB8PeS*t|W> z+oG-wa=TDiWhc#(+aOZI^=HWuXEz9|V#(`O9&vrgghRP7>9t6LdF2%Yto$@uA+pK7 z;AM#Dtd*u_F-Q&(KO>r zZVy?aA{A6rgT?292Znj9-SX-R4CSJ_Hl9817r5i^ij*t$0NDIhcXCaHAAVmiPkHnz z&vdPd1tyPO&3)ZJ?FZq9wb&L$ju0K-!@AevEe|%pRF*ENtd+w2U+Zroo61nYVZG+& zt7Vv5KD;w_u>-vdd_n+7KtGibTf?V!=R4O`ZYYkS{0Rx$SUd|UGKl#>i0Oqbgn^X0 zPx|LML7Uz6>Y#xOzoIf3s7Zpn6-<<7sPrBhq~%1zy}jr3AFVA)3#>U)xeggwqcD3Bndd$Ows# zWhPs}2gh`yQ6L7pGfhPS7a65o@XS(lvtuMZ5)Qlqr~gP$$;Yx+k9fycI^!oI;r<## zNCbO7L3fVb#Gy~WXR}lBRuMx6kWNL-c_0b(H?wD|z2prBnGyy%6%mb*&IXA&;f9|d z7B0NEhQd!83Jej_0;e^fG`e+(E(e1F?+Q>1&Ob`B$oQ=H&WEwJd#|4HdK!4{j8*9m ziGrck0sUGi{KBz-Z4wZJ#gr+~4LbS_6(NSTC-6hXj|`YeGvi4Sim6uF>|C`y!2h~v z>EyTOf7|zsvK4D`MN+Bc-UCt|JD1jzz%F6lhR)Il(7ucfF2_6l@d!Yfy`?v1lqF-W z3|!dh?NG1dM`zBbi|}IPCh!$8>M;jK=)8C1&;kIAu=Oh~fK%1wg<5qRmqBNKRGd&?}IAB`;bS7K)oOY!XG~Y>%C@Im=YKI zURFz7tj{LM1JIcu2)T&BbV~i2nZDCNh1fB)FkT+SnKLQUoQdQS`O5q)lQ=b+Q%}Ves}bidF8D(cfe3#&KPHZ-0zSjBO$TFrQ#mLPzg>9h_hg#aeIk-%zVe zZ!A_YLFL-p$EdEC*8BvT7O|hKj-r(u>nS0%l5?xSMlr*%TEwuCxl$C)PE5b@`(+i`WE2Flod=Q&7i1Bi*qp6kvmRQyp5QHK&> zj>Sfb-Av&Psql8sHteZFhd)&ED>6BWtSbo03Arsx;y*IFtY9v#Cpd_eG$YZ?#QTs|MzfdQTZpml7h zvYO%d%Q1$m!)fY|S}KiDW9eC>DkJSI13Lm?^JT7R)8#Bm&-*$8#DeK6q77;PBiXtE z@Q8gj{VaWYN+@UOi5gSe_kwTjIMC{2hyr1d<*T280Be1KSi($oLL#kVoLGk?zzWgW zf?Y(xbuw*>zQ;++?sekGjcqzSpK!7mD8u_TNG!+_hAZUp%z>Pzo=;DzQICFXBX#_= zxavH)@+Tv;^$&rJ+=nhP&fmnH;BBzs{!#q($kE^PtBT~s)YHOk9RTZ+7i8ev)U9k zVjB6VLGJ}0BduaF(Ym*#p&m*;<|Qk3Hgr2%H60nfjM9^Lj#7W;!n{%Q+KGyUK&5m` zD_sWfB9F68g-x5y!+{rS)RIGvr6#6J`Y$q}DZCh}T-El<@a8myK5O*(f{QV!dE8Fw zXyPQ-&>T31sJS@%IE<#8CESw!0pFIUy5K=iS=B3voR7+QeryNS^g=TiET&y(EQsVv z9}BIk=J6qzy54`M2mRonI*W+vw{!Q`;}!HC>W!xZYV1ChZv}0$bM7~t7lC<%%B~Do zfR=ZHcGTo_uVz)_QZKi~UYERwt!J`FX213iwZt}k7@zMcONz>1wfHr|)o5DES}?65 z`<=LzYaTEB#nP2lr6>wM1X8!Nh^^K!331hX63`z98{G=8IJ%O%G8{CMdb@d5j1Z&# z+o#Xk7uqMiFmkL{12h|Lc=#ZSq?Ut7-%FjO$WP+2H ztLB&Ao%S`m@5AA2#Tu+p>w^;2m^R9%G)pT{YJypYcxJOjofBb)!jh~Z^irWT$~s&5 zmiZKPya)=M$@>AjsD?_pXIm0znSxne6rD3NgmDYbF*OMf+27>_DuOIl%Oa*tPN-HB zSFs3*^tnmg^z}j&W#SUwb-c08su=M&R&M@11RWn4l75k5RPnl3W#a10v_5lo8PeU- zORY@C4ycLLHC<4NA{y!A?^D#BW7EY`s*b#rQDnR1W`FSNd?&COE2_04^&bB=xqK;j zwnrIjn0j0!wvEwp%P{L zi3RMlGE0tkeICrG-l^La9DJJ7Kc&f#_aBb*(h11>F@A(efrc#I(QS+6OVRb>#sULn z9q%!+%CQWo$;7UYW{lnbjVx7Bu>{@M7-nc>COYW**n4UKSo}RG^nN z8?#G^yrUUk#d#Mrxc7b4WNNZfiM+r?xJgq1YXzk!V*dT<`sJ!f=`lr>2RDTb+qx?- zi4z(d)N8Xps{ZNVgf+N^O7<5ZmK$@K%OO6dIV@9&M>Pb7+!cCmC^(DoEJ(q?uO+7w zVcgL_?$bEQ+QR_@@8z7o(bS`=bqEr+4uAMJdX5qnwd6E<*rw)t8(en$LWPd0-fK%6GgT~F3QRL9QT=9!+Gcn z^HObUMQ|iBeEjmtqUnI>SPpUPXyVK{b;s@m?1nsCA3bBEkoIV5pvCDJ7Ed2uV*~T0 z>q<9#miL-QIveV=ifZ^DcxLIhzFOZD0EZWtHMJ)&FI^Ak!uIbrP!87YK21OAAogRr zB(rzgStf1&@8U|K>^__9ngZf?y+zO!YWR$8=(IG;HKe#j0oYPgv^lcnx32kwj=TI_ zt!bUf1AQUa`8%PJK*6z=acHnIUAmuxWq!Xy=3S!S)WYr;S$zUMjzb@shV>XWH&wC& zDbh32R%xsZz49;H&fa9OY!qre)I4?xVJ7d3LyE(%Y1`V}&zLn`blZg^=GSq1x-#8y zd6R+#&@tCVajUH!O|;H#%PT`ZJGVk>x{QkztY7z*t+wZQ)Imaol@YkC?nI+?{*4ghuZ%zxH3LPA}mq4?3497|wn-klSsz00g)Plp9Z5{s$RL z&g`P89D|o@;&(E?yDOe4|NL`@2$n|v;JOQMs^Y+TkmCPEnXr~2 z#KsPP?oouRc$go|W2r&WE5Pnwx^b}9DkJ;Sg_bQCf&K|#S|AZ=2 zHP+eBwN>HUhM(b*$vdiy=-pWJZ@#P7>zInpAL&c49SIYSi+5(5K6|Gt?VxhI^c0a@)}xOI=N>fxb)n zV|Trpy(*z93!0CV>!ey*eSYrl@uCNaXEf9Mf%dhhmh}ITr9jZwGp#|@C-}uTJpJT{ zhPhj?S^b&cde}!o4fy4HvG|JAD@0EHv10SLI{@xq^FVJET_&Y9kiZHFfq+=R_L(lYs%9k8~5Jg&%B0YlgvoQ z7wzX#!(ol8L+1vSIhFWTdqS{ss(gRqUR1`Hby%MZ^`KUY*(M@~e;Myf1*=rx_D_vN?_#5iRCL z_?fFq@NkN!gv!Tj0smtWCwsbGY^`FMi;n0yXtAq7Qe$7u6Wg{#_&gQ1L+04W9QKl% zXLCu+MbH8JnEtEBlGv7f%B-7)s{YH7c{uf}cxIN>%%aA>7$_AQIs5s%Sk)}C#qX1t zeqWYKE4y|iYQ;;J(@|v(oe}rqC~7a<l~L?>_TId&sjsp&n!l*UDouze~wF;}J_rHc{!?v@*WhiWag8+)%PE{_Dm? z@&#HMl5UWX#g9(>Adkb@H0LI%uu&(?KGN7_Mtrnu{WBs z64nJT@N5n&RyTwlkA%e3o)>mC5J)|p;YsQ4tZ0dP!0)pEo93lsHHoEl%O&`mW~ZH{oC!Pew{XjT2XT+? zEU5G0fcGHr6nu%u#+EA8wr-OO$q`jc=r|}buh#RqmZ#t_w|)L*|AlKR+AmjqJq}2Y zR4rEt^AQ8E<*d}NZCnMnL2L&A!$#4%iP`QJZxa{IgQ#r_vGm34VY?C_#aY7{ji<>H zA#OpZ83(!^NnP+g_|A|VI`GZD@-w(KDZRg*^F5A`kow-CWMsBNB2D3p3?q^cseP3) z2@g<>xpj@X>k_+>FBVgTJuJ9{wrcQddI?w}L{Lf~l_YFkDO9=sEUrB?j|Fh ze}rl=_F|mY&y9}P>`(HSMP9R~k4+z)66~E*y2bIudlK>3}`{)n6 z7>_Ama^ub|`f0vk3lgX!sLTM{u>yV$Io*K z3QL(KrG+BvJofxN0rLclvLLj4Hop5;oZ^oU8Cj>cWken0CkAwKZxgz%$IY}~m|b$V zJo$2!qcCI>>cgpeeS=ilAxc((9!#B)|7d{fr9fo0QKvqL1TX#cjC)d3b=v6pXZxq3flKwNQNH1MZ&tz{X+2r(?X`H%3$mbnOvhc4t^Z`$-|wZ7rvN z>ebtrOtW2>W87Cg1^y$x1C)d#U8bA^{+;r(b@I#g%NpaR_TqZ)MIQ8N^xM#Z|IPp7*-$TkP(Rd-8macUP#RV~75b++{m zce%v2vu!nBzj*4AuB69 za#8D&KGk7)>5U}yI;&~Ks>Bg0z5Lp5@;C6XCI%wQCbTbr;)Cf@g$WVQ1mhH1Y*kx9qsa|ro;L-4il2VPNv0L7 z`u=0WHK>7F=sDgT;O(q6mmq5pwC;`V-ao@t-UBWMYj^y#PlZG&qh{L@?JGN}on+n? zCt8d2Qw8Q8PUg?#Uvs9r^<mO%mRmr?R6M;Lp|#1=>UopIClRxRJ8YiSIHZtfAs z>$K393aJiOQEF6=1Hc`&t=qU}!J5s~K~}P78(Y!9s{4?@H0;s1=!$i#0&ki+*F$Ka z?R7|r@G+V*xo#?N8V|%w;HbLH-+8sOzXZ}AbhN<9RH9zRonq}gdBf{^emH1P=KF7q z(ybRN6wl+_BInEGB&k!#cLS+6`)dnyA74EHHZvl;ZQ;!agNJ)k{H9=cp6jm~98+~|)4K(=V-pe8EoUJ^{;R#Py{Rby z%h%NyI=-dHZ(d&D@~SvA?OP|lG(Ik&i*-gBa&rbn%3nMgS@xjs<2QZ94Clq%8eBQxS(c5}PPOhmu2uUQQA2)~nz0Fj7E zk4P~WJVj&Z{d|H6ep0I_tW7cal^27@ zFGu|jYbX$n!zQ3@J?3um)7oQ}hWPR50uWd*6twU}2p@a|(ZOo)0Vf9i`0wlg@0Z2s zc^Mbx(&HB7#n_z5?REfB%S13D1=GY;M-eWzT-zAF!{+aQnxyZOK6^=Q5Cj|6V1`K8 ztR|wKVMAV_B}`{;1p)$03Mgg#qQY!r2F9L*V`11tpBd7oYUfe%xLe$`kJQPn(AP?~71u?hgm ztfRbhG!Hxq4dKO#kIJdTINLqekhtDbFmyHMFT|WB@e7$u%#ML;1~pmrASB3^$#OJT z9lMVTe6w#PQ)zK_fBoNYXXp;xG=Z_Rf$wk@G-(?R?nJoo&j9@lL`+{}h&FW5mZp}c ztU87(H;l;d> zEMVKJng=E~?1yHS4d+4RVkI`x`(`hW*pY}P01TMPk50BSsR1wWp>>o&$5oLHPMuz2 zUKD_xK$Bsc^0;HK?1F->cv9VS#BVbWKDew!{ zjekIf5H|h``ac2e5xM8Z;FcCgYUvzKu~JpP3a4_O`x(8y19hVY!2gEdYPuYBWgV4* z$BlnLk1G+YQ*e7LPo2#n3bvj}bl(vG77Dy$J%~bbMB}24fVM&Kj(kB;OE3#4XjKCLm3C1mG8TQ(2WvNUI>XkDJvWkA79YE?&6&D14ga<*_| zX=_I{7PYZK55hB#_z(b%1Mm&IiX?b(=s!j_|Ap;qZVx!H00Uv=0O3JrO(o==>Kf_* z5Y)i6?%6<;5}jMNu(0NMzKD}GhmArjdZQqf2)g0r9^IBmuL#LK#B3L;~C!Gsnz(>a>-h6bF($Y?4ZE?@2BD$w92I2;=+@RdhxC)!VL;%u zk>l&&j2wQj-a3Fl3Vh*ZH6nFwLsR)!meqwDQ_`XXPji0tc}`Er;iigJxU`)A8^ zR<#fi3lzh<9KQq+u+nnVo1(O823A_o3lqV;)_mGGs?QF>TqP1i@{NMmq54-7n= zyTZqcbZ-R)&{zc-jXBt?yYZn+^VKD@HHnlEj!48r8%%Gk+}@7+qVE%J84yY}89?l- z;9+AIIbeK(GyspHFrA8y1&*?0#@q_j&K_h_rv;92AzH`pZdv|sZT(UFMQB%vMuV=LqV!hnWr z;KbwfUccsliKBDq)v-1MG0S3IoPP=TlD_ai;s`@zYSH69m+XB`&#;=264{?vn)V)G zsT^1}4Mc~%oHZGkBe$dvXH@*dS6>02gS<0Gv}}Q#t&`d@C}26VzGx_F1Yi0i*sU!K z&a|3Pdp$(=;vPaeE9GKL%@g3{omJU9sXOE|HuKz;6E<;PdfZ3 k75`&{|EEfFwsHl(mEWVvVfO(8q-#!V5l*E4a`E^71A#6IhX4Qo literal 0 HcmV?d00001 diff --git a/src/main/core/agent-hooks/classifiers/devin.ts b/src/main/core/agent-hooks/classifiers/devin.ts new file mode 100644 index 0000000000..abe3686ce3 --- /dev/null +++ b/src/main/core/agent-hooks/classifiers/devin.ts @@ -0,0 +1,50 @@ +import { createProviderClassifier, type ClassificationResult } from './base'; + +export function createDevinClassifier() { + return createProviderClassifier((text: string): ClassificationResult => { + const tail = text.slice(-500); + + if (/approve|reject|permission|allow|confirm|accept edits|bypass/i.test(tail)) { + return { + type: 'notification', + notificationType: 'permission_prompt', + }; + } + + if (/Type @|\/help|\/mode|\/plan|\/ask|What would you like|Start coding/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/Ready|Awaiting|Press Enter|Next command/i.test(tail)) { + return { + type: 'notification', + notificationType: 'idle_prompt', + }; + } + + if (/Successfully authenticated|Login successful|Authenticated as/i.test(text)) { + return { + type: 'notification', + notificationType: 'auth_success', + }; + } + + if (/What.*\?|How.*\?|Which.*\?|Please (provide|specify|clarify)/i.test(tail)) { + return { + type: 'notification', + notificationType: 'elicitation_dialog', + }; + } + + if (/error:|fatal:|exception|failed/i.test(text)) { + return { + type: 'error', + }; + } + + return undefined; + }); +} diff --git a/src/main/core/agent-hooks/classifiers/index.ts b/src/main/core/agent-hooks/classifiers/index.ts index a35dd63cec..be5365cf16 100644 --- a/src/main/core/agent-hooks/classifiers/index.ts +++ b/src/main/core/agent-hooks/classifiers/index.ts @@ -9,6 +9,7 @@ import { createCodebuffClassifier } from './codebuff'; import { createContinueClassifier } from './continue'; import { createCopilotClassifier } from './copilot'; import { createCursorClassifier } from './cursor'; +import { createDevinClassifier } from './devin'; import { createDroidClassifier } from './droid'; import { createGeminiClassifier } from './gemini'; import { createGenericClassifier } from './generic'; @@ -34,6 +35,7 @@ const classifierFactories: Partial ProviderClassif continue: createContinueClassifier, copilot: createCopilotClassifier, cursor: createCursorClassifier, + devin: createDevinClassifier, droid: createDroidClassifier, gemini: createGeminiClassifier, goose: createGooseClassifier, diff --git a/src/renderer/features/tasks/editor/stores/files-store-utils.ts b/src/renderer/features/tasks/editor/stores/files-store-utils.ts index 746fe9bbba..2f5e5e4acf 100644 --- a/src/renderer/features/tasks/editor/stores/files-store-utils.ts +++ b/src/renderer/features/tasks/editor/stores/files-store-utils.ts @@ -33,6 +33,7 @@ const EXCLUDED_NAMES = new Set([ '.conductor', '.cursor', '.claude', + '.devin', '.amp', '.codex', '.aider', diff --git a/src/renderer/lib/providers/meta.ts b/src/renderer/lib/providers/meta.ts index 096af94c09..0d926f5819 100644 --- a/src/renderer/lib/providers/meta.ts +++ b/src/renderer/lib/providers/meta.ts @@ -8,6 +8,7 @@ import clineIcon from '@/assets/images/cline.png'; import codebuffIcon from '@/assets/images/codebuff.png'; import continueIcon from '@/assets/images/continue.png'; import cursorlogoIcon from '@/assets/images/cursor.svg?raw'; +import devinIcon from '@/assets/images/devin.png'; import factorydroidIcon from '@/assets/images/droid.svg?raw'; import geminiIcon from '@/assets/images/gemini.png'; import ghcopilotIcon from '@/assets/images/gh-copilot.svg?raw'; @@ -35,6 +36,7 @@ const ICONS: Record = { 'droid.svg': factorydroidIcon, 'gemini.png': geminiIcon, 'cursor.svg': cursorlogoIcon, + 'devin.png': devinIcon, 'gh-copilot.svg': ghcopilotIcon, 'goose.png': gooseIcon, 'kimi.png': kimiIcon, diff --git a/src/renderer/utils/agentConfig.ts b/src/renderer/utils/agentConfig.ts index 2e85dcb5b8..8cd147abfc 100644 --- a/src/renderer/utils/agentConfig.ts +++ b/src/renderer/utils/agentConfig.ts @@ -9,6 +9,7 @@ import clineLogo from '../../assets/images/cline.png'; import codebuffLogo from '../../assets/images/codebuff.png'; import continueLogo from '../../assets/images/continue.png'; import cursorLogoSvg from '../../assets/images/cursor.svg?raw'; +import devinLogo from '../../assets/images/devin.png'; import factoryLogoSvg from '../../assets/images/droid.svg?raw'; import geminiLogo from '../../assets/images/gemini.png'; import copilotLogoSvg from '../../assets/images/gh-copilot.svg?raw'; @@ -34,6 +35,7 @@ export type AgentInfo = { export const agentConfig: Record = { claude: { name: 'Claude Code', logo: claudeLogo, alt: 'Claude Code' }, codex: { name: 'Codex', logo: openaiLogoSvg, alt: 'Codex', isSvg: true }, + devin: { name: 'Devin', logo: devinLogo, alt: 'Devin' }, cursor: { name: 'Cursor', logo: cursorLogoSvg, alt: 'Cursor CLI', isSvg: true }, gemini: { name: 'Gemini', logo: geminiLogo, alt: 'Gemini CLI' }, mistral: { name: 'Mistral Vibe', logo: mistralLogo, alt: 'Mistral Vibe CLI' }, diff --git a/src/shared/agent-provider-registry.test.ts b/src/shared/agent-provider-registry.test.ts new file mode 100644 index 0000000000..d2cc99cab1 --- /dev/null +++ b/src/shared/agent-provider-registry.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { AGENT_PROVIDER_IDS, getProvider } from './agent-provider-registry'; + +describe('agent provider registry', () => { + it('registers Devin with prompt, resume, detection, and auto-approve metadata', () => { + expect(AGENT_PROVIDER_IDS).toContain('devin'); + + const provider = getProvider('devin'); + expect(provider).toMatchObject({ + id: 'devin', + name: 'Devin', + cli: 'devin', + commands: ['devin'], + versionArgs: ['--version'], + initialPromptFlag: '--', + resumeFlag: '--continue', + autoApproveFlag: '--permission-mode=bypass', + planActivateCommand: '/plan', + icon: 'devin.png', + terminalOnly: true, + }); + }); +}); diff --git a/src/shared/agent-provider-registry.ts b/src/shared/agent-provider-registry.ts index 7beace9b5e..76fd659be5 100644 --- a/src/shared/agent-provider-registry.ts +++ b/src/shared/agent-provider-registry.ts @@ -1,6 +1,7 @@ export const AGENT_PROVIDER_IDS = [ 'codex', 'claude', + 'devin', 'qwen', 'droid', 'gemini', @@ -104,6 +105,24 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ terminalOnly: true, supportsHooks: true, }, + { + id: 'devin', + name: 'Devin', + description: + "Cognition's Devin for Terminal agent for local, interactive coding sessions with Devin Cloud integration.", + docUrl: 'https://cli.devin.ai/docs', + installCommand: 'curl -fsSL https://cli.devin.ai/install.sh | bash', + commands: ['devin'], + versionArgs: ['--version'], + cli: 'devin', + autoApproveFlag: '--permission-mode=bypass', + initialPromptFlag: '--', + resumeFlag: '--continue', + planActivateCommand: '/plan', + icon: 'devin.png', + alt: 'Devin', + terminalOnly: true, + }, { id: 'cursor', name: 'Cursor', From 344d9dfced9749a6439e854c9da5dfb8f40dd4ca Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 12:50:25 +0200 Subject: [PATCH 056/263] fix: do not error on file not found --- src/main/core/agent-hooks/hook-config.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index c2e2c4e072..aabebb5f0d 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -53,10 +53,12 @@ export class HookConfigWriter { async writeClaudeHooks(): Promise { if (!(await resolveCommandPath('claude', this.exec))) return false; - const config: Record = await this.fs - .read(CLAUDE_SETTINGS_PATH) - .then((r) => JSON.parse(r.content) ?? {}) - .catch(() => ({})); + const config: Record = (await this.fs.exists(CLAUDE_SETTINGS_PATH)) + ? await this.fs + .read(CLAUDE_SETTINGS_PATH) + .then((r) => JSON.parse(r.content) ?? {}) + .catch(() => ({})) + : {}; const hooks = (config.hooks ?? {}) as Record; @@ -72,10 +74,12 @@ export class HookConfigWriter { async writeCodexNotify(): Promise { if (!(await resolveCommandPath('codex', this.exec))) return false; - const config: Record = await this.fs - .read(CODEX_CONFIG_PATH) - .then((result) => toml.parse(result.content) ?? {}) - .catch(() => ({})); + const config: Record = (await this.fs.exists(CODEX_CONFIG_PATH)) + ? await this.fs + .read(CODEX_CONFIG_PATH) + .then((result) => toml.parse(result.content) ?? {}) + .catch(() => ({})) + : {}; config.notify = makeCodexNotifyCommand(); await this.fs.write(CODEX_CONFIG_PATH, toml.stringify(config)); From e02a3ac6ce28a3cc78d5590767bcaada59bbc3bd Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 14:26:51 +0200 Subject: [PATCH 057/263] fix: correctly map tmux agent sessions --- .../importers/relational/conversations.ts | 235 +++++++++++++++++- .../importers/relational/relational.test.ts | 87 +++++++ src/main/db/legacy-port/service.ts | 2 + 3 files changed, 312 insertions(+), 12 deletions(-) diff --git a/src/main/db/legacy-port/importers/relational/conversations.ts b/src/main/db/legacy-port/importers/relational/conversations.ts index 984aff7bcb..0ef05cfcb5 100644 --- a/src/main/db/legacy-port/importers/relational/conversations.ts +++ b/src/main/db/legacy-port/importers/relational/conversations.ts @@ -1,5 +1,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { makePtySessionId } from '@shared/ptySessionId'; +import { makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import type { ExecFn } from '@main/core/utils/exec'; import { conversations, tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { readLegacyRows, toIsoTimestamp, toTrimmedString } from './helpers'; @@ -9,6 +12,8 @@ import { createPortSummary, type PortContext, type PortSummary } from './types'; const LEGACY_PTY_SESSION_MAP_FILE = 'pty-session-map.json'; const LEGACY_CLAUDE_CHAT_PREFIX = 'claude-chat-'; const LEGACY_CLAUDE_MAIN_PREFIX = 'claude-main-'; +const LEGACY_CHAT_SEPARATOR = '-chat-'; +const LEGACY_MAIN_SEPARATOR = '-main-'; const LEGACY_OPTIMISTIC_TASK_PREFIX = 'optimistic-'; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const CONVERSATION_ID_TIMESTAMP_PATTERN = /-(\d{10,})$/; @@ -19,10 +24,17 @@ type LegacyPtySessionMapEntry = { resumeTarget?: unknown; }; -type LegacyClaudeResumeTargets = { +type LegacyPtySessionTargets = { chatConversationIdToUuid: Map; mainTaskIdToUuid: Map; optimisticMainByTimestamp: Array<{ timestampMs: number; resumeUuid: string }>; + chatPtyIdByProviderAndConversationId: Map; + mainPtyIdByProviderAndTaskId: Map; + optimisticMainPtyByProviderAndTimestamp: Array<{ + providerId: string; + timestampMs: number; + legacyPtyId: string; + }>; }; function isPlainRecord(value: unknown): value is Record { @@ -33,11 +45,14 @@ function isValidResumeUuid(value: string): boolean { return UUID_PATTERN.test(value); } -function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResumeTargets { - const targets: LegacyClaudeResumeTargets = { +function readLegacyPtySessionTargets(userDataPath?: string): LegacyPtySessionTargets { + const targets: LegacyPtySessionTargets = { chatConversationIdToUuid: new Map(), mainTaskIdToUuid: new Map(), optimisticMainByTimestamp: [], + chatPtyIdByProviderAndConversationId: new Map(), + mainPtyIdByProviderAndTaskId: new Map(), + optimisticMainPtyByProviderAndTimestamp: [], }; if (!userDataPath) return targets; @@ -59,6 +74,36 @@ function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResum if (!isPlainRecord(rawJson)) return targets; for (const [ptyKey, rawEntry] of Object.entries(rawJson)) { + const parsedPtyKey = parseLegacyPtyKey(ptyKey); + if (parsedPtyKey) { + const providerId = parsedPtyKey.providerId.toLowerCase(); + const lookupKey = legacyPtyLookupKey(providerId, parsedPtyKey.suffix); + + if (parsedPtyKey.kind === 'chat') { + if (!targets.chatPtyIdByProviderAndConversationId.has(lookupKey)) { + targets.chatPtyIdByProviderAndConversationId.set(lookupKey, ptyKey); + } + } else { + if (!targets.mainPtyIdByProviderAndTaskId.has(lookupKey)) { + targets.mainPtyIdByProviderAndTaskId.set(lookupKey, ptyKey); + } + + if (parsedPtyKey.suffix.startsWith(LEGACY_OPTIMISTIC_TASK_PREFIX)) { + const optimisticTimestampPart = toTrimmedString( + parsedPtyKey.suffix.slice(LEGACY_OPTIMISTIC_TASK_PREFIX.length) + ); + const optimisticTimestampMs = Number.parseInt(optimisticTimestampPart ?? '', 10); + if (Number.isFinite(optimisticTimestampMs)) { + targets.optimisticMainPtyByProviderAndTimestamp.push({ + providerId, + timestampMs: optimisticTimestampMs, + legacyPtyId: ptyKey, + }); + } + } + } + } + if (!isPlainRecord(rawEntry)) continue; const entry = rawEntry as LegacyPtySessionMapEntry; @@ -99,10 +144,47 @@ function readLegacyClaudeResumeTargets(userDataPath?: string): LegacyClaudeResum } targets.optimisticMainByTimestamp.sort((a, b) => a.timestampMs - b.timestampMs); + targets.optimisticMainPtyByProviderAndTimestamp.sort((a, b) => a.timestampMs - b.timestampMs); return targets; } +function parseLegacyPtyKey( + ptyKey: string +): { providerId: string; kind: 'main' | 'chat'; suffix: string } | undefined { + const chatIndex = ptyKey.indexOf(LEGACY_CHAT_SEPARATOR); + if (chatIndex > 0) { + const suffix = toTrimmedString(ptyKey.slice(chatIndex + LEGACY_CHAT_SEPARATOR.length)); + if (!suffix) return undefined; + return { + providerId: ptyKey.slice(0, chatIndex), + kind: 'chat', + suffix, + }; + } + + const mainIndex = ptyKey.indexOf(LEGACY_MAIN_SEPARATOR); + if (mainIndex > 0) { + const suffix = toTrimmedString(ptyKey.slice(mainIndex + LEGACY_MAIN_SEPARATOR.length)); + if (!suffix) return undefined; + return { + providerId: ptyKey.slice(0, mainIndex), + kind: 'main', + suffix, + }; + } + + return undefined; +} + +function legacyPtyLookupKey(providerId: string, suffix: string): string { + return `${providerId}:${suffix}`; +} + +function makeLegacyTmuxSessionName(legacyPtyId: string): string { + return `emdash-${legacyPtyId.replace(/[^a-zA-Z0-9._-]/g, '-')}`; +} + function parseConversationTimestampMs(conversationId: string): number | undefined { const match = conversationId.match(CONVERSATION_ID_TIMESTAMP_PATTERN); if (!match) return undefined; @@ -124,7 +206,7 @@ function parseTaskIdFromConversationId(conversationId: string): string | undefin function findOptimisticMainResumeUuidForConversation( conversationId: string, - targets: LegacyClaudeResumeTargets + targets: LegacyPtySessionTargets ): string | undefined { const conversationTimestampMs = parseConversationTimestampMs(conversationId); if (!conversationTimestampMs || targets.optimisticMainByTimestamp.length === 0) { @@ -158,19 +240,132 @@ function findOptimisticMainResumeUuidForConversation( return bestMatch.resumeUuid; } +function findOptimisticMainPtyIdForConversation( + conversationId: string, + providerId: string, + targets: LegacyPtySessionTargets +): string | undefined { + const conversationTimestampMs = parseConversationTimestampMs(conversationId); + if (!conversationTimestampMs || targets.optimisticMainPtyByProviderAndTimestamp.length === 0) { + return undefined; + } + + let bestMatch: { distanceMs: number; legacyPtyId: string } | undefined; + let secondBestDistanceMs: number | undefined; + + for (const candidate of targets.optimisticMainPtyByProviderAndTimestamp) { + if (candidate.providerId !== providerId) continue; + + const distanceMs = Math.abs(candidate.timestampMs - conversationTimestampMs); + if (distanceMs > MAX_OPTIMISTIC_MAIN_TIMESTAMP_DRIFT_MS) continue; + + if (!bestMatch || distanceMs < bestMatch.distanceMs) { + secondBestDistanceMs = bestMatch?.distanceMs; + bestMatch = { distanceMs, legacyPtyId: candidate.legacyPtyId }; + continue; + } + + if (secondBestDistanceMs === undefined || distanceMs < secondBestDistanceMs) { + secondBestDistanceMs = distanceMs; + } + } + + if (!bestMatch) return undefined; + + if (secondBestDistanceMs !== undefined && secondBestDistanceMs === bestMatch.distanceMs) { + return undefined; + } + + return bestMatch.legacyPtyId; +} + +function pickLegacyPtyIdForConversation(params: { + legacyConversationId: string; + legacyTaskId: string; + legacyProvider: string | null; + legacyPtySessionTargets: LegacyPtySessionTargets; +}): string | undefined { + const { legacyConversationId, legacyTaskId, legacyProvider, legacyPtySessionTargets } = params; + const providerId = legacyProvider?.toLowerCase(); + if (!providerId) return undefined; + + return ( + legacyPtySessionTargets.chatPtyIdByProviderAndConversationId.get( + legacyPtyLookupKey(providerId, legacyConversationId) + ) ?? + legacyPtySessionTargets.mainPtyIdByProviderAndTaskId.get( + legacyPtyLookupKey(providerId, legacyTaskId) + ) ?? + (() => { + const taskIdFromConversationId = parseTaskIdFromConversationId(legacyConversationId); + return taskIdFromConversationId + ? legacyPtySessionTargets.mainPtyIdByProviderAndTaskId.get( + legacyPtyLookupKey(providerId, taskIdFromConversationId) + ) + : undefined; + })() ?? + findOptimisticMainPtyIdForConversation( + legacyConversationId, + providerId, + legacyPtySessionTargets + ) + ); +} + +async function renameLegacyTmuxSession(params: { + tmuxExec: ExecFn | undefined; + legacyPtyId: string | undefined; + mappedProjectId: string; + mappedTaskId: string; + conversationId: string; +}): Promise { + const { tmuxExec, legacyPtyId, mappedProjectId, mappedTaskId, conversationId } = params; + if (!tmuxExec || !legacyPtyId) return; + + const oldName = makeLegacyTmuxSessionName(legacyPtyId); + const newName = makeTmuxSessionName( + makePtySessionId(mappedProjectId, mappedTaskId, conversationId) + ); + if (oldName === newName) return; + + try { + await tmuxExec('tmux', ['has-session', '-t', oldName]); + } catch { + return; + } + + try { + await tmuxExec('tmux', ['has-session', '-t', newName]); + return; + } catch { + // Expected when the v1 session name has not been created yet. + } + + try { + await tmuxExec('tmux', ['rename-session', '-t', oldName, newName]); + } catch (error) { + log.debug('legacy-port: conversations: failed to rename legacy tmux session', { + legacyPtyId, + oldName, + newName, + error: error instanceof Error ? error.message : String(error), + }); + } +} + function pickConversationIdForInsert(params: { legacyConversationId: string; legacyTaskId: string; legacyProvider: string | null; conversationIds: Set; - claudeResumeTargets: LegacyClaudeResumeTargets; + legacyPtySessionTargets: LegacyPtySessionTargets; }): string { const { legacyConversationId, legacyTaskId, legacyProvider, conversationIds, - claudeResumeTargets, + legacyPtySessionTargets, } = params; if (legacyProvider?.toLowerCase() !== 'claude') { @@ -178,15 +373,15 @@ function pickConversationIdForInsert(params: { } const candidateResumeUuid = - claudeResumeTargets.chatConversationIdToUuid.get(legacyConversationId) ?? - claudeResumeTargets.mainTaskIdToUuid.get(legacyTaskId) ?? + legacyPtySessionTargets.chatConversationIdToUuid.get(legacyConversationId) ?? + legacyPtySessionTargets.mainTaskIdToUuid.get(legacyTaskId) ?? (() => { const taskIdFromConversationId = parseTaskIdFromConversationId(legacyConversationId); return taskIdFromConversationId - ? claudeResumeTargets.mainTaskIdToUuid.get(taskIdFromConversationId) + ? legacyPtySessionTargets.mainTaskIdToUuid.get(taskIdFromConversationId) : undefined; })() ?? - findOptimisticMainResumeUuidForConversation(legacyConversationId, claudeResumeTargets); + findOptimisticMainResumeUuidForConversation(legacyConversationId, legacyPtySessionTargets); if (!candidateResumeUuid || !isValidResumeUuid(candidateResumeUuid)) { return legacyConversationId; @@ -210,13 +405,15 @@ export async function portConversations({ remap, mergedLegacyTaskIds, userDataPath, + tmuxExec, }: PortContext & { mergedLegacyTaskIds: Set; userDataPath?: string; + tmuxExec?: ExecFn; }): Promise { const summary = createPortSummary('conversations'); const nowIso = new Date().toISOString(); - const claudeResumeTargets = readLegacyClaudeResumeTargets(userDataPath); + const legacyPtySessionTargets = readLegacyPtySessionTargets(userDataPath); const taskRows = await appDb .select({ @@ -280,7 +477,13 @@ export async function portConversations({ legacyTaskId, legacyProvider, conversationIds, - claudeResumeTargets, + legacyPtySessionTargets, + }); + const legacyPtyId = pickLegacyPtyIdForConversation({ + legacyConversationId, + legacyTaskId, + legacyProvider, + legacyPtySessionTargets, }); const insertValues = { @@ -317,6 +520,14 @@ export async function portConversations({ continue; } + await renameLegacyTmuxSession({ + tmuxExec, + legacyPtyId, + mappedProjectId, + mappedTaskId, + conversationId: insertResult.id, + }); + conversationIds.add(insertResult.id); summary.inserted += 1; } diff --git a/src/main/db/legacy-port/importers/relational/relational.test.ts b/src/main/db/legacy-port/importers/relational/relational.test.ts index 2057946cc7..03d10bdca7 100644 --- a/src/main/db/legacy-port/importers/relational/relational.test.ts +++ b/src/main/db/legacy-port/importers/relational/relational.test.ts @@ -3,6 +3,8 @@ import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; +import { makePtySessionId } from '@shared/ptySessionId'; +import { makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { createDrizzleClient } from '../../../drizzleClient'; import { portConversations } from './conversations'; import { portProjects } from './projects'; @@ -622,4 +624,89 @@ describe('legacy-port table passes', () => { { id: 'conv-legacy-codex', title: 'Legacy Codex Conversation', provider: 'codex' }, ]); }); + + it('renames legacy tmux sessions to v1 deterministic names when importing conversations', async () => { + const { appSqlite, appDb } = createAppDb(); + const legacyDb = createLegacyDb(); + openDbs.push(appSqlite, legacyDb); + + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legacy-port-tmux-')); + tempDirs.push(userDataDir); + + const mappedChatUuid = '6ba95736-36d7-401e-9ef6-01655fb9162a'; + fs.writeFileSync( + path.join(userDataDir, 'pty-session-map.json'), + JSON.stringify({ + 'claude-chat-conv-legacy-chat': { uuid: mappedChatUuid }, + }), + 'utf8' + ); + + legacyDb + .prepare( + `INSERT INTO projects (id, name, path, base_ref, is_remote, remote_path, ssh_connection_id) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run('proj-legacy-tmux', 'Legacy Tmux', '/legacy/tmux', 'main', 0, null, null); + legacyDb + .prepare( + `INSERT INTO tasks (id, project_id, name, status, branch, updated_at) VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + 'task-legacy-tmux', + 'proj-legacy-tmux', + 'Legacy Tmux Task', + 'running', + 'feature/tmux', + '2026-01-03T12:00:00.000Z' + ); + legacyDb + .prepare(`INSERT INTO conversations (id, task_id, title, provider) VALUES (?, ?, ?, ?)`) + .run('conv-legacy-chat', 'task-legacy-tmux', 'Legacy Claude Chat', 'claude'); + + const calls: Array<{ command: string; args?: string[] }> = []; + const tmuxExec = async (command: string, args?: string[]) => { + calls.push({ command, args }); + if ( + command === 'tmux' && + args?.[0] === 'has-session' && + args[2] !== 'emdash-claude-chat-conv-legacy-chat' + ) { + throw new Error('missing'); + } + return { stdout: '', stderr: '' }; + }; + + const remap = createRemapTables(); + await portSshConnections({ appDb, legacyDb, remap }); + await portProjects({ appDb, legacyDb, remap }); + const taskResult = await portTasks({ appDb, legacyDb, remap }); + + await portConversations({ + appDb, + legacyDb, + remap, + mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, + userDataPath: userDataDir, + tmuxExec, + }); + + const newTmuxName = makeTmuxSessionName( + makePtySessionId('proj-legacy-tmux', 'task-legacy-tmux', mappedChatUuid) + ); + + expect(calls).toEqual([ + { + command: 'tmux', + args: ['has-session', '-t', 'emdash-claude-chat-conv-legacy-chat'], + }, + { + command: 'tmux', + args: ['has-session', '-t', newTmuxName], + }, + { + command: 'tmux', + args: ['rename-session', '-t', 'emdash-claude-chat-conv-legacy-chat', newTmuxName], + }, + ]); + }); }); diff --git a/src/main/db/legacy-port/service.ts b/src/main/db/legacy-port/service.ts index f58e86c53d..a9e44f3d32 100644 --- a/src/main/db/legacy-port/service.ts +++ b/src/main/db/legacy-port/service.ts @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import type { LegacyImportSource } from '@shared/legacy-port'; import type { StartupDataGateStatus } from '@shared/startup-data-gate'; +import { getLocalExec } from '@main/core/utils/exec'; import { log } from '../../lib/logger'; import * as schema from '../schema'; import { @@ -219,6 +220,7 @@ export async function runLegacyPort( remap, mergedLegacyTaskIds: taskResult.mergedLegacyTaskIds, userDataPath, + tmuxExec: getLocalExec(), }); return { sshSummary, projectsSummary, taskResult, conversationsSummary }; From 8d53bd1d83269d3f182f7421e19fce412bc243d3 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 14:35:16 +0200 Subject: [PATCH 058/263] feat: add task provision manager --- drizzle/0007_jittery_johnny_blaze.sql | 1 - drizzle/0007_secret_boomer.sql | 1 + drizzle/meta/0007_snapshot.json | 7 +- drizzle/meta/_journal.json | 4 +- src/main/core/fs/impl/ssh-fs.ts | 12 +- src/main/core/fs/types.ts | 8 + .../projects/impl/local-project-provider.ts | 379 +++++--------- .../projects/impl/ssh-project-provider.ts | 489 ++++++------------ .../core/projects/operations/deleteProject.ts | 2 +- src/main/core/projects/project-provider.ts | 19 +- .../core/projects/task-provision-manager.ts | 136 +++++ src/main/core/projects/utils.ts | 2 +- src/main/core/pty/controller.ts | 25 +- src/main/core/tasks/archiveTask.ts | 2 +- src/main/core/tasks/core.ts | 2 +- src/main/core/tasks/createTask.ts | 4 +- src/main/core/tasks/deleteTask.ts | 2 +- src/main/core/tasks/getBootstrapStatus.ts | 2 +- src/main/core/tasks/provisionTask.ts | 4 +- src/main/core/tasks/teardownTask.ts | 2 +- src/main/db/schema.ts | 2 +- 21 files changed, 464 insertions(+), 641 deletions(-) delete mode 100644 drizzle/0007_jittery_johnny_blaze.sql create mode 100644 drizzle/0007_secret_boomer.sql create mode 100644 src/main/core/projects/task-provision-manager.ts diff --git a/drizzle/0007_jittery_johnny_blaze.sql b/drizzle/0007_jittery_johnny_blaze.sql deleted file mode 100644 index 1fd5dd4dc3..0000000000 --- a/drizzle/0007_jittery_johnny_blaze.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `tasks` ADD `workspace_provider` text DEFAULT 'local' NOT NULL; \ No newline at end of file diff --git a/drizzle/0007_secret_boomer.sql b/drizzle/0007_secret_boomer.sql new file mode 100644 index 0000000000..ebeae7a81d --- /dev/null +++ b/drizzle/0007_secret_boomer.sql @@ -0,0 +1 @@ +ALTER TABLE `tasks` ADD `workspace_provider` text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json index 92379b4525..affb5099fb 100644 --- a/drizzle/meta/0007_snapshot.json +++ b/drizzle/meta/0007_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "c679c995-005e-4295-9241-bccec75aadd9", + "id": "d6fa5a7a-51ad-49fb-8c73-7b3c35e074a3", "prevId": "79109ea2-737d-48d0-b5c6-ec2ceb4b0d3c", "tables": { "app_secrets": { @@ -1153,9 +1153,8 @@ "name": "workspace_provider", "type": "text", "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'local'" + "notNull": false, + "autoincrement": false } }, "indexes": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d4500369d9..88f2efb188 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -54,8 +54,8 @@ { "idx": 7, "version": "6", - "when": 1777376101478, - "tag": "0007_jittery_johnny_blaze", + "when": 1777378774526, + "tag": "0007_secret_boomer", "breakpoints": true } ] diff --git a/src/main/core/fs/impl/ssh-fs.ts b/src/main/core/fs/impl/ssh-fs.ts index eea328a01c..997771fb29 100644 --- a/src/main/core/fs/impl/ssh-fs.ts +++ b/src/main/core/fs/impl/ssh-fs.ts @@ -5,9 +5,9 @@ import type { SFTPWrapper } from 'ssh2'; import type { FileWatchEvent } from '@shared/fs'; +import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import { log } from '@main/lib/logger'; -import { quoteShellArg } from '../../../utils/shellEscape'; -import type { SshClientProxy } from '../../ssh/ssh-client-proxy'; +import { quoteShellArg } from '@main/utils/shellEscape'; import { DEFAULT_EMDASH_CONFIG, FileSystemError, @@ -483,6 +483,14 @@ export class SshFileSystem implements FileSystemProvider { } } + async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { + const sftp = await this.getSftp(); + const remoteFull = this.resolveRemotePath(destRelPath); + await new Promise((resolve, reject) => { + sftp.fastPut(localAbsPath, remoteFull, (e) => (e ? reject(e) : resolve())); + }); + } + async copyFile(src: string, dest: string): Promise { const fullSrc = this.resolveRemotePath(src); const fullDest = this.resolveRemotePath(dest); diff --git a/src/main/core/fs/types.ts b/src/main/core/fs/types.ts index 936216d6e9..3036f294b2 100644 --- a/src/main/core/fs/types.ts +++ b/src/main/core/fs/types.ts @@ -267,6 +267,14 @@ export interface FileSystemProvider { mkdir(diPath: string, options?: { recursive?: boolean }): Promise; + /** + * Copy an absolute local file into this filesystem at the given relative path. + * For SSH: transfers via SFTP fastPut. For local: delegates to fs.copyFile. + * @param localAbsPath - Absolute path of the source file on the local machine + * @param destRelPath - Destination path relative to this filesystem's root + */ + copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; + /** * Watch the worktree for filesystem changes. Returns a FileWatcher handle; * call update() to hint which paths matter (SSH uses this for polling), diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 407d5faf4c..a4dc52c88b 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -3,13 +3,11 @@ import path from 'node:path'; import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; -import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import type { LocalProject, ProjectRemoteState } from '@shared/projects'; +import type { LocalProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; -import { err, ok, type Result } from '@shared/result'; -import { type Task, type TaskBootstrapStatus } from '@shared/tasks'; -import { type Terminal } from '@shared/terminals'; +import type { Task } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; @@ -23,28 +21,16 @@ import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import type { Workspace } from '@main/core/workspaces/workspace'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { quoteShellArg } from '@main/utils/shellEscape'; -import { - type ProjectProvider, - type ProvisionTaskError, - type TaskProvider, - type TeardownTaskError, -} from '../project-provider'; +import { type ProjectProvider, type TaskProvider } from '../project-provider'; import { parseProvisionOutput } from '../provision-output'; -import { - formatProvisionTaskError, - TASK_TIMEOUT_MS, - toProvisionError, - toTeardownError, -} from '../provision-task-error'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettings, ProjectSettingsProvider } from '../settings/schema'; +import type { ProjectSettings } from '../settings/schema'; import { buildTaskFromWorkspace } from '../task-builder'; -import { withTimeout } from '../utils'; +import { TaskProvisionManager } from '../task-provision-manager'; import { createWorkspaceFactory } from '../workspace-factory'; import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; @@ -52,150 +38,89 @@ import { WorktreeService } from '../worktrees/worktree-service'; export async function createLocalProvider( project: LocalProject, rootFs: FileSystemProvider -): Promise { +): Promise { const settings = new LocalProjectSettingsProvider( project.path, bareRefName(project.baseRef), rootFs ); const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); - await fs.promises.mkdir(worktreePoolPath, { recursive: true }); - return new LocalProjectProvider(project, rootFs, { settings, worktreePoolPath }); -} + const localFs = new LocalFileSystem(project.path); + const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); + const repoGit = new GitService(project.path, gitExec, localFs); + const repository = new GitRepositoryService(repoGit, settings); + const worktreeService = new WorktreeService({ + worktreePoolPath, + repoPath: project.path, + projectSettings: settings, + exec: gitExec, + rootFs, + }); + const gitWatcher = new GitWatcherService(project.id, project.path); + void gitWatcher.start(); + + const gitFetchService = new GitFetchService( + repoGit, + async () => (await githubConnectionService.getToken()) !== null + ); + gitFetchService.start(); -export class LocalProjectProvider implements ProjectProvider { - readonly type = 'local'; - readonly settings: ProjectSettingsProvider; - readonly repository: GitRepositoryService; - readonly fs: FileSystemProvider; + const configChangeUnsub = events.on(gitRefChangedChannel, (p) => { + if (p.projectId === project.id && p.kind === 'config') { + void prSyncScheduler.onRemoteChanged(project.id); + } + }); - private tasks = new Map(); - private provisioningTasks = new Map>>(); - private tearingDownTasks = new Map>>(); - private bootstrapErrors = new Map(); - private worktreeService: WorktreeService; - private workspaceRegistry = new WorkspaceRegistry(); - private readonly localExec = getLocalExec(); - private readonly _gitWatcher: GitWatcherService; - private readonly _gitFetchService: GitFetchService; - private _configChangeUnsubscribe: (() => void) | undefined; - private _remoteHandles = new Map< + const workspaceRegistry = new WorkspaceRegistry(); + const localExec = getLocalExec(); + const remoteHandles = new Map< string, - { - terminationId: string | undefined; - terminateCommand: string; - } + { terminationId: string | undefined; terminateCommand: string } >(); - constructor( - private readonly project: LocalProject, - readonly rootFs: FileSystemProvider, - options: { - settings: ProjectSettingsProvider; - worktreePoolPath: string; - } - ) { - this.settings = options.settings; - this.fs = new LocalFileSystem(project.path); - const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, this.fs); - this.repository = new GitRepositoryService(repoGit, this.settings); - this.worktreeService = new WorktreeService({ - worktreePoolPath: options.worktreePoolPath, - repoPath: project.path, - projectSettings: this.settings, - exec: gitExec, - rootFs: rootFs, - }); - this._gitWatcher = new GitWatcherService(project.id, project.path); - void this._gitWatcher.start(); - - this._gitFetchService = new GitFetchService( - repoGit, - async () => (await githubConnectionService.getToken()) !== null - ); - this._gitFetchService.start(); - - // Re-sync remotes whenever .git/config changes (remote added/removed/changed) - this._configChangeUnsubscribe = events.on(gitRefChangedChannel, (p) => { - if (p.projectId === project.id && p.kind === 'config') { - void prSyncScheduler.onRemoteChanged(project.id); - } - }); - } - - async provisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise> { - const existing = this.tasks.get(task.id); - if (existing) return ok(existing); - if (this.provisioningTasks.has(task.id)) return this.provisioningTasks.get(task.id)!; - - const promise = withTimeout( - this.doProvisionTask(task, conversations, terminals), - TASK_TIMEOUT_MS - ) - .then((taskEnv) => { - this.tasks.set(task.id, taskEnv); - this.provisioningTasks.delete(task.id); - return ok(taskEnv); - }) - .catch((e) => { - const provisionError = toProvisionError(e); - this.bootstrapErrors.set(task.id, provisionError); - this.provisioningTasks.delete(task.id); - log.error('LocalProjectProvider: failed to provision task', { - taskId: task.id, - error: String(e), - }); - return err(provisionError); - }); - - this.provisioningTasks.set(task.id, promise); - return promise; - } - - private async doProvisionTask( + async function doProvisionTask( task: Task, conversations: Conversation[], terminals: Terminal[] ): Promise { log.debug('LocalProjectProvider: doProvisionTask START', { taskId: task.id }); - const settings = await this.settings.get(); - if (task.workspaceProvider === 'ssh' && settings.workspaceProvider?.type === 'script') { - return this.doProvisionRemoteTask(task, conversations, terminals, settings.workspaceProvider); + const projectSettings = await settings.get(); + const useSsh = + task.workspaceProvider != null + ? task.workspaceProvider === 'ssh' + : projectSettings.workspaceProvider?.type === 'script'; + if (useSsh && projectSettings.workspaceProvider?.type === 'script') { + return doProvisionRemoteTask( + task, + conversations, + terminals, + projectSettings.workspaceProvider + ); } - // Refresh remote-tracking refs in the background so they are as fresh as - // possible during the lifetime of this task. Non-blocking — provision - // continues without waiting for the network round-trip. - void this._gitFetchService.fetch(); - - // Sync PRs for this task's branch in the background. - void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); + void gitFetchService.fetch(); + void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); const workspaceId = workspaceKey(task.taskBranch); events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'resolving-worktree', message: 'Resolving worktree…', }); - const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); + const workDir = await resolveTaskWorkDir(task, project.path, worktreeService); events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'initialising-workspace', message: 'Initialising workspace…', }); - const workspace = await this.workspaceRegistry.acquire( + const workspace = await workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( workspaceId, @@ -203,19 +128,19 @@ export class LocalProjectProvider implements ProjectProvider { { task, workDir, - projectId: this.project.id, - projectPath: this.project.path, - settings: this.settings, + projectId: project.id, + projectPath: project.path, + settings, logPrefix: 'LocalProjectProvider', - repository: this.repository, - fetchService: this._gitFetchService, + repository, + fetchService: gitFetchService, extraHooks: { onCreate: async (ws) => { - const mainDotGitAbs = path.resolve(this.project.path, '.git'); + const mainDotGitAbs = path.resolve(project.path, '.git'); const relativeGitDir = await ws.git.getWorktreeGitDir(mainDotGitAbs); - this._gitWatcher.registerWorktree(workspaceId, relativeGitDir); + gitWatcher.registerWorktree(workspaceId, relativeGitDir); }, - onDestroy: async () => this._gitWatcher.unregisterWorktree(workspaceId), + onDestroy: async () => gitWatcher.unregisterWorktree(workspaceId), }, } ) @@ -225,7 +150,7 @@ export class LocalProjectProvider implements ProjectProvider { try { events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'starting-sessions', message: 'Starting sessions…', }); @@ -233,9 +158,9 @@ export class LocalProjectProvider implements ProjectProvider { task, workspace, { kind: 'local' }, - this.project.id, - this.project.path, - this.settings, + project.id, + project.path, + settings, { conversations, terminals }, 'LocalProjectProvider' ); @@ -244,12 +169,12 @@ export class LocalProjectProvider implements ProjectProvider { return taskProvider; } finally { if (!provisionSucceeded) { - await this.workspaceRegistry.release(workspace.id).catch(() => {}); + await workspaceRegistry.release(workspace.id).catch(() => {}); } } } - private async doProvisionRemoteTask( + async function doProvisionRemoteTask( task: Task, conversations: Conversation[], terminals: Terminal[], @@ -257,13 +182,13 @@ export class LocalProjectProvider implements ProjectProvider { ): Promise { events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'running-provision-script', message: 'Running provision script…', }); - const { stdout } = await this.localExec('/bin/sh', ['-c', wpConfig.provisionCommand], { - cwd: this.project.path, + const { stdout } = await localExec('/bin/sh', ['-c', wpConfig.provisionCommand], { + cwd: project.path, }); const parseResult = parseProvisionOutput(stdout); @@ -274,7 +199,7 @@ export class LocalProjectProvider implements ProjectProvider { events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'connecting', message: `Connecting to ${output.host}…`, }); @@ -287,22 +212,22 @@ export class LocalProjectProvider implements ProjectProvider { agent: process.env['SSH_AUTH_SOCK'], }); - this._remoteHandles.set(task.id, { + remoteHandles.set(task.id, { terminationId: output.id, terminateCommand: wpConfig.terminateCommand, }); events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'setting-up-workspace', message: 'Setting up workspace…', }); - const workDir = output.worktreePath ?? this.project.path; + const workDir = output.worktreePath ?? project.path; const workspaceId = workspaceKey(task.taskBranch); - const workspace = await this.workspaceRegistry.acquire( + const workspace = await workspaceRegistry.acquire( workspaceId, createWorkspaceFactory( workspaceId, @@ -310,9 +235,9 @@ export class LocalProjectProvider implements ProjectProvider { { task, workDir, - projectId: this.project.id, - projectPath: this.project.path, - settings: this.settings, + projectId: project.id, + projectPath: project.path, + settings, logPrefix: 'LocalProjectProvider[remote]', extraHooks: { onDestroy: async () => { @@ -327,7 +252,7 @@ export class LocalProjectProvider implements ProjectProvider { try { events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, + projectId: project.id, step: 'starting-sessions', message: 'Starting sessions…', }); @@ -335,9 +260,9 @@ export class LocalProjectProvider implements ProjectProvider { task, workspace, { kind: 'ssh', proxy }, - this.project.id, - this.project.path, - this.settings, + project.id, + project.path, + settings, { conversations, terminals }, 'LocalProjectProvider[remote]' ); @@ -346,124 +271,68 @@ export class LocalProjectProvider implements ProjectProvider { return taskProvider; } finally { if (!provisionSucceeded) { - await this.workspaceRegistry.release(workspace.id).catch(() => {}); + await workspaceRegistry.release(workspace.id).catch(() => {}); } } } - getTask(taskId: string): TaskProvider | undefined { - return this.tasks.get(taskId); - } - - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - if (this.tasks.has(taskId)) return { status: 'ready' }; - if (this.provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; - const bootstrapError = this.bootstrapErrors.get(taskId); - if (bootstrapError) - return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; - return { status: 'not-started' }; - } - - async teardownTask(taskId: string): Promise> { - if (this.tearingDownTasks.has(taskId)) return this.tearingDownTasks.get(taskId)!; - const task = this.tasks.get(taskId); - if (!task) { - await this.cleanupDetachedTmuxSessions(taskId); - return ok(); - } - - const promise = withTimeout(this.doTeardownTask(task), TASK_TIMEOUT_MS) - .then(() => ok()) - .catch(async (e) => { - log.error('LocalProjectProvider: failed to teardown task', { - taskId, - error: String(e), - }); - await this.cleanupDetachedTmuxSessions(taskId).catch((cleanupError) => { - log.warn('LocalProjectProvider: fallback tmux cleanup failed', { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - }) - .finally(() => { - this.tasks.delete(taskId); - this.tearingDownTasks.delete(taskId); - }); - - this.tearingDownTasks.set(taskId, promise); - return promise; - } - - getWorkspace(workspaceId: string): Workspace | undefined { - return this.workspaceRegistry.get(workspaceId); - } - - private async doTeardownTask(task: TaskProvider): Promise { + async function doTeardownTask(task: TaskProvider): Promise { await task.conversations.destroyAll(); await task.terminals.destroyAll(); - await this.workspaceRegistry.release(workspaceKey(task.taskBranch)); + await workspaceRegistry.release(workspaceKey(task.taskBranch)); - const handle = this._remoteHandles.get(task.taskId); + const handle = remoteHandles.get(task.taskId); if (handle) { const cmd = handle.terminationId ? `REMOTE_WORKSPACE_ID=${quoteShellArg(handle.terminationId)} ${handle.terminateCommand}` : handle.terminateCommand; - await this.localExec('/bin/sh', ['-c', cmd], { cwd: this.project.path }).catch((e) => { + await localExec('/bin/sh', ['-c', cmd], { cwd: project.path }).catch((e) => { log.warn('LocalProjectProvider: terminate command failed', { error: String(e) }); }); - this._remoteHandles.delete(task.taskId); + remoteHandles.delete(task.taskId); } } - private async cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(this.project.id, taskId); + async function cleanupDetachedTmuxSessions(taskId: string): Promise { + const { conversationIds, terminalIds } = await getTaskSessionLeafIds(project.id, taskId); const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(this.project.id, taskId, leafId) + makePtySessionId(project.id, taskId, leafId) ); await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(this.localExec, makeTmuxSessionName(sessionId))) + sessionIds.map((sessionId) => killTmuxSession(localExec, makeTmuxSessionName(sessionId))) ); } - async getWorktreeForBranch(branchName: string): Promise { - return this.worktreeService.getWorktree(branchName); - } - - async removeTaskWorktree(taskBranch: string): Promise { - const worktreePath = await this.worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await this.worktreeService.removeWorktree(worktreePath); - } - } - - async fetch(): Promise> { - return this._gitFetchService.fetch(); - } - - async cleanup(): Promise { - this._configChangeUnsubscribe?.(); - this._gitFetchService.stop(); - await this._gitWatcher.stop(); - - const settings = await this.settings.get(); - - if (settings.tmux) { - await Promise.all( - Array.from(this.tasks.values()).map((task) => - Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) - ) - ); - this.tasks.clear(); - await this.workspaceRegistry.releaseAll(); - } else { - await Promise.all(Array.from(this.tasks.keys()).map((id) => this.teardownTask(id))); - await this.workspaceRegistry.releaseAll(); - } - } + const taskManager = new TaskProvisionManager( + 'LocalProjectProvider', + doProvisionTask, + doTeardownTask, + cleanupDetachedTmuxSessions + ); - getRemoteState(): Promise { - return this.repository.getRemoteState(); - } + return { + type: 'local', + settings, + repository, + fs: localFs, + tasks: taskManager, + getWorkspace: (id) => workspaceRegistry.get(id), + getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), + removeTaskWorktree: async (taskBranch) => { + const worktreePath = await worktreeService.getWorktree(taskBranch); + if (worktreePath) { + await worktreeService.removeWorktree(worktreePath); + } + }, + fetch: () => gitFetchService.fetch(), + getRemoteState: () => repository.getRemoteState(), + cleanup: async () => { + configChangeUnsub(); + gitFetchService.stop(); + await gitWatcher.stop(); + const projectSettings = await settings.get(); + await taskManager.teardownAll({ tmux: projectSettings.tmux ?? false }); + await workspaceRegistry.releaseAll(); + }, + }; } diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 0121024c15..3d98639cf8 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -1,14 +1,10 @@ -import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import type { SFTPWrapper } from 'ssh2'; import type { Conversation } from '@shared/conversations'; import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; -import type { FetchError } from '@shared/git'; import { bareRefName } from '@shared/git-utils'; -import type { ProjectRemoteState, SshProject } from '@shared/projects'; +import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; -import { err, ok, type Result } from '@shared/result'; -import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { workspaceKey } from '@shared/workspace-key'; import type { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; @@ -28,26 +24,13 @@ import { import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; -import type { Workspace } from '@main/core/workspaces/workspace'; import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { - type ProjectProvider, - type ProvisionTaskError, - type TaskProvider, - type TeardownTaskError, -} from '../project-provider'; -import { - formatProvisionTaskError, - TASK_TIMEOUT_MS, - toProvisionError, - toTeardownError, -} from '../provision-task-error'; +import { type ProjectProvider, type TaskProvider } from '../project-provider'; import { SshProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettingsProvider } from '../settings/schema'; import { buildTaskFromWorkspace } from '../task-builder'; -import { withTimeout } from '../utils'; +import { TaskProvisionManager } from '../task-provision-manager'; import { createWorkspaceFactory } from '../workspace-factory'; import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; @@ -56,7 +39,7 @@ export async function createSshProvider( project: SshProject, rootFs: FileSystemProvider, proxy: SshClientProxy -): Promise { +): Promise { try { const projectFs = new SshFileSystem(proxy, project.path); const exec = getSshExec(proxy); @@ -71,356 +54,186 @@ export async function createSshProvider( const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); await rootFs.mkdir(worktreePoolPath, { recursive: true }); - return new SshProjectProvider(project, rootFs, proxy, { - fs: projectFs, - settings, + const gitExec = getGitSshExec(proxy, () => githubConnectionService.getToken()); + const repoGit = new GitService(project.path, gitExec, projectFs, false); + const repository = new GitRepositoryService(repoGit, settings); + const worktreeService = new WorktreeService({ worktreePoolPath, - }); - } catch (error) { - log.warn('createSshProvider: SSH connection failed', { - projectId: project.id, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - -export class SshProjectProvider implements ProjectProvider { - readonly type = 'ssh'; - readonly settings: ProjectSettingsProvider; - readonly repository: GitRepositoryService; - readonly fs: SshFileSystem; - - private tasks = new Map(); - private conversationProviders = new Map(); - private terminalProviders = new Map(); - private provisioningTasks = new Map>>(); - private tearingDownTasks = new Map>>(); - private bootstrapErrors = new Map(); - private worktreeService: WorktreeService; - private workspaceRegistry = new WorkspaceRegistry(); - private cachedSftp: SFTPWrapper | undefined; - private readonly _gitFetchService: GitFetchService; - - constructor( - private readonly project: SshProject, - rootFs: FileSystemProvider, - private readonly proxy: SshClientProxy, - options: { - fs: SshFileSystem; - settings: ProjectSettingsProvider; - worktreePoolPath: string; - } - ) { - this.fs = options.fs; - this.settings = options.settings; - const gitExec = getGitSshExec(this.proxy, () => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, this.fs, false); - this.repository = new GitRepositoryService(repoGit, this.settings); - this.worktreeService = new WorktreeService({ - worktreePoolPath: options.worktreePoolPath, repoPath: project.path, - projectSettings: this.settings, + projectSettings: settings, exec: gitExec, - rootFs: rootFs, + rootFs, }); - this._gitFetchService = new GitFetchService( + const gitFetchService = new GitFetchService( repoGit, async () => (await githubConnectionService.getToken()) !== null ); - this._gitFetchService.start(); - sshConnectionManager.on('connection-event', this.handleConnectionEvent); - } + gitFetchService.start(); - private handleConnectionEvent = (evt: SshConnectionEvent): void => { - if (evt.type === 'reconnected' && evt.connectionId === this.project.connectionId) { - // Re-sync remote-tracking refs as soon as the connection is restored. - void this._gitFetchService.fetch(); - this.rehydrateTerminals().catch((e: unknown) => { - log.error('SshProjectProvider: rehydrateTerminals failed after reconnect', { - projectId: this.project.id, - connectionId: this.project.connectionId, - error: String(e), - }); - }); - } - }; + const workspaceRegistry = new WorkspaceRegistry(); + const conversationProviders = new Map(); + const terminalProviders = new Map(); - private getSftp(): Promise { - if (this.cachedSftp) return Promise.resolve(this.cachedSftp); - return new Promise((resolve, reject) => { - this.proxy.client.sftp((err, sftp) => { - if (err) return reject(err); - this.cachedSftp = sftp; - sftp.on('close', () => { - this.cachedSftp = undefined; - }); - resolve(sftp); - }); - }); - } + async function doProvisionTask( + task: Task, + conversations: Conversation[], + terminals: Terminal[] + ): Promise { + log.debug('SshProjectProvider: doProvisionTask START', { taskId: task.id }); - async provisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise> { - const existing = this.tasks.get(task.id); - if (existing) return ok(existing); - if (this.provisioningTasks.has(task.id)) return this.provisioningTasks.get(task.id)!; + void gitFetchService.fetch(); + void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - const promise = withTimeout( - this.doProvisionTask(task, conversations, terminals), - TASK_TIMEOUT_MS - ) - .then((taskEnv) => { - this.tasks.set(task.id, taskEnv); - this.provisioningTasks.delete(task.id); - return ok(taskEnv); - }) - .catch((e) => { - const provisionError = toProvisionError(e); - this.bootstrapErrors.set(task.id, provisionError); - this.provisioningTasks.delete(task.id); - log.error('SshProjectProvider: failed to provision task', { - taskId: task.id, - error: String(e), - }); - return err(provisionError); - }); - - this.provisioningTasks.set(task.id, promise); - return promise; - } - - private async doProvisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise { - log.debug('SshProjectProvider: doProvisionTask START', { - taskId: task.id, - }); + const workspaceId = workspaceKey(task.taskBranch); - // Refresh remote-tracking refs in the background so they are as fresh as - // possible during the lifetime of this task. Non-blocking — provision - // continues without waiting for the network round-trip. - void this._gitFetchService.fetch(); - - // Sync PRs for this task's branch in the background. - void prSyncScheduler.onTaskProvisioned(this.project.id, task.taskBranch); - - const workspaceId = workspaceKey(task.taskBranch); - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: this.project.id, - step: 'resolving-worktree', - message: 'Resolving worktree…', - }); - const workDir = await resolveTaskWorkDir(task, this.project.path, this.worktreeService); - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: this.project.id, - step: 'initialising-workspace', - message: 'Initialising workspace…', - }); - const workspace = await this.workspaceRegistry.acquire( - workspaceId, - createWorkspaceFactory( - workspaceId, - { kind: 'ssh', proxy: this.proxy }, - { - task, - workDir, - projectId: this.project.id, - projectPath: this.project.path, - settings: this.settings, - logPrefix: 'SshProjectProvider', - repository: this.repository, - fetchService: this._gitFetchService, - } - ) - ); + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: project.id, + step: 'resolving-worktree', + message: 'Resolving worktree…', + }); + const workDir = await resolveTaskWorkDir(task, project.path, worktreeService); - let provisionSucceeded = false; - try { events.emit(taskProvisionProgressChannel, { taskId: task.id, - projectId: this.project.id, - step: 'starting-sessions', - message: 'Starting sessions…', + projectId: project.id, + step: 'initialising-workspace', + message: 'Initialising workspace…', }); - const { taskProvider, conversationProvider, terminalProvider } = await buildTaskFromWorkspace( - task, - workspace, - { kind: 'ssh', proxy: this.proxy }, - this.project.id, - this.project.path, - this.settings, - { conversations, terminals }, - 'SshProjectProvider' + const workspace = await workspaceRegistry.acquire( + workspaceId, + createWorkspaceFactory( + workspaceId, + { kind: 'ssh', proxy }, + { + task, + workDir, + projectId: project.id, + projectPath: project.path, + settings, + logPrefix: 'SshProjectProvider', + repository, + fetchService: gitFetchService, + } + ) ); - this.terminalProviders.set(task.id, terminalProvider as SshTerminalProvider); - this.conversationProviders.set(task.id, conversationProvider as SshConversationProvider); - log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); - provisionSucceeded = true; - return taskProvider; - } finally { - if (!provisionSucceeded) { - await this.workspaceRegistry.release(workspace.id).catch(() => {}); + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId: project.id, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const { taskProvider, conversationProvider, terminalProvider } = + await buildTaskFromWorkspace( + task, + workspace, + { kind: 'ssh', proxy }, + project.id, + project.path, + settings, + { conversations, terminals }, + 'SshProjectProvider' + ); + + terminalProviders.set(task.id, terminalProvider as SshTerminalProvider); + conversationProviders.set(task.id, conversationProvider as SshConversationProvider); + log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); + provisionSucceeded = true; + return taskProvider; + } finally { + if (!provisionSucceeded) { + await workspaceRegistry.release(workspace.id).catch(() => {}); + } } } - } - getTask(taskId: string): TaskProvider | undefined { - return this.tasks.get(taskId); - } - - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - if (this.tasks.has(taskId)) return { status: 'ready' }; - if (this.provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; - const bootstrapError = this.bootstrapErrors.get(taskId); - if (bootstrapError) - return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; - return { status: 'not-started' }; - } - - async teardownTask(taskId: string): Promise> { - if (this.tearingDownTasks.has(taskId)) return this.tearingDownTasks.get(taskId)!; - const task = this.tasks.get(taskId); - if (!task) { - await this.cleanupDetachedTmuxSessions(taskId); - return ok(); + async function doTeardownTask(task: TaskProvider): Promise { + await task.conversations.destroyAll(); + await task.terminals.destroyAll(); + await workspaceRegistry.release(workspaceKey(task.taskBranch)); } - const promise = withTimeout(this.doTeardownTask(task), TASK_TIMEOUT_MS) - .then(() => ok()) - .catch(async (e) => { - log.error('SshProjectProvider: failed to teardown task', { - taskId, - error: String(e), - }); - await this.cleanupDetachedTmuxSessions(taskId).catch((cleanupError) => { - log.warn('SshProjectProvider: fallback tmux cleanup failed', { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - }) - .finally(() => { - this.tasks.delete(taskId); - this.tearingDownTasks.delete(taskId); - this.conversationProviders.delete(taskId); - this.terminalProviders.delete(taskId); - }); - - this.tearingDownTasks.set(taskId, promise); - return promise; - } - - getWorkspace(workspaceId: string): Workspace | undefined { - return this.workspaceRegistry.get(workspaceId); - } - - private async doTeardownTask(task: TaskProvider): Promise { - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - await this.workspaceRegistry.release(workspaceKey(task.taskBranch)); - } - - private async cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(this.project.id, taskId); - const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(this.project.id, taskId, leafId) - ); - const exec = getSshExec(this.proxy); - await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(exec, makeTmuxSessionName(sessionId))) - ); - } - - async getWorktreeForBranch(branchName: string): Promise { - return this.worktreeService.getWorktree(branchName); - } - - async removeTaskWorktree(taskBranch: string): Promise { - const worktreePath = await this.worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await this.worktreeService.removeWorktree(worktreePath); + async function cleanupDetachedTmuxSessions(taskId: string): Promise { + const { conversationIds, terminalIds } = await getTaskSessionLeafIds(project.id, taskId); + const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => + makePtySessionId(project.id, taskId, leafId) + ); + const sshExec = getSshExec(proxy); + await Promise.all( + sessionIds.map((sessionId) => killTmuxSession(sshExec, makeTmuxSessionName(sessionId))) + ); } - } - async fetch(): Promise> { - return this._gitFetchService.fetch(); - } - - async cleanup(): Promise { - this._gitFetchService.stop(); - sshConnectionManager.off('connection-event', this.handleConnectionEvent); - - const settings = await this.settings.get(); + const taskManager = new TaskProvisionManager( + 'SshProjectProvider', + doProvisionTask, + doTeardownTask, + cleanupDetachedTmuxSessions, + (taskId) => { + conversationProviders.delete(taskId); + terminalProviders.delete(taskId); + } + ); - if (settings.tmux) { + async function rehydrateTerminals(): Promise { await Promise.all( - Array.from(this.tasks.values()).map((task) => - Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) + Array.from(terminalProviders.values()).map((provider) => + provider.rehydrate().catch((e: unknown) => { + log.error('SshEnvironmentProvider: rehydrateTerminals failed for a provider', { + error: String(e), + }); + }) ) ); - this.tasks.clear(); - this.conversationProviders.clear(); - this.terminalProviders.clear(); - await this.workspaceRegistry.releaseAll(); - } else { - await Promise.all(Array.from(this.tasks.keys()).map((id) => this.teardownTask(id))); - await this.workspaceRegistry.releaseAll(); } - } - /** - * Re-spawn all terminal sessions for every active task after an SSH reconnect. - * Agent sessions are intentionally excluded — they must be restarted manually. - */ - private async rehydrateTerminals(): Promise { - await Promise.all( - Array.from(this.terminalProviders.values()).map((provider) => - provider.rehydrate().catch((e: unknown) => { - log.error('SshEnvironmentProvider: rehydrateTerminals failed for a provider', { + function handleConnectionEvent(evt: SshConnectionEvent): void { + if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { + void gitFetchService.fetch(); + rehydrateTerminals().catch((e: unknown) => { + log.error('SshProjectProvider: rehydrateTerminals failed after reconnect', { + projectId: project.id, + connectionId: project.connectionId, error: String(e), }); - }) - ) - ); - } - - /** - * Upload local files into the task's working directory via SFTP and return - * their remote paths. - */ - async uploadFiles(taskId: string, localPaths: string[]): Promise { - const env = this.tasks.get(taskId); - if (!env) throw new Error(`No provisioned environment for task: ${taskId}`); - - const sftp = await this.getSftp(); - const wsId = workspaceKey(env.taskBranch); - const destDir = this.workspaceRegistry.get(wsId)?.path ?? env.taskId; - - return Promise.all( - localPaths.map(async (localPath) => { - const remoteName = `${randomUUID()}-${path.basename(localPath)}`; - const remotePath = `${destDir}/${remoteName}`; - await new Promise((resolve, reject) => { - sftp.fastPut(localPath, remotePath, (e) => (e ? reject(e) : resolve())); }); - return remotePath; - }) - ); - } + } + } + + sshConnectionManager.on('connection-event', handleConnectionEvent); - getRemoteState(): Promise { - return this.repository.getRemoteState(); + return { + type: 'ssh', + settings, + repository, + fs: projectFs, + tasks: taskManager, + getWorkspace: (id) => workspaceRegistry.get(id), + getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), + removeTaskWorktree: async (taskBranch) => { + const worktreePath = await worktreeService.getWorktree(taskBranch); + if (worktreePath) { + await worktreeService.removeWorktree(worktreePath); + } + }, + fetch: () => gitFetchService.fetch(), + getRemoteState: () => repository.getRemoteState(), + cleanup: async () => { + gitFetchService.stop(); + sshConnectionManager.off('connection-event', handleConnectionEvent); + const projectSettings = await settings.get(); + await taskManager.teardownAll({ tmux: projectSettings.tmux ?? false }); + await workspaceRegistry.releaseAll(); + }, + }; + } catch (error) { + log.warn('createSshProvider: SSH connection failed', { + projectId: project.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; } } diff --git a/src/main/core/projects/operations/deleteProject.ts b/src/main/core/projects/operations/deleteProject.ts index 7060d75bad..0acab53e9b 100644 --- a/src/main/core/projects/operations/deleteProject.ts +++ b/src/main/core/projects/operations/deleteProject.ts @@ -12,7 +12,7 @@ export async function deleteProject(id: string): Promise { if (provider) { const projectTasks = await getTasks(id); await Promise.allSettled([ - ...projectTasks.map((t) => provider.teardownTask(t.id)), + ...projectTasks.map((t) => provider.tasks.teardownTask(t.id)), ...projectTasks.map((t) => viewStateService.del(`task:${t.id}`)), ]); } diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 889abd5f0a..4d899d1c23 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -1,21 +1,13 @@ -import type { Conversation } from '@shared/conversations'; import type { Branch, FetchError } from '@shared/git'; import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; -import type { Task, TaskBootstrapStatus } from '@shared/tasks'; -import type { Terminal } from '@shared/terminals'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { ConversationProvider } from '../conversations/types'; import type { GitRepositoryService } from '../git/repository-service'; import type { TerminalProvider } from '../terminals/terminal-provider'; import type { Workspace } from '../workspaces/workspace'; import type { ProjectSettingsProvider } from './settings/schema'; - -export type BaseTaskProvisionArgs = { - taskId: string; - conversations: Conversation[]; - terminals: Terminal[]; -}; +import type { TaskProvisionManager } from './task-provision-manager'; export type ProvisionTaskError = | { type: 'timeout'; message: string; timeout: number } @@ -41,16 +33,9 @@ export interface ProjectProvider { readonly settings: ProjectSettingsProvider; readonly repository: GitRepositoryService; readonly fs: FileSystemProvider; + readonly tasks: TaskProvisionManager; getRemoteState(): Promise; getWorkspace(workspaceId: string): Workspace | undefined; - provisionTask( - args: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise>; - getTask(taskId: string): TaskProvider | undefined; - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus; - teardownTask(taskId: string): Promise>; getWorktreeForBranch(branchName: string): Promise; removeTaskWorktree(taskBranch: string): Promise; fetch(): Promise>; diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts new file mode 100644 index 0000000000..5b2c3495f0 --- /dev/null +++ b/src/main/core/projects/task-provision-manager.ts @@ -0,0 +1,136 @@ +import type { Conversation } from '@shared/conversations'; +import { err, ok, type Result } from '@shared/result'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import { log } from '@main/lib/logger'; +import type { ProvisionTaskError, TaskProvider, TeardownTaskError } from './project-provider'; +import { + formatProvisionTaskError, + TASK_TIMEOUT_MS, + toProvisionError, + toTeardownError, +} from './provision-task-error'; +import { withTimeout } from './utils'; + +type ProvisionFn = ( + task: Task, + conversations: Conversation[], + terminals: Terminal[] +) => Promise; + +type TeardownFn = (task: TaskProvider) => Promise; + +type DetachedCleanupFn = (taskId: string) => Promise; + +export type TeardownAllOpts = { tmux: boolean }; + +export class TaskProvisionManager { + private readonly _tasks = new Map(); + private readonly _provisioningTasks = new Map< + string, + Promise> + >(); + private readonly _tearingDownTasks = new Map>>(); + private readonly _bootstrapErrors = new Map(); + + constructor( + private readonly logPrefix: string, + private readonly provisionFn: ProvisionFn, + private readonly teardownFn: TeardownFn, + private readonly detachedCleanupFn: DetachedCleanupFn, + private readonly onTeardownFinally?: (taskId: string) => void + ) {} + + async provisionTask( + task: Task, + conversations: Conversation[], + terminals: Terminal[] + ): Promise> { + const existing = this._tasks.get(task.id); + if (existing) return ok(existing); + + const inFlight = this._provisioningTasks.get(task.id); + if (inFlight) return inFlight; + + const promise = withTimeout(this.provisionFn(task, conversations, terminals), TASK_TIMEOUT_MS) + .then((taskEnv) => { + this._tasks.set(task.id, taskEnv); + this._provisioningTasks.delete(task.id); + return ok(taskEnv); + }) + .catch((e) => { + const provisionError = toProvisionError(e); + this._bootstrapErrors.set(task.id, provisionError); + this._provisioningTasks.delete(task.id); + log.error(`${this.logPrefix}: failed to provision task`, { + taskId: task.id, + error: String(e), + }); + return err(provisionError); + }); + + this._provisioningTasks.set(task.id, promise); + return promise; + } + + getTask(taskId: string): TaskProvider | undefined { + return this._tasks.get(taskId); + } + + getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { + if (this._tasks.has(taskId)) return { status: 'ready' }; + if (this._provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; + const bootstrapError = this._bootstrapErrors.get(taskId); + if (bootstrapError) + return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; + return { status: 'not-started' }; + } + + async teardownTask(taskId: string): Promise> { + const inFlight = this._tearingDownTasks.get(taskId); + if (inFlight) return inFlight; + + const task = this._tasks.get(taskId); + if (!task) { + await this.detachedCleanupFn(taskId); + return ok(); + } + + const promise = withTimeout(this.teardownFn(task), TASK_TIMEOUT_MS) + .then(() => ok()) + .catch(async (e) => { + log.error(`${this.logPrefix}: failed to teardown task`, { + taskId, + error: String(e), + }); + await this.detachedCleanupFn(taskId).catch((cleanupError) => { + log.warn(`${this.logPrefix}: fallback cleanup failed`, { + taskId, + error: String(cleanupError), + }); + }); + return err(toTeardownError(e)); + }) + .finally(() => { + this._tasks.delete(taskId); + this._tearingDownTasks.delete(taskId); + this.onTeardownFinally?.(taskId); + }); + + this._tearingDownTasks.set(taskId, promise); + return promise; + } + + async teardownAll(opts: TeardownAllOpts): Promise { + if (opts.tmux) { + await Promise.all( + Array.from(this._tasks.values()).map((task) => + Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) + ) + ); + this._tasks.clear(); + } else { + await Promise.all(Array.from(this._tasks.keys()).map((id) => this.teardownTask(id))); + } + } +} diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index 4c1af08d17..10b6c011ee 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -1,7 +1,7 @@ import { projectManager } from './project-manager'; export function resolveTask(projectId: string, taskId: string) { - return projectManager.getProject(projectId)?.getTask(taskId) ?? null; + return projectManager.getProject(projectId)?.tasks.getTask(taskId) ?? null; } export function resolveWorkspace(projectId: string, workspaceId: string) { diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index fa69a23522..082811d477 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -1,7 +1,9 @@ +import { randomUUID } from 'node:crypto'; +import { basename } from 'node:path'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; +import { workspaceKey } from '@shared/workspace-key'; import { log } from '@main/lib/logger'; -import type { SshProjectProvider } from '../projects/impl/ssh-project-provider'; import { projectManager } from '../projects/project-manager'; import { ptySessionRegistry } from './pty-session-registry'; @@ -65,18 +67,21 @@ export const ptyController = createRPCController({ uploadFiles: async (args: { sessionId: string; localPaths: string[] }) => { try { const [projectId, scopeId] = args.sessionId.split(':'); - if (!projectId || !scopeId) { - return err({ type: 'invalid_session' as const }); - } + if (!projectId || !scopeId) return err({ type: 'invalid_session' as const }); const provider = projectManager.getProject(projectId); - if (!provider || provider.type !== 'ssh') { - return err({ type: 'not_ssh' as const }); - } + if (!provider) return err({ type: 'not_ssh' as const }); + + const wsId = workspaceKey(scopeId); + const workspace = provider.getWorkspace(wsId); + if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); - const remotePaths = await (provider as SshProjectProvider).uploadFiles( - scopeId, - args.localPaths + const remotePaths = await Promise.all( + args.localPaths.map(async (localPath) => { + const remoteName = `${randomUUID()}-${basename(localPath)}`; + await workspace.fs.copyLocalFile!(localPath, remoteName); + return `${workspace.path}/${remoteName}`; + }) ); return ok({ remotePaths }); } catch (e: unknown) { diff --git a/src/main/core/tasks/archiveTask.ts b/src/main/core/tasks/archiveTask.ts index 443c5343bf..78fc3a7f10 100644 --- a/src/main/core/tasks/archiveTask.ts +++ b/src/main/core/tasks/archiveTask.ts @@ -24,7 +24,7 @@ export async function archiveTask(projectId: string, taskId: string): Promise { if (!teardownResult.success) { diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/core.ts index 836836a4cd..0a153fb93e 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/core.ts @@ -25,6 +25,6 @@ export function mapTaskRowToTask( updatedAt: row.updatedAt, statusChangedAt: row.statusChangedAt, isPinned: row.isPinned === 1, - workspaceProvider: (row.workspaceProvider as 'local' | 'ssh') ?? 'local', + workspaceProvider: (row.workspaceProvider as 'local' | 'ssh') ?? undefined, }; } diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/createTask.ts index b1fc9fdeca..fa7bae7355 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/createTask.ts @@ -190,7 +190,7 @@ export async function createTask( status: initialStatus, sourceBranch: toStoredBranch(dbSourceBranch), linkedIssue: params.linkedIssue ? JSON.stringify(params.linkedIssue) : null, - workspaceProvider: params.workspaceProvider ?? 'local', + workspaceProvider: params.workspaceProvider ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, statusChangedAt: sql`CURRENT_TIMESTAMP`, lastInteractedAt: sql`CURRENT_TIMESTAMP`, @@ -211,7 +211,7 @@ export async function createTask( const task = mapTaskRowToTask(taskRow, prs); - const provisionResult = await project.provisionTask(task, [], []); + const provisionResult = await project.tasks.provisionTask(task, [], []); if (!provisionResult.success) { return err(mapProvisionError(provisionResult.error)); } diff --git a/src/main/core/tasks/deleteTask.ts b/src/main/core/tasks/deleteTask.ts index 3126384f53..32eaad4c38 100644 --- a/src/main/core/tasks/deleteTask.ts +++ b/src/main/core/tasks/deleteTask.ts @@ -14,7 +14,7 @@ export async function deleteTask(projectId: string, taskId: string): Promise { + const teardownResult = await project.tasks.teardownTask(taskId).catch((e) => { log.warn('deleteTask: teardown failed', { taskId, error: String(e) }); return null; }); diff --git a/src/main/core/tasks/getBootstrapStatus.ts b/src/main/core/tasks/getBootstrapStatus.ts index c72ec86c6e..0ff35cde4a 100644 --- a/src/main/core/tasks/getBootstrapStatus.ts +++ b/src/main/core/tasks/getBootstrapStatus.ts @@ -9,7 +9,7 @@ export async function getBootstrapStatus( const project = projectManager.getProject(projectId); if (!project) throw new Error(`Project not found: ${projectId}`); - const status = project.getTaskBootstrapStatus(taskId); + const status = project.tasks.getTaskBootstrapStatus(taskId); log.debug('getBootstrapStatus', { taskId, status: status.status }); return status; } diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index 59e2c2ee88..72eca3933b 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -17,7 +17,7 @@ export async function provisionTask(taskId: string) { const project = projectManager.getProject(task.projectId); if (!project) throw new Error(`Project not found: ${task.projectId}`); - const existingTask = project.getTask(taskId); + const existingTask = project.tasks.getTask(taskId); if (existingTask) { const wsId = workspaceKey(existingTask.taskBranch); @@ -37,7 +37,7 @@ export async function provisionTask(taskId: string) { .then((rows) => rows.map((r) => mapConversationRowToConversation(r, true))), ]); - const result = await project.provisionTask(task, existingConversations, existingTerminals); + const result = await project.tasks.provisionTask(task, existingConversations, existingTerminals); if (!result.success) { throw new Error(`Failed to provision task: ${formatProvisionTaskError(result.error)}`); } diff --git a/src/main/core/tasks/teardownTask.ts b/src/main/core/tasks/teardownTask.ts index ec32c82d22..7b6e01c549 100644 --- a/src/main/core/tasks/teardownTask.ts +++ b/src/main/core/tasks/teardownTask.ts @@ -3,5 +3,5 @@ import { projectManager } from '../projects/project-manager'; export async function teardownTask(projectId: string, taskId: string) { const project = projectManager.getProject(projectId); if (!project) throw new Error(`Project not found: ${projectId}`); - return await project.teardownTask(taskId); + return await project.tasks.teardownTask(taskId); } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 174e1a04b6..1e0b018aa0 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -110,7 +110,7 @@ export const tasks = sqliteTable( .notNull() .default(sql`CURRENT_TIMESTAMP`), isPinned: integer('is_pinned').notNull().default(0), // boolean, 0=false, 1=true - workspaceProvider: text('workspace_provider').notNull().default('local'), // 'local' | 'ssh' + workspaceProvider: text('workspace_provider'), // 'local' | 'ssh' | null (null = inherit from project settings) }, (table) => ({ projectIdIdx: index('idx_tasks_project_id').on(table.projectId), From 7ddd764e9d488370846e0795384888b30cdaba0d Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:43:23 +0200 Subject: [PATCH 059/263] Fix README download links --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 66307acd6a..540921e7ee 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,19 @@ Connect to remote machines via SSH/SFTP to work with remote codebases. Emdash su # Installation ### macOS -- Apple Silicon: https://github.com/generalaction/emdash/releases/latest/download/emdash-arm64.dmg -- Intel x64: https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.dmg +- Apple Silicon: https://releases.emdash.sh/emdash-arm64.dmg +- Intel x64: https://releases.emdash.sh/emdash-x64.dmg [![Homebrew](https://img.shields.io/badge/-Homebrew-000000?style=for-the-badge&logo=homebrew&logoColor=FBB040)](https://formulae.brew.sh/cask/emdash) > macOS users can also: `brew install --cask emdash` ### Windows -- Installer (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.msi -- Portable (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x64.exe +- Installer (x64): https://releases.emdash.sh/emdash-x64.msi +- Portable (x64): https://releases.emdash.sh/emdash-x64.exe ### Linux -- AppImage (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-x86_64.AppImage -- Debian package (x64): https://github.com/generalaction/emdash/releases/latest/download/emdash-amd64.deb +- AppImage (x64): https://releases.emdash.sh/emdash-x86_64.AppImage +- Debian package (x64): https://releases.emdash.sh/emdash-amd64.deb ### Release Overview From ecdcdd2dc97fb1231f09bf9dcb6f35adb1e3d00f Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:05:16 +0200 Subject: [PATCH 060/263] fix(pty): resolve Windows shim commands --- src/main/core/pty/pty-spawn-platform.test.ts | 65 ++++++++++++++ src/main/core/pty/pty-spawn-platform.ts | 94 ++++++++++++++++++-- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/src/main/core/pty/pty-spawn-platform.test.ts b/src/main/core/pty/pty-spawn-platform.test.ts index c4944c8d7c..8833d338bf 100644 --- a/src/main/core/pty/pty-spawn-platform.test.ts +++ b/src/main/core/pty/pty-spawn-platform.test.ts @@ -11,6 +11,11 @@ const posixEnv = { } satisfies NodeJS.ProcessEnv; describe('resolveLocalPtySpawn - Windows', () => { + const windowsPathEnv = { + ...winEnv, + Path: 'C:\\Users\\me\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs', + } satisfies NodeJS.ProcessEnv; + it('uses ComSpec for interactive shells without POSIX flags', () => { const result = resolveLocalPtySpawn({ platform: 'win32', @@ -45,6 +50,66 @@ describe('resolveLocalPtySpawn - Windows', () => { }); }); + it('resolves extensionless commands through PATH and PATHEXT before wrapping cmd shims', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: (candidate) => candidate === 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.CMD', + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'codex', args: ['hello world'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'C:\\Users\\me\\AppData\\Roaming\\npm\\codex.CMD "hello world"'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('direct-spawns extensionless commands that resolve to exe files', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: (candidate) => candidate === 'C:\\Program Files\\nodejs\\node.EXE', + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'node', args: ['--version'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Program Files\\nodejs\\node.EXE', + args: ['--version'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + + it('falls back to cmd.exe for unresolved extensionless commands', () => { + const result = resolveLocalPtySpawn({ + platform: 'win32', + env: windowsPathEnv, + fileExists: () => false, + intent: { + kind: 'run-command', + cwd: 'C:\\repo', + command: { kind: 'argv', command: 'codex', args: ['A&B', '100%'] }, + }, + }); + + expect(result).toEqual({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'codex "A^&B" "100%%"'], + cwd: 'C:\\repo', + warnings: [], + }); + }); + it('wraps cmd and bat argv commands through cmd.exe', () => { const result = resolveLocalPtySpawn({ platform: 'win32', diff --git a/src/main/core/pty/pty-spawn-platform.ts b/src/main/core/pty/pty-spawn-platform.ts index 0212a6bf36..c9d423a6ff 100644 --- a/src/main/core/pty/pty-spawn-platform.ts +++ b/src/main/core/pty/pty-spawn-platform.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import path from 'node:path'; import { log } from '@main/lib/logger'; import { buildTmuxShellLine } from './tmux-session-name'; @@ -30,6 +31,8 @@ export type ResolvedLocalPtySpawn = { warnings: LocalPtySpawnWarning[]; }; +type FileExists = (candidate: string) => boolean; + function getPosixShell(env: NodeJS.ProcessEnv): string { return env.SHELL || '/bin/sh'; } @@ -61,6 +64,64 @@ function quoteForCmdExe(input: string): string { .replace(/(["^&|<>()])/g, '^$1')}"`; } +function getWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const lowerKey = key.toLowerCase(); + const envKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); + return envKey ? env[envKey] : undefined; +} + +function getWindowsPathDirs(env: NodeJS.ProcessEnv): string[] { + const rawPath = getWindowsEnvValue(env, 'PATH') ?? ''; + return rawPath.split(path.win32.delimiter).filter(Boolean); +} + +function getWindowsPathExts(env: NodeJS.ProcessEnv): string[] { + const rawPathExt = + getWindowsEnvValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC'; + return rawPathExt + .split(';') + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)); +} + +function hasWindowsPathSeparator(command: string): boolean { + return command.includes('\\') || command.includes('/'); +} + +function resolveWindowsCommandPath({ + command, + cwd, + env, + fileExists, +}: { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + fileExists: FileExists; +}): string | null { + if (path.win32.extname(command)) { + return null; + } + + const baseCandidates = + hasWindowsPathSeparator(command) || path.win32.isAbsolute(command) + ? [path.win32.isAbsolute(command) ? command : path.win32.join(cwd, command)] + : [ + path.win32.join(cwd, command), + ...getWindowsPathDirs(env).map((dir) => path.win32.join(dir, command)), + ]; + + for (const base of baseCandidates) { + for (const ext of getWindowsPathExts(env)) { + const candidate = `${base}${ext}`; + if (fileExists(candidate)) return candidate; + } + } + + return null; +} + function windowsWarnings(intent: PtySpawnIntent): LocalPtySpawnWarning[] { const warnings: LocalPtySpawnWarning[] = []; if (intent.shellSetup) warnings.push('shell_setup_ignored_on_windows'); @@ -70,7 +131,8 @@ function windowsWarnings(intent: PtySpawnIntent): LocalPtySpawnWarning[] { function resolveWindowsSpawn( intent: PtySpawnIntent, - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + fileExists: FileExists ): ResolvedLocalPtySpawn { const warnings = windowsWarnings(intent); const shell = getWindowsShell(env); @@ -89,12 +151,19 @@ function resolveWindowsSpawn( } const { command, args } = intent.command; - const ext = path.extname(command).toLowerCase(); + const resolvedCommand = + resolveWindowsCommandPath({ + command, + cwd: intent.cwd, + env, + fileExists, + }) ?? command; + const ext = path.win32.extname(resolvedCommand).toLowerCase(); if (ext === '.cmd' || ext === '.bat') { return { command: shell, - args: ['/d', '/s', '/c', [command, ...args].map(quoteForCmdExe).join(' ')], + args: ['/d', '/s', '/c', [resolvedCommand, ...args].map(quoteForCmdExe).join(' ')], cwd: intent.cwd, warnings, }; @@ -103,13 +172,22 @@ function resolveWindowsSpawn( if (ext === '.ps1') { return { command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', command, ...args], + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedCommand, ...args], + cwd: intent.cwd, + warnings, + }; + } + + if (!ext) { + return { + command: shell, + args: ['/d', '/s', '/c', [command, ...args].map(quoteForCmdExe).join(' ')], cwd: intent.cwd, warnings, }; } - return { command, args, cwd: intent.cwd, warnings }; + return { command: resolvedCommand, args, cwd: intent.cwd, warnings }; } function resolvePosixSpawn(intent: PtySpawnIntent, env: NodeJS.ProcessEnv): ResolvedLocalPtySpawn { @@ -169,12 +247,16 @@ export function resolveLocalPtySpawn({ intent, platform, env, + fileExists = existsSync, }: { intent: PtySpawnIntent; platform: NodeJS.Platform; env: NodeJS.ProcessEnv; + fileExists?: FileExists; }): ResolvedLocalPtySpawn { - return isWindows(platform) ? resolveWindowsSpawn(intent, env) : resolvePosixSpawn(intent, env); + return isWindows(platform) + ? resolveWindowsSpawn(intent, env, fileExists) + : resolvePosixSpawn(intent, env); } export function logLocalPtySpawnWarnings( From 68b7cbf09503356022b43cdfd0369b96d442aa82 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:08:47 +0200 Subject: [PATCH 061/263] Remove Devin registry test --- src/shared/agent-provider-registry.test.ts | 23 ---------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/shared/agent-provider-registry.test.ts diff --git a/src/shared/agent-provider-registry.test.ts b/src/shared/agent-provider-registry.test.ts deleted file mode 100644 index d2cc99cab1..0000000000 --- a/src/shared/agent-provider-registry.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { AGENT_PROVIDER_IDS, getProvider } from './agent-provider-registry'; - -describe('agent provider registry', () => { - it('registers Devin with prompt, resume, detection, and auto-approve metadata', () => { - expect(AGENT_PROVIDER_IDS).toContain('devin'); - - const provider = getProvider('devin'); - expect(provider).toMatchObject({ - id: 'devin', - name: 'Devin', - cli: 'devin', - commands: ['devin'], - versionArgs: ['--version'], - initialPromptFlag: '--', - resumeFlag: '--continue', - autoApproveFlag: '--permission-mode=bypass', - planActivateCommand: '/plan', - icon: 'devin.png', - terminalOnly: true, - }); - }); -}); From 4072d866390343ac56e5059cf980031da8031d89 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:14:08 +0200 Subject: [PATCH 062/263] fix(pty): preserve Windows Path casing --- src/main/core/pty/pty-env.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index 91626a79ca..7b659b04d4 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -170,7 +170,10 @@ export function buildAgentEnv(options: AgentEnvOptions = {}): Record = { TERM: 'xterm-256color', COLORTERM: 'truecolor', From 51e4f2657a0d4eb767359f807d6a97b39a83f62f Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:19:37 +0200 Subject: [PATCH 063/263] fix(pty): suppress Windows pipe errors --- src/main/core/pty/local-pty.ts | 2 ++ src/main/core/pty/node-pty-errors.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/main/core/pty/node-pty-errors.ts diff --git a/src/main/core/pty/local-pty.ts b/src/main/core/pty/local-pty.ts index 78cafcde1e..235940fb6d 100644 --- a/src/main/core/pty/local-pty.ts +++ b/src/main/core/pty/local-pty.ts @@ -2,6 +2,7 @@ import * as nodePty from 'node-pty'; import type { IPty } from 'node-pty'; import { log } from '@main/lib/logger'; import { normalizeSignal } from './exit-signals'; +import { suppressExpectedNodePtyErrors } from './node-pty-errors'; import type { Pty, PtyDimensions, PtyExitInfo } from './pty'; export interface LocalSpawnOptions extends PtyDimensions { @@ -35,6 +36,7 @@ export function spawnLocalPty(options: LocalSpawnOptions): LocalPtySession { cwd, env, }); + suppressExpectedNodePtyErrors(proc); return new LocalPtySession(id, proc); } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); diff --git a/src/main/core/pty/node-pty-errors.ts b/src/main/core/pty/node-pty-errors.ts new file mode 100644 index 0000000000..2a515b155f --- /dev/null +++ b/src/main/core/pty/node-pty-errors.ts @@ -0,0 +1,21 @@ +import type { IPty } from 'node-pty'; +import { log } from '@main/lib/logger'; + +type NodePtyWithErrorEvents = IPty & { + on?: (event: 'error', handler: (error: NodeJS.ErrnoException) => void) => void; +}; + +export function suppressExpectedNodePtyErrors( + proc: IPty, + platform: NodeJS.Platform = process.platform +): void { + if (platform !== 'win32') return; + + (proc as NodePtyWithErrorEvents).on?.('error', (error) => { + if (error.code === 'EPIPE' || error.code === 'EIO') return; + log.warn('node-pty: unexpected PTY error', { + code: error.code, + message: error.message, + }); + }); +} From c4e6eb6ec832ee30c2e6c17ef0a3c01897383af6 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:27:32 +0200 Subject: [PATCH 064/263] fix: codex notifiy command for windows --- .../core/agent-hooks/agent-notify-command.ts | 88 +++++++++++++++++++ src/main/core/agent-hooks/hook-config.ts | 28 +----- 2 files changed, 89 insertions(+), 27 deletions(-) create mode 100644 src/main/core/agent-hooks/agent-notify-command.ts diff --git a/src/main/core/agent-hooks/agent-notify-command.ts b/src/main/core/agent-hooks/agent-notify-command.ts new file mode 100644 index 0000000000..7fcdeafa1f --- /dev/null +++ b/src/main/core/agent-hooks/agent-notify-command.ts @@ -0,0 +1,88 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, win32 } from 'node:path'; +import { log } from '@main/lib/logger'; + +export type CodexNotifyCommandOptions = { + platform?: NodeJS.Platform; + writeFile?: (path: string, content: string) => void; + mkdir?: (path: string) => void; + scriptPath?: string; +}; + +export function makeClaudeHookCommand(eventType: string): string { + return ( + 'curl -sf -X POST ' + + '-H "Content-Type: application/json" ' + + '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + + '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + + `-H "X-Emdash-Event-Type: ${eventType}" ` + + '-d @- ' + + '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true' + ); +} + +function makePosixCodexNotifyCommand(): string[] { + return [ + 'bash', + '-c', + 'curl -sf -X POST ' + + "-H 'Content-Type: application/json' " + + '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + + '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + + '-H "X-Emdash-Event-Type: notification" ' + + '-d "$1" ' + + '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true', + '_', + ]; +} + +function windowsCodexNotifyScript(): string { + return [ + 'param([string]$payload)', + 'try {', + ' Invoke-WebRequest -UseBasicParsing -Method POST ' + + "-Uri ('http://127.0.0.1:' + $env:EMDASH_HOOK_PORT + '/hook') " + + '-Headers @{ ' + + "'Content-Type' = 'application/json'; " + + "'X-Emdash-Token' = $env:EMDASH_HOOK_TOKEN; " + + "'X-Emdash-Pty-Id' = $env:EMDASH_PTY_ID; " + + "'X-Emdash-Event-Type' = 'notification' " + + '} -Body $payload | Out-Null', + '} catch {', + ' exit 0', + '}', + '', + ].join('\n'); +} + +function ensureWindowsCodexNotifyScript(options: CodexNotifyCommandOptions): string { + const platform = options.platform ?? process.platform; + const scriptPath = options.scriptPath ?? join(tmpdir(), 'emdash-codex-notify.ps1'); + const scriptDir = platform === 'win32' ? win32.dirname(scriptPath) : dirname(scriptPath); + const mkdir = options.mkdir ?? ((path: string) => mkdirSync(path, { recursive: true })); + const writeFile = options.writeFile ?? writeFileSync; + + try { + mkdir(scriptDir); + writeFile(scriptPath, windowsCodexNotifyScript()); + } catch (err) { + log.warn('CodexNotifyCommand: failed to write Windows notify script', { + path: scriptPath, + error: String(err), + }); + } + + return scriptPath; +} + +function makeWindowsCodexNotifyCommand(options: CodexNotifyCommandOptions): string[] { + return ['powershell.exe', '-NoProfile', '-File', ensureWindowsCodexNotifyScript(options)]; +} + +export function makeCodexNotifyCommand(options: CodexNotifyCommandOptions = {}): string[] { + const platform = options.platform ?? process.platform; + return platform === 'win32' + ? makeWindowsCodexNotifyCommand(options) + : makePosixCodexNotifyCommand(); +} diff --git a/src/main/core/agent-hooks/hook-config.ts b/src/main/core/agent-hooks/hook-config.ts index c2e2c4e072..7f7ac7b90d 100644 --- a/src/main/core/agent-hooks/hook-config.ts +++ b/src/main/core/agent-hooks/hook-config.ts @@ -4,6 +4,7 @@ import { resolveCommandPath } from '@main/core/dependencies/probe'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; +import { makeClaudeHookCommand, makeCodexNotifyCommand } from './agent-notify-command'; const EMDASH_MARKER = 'EMDASH_HOOK_PORT'; @@ -17,33 +18,6 @@ const HOOK_EVENT_MAP = [ { eventType: 'stop', hookKey: 'Stop' }, ] satisfies { eventType: string; hookKey: string }[]; -function makeClaudeHookCommand(eventType: string): string { - return ( - 'curl -sf -X POST ' + - '-H "Content-Type: application/json" ' + - '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + - '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + - `-H "X-Emdash-Event-Type: ${eventType}" ` + - '-d @- ' + - '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true' - ); -} - -function makeCodexNotifyCommand(): string[] { - return [ - 'bash', - '-c', - 'curl -sf -X POST ' + - "-H 'Content-Type: application/json' " + - '-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ' + - '-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ' + - '-H "X-Emdash-Event-Type: notification" ' + - '-d "$1" ' + - '"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true', - '_', - ]; -} - export class HookConfigWriter { constructor( private readonly fs: FileSystemProvider, From 6827074ed8bd54a0bfaabc0a76d65345313f6dc5 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 15:33:56 +0200 Subject: [PATCH 065/263] feat: add workspace registry singleton --- drizzle/0008_premium_azazel.sql | 2 + drizzle/meta/0008_snapshot.json | 1291 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../projects/impl/local-project-provider.ts | 69 +- .../projects/impl/ssh-project-provider.ts | 31 +- src/main/core/projects/project-provider.ts | 6 +- src/main/core/projects/provision-output.ts | 2 +- src/main/core/projects/task-builder.ts | 1 + .../core/projects/task-provision-manager.ts | 24 +- src/main/core/projects/utils.ts | 5 +- src/main/core/projects/workspace-factory.ts | 5 + src/main/core/pty/controller.ts | 8 +- src/main/core/repository/controller.ts | 5 +- src/main/core/tasks/archiveTask.ts | 2 +- src/main/core/tasks/core.ts | 2 + src/main/core/tasks/deleteTask.ts | 2 +- src/main/core/tasks/getTaskSettings.ts | 9 +- src/main/core/tasks/provisionTask.ts | 17 +- src/main/core/tasks/teardownTask.ts | 2 +- src/main/core/workspaces/workspace-id.ts | 29 + .../workspaces/workspace-registry.test.ts | 106 +- .../core/workspaces/workspace-registry.ts | 42 +- src/main/db/schema.ts | 2 + .../features/tasks/stores/task-manager.ts | 1 + src/renderer/features/tasks/stores/task.ts | 14 +- src/shared/tasks.ts | 2 + src/shared/workspace-key.ts | 3 - 27 files changed, 1579 insertions(+), 110 deletions(-) create mode 100644 drizzle/0008_premium_azazel.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/main/core/workspaces/workspace-id.ts delete mode 100644 src/shared/workspace-key.ts diff --git a/drizzle/0008_premium_azazel.sql b/drizzle/0008_premium_azazel.sql new file mode 100644 index 0000000000..694f2dc9d8 --- /dev/null +++ b/drizzle/0008_premium_azazel.sql @@ -0,0 +1,2 @@ +ALTER TABLE `tasks` ADD `workspace_id` text;--> statement-breakpoint +ALTER TABLE `tasks` ADD `workspace_provider_data` text; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000000..edeeb588eb --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1291 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cde091fd-ff9f-4606-a76d-48364c5f9e6e", + "prevId": "d6fa5a7a-51ad-49fb-8c73-7b3c35e074a3", + "tables": { + "app_secrets": { + "name": "app_secrets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_app_secrets_key": { + "name": "idx_app_secrets_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_app_settings_key": { + "name": "idx_app_settings_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_conversations_task_id": { + "name": "idx_conversations_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_task_id_tasks_id_fk": { + "name": "conversations_task_id_tasks_id_fk", + "tableFrom": "conversations", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "editor_buffers": { + "name": "editor_buffers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_editor_buffers_workspace_file": { + "name": "idx_editor_buffers_workspace_file", + "columns": ["workspace_id", "file_path"], + "isUnique": false + } + }, + "foreignKeys": { + "editor_buffers_project_id_projects_id_fk": { + "name": "editor_buffers_project_id_projects_id_fk", + "tableFrom": "editor_buffers", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "kv": { + "name": "kv", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_kv_key": { + "name": "idx_kv_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_messages_conversation_id": { + "name": "idx_messages_conversation_id", + "columns": ["conversation_id"], + "isUnique": false + }, + "idx_messages_timestamp": { + "name": "idx_messages_timestamp", + "columns": ["timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "project_remotes": { + "name": "project_remotes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_remotes_project_id_projects_id_fk": { + "name": "project_remotes_project_id_projects_id_fk", + "tableFrom": "project_remotes", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_remotes_project_id_remote_name_pk": { + "columns": ["project_id", "remote_name"], + "name": "project_remotes_project_id_remote_name_pk" + } + }, + "uniqueConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_connection_id": { + "name": "ssh_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_projects_path": { + "name": "idx_projects_path", + "columns": ["path"], + "isUnique": true + }, + "idx_projects_ssh_connection_id": { + "name": "idx_projects_ssh_connection_id", + "columns": ["ssh_connection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "projects_ssh_connection_id_ssh_connections_id_fk": { + "name": "projects_ssh_connection_id_ssh_connections_id_fk", + "tableFrom": "projects", + "tableTo": "ssh_connections", + "columnsFrom": ["ssh_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_assignees": { + "name": "pull_request_assignees", + "columns": { + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_pra_pull_request_url": { + "name": "idx_pra_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + }, + "idx_pra_user_id": { + "name": "idx_pra_user_id", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_assignees_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_request_assignees_user_id_pull_request_users_user_id_fk": { + "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_request_assignees", + "tableTo": "pull_request_users", + "columnsFrom": ["user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_assignees_pull_request_url_user_id_pk": { + "columns": ["pull_request_url", "user_id"], + "name": "pull_request_assignees_pull_request_url_user_id_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_checks": { + "name": "pull_request_checks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details_url": { + "name": "details_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "app_logo_url": { + "name": "app_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prc_pull_request_url": { + "name": "idx_prc_pull_request_url", + "columns": ["pull_request_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_checks_pull_request_url_pull_requests_url_fk": { + "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", + "tableFrom": "pull_request_checks", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_url"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_request_labels": { + "name": "pull_request_labels", + "columns": { + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_prl_name": { + "name": "idx_prl_name", + "columns": ["name"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_request_labels_pull_request_id_pull_requests_url_fk": { + "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", + "tableFrom": "pull_request_labels", + "tableTo": "pull_requests", + "columnsFrom": ["pull_request_id"], + "columnsTo": ["url"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_request_labels_pull_request_id_name_pk": { + "columns": ["pull_request_id", "name"], + "name": "pull_request_labels_pull_request_id_name_pk" + } + }, + "uniqueConstraints": {} + }, + "pull_request_users": { + "name": "pull_request_users", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_updated_at": { + "name": "user_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_created_at": { + "name": "user_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_name": { + "name": "base_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_ref_oid": { + "name": "base_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_repository_url": { + "name": "head_repository_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_name": { + "name": "head_ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_ref_oid": { + "name": "head_ref_oid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mergeable_status": { + "name": "mergeable_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_state_status": { + "name": "merge_state_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_created_at": { + "name": "pull_request_created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pull_request_updated_at": { + "name": "pull_request_updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_pull_requests_url": { + "name": "idx_pull_requests_url", + "columns": ["url"], + "isUnique": true + }, + "idx_pull_requests_repository_url": { + "name": "idx_pull_requests_repository_url", + "columns": ["repository_url"], + "isUnique": false + }, + "idx_pull_requests_head_repository_url": { + "name": "idx_pull_requests_head_repository_url", + "columns": ["head_repository_url"], + "isUnique": false + } + }, + "foreignKeys": { + "pull_requests_author_user_id_pull_request_users_user_id_fk": { + "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", + "tableFrom": "pull_requests", + "tableTo": "pull_request_users", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ssh_connections": { + "name": "ssh_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "private_key_path": { + "name": "private_key_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_agent": { + "name": "use_agent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_connections_name": { + "name": "idx_ssh_connections_name", + "columns": ["name"], + "isUnique": true + }, + "idx_ssh_connections_host": { + "name": "idx_ssh_connections_host", + "columns": ["host"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_branch": { + "name": "task_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_issue": { + "name": "linked_issue", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_interacted_at": { + "name": "last_interacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "workspace_provider": { + "name": "workspace_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tasks_project_id": { + "name": "idx_tasks_project_id", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "terminals": { + "name": "terminals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_terminals_task_id": { + "name": "idx_terminals_task_id", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "terminals_project_id_projects_id_fk": { + "name": "terminals_project_id_projects_id_fk", + "tableFrom": "terminals", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminals_task_id_tasks_id_fk": { + "name": "terminals_task_id_tasks_id_fk", + "tableFrom": "terminals", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 88f2efb188..60da07cfbc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777378774526, "tag": "0007_secret_boomer", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1777382026422, + "tag": "0008_premium_azazel", + "breakpoints": true } ] } diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index a4dc52c88b..cb9e55044e 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -8,7 +8,6 @@ import type { LocalProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; -import { workspaceKey } from '@shared/workspace-key'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { GitFetchService } from '@main/core/git/git-fetch-service'; @@ -21,11 +20,12 @@ import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { localWorkspaceId, remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { quoteShellArg } from '@main/utils/shellEscape'; -import { type ProjectProvider, type TaskProvider } from '../project-provider'; +import { type ProjectProvider, type TaskProvider, type TeardownMode } from '../project-provider'; import { parseProvisionOutput } from '../provision-output'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettings } from '../settings/schema'; @@ -73,12 +73,7 @@ export async function createLocalProvider( } }); - const workspaceRegistry = new WorkspaceRegistry(); const localExec = getLocalExec(); - const remoteHandles = new Map< - string, - { terminationId: string | undefined; terminateCommand: string } - >(); async function doProvisionTask( task: Task, @@ -104,7 +99,7 @@ export async function createLocalProvider( void gitFetchService.fetch(); void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - const workspaceId = workspaceKey(task.taskBranch); + const workspaceId = localWorkspaceId(project.id, task.taskBranch); events.emit(taskProvisionProgressChannel, { taskId: task.id, @@ -122,6 +117,7 @@ export async function createLocalProvider( }); const workspace = await workspaceRegistry.acquire( workspaceId, + project.id, createWorkspaceFactory( workspaceId, { kind: 'local' }, @@ -169,7 +165,7 @@ export async function createLocalProvider( return taskProvider; } finally { if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id).catch(() => {}); + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); } } } @@ -212,11 +208,6 @@ export async function createLocalProvider( agent: process.env['SSH_AUTH_SOCK'], }); - remoteHandles.set(task.id, { - terminationId: output.id, - terminateCommand: wpConfig.terminateCommand, - }); - events.emit(taskProvisionProgressChannel, { taskId: task.id, projectId: project.id, @@ -225,10 +216,11 @@ export async function createLocalProvider( }); const workDir = output.worktreePath ?? project.path; - const workspaceId = workspaceKey(task.taskBranch); + const workspaceId = remoteTaskWorkspaceId(output.id ?? task.id); const workspace = await workspaceRegistry.acquire( workspaceId, + project.id, createWorkspaceFactory( workspaceId, { kind: 'ssh', proxy }, @@ -241,6 +233,15 @@ export async function createLocalProvider( logPrefix: 'LocalProjectProvider[remote]', extraHooks: { onDestroy: async () => { + const cmd = output.id + ? `REMOTE_WORKSPACE_ID=${quoteShellArg(output.id)} ${wpConfig.terminateCommand}` + : wpConfig.terminateCommand; + await localExec('/bin/sh', ['-c', cmd], { cwd: project.path }).catch((e) => { + log.warn('LocalProjectProvider: terminate command failed', { error: String(e) }); + }); + await sshConnectionManager.disconnect(connectionId); + }, + onDetach: async () => { await sshConnectionManager.disconnect(connectionId); }, }, @@ -256,7 +257,7 @@ export async function createLocalProvider( step: 'starting-sessions', message: 'Starting sessions…', }); - const { taskProvider } = await buildTaskFromWorkspace( + const { taskProvider: baseTaskProvider } = await buildTaskFromWorkspace( task, workspace, { kind: 'ssh', proxy }, @@ -266,31 +267,29 @@ export async function createLocalProvider( { conversations, terminals }, 'LocalProjectProvider[remote]' ); + const taskProvider: TaskProvider = { + ...baseTaskProvider, + workspaceProviderData: JSON.stringify({ ...wpConfig, remoteWorkspaceId: output.id }), + }; log.debug('LocalProjectProvider: doProvisionRemoteTask DONE', { taskId: task.id }); provisionSucceeded = true; return taskProvider; } finally { if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id).catch(() => {}); + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); } } } - async function doTeardownTask(task: TaskProvider): Promise { - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - await workspaceRegistry.release(workspaceKey(task.taskBranch)); - - const handle = remoteHandles.get(task.taskId); - if (handle) { - const cmd = handle.terminationId - ? `REMOTE_WORKSPACE_ID=${quoteShellArg(handle.terminationId)} ${handle.terminateCommand}` - : handle.terminateCommand; - await localExec('/bin/sh', ['-c', cmd], { cwd: project.path }).catch((e) => { - log.warn('LocalProjectProvider: terminate command failed', { error: String(e) }); - }); - remoteHandles.delete(task.taskId); + async function doTeardownTask(task: TaskProvider, mode: TeardownMode): Promise { + if (mode === 'detach') { + await task.conversations.detachAll(); + await task.terminals.detachAll(); + } else { + await task.conversations.destroyAll(); + await task.terminals.destroyAll(); } + await workspaceRegistry.release(task.workspaceId, mode); } async function cleanupDetachedTmuxSessions(taskId: string): Promise { @@ -316,7 +315,6 @@ export async function createLocalProvider( repository, fs: localFs, tasks: taskManager, - getWorkspace: (id) => workspaceRegistry.get(id), getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), removeTaskWorktree: async (taskBranch) => { const worktreePath = await worktreeService.getWorktree(taskBranch); @@ -331,8 +329,9 @@ export async function createLocalProvider( gitFetchService.stop(); await gitWatcher.stop(); const projectSettings = await settings.get(); - await taskManager.teardownAll({ tmux: projectSettings.tmux ?? false }); - await workspaceRegistry.releaseAll(); + const mode = projectSettings.tmux ? 'detach' : 'terminate'; + await taskManager.teardownAll({ mode }); + await workspaceRegistry.releaseAllForProject(project.id, mode); }, }; } diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 3d98639cf8..0945819bbc 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -6,7 +6,6 @@ import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; -import { workspaceKey } from '@shared/workspace-key'; import type { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; @@ -24,10 +23,11 @@ import { import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; -import { WorkspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { sshWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { type ProjectProvider, type TaskProvider } from '../project-provider'; +import { type ProjectProvider, type TaskProvider, type TeardownMode } from '../project-provider'; import { SshProjectSettingsProvider } from '../settings/project-settings'; import { buildTaskFromWorkspace } from '../task-builder'; import { TaskProvisionManager } from '../task-provision-manager'; @@ -70,7 +70,6 @@ export async function createSshProvider( ); gitFetchService.start(); - const workspaceRegistry = new WorkspaceRegistry(); const conversationProviders = new Map(); const terminalProviders = new Map(); @@ -84,7 +83,7 @@ export async function createSshProvider( void gitFetchService.fetch(); void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - const workspaceId = workspaceKey(task.taskBranch); + const workspaceId = sshWorkspaceId(project.id, task.taskBranch); events.emit(taskProvisionProgressChannel, { taskId: task.id, @@ -102,6 +101,7 @@ export async function createSshProvider( }); const workspace = await workspaceRegistry.acquire( workspaceId, + project.id, createWorkspaceFactory( workspaceId, { kind: 'ssh', proxy }, @@ -145,15 +145,20 @@ export async function createSshProvider( return taskProvider; } finally { if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id).catch(() => {}); + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); } } } - async function doTeardownTask(task: TaskProvider): Promise { - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - await workspaceRegistry.release(workspaceKey(task.taskBranch)); + async function doTeardownTask(task: TaskProvider, mode: TeardownMode): Promise { + if (mode === 'detach') { + await task.conversations.detachAll(); + await task.terminals.detachAll(); + } else { + await task.conversations.destroyAll(); + await task.terminals.destroyAll(); + } + await workspaceRegistry.release(task.workspaceId, mode); } async function cleanupDetachedTmuxSessions(taskId: string): Promise { @@ -211,7 +216,6 @@ export async function createSshProvider( repository, fs: projectFs, tasks: taskManager, - getWorkspace: (id) => workspaceRegistry.get(id), getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), removeTaskWorktree: async (taskBranch) => { const worktreePath = await worktreeService.getWorktree(taskBranch); @@ -225,8 +229,9 @@ export async function createSshProvider( gitFetchService.stop(); sshConnectionManager.off('connection-event', handleConnectionEvent); const projectSettings = await settings.get(); - await taskManager.teardownAll({ tmux: projectSettings.tmux ?? false }); - await workspaceRegistry.releaseAll(); + const mode = projectSettings.tmux ? 'detach' : 'terminate'; + await taskManager.teardownAll({ mode }); + await workspaceRegistry.releaseAllForProject(project.id, mode); }, }; } catch (error) { diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 4d899d1c23..a4e7cb5274 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -5,10 +5,11 @@ import type { FileSystemProvider } from '@main/core/fs/types'; import type { ConversationProvider } from '../conversations/types'; import type { GitRepositoryService } from '../git/repository-service'; import type { TerminalProvider } from '../terminals/terminal-provider'; -import type { Workspace } from '../workspaces/workspace'; import type { ProjectSettingsProvider } from './settings/schema'; import type { TaskProvisionManager } from './task-provision-manager'; +export type { TeardownMode } from '../workspaces/workspace-registry'; + export type ProvisionTaskError = | { type: 'timeout'; message: string; timeout: number } | { type: 'branch-not-found'; branch: string } @@ -21,11 +22,13 @@ export type TeardownTaskError = export interface TaskProvider { readonly taskId: string; + readonly workspaceId: string; readonly taskBranch: string | undefined; readonly sourceBranch: Branch | undefined; readonly taskEnvVars: Record; readonly conversations: ConversationProvider; readonly terminals: TerminalProvider; + readonly workspaceProviderData?: string; // JSON, BYOI only; written to DB after provision } export interface ProjectProvider { @@ -35,7 +38,6 @@ export interface ProjectProvider { readonly fs: FileSystemProvider; readonly tasks: TaskProvisionManager; getRemoteState(): Promise; - getWorkspace(workspaceId: string): Workspace | undefined; getWorktreeForBranch(branchName: string): Promise; removeTaskWorktree(taskBranch: string): Promise; fetch(): Promise>; diff --git a/src/main/core/projects/provision-output.ts b/src/main/core/projects/provision-output.ts index da5c957491..2437bdd77a 100644 --- a/src/main/core/projects/provision-output.ts +++ b/src/main/core/projects/provision-output.ts @@ -2,8 +2,8 @@ import z from 'zod'; import { err, ok, type Result } from '@shared/result'; const provisionOutputSchema = z.object({ - host: z.string().min(1, 'Provisioner output must contain a non-empty "host" field').trim(), id: z.string().optional(), + host: z.string().min(1, 'Provisioner output must contain a non-empty "host" field').trim(), port: z.number().optional(), username: z.string().optional(), worktreePath: z.string().optional(), diff --git a/src/main/core/projects/task-builder.ts b/src/main/core/projects/task-builder.ts index bbbcacb81a..00a20703d9 100644 --- a/src/main/core/projects/task-builder.ts +++ b/src/main/core/projects/task-builder.ts @@ -53,6 +53,7 @@ export async function buildTaskFromWorkspace( const taskProvider: TaskProvider = { taskId: task.id, + workspaceId: workspace.id, taskBranch: task.taskBranch, sourceBranch: task.sourceBranch, taskEnvVars, diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts index 5b2c3495f0..51f6186d25 100644 --- a/src/main/core/projects/task-provision-manager.ts +++ b/src/main/core/projects/task-provision-manager.ts @@ -3,7 +3,12 @@ import { err, ok, type Result } from '@shared/result'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { log } from '@main/lib/logger'; -import type { ProvisionTaskError, TaskProvider, TeardownTaskError } from './project-provider'; +import type { + ProvisionTaskError, + TaskProvider, + TeardownMode, + TeardownTaskError, +} from './project-provider'; import { formatProvisionTaskError, TASK_TIMEOUT_MS, @@ -18,11 +23,11 @@ type ProvisionFn = ( terminals: Terminal[] ) => Promise; -type TeardownFn = (task: TaskProvider) => Promise; +type TeardownFn = (task: TaskProvider, mode: TeardownMode) => Promise; type DetachedCleanupFn = (taskId: string) => Promise; -export type TeardownAllOpts = { tmux: boolean }; +export type TeardownAllOpts = { mode: TeardownMode }; export class TaskProvisionManager { private readonly _tasks = new Map(); @@ -86,7 +91,10 @@ export class TaskProvisionManager { return { status: 'not-started' }; } - async teardownTask(taskId: string): Promise> { + async teardownTask( + taskId: string, + mode: TeardownMode = 'terminate' + ): Promise> { const inFlight = this._tearingDownTasks.get(taskId); if (inFlight) return inFlight; @@ -96,7 +104,7 @@ export class TaskProvisionManager { return ok(); } - const promise = withTimeout(this.teardownFn(task), TASK_TIMEOUT_MS) + const promise = withTimeout(this.teardownFn(task, mode), TASK_TIMEOUT_MS) .then(() => ok()) .catch(async (e) => { log.error(`${this.logPrefix}: failed to teardown task`, { @@ -122,7 +130,7 @@ export class TaskProvisionManager { } async teardownAll(opts: TeardownAllOpts): Promise { - if (opts.tmux) { + if (opts.mode === 'detach') { await Promise.all( Array.from(this._tasks.values()).map((task) => Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) @@ -130,7 +138,9 @@ export class TaskProvisionManager { ); this._tasks.clear(); } else { - await Promise.all(Array.from(this._tasks.keys()).map((id) => this.teardownTask(id))); + await Promise.all( + Array.from(this._tasks.keys()).map((id) => this.teardownTask(id, 'terminate')) + ); } } } diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index 10b6c011ee..77100ee21b 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -1,11 +1,12 @@ +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { projectManager } from './project-manager'; export function resolveTask(projectId: string, taskId: string) { return projectManager.getProject(projectId)?.tasks.getTask(taskId) ?? null; } -export function resolveWorkspace(projectId: string, workspaceId: string) { - return projectManager.getProject(projectId)?.getWorkspace(workspaceId) ?? null; +export function resolveWorkspace(_projectId: string, workspaceId: string) { + return workspaceRegistry.get(workspaceId) ?? null; } export class TimeoutSignal extends Error { diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/projects/workspace-factory.ts index db8931e7bf..5873943264 100644 --- a/src/main/core/projects/workspace-factory.ts +++ b/src/main/core/projects/workspace-factory.ts @@ -41,6 +41,7 @@ type WorkspaceFactoryContext = { extraHooks?: { onCreate?: (ws: Workspace) => Promise; onDestroy?: (ws: Workspace) => Promise; + onDetach?: (ws: Workspace) => Promise; }; }; @@ -204,6 +205,10 @@ export function createWorkspaceFactory( } await context.extraHooks?.onDestroy?.(ws); }, + + onDetach: context.extraHooks?.onDetach + ? (ws) => context.extraHooks!.onDetach!(ws) + : undefined, }; }; } diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 082811d477..7867c47327 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -2,9 +2,9 @@ import { randomUUID } from 'node:crypto'; import { basename } from 'node:path'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; -import { workspaceKey } from '@shared/workspace-key'; import { log } from '@main/lib/logger'; import { projectManager } from '../projects/project-manager'; +import { workspaceRegistry } from '../workspaces/workspace-registry'; import { ptySessionRegistry } from './pty-session-registry'; export const ptyController = createRPCController({ @@ -72,8 +72,10 @@ export const ptyController = createRPCController({ const provider = projectManager.getProject(projectId); if (!provider) return err({ type: 'not_ssh' as const }); - const wsId = workspaceKey(scopeId); - const workspace = provider.getWorkspace(wsId); + const taskProvider = provider.tasks.getTask(scopeId); + if (!taskProvider) return err({ type: 'not_ssh' as const }); + + const workspace = workspaceRegistry.get(taskProvider.workspaceId); if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); const remotePaths = await Promise.all( diff --git a/src/main/core/repository/controller.ts b/src/main/core/repository/controller.ts index beba4f2fbf..efd78a36ff 100644 --- a/src/main/core/repository/controller.ts +++ b/src/main/core/repository/controller.ts @@ -6,12 +6,13 @@ import { events } from '@main/lib/events'; import { capture } from '@main/lib/telemetry'; import type { GitRepositoryService } from '../git/repository-service'; import { projectManager } from '../projects/project-manager'; +import { workspaceRegistry } from '../workspaces/workspace-registry'; function resolveRepository(projectId: string, workspaceId?: string): GitRepositoryService { const project = projectManager.getProject(projectId); if (!project) throw new Error('Project not found'); if (workspaceId) { - const ws = project.getWorkspace(workspaceId); + const ws = workspaceRegistry.get(workspaceId); if (ws) return ws.repository; } return project.repository; @@ -73,7 +74,7 @@ export const repositoryController = createRPCController({ let result; if (workspaceId) { - const ws = project.getWorkspace(workspaceId); + const ws = workspaceRegistry.get(workspaceId); result = ws ? await ws.fetchService.fetch() : await project.fetch(); } else { result = await project.fetch(); diff --git a/src/main/core/tasks/archiveTask.ts b/src/main/core/tasks/archiveTask.ts index 78fc3a7f10..fdc2ccc60b 100644 --- a/src/main/core/tasks/archiveTask.ts +++ b/src/main/core/tasks/archiveTask.ts @@ -25,7 +25,7 @@ export async function archiveTask(projectId: string, taskId: string): Promise { if (!teardownResult.success) { log.warn('archiveTask: teardown failed', { taskId, error: teardownResult.error.message }); diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/core.ts index 0a153fb93e..84c50fcf6e 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/core.ts @@ -26,5 +26,7 @@ export function mapTaskRowToTask( statusChangedAt: row.statusChangedAt, isPinned: row.isPinned === 1, workspaceProvider: (row.workspaceProvider as 'local' | 'ssh') ?? undefined, + workspaceId: row.workspaceId ?? undefined, + workspaceProviderData: row.workspaceProviderData ?? undefined, }; } diff --git a/src/main/core/tasks/deleteTask.ts b/src/main/core/tasks/deleteTask.ts index 32eaad4c38..0593e8d7b9 100644 --- a/src/main/core/tasks/deleteTask.ts +++ b/src/main/core/tasks/deleteTask.ts @@ -14,7 +14,7 @@ export async function deleteTask(projectId: string, taskId: string): Promise { + const teardownResult = await project.tasks.teardownTask(taskId, 'terminate').catch((e) => { log.warn('deleteTask: teardown failed', { taskId, error: String(e) }); return null; }); diff --git a/src/main/core/tasks/getTaskSettings.ts b/src/main/core/tasks/getTaskSettings.ts index 41d1ab025b..4f81bf2ac3 100644 --- a/src/main/core/tasks/getTaskSettings.ts +++ b/src/main/core/tasks/getTaskSettings.ts @@ -1,17 +1,16 @@ -import { workspaceKey } from '@shared/workspace-key'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/task-settings'; -import { resolveTask, resolveWorkspace } from '@main/core/projects/utils'; +import { resolveTask } from '@main/core/projects/utils'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; export async function getTaskSettings(projectId: string, taskId: string): Promise { const task = resolveTask(projectId, taskId); if (!task) { throw new Error(`Task ${taskId} not found or not provisioned`); } - const wsId = workspaceKey(task.taskBranch); - const workspace = resolveWorkspace(projectId, wsId); + const workspace = workspaceRegistry.get(task.workspaceId); if (!workspace) { - throw new Error(`Workspace ${wsId} not found in project ${projectId}`); + throw new Error(`Workspace ${task.workspaceId} not found`); } return getEffectiveTaskSettings({ diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index 72eca3933b..748d299b77 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -1,9 +1,9 @@ import { eq, sql } from 'drizzle-orm'; -import { workspaceKey } from '@shared/workspace-key'; import { mapConversationRowToConversation } from '@main/core/conversations/utils'; import { projectManager } from '@main/core/projects/project-manager'; import { formatProvisionTaskError } from '@main/core/projects/provision-task-error'; import { mapTerminalRowToTerminal } from '@main/core/terminals/core'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; import { conversations, tasks, terminals } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; @@ -20,8 +20,8 @@ export async function provisionTask(taskId: string) { const existingTask = project.tasks.getTask(taskId); if (existingTask) { - const wsId = workspaceKey(existingTask.taskBranch); - return { path: project.getWorkspace(wsId)?.path ?? '', workspaceId: wsId }; + const wsId = existingTask.workspaceId; + return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId }; } const [existingTerminals, existingConversations] = await Promise.all([ @@ -42,15 +42,20 @@ export async function provisionTask(taskId: string) { throw new Error(`Failed to provision task: ${formatProvisionTaskError(result.error)}`); } + const wsId = result.data.workspaceId; + await db .update(tasks) - .set({ lastInteractedAt: sql`CURRENT_TIMESTAMP` }) + .set({ + lastInteractedAt: sql`CURRENT_TIMESTAMP`, + workspaceId: wsId, + workspaceProviderData: result.data.workspaceProviderData ?? null, + }) .where(eq(tasks.id, taskId)); capture('task_provisioned', { project_id: task.projectId, task_id: task.id, }); - const wsId = workspaceKey(task.taskBranch); - return { path: project.getWorkspace(wsId)?.path ?? '', workspaceId: wsId }; + return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId }; } diff --git a/src/main/core/tasks/teardownTask.ts b/src/main/core/tasks/teardownTask.ts index 7b6e01c549..53a9f22c86 100644 --- a/src/main/core/tasks/teardownTask.ts +++ b/src/main/core/tasks/teardownTask.ts @@ -3,5 +3,5 @@ import { projectManager } from '../projects/project-manager'; export async function teardownTask(projectId: string, taskId: string) { const project = projectManager.getProject(projectId); if (!project) throw new Error(`Project not found: ${projectId}`); - return await project.tasks.teardownTask(taskId); + return await project.tasks.teardownTask(taskId, 'terminate'); } diff --git a/src/main/core/workspaces/workspace-id.ts b/src/main/core/workspaces/workspace-id.ts new file mode 100644 index 0000000000..63d5da85ad --- /dev/null +++ b/src/main/core/workspaces/workspace-id.ts @@ -0,0 +1,29 @@ +/** + * Typed workspace ID utilities. + * + * Key scheme: + * local:{projectId}:branch:{branch} — local worktree, shared across tasks on the same branch + * local:{projectId}:root — local project root (no worktree) + * ssh:{projectId}:branch:{branch} — SSH project worktree + * ssh:{projectId}:root — SSH project root + * remote:{remoteId} — BYOI remote task; keyed by output.id when available, + * else task ID. Tasks sharing the same output.id share + * the same workspace entry with ref-counting. + */ + +export function localWorkspaceId(projectId: string, taskBranch: string | undefined): string { + return taskBranch ? `local:${projectId}:branch:${taskBranch}` : `local:${projectId}:root`; +} + +export function sshWorkspaceId(projectId: string, taskBranch: string | undefined): string { + return taskBranch ? `ssh:${projectId}:branch:${taskBranch}` : `ssh:${projectId}:root`; +} + +/** + * BYOI remote task workspace. + * Pass `output.id` when the provision script returns one; fall back to the task ID. + * Caller: `remoteTaskWorkspaceId(output.id ?? task.id)` + */ +export function remoteTaskWorkspaceId(remoteId: string): string { + return `remote:${remoteId}`; +} diff --git a/src/main/core/workspaces/workspace-registry.test.ts b/src/main/core/workspaces/workspace-registry.test.ts index 5cdd05336c..59c5256672 100644 --- a/src/main/core/workspaces/workspace-registry.test.ts +++ b/src/main/core/workspaces/workspace-registry.test.ts @@ -34,8 +34,8 @@ describe('WorkspaceRegistry', () => { const { workspace } = makeWorkspace('branch:main'); const factory = vi.fn(async () => ({ workspace })); - const first = await registry.acquire('branch:main', factory); - const second = await registry.acquire('branch:main', factory); + const first = await registry.acquire('branch:main', 'test-project', factory); + const second = await registry.acquire('branch:main', 'test-project', factory); expect(first).toBe(workspace); expect(second).toBe(workspace); @@ -55,8 +55,8 @@ describe('WorkspaceRegistry', () => { }) ); - const first = registry.acquire('branch:main', factory); - const second = registry.acquire('branch:main', factory); + const first = registry.acquire('branch:main', 'test-project', factory); + const second = registry.acquire('branch:main', 'test-project', factory); expect(factory).toHaveBeenCalledTimes(1); resolveFactory?.({ workspace }); @@ -71,8 +71,8 @@ describe('WorkspaceRegistry', () => { const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); const factory = vi.fn(async () => ({ workspace })); - await registry.acquire('branch:main', factory); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); await registry.release('branch:main'); expect(dispose).not.toHaveBeenCalled(); @@ -91,9 +91,13 @@ describe('WorkspaceRegistry', () => { const first = makeWorkspace('branch:main'); const second = makeWorkspace('root:'); - await registry.acquire('branch:main', async () => ({ workspace: first.workspace })); - await registry.acquire('branch:main', async () => ({ workspace: first.workspace })); - await registry.acquire('root:', async () => ({ workspace: second.workspace })); + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace: first.workspace, + })); + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace: first.workspace, + })); + await registry.acquire('root:', 'test-project', async () => ({ workspace: second.workspace })); await registry.releaseAll(); @@ -116,11 +120,11 @@ describe('WorkspaceRegistry', () => { const onCreateSideEffect = vi.fn(); const factory = vi.fn(async () => ({ workspace, onCreateSideEffect })); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); expect(onCreateSideEffect).toHaveBeenCalledTimes(1); expect(onCreateSideEffect).toHaveBeenCalledWith(workspace); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); expect(onCreateSideEffect).toHaveBeenCalledTimes(1); }); @@ -134,7 +138,7 @@ describe('WorkspaceRegistry', () => { }); const factory = vi.fn(async () => ({ workspace, onCreate })); - const acquired = registry.acquire('branch:main', factory).then((ws) => { + const acquired = registry.acquire('branch:main', 'test-project', factory).then((ws) => { order.push('acquired'); return ws; }); @@ -151,8 +155,8 @@ describe('WorkspaceRegistry', () => { const onCreate = vi.fn(async () => {}); const factory = vi.fn(async () => ({ workspace, onCreate })); - await registry.acquire('branch:main', factory); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); expect(onCreate).toHaveBeenCalledTimes(1); }); @@ -163,8 +167,8 @@ describe('WorkspaceRegistry', () => { const onDestroy = vi.fn(async () => {}); const factory = vi.fn(async () => ({ workspace, onDestroy })); - await registry.acquire('branch:main', factory); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); await registry.release('branch:main'); expect(onDestroy).not.toHaveBeenCalled(); @@ -191,7 +195,7 @@ describe('WorkspaceRegistry', () => { }); const factory = vi.fn(async () => ({ workspace, onDestroy })); - await registry.acquire('branch:main', factory); + await registry.acquire('branch:main', 'test-project', factory); await registry.release('branch:main'); expect(order).toEqual(['onDestroy', 'gitDispose', 'lifecycleDispose']); @@ -204,11 +208,11 @@ describe('WorkspaceRegistry', () => { const onDestroyFirst = vi.fn(async () => {}); const onDestroySecond = vi.fn(async () => {}); - await registry.acquire('branch:main', async () => ({ + await registry.acquire('branch:main', 'test-project', async () => ({ workspace: first.workspace, onDestroy: onDestroyFirst, })); - await registry.acquire('root:', async () => ({ + await registry.acquire('root:', 'test-project', async () => ({ workspace: second.workspace, onDestroy: onDestroySecond, })); @@ -220,4 +224,68 @@ describe('WorkspaceRegistry', () => { expect(onDestroySecond).toHaveBeenCalledTimes(1); expect(onDestroySecond).toHaveBeenCalledWith(second.workspace); }); + + it('calls onDetach (not onDestroy) when releasing with detach mode', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.release('branch:main', 'detach'); + + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach).toHaveBeenCalledWith(workspace); + expect(onDestroy).not.toHaveBeenCalled(); + }); + + it('calls onDestroy (not onDetach) when releasing with terminate mode', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDestroy, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.release('branch:main', 'terminate'); + + expect(onDestroy).toHaveBeenCalledTimes(1); + expect(onDestroy).toHaveBeenCalledWith(workspace); + expect(onDetach).not.toHaveBeenCalled(); + }); + + it('does not call onDetach when ref count has not reached zero', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDetach = vi.fn(async () => {}); + const factory = vi.fn(async () => ({ workspace, onDetach })); + + await registry.acquire('branch:main', 'test-project', factory); + await registry.acquire('branch:main', 'test-project', factory); + + await registry.release('branch:main', 'detach'); + expect(onDetach).not.toHaveBeenCalled(); + + await registry.release('branch:main', 'detach'); + expect(onDetach).toHaveBeenCalledTimes(1); + }); + + it('releaseAllForProject passes detach mode to hooks', async () => { + const registry = new WorkspaceRegistry(); + const { workspace } = makeWorkspace('branch:main'); + const onDestroy = vi.fn(async () => {}); + const onDetach = vi.fn(async () => {}); + + await registry.acquire('branch:main', 'test-project', async () => ({ + workspace, + onDestroy, + onDetach, + })); + + await registry.releaseAllForProject('test-project', 'detach'); + + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDestroy).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/core/workspaces/workspace-registry.ts b/src/main/core/workspaces/workspace-registry.ts index 6bb4231c25..f4107fac34 100644 --- a/src/main/core/workspaces/workspace-registry.ts +++ b/src/main/core/workspaces/workspace-registry.ts @@ -1,9 +1,12 @@ import type { Workspace } from './workspace'; +export type TeardownMode = 'detach' | 'terminate'; + type WorkspaceHooks = { onCreate?: (workspace: Workspace) => Promise; onCreateSideEffect?: (workspace: Workspace) => void; onDestroy?: (workspace: Workspace) => Promise; + onDetach?: (workspace: Workspace) => Promise; }; export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; @@ -11,14 +14,20 @@ export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; type WorkspaceEntry = { workspace: Workspace; refCount: number; + projectId: string; onDestroy?: (workspace: Workspace) => Promise; + onDetach?: (workspace: Workspace) => Promise; }; export class WorkspaceRegistry { private entries = new Map(); private acquiring = new Map>(); - async acquire(key: string, factory: () => Promise): Promise { + async acquire( + key: string, + projectId: string, + factory: () => Promise + ): Promise { const existing = this.entries.get(key); if (existing) { existing.refCount += 1; @@ -38,7 +47,9 @@ export class WorkspaceRegistry { this.entries.set(key, { workspace: result.workspace, refCount: 1, + projectId, onDestroy: result.onDestroy, + onDetach: result.onDetach, }); result.onCreateSideEffect?.(result.workspace); await result.onCreate?.(result.workspace); @@ -52,13 +63,13 @@ export class WorkspaceRegistry { return pending; } - async release(key: string): Promise { + async release(key: string, mode: TeardownMode = 'terminate'): Promise { const entry = this.entries.get(key); if (!entry) { const inFlight = this.acquiring.get(key); if (inFlight) { await inFlight; - await this.release(key); + await this.release(key, mode); } return; } @@ -69,9 +80,14 @@ export class WorkspaceRegistry { } this.entries.delete(key); - await entry.onDestroy?.(entry.workspace); + if (mode === 'terminate') { + await entry.onDestroy?.(entry.workspace); + } entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); + if (mode === 'detach') { + await entry.onDetach?.(entry.workspace); + } } get(key: string): Workspace | undefined { @@ -82,15 +98,29 @@ export class WorkspaceRegistry { return this.entries.get(key)?.refCount ?? 0; } - async releaseAll(): Promise { + async releaseAllForProject(projectId: string, mode: TeardownMode = 'terminate'): Promise { + const keys = Array.from(this.entries.entries()) + .filter(([, e]) => e.projectId === projectId) + .map(([k]) => k); + await Promise.all(keys.map((k) => this.release(k, mode))); + } + + async releaseAll(mode: TeardownMode = 'terminate'): Promise { const entries = Array.from(this.entries.values()); this.entries.clear(); await Promise.all( entries.map(async (entry) => { - await entry.onDestroy?.(entry.workspace); + if (mode === 'terminate') { + await entry.onDestroy?.(entry.workspace); + } entry.workspace.git.dispose(); await entry.workspace.lifecycleService.dispose(); + if (mode === 'detach') { + await entry.onDetach?.(entry.workspace); + } }) ); } } + +export const workspaceRegistry = new WorkspaceRegistry(); diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 1e0b018aa0..2de8e420b0 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -111,6 +111,8 @@ export const tasks = sqliteTable( .default(sql`CURRENT_TIMESTAMP`), isPinned: integer('is_pinned').notNull().default(0), // boolean, 0=false, 1=true workspaceProvider: text('workspace_provider'), // 'local' | 'ssh' | null (null = inherit from project settings) + workspaceId: text('workspace_id'), + workspaceProviderData: text('workspace_provider_data'), // JSON, BYOI only }, (table) => ({ projectIdIdx: index('idx_tasks_project_id').on(table.projectId), diff --git a/src/renderer/features/tasks/stores/task-manager.ts b/src/renderer/features/tasks/stores/task-manager.ts index d8461d8e72..bf478c3686 100644 --- a/src/renderer/features/tasks/stores/task-manager.ts +++ b/src/renderer/features/tasks/stores/task-manager.ts @@ -281,6 +281,7 @@ export class TaskManagerStore { current.transitionToProvisioned( { ...current.data, lastInteractedAt: new Date().toISOString() }, result.path, + result.workspaceId, this._settingsStore, this._baseRef, savedSnapshot as TaskViewSnapshot | undefined diff --git a/src/renderer/features/tasks/stores/task.ts b/src/renderer/features/tasks/stores/task.ts index 64de518619..b65bc2809b 100644 --- a/src/renderer/features/tasks/stores/task.ts +++ b/src/renderer/features/tasks/stores/task.ts @@ -1,7 +1,6 @@ import { makeAutoObservable, observable, runInAction } from 'mobx'; import type { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; import type { TaskViewSnapshot } from '@shared/view-state'; -import { workspaceKey } from '@shared/workspace-key'; import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; import { ConversationManagerStore } from '@renderer/features/tasks/conversations/conversation-manager'; @@ -61,6 +60,7 @@ export class ProvisionedTask { constructor( taskStore: TaskStore, path: string, + workspaceId: string, settingsStore: ProjectSettingsStore, baseRef: string, savedSnapshot?: TaskViewSnapshot @@ -69,7 +69,7 @@ export class ProvisionedTask { const taskData = taskStore.data as Task; this._taskData = taskData; this.path = path; - this.workspaceId = workspaceKey(taskData.taskBranch); + this.workspaceId = workspaceId; this.workspace = workspaceRegistry.acquire( taskData.projectId, @@ -165,12 +165,20 @@ export class TaskStore { transitionToProvisioned( data: Task, path: string, + workspaceId: string, settingsStore: ProjectSettingsStore, baseRef: string, savedSnapshot?: TaskViewSnapshot ): void { this.data = data; - this.provisionedTask = new ProvisionedTask(this, path, settingsStore, baseRef, savedSnapshot); + this.provisionedTask = new ProvisionedTask( + this, + path, + workspaceId, + settingsStore, + baseRef, + savedSnapshot + ); this.state = 'provisioned'; this.phase = null; this.errorMessage = undefined; diff --git a/src/shared/tasks.ts b/src/shared/tasks.ts index 98e90e1fbf..7662ff70a6 100644 --- a/src/shared/tasks.ts +++ b/src/shared/tasks.ts @@ -36,6 +36,8 @@ export type Task = { prs: PullRequest[]; conversations: Record; workspaceProvider?: 'local' | 'ssh'; + workspaceId?: string; + workspaceProviderData?: string; // JSON, BYOI only }; export type TaskBootstrapStatus = diff --git a/src/shared/workspace-key.ts b/src/shared/workspace-key.ts deleted file mode 100644 index 4b5ed75584..0000000000 --- a/src/shared/workspace-key.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function workspaceKey(taskBranch: string | undefined): string { - return taskBranch ? `branch:${taskBranch}` : 'root:'; -} From dbc523092b18e20c21f96d0b16470304b7b36a12 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:34:50 +0200 Subject: [PATCH 066/263] fix(windows): centralize env path handling --- src/main/core/pty/pty-env.ts | 45 ++++++++++++++----------- src/main/core/pty/pty-spawn-platform.ts | 7 +--- src/main/utils/userEnv.ts | 12 +++++++ src/main/utils/windows-env.ts | 30 +++++++++++++++++ 4 files changed, 69 insertions(+), 25 deletions(-) create mode 100644 src/main/utils/windows-env.ts diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index 7b659b04d4..415283e490 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import { detectSshAuthSock } from '@main/utils/shellEnv'; +import { getWindowsEnvValue } from '@main/utils/windows-env'; export const AGENT_ENV_VARS = [ 'AMP_API_KEY', @@ -60,25 +61,31 @@ function getWindowsEssentialEnv(resolvedPath: string): Record { const home = os.homedir(); return { PATH: resolvedPath, - PATHEXT: process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', - SystemRoot: process.env.SystemRoot || 'C:\\Windows', - ComSpec: process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', - TEMP: process.env.TEMP || process.env.TMP || '', - TMP: process.env.TMP || process.env.TEMP || '', - USERPROFILE: process.env.USERPROFILE || home, - APPDATA: process.env.APPDATA || '', - LOCALAPPDATA: process.env.LOCALAPPDATA || '', - HOMEDRIVE: process.env.HOMEDRIVE || '', - HOMEPATH: process.env.HOMEPATH || '', - USERNAME: process.env.USERNAME || os.userInfo().username, - ProgramFiles: process.env.ProgramFiles || 'C:\\Program Files', - 'ProgramFiles(x86)': process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', - ProgramData: process.env.ProgramData || 'C:\\ProgramData', - CommonProgramFiles: process.env.CommonProgramFiles || 'C:\\Program Files\\Common Files', + PATHEXT: + getWindowsEnvValue(process.env, 'PATHEXT') || + '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC', + SystemRoot: getWindowsEnvValue(process.env, 'SystemRoot') || 'C:\\Windows', + ComSpec: getWindowsEnvValue(process.env, 'ComSpec') || 'C:\\Windows\\System32\\cmd.exe', + TEMP: getWindowsEnvValue(process.env, 'TEMP') || getWindowsEnvValue(process.env, 'TMP') || '', + TMP: getWindowsEnvValue(process.env, 'TMP') || getWindowsEnvValue(process.env, 'TEMP') || '', + USERPROFILE: getWindowsEnvValue(process.env, 'USERPROFILE') || home, + APPDATA: getWindowsEnvValue(process.env, 'APPDATA') || '', + LOCALAPPDATA: getWindowsEnvValue(process.env, 'LOCALAPPDATA') || '', + HOMEDRIVE: getWindowsEnvValue(process.env, 'HOMEDRIVE') || '', + HOMEPATH: getWindowsEnvValue(process.env, 'HOMEPATH') || '', + USERNAME: getWindowsEnvValue(process.env, 'USERNAME') || os.userInfo().username, + ProgramFiles: getWindowsEnvValue(process.env, 'ProgramFiles') || 'C:\\Program Files', + 'ProgramFiles(x86)': + getWindowsEnvValue(process.env, 'ProgramFiles(x86)') || 'C:\\Program Files (x86)', + ProgramData: getWindowsEnvValue(process.env, 'ProgramData') || 'C:\\ProgramData', + CommonProgramFiles: + getWindowsEnvValue(process.env, 'CommonProgramFiles') || 'C:\\Program Files\\Common Files', 'CommonProgramFiles(x86)': - process.env['CommonProgramFiles(x86)'] || 'C:\\Program Files (x86)\\Common Files', - ProgramW6432: process.env.ProgramW6432 || 'C:\\Program Files', - CommonProgramW6432: process.env.CommonProgramW6432 || 'C:\\Program Files\\Common Files', + getWindowsEnvValue(process.env, 'CommonProgramFiles(x86)') || + 'C:\\Program Files (x86)\\Common Files', + ProgramW6432: getWindowsEnvValue(process.env, 'ProgramW6432') || 'C:\\Program Files', + CommonProgramW6432: + getWindowsEnvValue(process.env, 'CommonProgramW6432') || 'C:\\Program Files\\Common Files', }; } @@ -172,7 +179,7 @@ export function buildAgentEnv(options: AgentEnvOptions = {}): Record = { TERM: 'xterm-256color', diff --git a/src/main/core/pty/pty-spawn-platform.ts b/src/main/core/pty/pty-spawn-platform.ts index c9d423a6ff..ad586e7b95 100644 --- a/src/main/core/pty/pty-spawn-platform.ts +++ b/src/main/core/pty/pty-spawn-platform.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { log } from '@main/lib/logger'; +import { getWindowsEnvValue } from '@main/utils/windows-env'; import { buildTmuxShellLine } from './tmux-session-name'; export type PtyCommandSpec = @@ -64,12 +65,6 @@ function quoteForCmdExe(input: string): string { .replace(/(["^&|<>()])/g, '^$1')}"`; } -function getWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { - const lowerKey = key.toLowerCase(); - const envKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); - return envKey ? env[envKey] : undefined; -} - function getWindowsPathDirs(env: NodeJS.ProcessEnv): string[] { const rawPath = getWindowsEnvValue(env, 'PATH') ?? ''; return rawPath.split(path.win32.delimiter).filter(Boolean); diff --git a/src/main/utils/userEnv.ts b/src/main/utils/userEnv.ts index 3b7230bab3..1c5732a481 100644 --- a/src/main/utils/userEnv.ts +++ b/src/main/utils/userEnv.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { log } from '@main/lib/logger'; +import { prependWindowsPathEntry } from './windows-env'; /** * Keys that must never be overwritten from the shell env capture. @@ -80,6 +81,16 @@ export function ensureUserBinDirsInPath(candidates: string[] = USER_BIN_DIRS): s return additions; } +export function ensureWindowsNpmGlobalBinInPath( + env: NodeJS.ProcessEnv = process.env +): string | null { + const appData = env.APPDATA; + if (!appData) return null; + + const npmPath = path.win32.join(appData, 'npm'); + return prependWindowsPathEntry(env, npmPath) ? npmPath : null; +} + /** * Spawns `$SHELL -ilc 'env'` with a 5 s timeout. On any error (timeout, * missing shell, restricted environment) the function logs a warning and @@ -92,6 +103,7 @@ export function ensureUserBinDirsInPath(candidates: string[] = USER_BIN_DIRS): s export async function resolveUserEnv(): Promise { if (process.platform === 'win32') { // Windows PATH is managed differently; no login-shell capture needed. + ensureWindowsNpmGlobalBinInPath(); return; } diff --git a/src/main/utils/windows-env.ts b/src/main/utils/windows-env.ts new file mode 100644 index 0000000000..76e2165a03 --- /dev/null +++ b/src/main/utils/windows-env.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; + +export function getWindowsEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined { + if (env[key] !== undefined) return key; + + const lowerKey = key.toLowerCase(); + return Object.keys(env).find((candidate) => candidate.toLowerCase() === lowerKey); +} + +export function getWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const envKey = getWindowsEnvKey(env, key); + return envKey ? env[envKey] : undefined; +} + +export function getWindowsPathEnvKey(env: NodeJS.ProcessEnv): string { + return getWindowsEnvKey(env, 'PATH') ?? 'PATH'; +} + +export function prependWindowsPathEntry(env: NodeJS.ProcessEnv, entry: string): boolean { + const pathKey = getWindowsPathEnvKey(env); + const entries = (env[pathKey] ?? '').split(path.win32.delimiter).filter(Boolean); + const existing = new Set(entries.map((item) => item.toLowerCase())); + + if (existing.has(entry.toLowerCase())) { + return false; + } + + env[pathKey] = [entry, ...entries].join(path.win32.delimiter); + return true; +} From 203cc17663c14a93f5af845fe8bf95f703f3c87b Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 15:48:09 +0200 Subject: [PATCH 067/263] feat: persist byoi data --- .../projects/impl/local-project-provider.ts | 32 ++++++++++-------- .../projects/impl/ssh-project-provider.ts | 16 +++++---- src/main/core/projects/project-provider.ts | 18 +++++++--- src/main/core/projects/task-builder.ts | 1 - .../core/projects/task-provision-manager.ts | 33 +++++++++++++------ src/main/core/pty/controller.ts | 3 +- src/main/core/tasks/getTaskSettings.ts | 14 +++++--- src/main/core/tasks/provisionTask.ts | 15 ++++++--- 8 files changed, 87 insertions(+), 45 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index cb9e55044e..553720c0cd 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -21,11 +21,11 @@ import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; import { localWorkspaceId, remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { quoteShellArg } from '@main/utils/shellEscape'; -import { type ProjectProvider, type TaskProvider, type TeardownMode } from '../project-provider'; +import type { ProjectProvider, ProvisionResult, TaskProvider } from '../project-provider'; import { parseProvisionOutput } from '../provision-output'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; import type { ProjectSettings } from '../settings/schema'; @@ -79,7 +79,7 @@ export async function createLocalProvider( task: Task, conversations: Conversation[], terminals: Terminal[] - ): Promise { + ): Promise { log.debug('LocalProjectProvider: doProvisionTask START', { taskId: task.id }); const projectSettings = await settings.get(); @@ -162,7 +162,7 @@ export async function createLocalProvider( ); log.debug('LocalProjectProvider: doProvisionTask DONE', { taskId: task.id }); provisionSucceeded = true; - return taskProvider; + return { taskProvider, persistData: { workspaceId: workspace.id } }; } finally { if (!provisionSucceeded) { await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); @@ -175,7 +175,7 @@ export async function createLocalProvider( conversations: Conversation[], terminals: Terminal[], wpConfig: NonNullable - ): Promise { + ): Promise { events.emit(taskProvisionProgressChannel, { taskId: task.id, projectId: project.id, @@ -257,7 +257,7 @@ export async function createLocalProvider( step: 'starting-sessions', message: 'Starting sessions…', }); - const { taskProvider: baseTaskProvider } = await buildTaskFromWorkspace( + const { taskProvider } = await buildTaskFromWorkspace( task, workspace, { kind: 'ssh', proxy }, @@ -267,13 +267,15 @@ export async function createLocalProvider( { conversations, terminals }, 'LocalProjectProvider[remote]' ); - const taskProvider: TaskProvider = { - ...baseTaskProvider, - workspaceProviderData: JSON.stringify({ ...wpConfig, remoteWorkspaceId: output.id }), - }; log.debug('LocalProjectProvider: doProvisionRemoteTask DONE', { taskId: task.id }); provisionSucceeded = true; - return taskProvider; + return { + taskProvider, + persistData: { + workspaceId: workspace.id, + workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, + }, + }; } finally { if (!provisionSucceeded) { await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); @@ -281,7 +283,11 @@ export async function createLocalProvider( } } - async function doTeardownTask(task: TaskProvider, mode: TeardownMode): Promise { + async function doTeardownTask( + task: TaskProvider, + workspaceId: string, + mode: TeardownMode + ): Promise { if (mode === 'detach') { await task.conversations.detachAll(); await task.terminals.detachAll(); @@ -289,7 +295,7 @@ export async function createLocalProvider( await task.conversations.destroyAll(); await task.terminals.destroyAll(); } - await workspaceRegistry.release(task.workspaceId, mode); + await workspaceRegistry.release(workspaceId, mode); } async function cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 0945819bbc..6f67a5066b 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -24,10 +24,10 @@ import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; import { sshWorkspaceId } from '@main/core/workspaces/workspace-id'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { type ProjectProvider, type TaskProvider, type TeardownMode } from '../project-provider'; +import { type ProjectProvider, type ProvisionResult, type TaskProvider } from '../project-provider'; import { SshProjectSettingsProvider } from '../settings/project-settings'; import { buildTaskFromWorkspace } from '../task-builder'; import { TaskProvisionManager } from '../task-provision-manager'; @@ -77,7 +77,7 @@ export async function createSshProvider( task: Task, conversations: Conversation[], terminals: Terminal[] - ): Promise { + ): Promise { log.debug('SshProjectProvider: doProvisionTask START', { taskId: task.id }); void gitFetchService.fetch(); @@ -142,7 +142,7 @@ export async function createSshProvider( conversationProviders.set(task.id, conversationProvider as SshConversationProvider); log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); provisionSucceeded = true; - return taskProvider; + return { taskProvider, persistData: { workspaceId: workspace.id } }; } finally { if (!provisionSucceeded) { await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); @@ -150,7 +150,11 @@ export async function createSshProvider( } } - async function doTeardownTask(task: TaskProvider, mode: TeardownMode): Promise { + async function doTeardownTask( + task: TaskProvider, + workspaceId: string, + mode: TeardownMode + ): Promise { if (mode === 'detach') { await task.conversations.detachAll(); await task.terminals.detachAll(); @@ -158,7 +162,7 @@ export async function createSshProvider( await task.conversations.destroyAll(); await task.terminals.destroyAll(); } - await workspaceRegistry.release(task.workspaceId, mode); + await workspaceRegistry.release(workspaceId, mode); } async function cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index a4e7cb5274..0e2cf44dd7 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -8,8 +8,6 @@ import type { TerminalProvider } from '../terminals/terminal-provider'; import type { ProjectSettingsProvider } from './settings/schema'; import type { TaskProvisionManager } from './task-provision-manager'; -export type { TeardownMode } from '../workspaces/workspace-registry'; - export type ProvisionTaskError = | { type: 'timeout'; message: string; timeout: number } | { type: 'branch-not-found'; branch: string } @@ -20,15 +18,27 @@ export type TeardownTaskError = | { type: 'timeout'; message: string; timeout: number } | { type: 'error'; message: string }; +export type WorkspaceProviderData = { + provisionCommand: string; + terminateCommand: string; + remoteWorkspaceId?: string; +}; + +export type ProvisionResult = { + taskProvider: TaskProvider; + persistData: { + workspaceId: string; + workspaceProviderData?: WorkspaceProviderData; + }; +}; + export interface TaskProvider { readonly taskId: string; - readonly workspaceId: string; readonly taskBranch: string | undefined; readonly sourceBranch: Branch | undefined; readonly taskEnvVars: Record; readonly conversations: ConversationProvider; readonly terminals: TerminalProvider; - readonly workspaceProviderData?: string; // JSON, BYOI only; written to DB after provision } export interface ProjectProvider { diff --git a/src/main/core/projects/task-builder.ts b/src/main/core/projects/task-builder.ts index 00a20703d9..bbbcacb81a 100644 --- a/src/main/core/projects/task-builder.ts +++ b/src/main/core/projects/task-builder.ts @@ -53,7 +53,6 @@ export async function buildTaskFromWorkspace( const taskProvider: TaskProvider = { taskId: task.id, - workspaceId: workspace.id, taskBranch: task.taskBranch, sourceBranch: task.sourceBranch, taskEnvVars, diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts index 51f6186d25..7a43c337b8 100644 --- a/src/main/core/projects/task-provision-manager.ts +++ b/src/main/core/projects/task-provision-manager.ts @@ -3,10 +3,11 @@ import { err, ok, type Result } from '@shared/result'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { log } from '@main/lib/logger'; +import type { TeardownMode } from '../workspaces/workspace-registry'; import type { + ProvisionResult, ProvisionTaskError, TaskProvider, - TeardownMode, TeardownTaskError, } from './project-provider'; import { @@ -21,9 +22,9 @@ type ProvisionFn = ( task: Task, conversations: Conversation[], terminals: Terminal[] -) => Promise; +) => Promise; -type TeardownFn = (task: TaskProvider, mode: TeardownMode) => Promise; +type TeardownFn = (task: TaskProvider, workspaceId: string, mode: TeardownMode) => Promise; type DetachedCleanupFn = (taskId: string) => Promise; @@ -31,9 +32,10 @@ export type TeardownAllOpts = { mode: TeardownMode }; export class TaskProvisionManager { private readonly _tasks = new Map(); + private readonly _workspaceIds = new Map(); private readonly _provisioningTasks = new Map< string, - Promise> + Promise> >(); private readonly _tearingDownTasks = new Map>>(); private readonly _bootstrapErrors = new Map(); @@ -50,18 +52,22 @@ export class TaskProvisionManager { task: Task, conversations: Conversation[], terminals: Terminal[] - ): Promise> { + ): Promise> { const existing = this._tasks.get(task.id); - if (existing) return ok(existing); + if (existing) { + const workspaceId = this._workspaceIds.get(task.id) ?? ''; + return ok({ taskProvider: existing, persistData: { workspaceId } }); + } const inFlight = this._provisioningTasks.get(task.id); if (inFlight) return inFlight; const promise = withTimeout(this.provisionFn(task, conversations, terminals), TASK_TIMEOUT_MS) - .then((taskEnv) => { - this._tasks.set(task.id, taskEnv); + .then(({ taskProvider, persistData }) => { + this._tasks.set(task.id, taskProvider); + this._workspaceIds.set(task.id, persistData.workspaceId); this._provisioningTasks.delete(task.id); - return ok(taskEnv); + return ok({ taskProvider, persistData }); }) .catch((e) => { const provisionError = toProvisionError(e); @@ -82,6 +88,10 @@ export class TaskProvisionManager { return this._tasks.get(taskId); } + getWorkspaceId(taskId: string): string | undefined { + return this._workspaceIds.get(taskId); + } + getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { if (this._tasks.has(taskId)) return { status: 'ready' }; if (this._provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; @@ -104,7 +114,8 @@ export class TaskProvisionManager { return ok(); } - const promise = withTimeout(this.teardownFn(task, mode), TASK_TIMEOUT_MS) + const workspaceId = this._workspaceIds.get(taskId) ?? ''; + const promise = withTimeout(this.teardownFn(task, workspaceId, mode), TASK_TIMEOUT_MS) .then(() => ok()) .catch(async (e) => { log.error(`${this.logPrefix}: failed to teardown task`, { @@ -121,6 +132,7 @@ export class TaskProvisionManager { }) .finally(() => { this._tasks.delete(taskId); + this._workspaceIds.delete(taskId); this._tearingDownTasks.delete(taskId); this.onTeardownFinally?.(taskId); }); @@ -137,6 +149,7 @@ export class TaskProvisionManager { ) ); this._tasks.clear(); + this._workspaceIds.clear(); } else { await Promise.all( Array.from(this._tasks.keys()).map((id) => this.teardownTask(id, 'terminate')) diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 7867c47327..ef34f78e79 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -75,7 +75,8 @@ export const ptyController = createRPCController({ const taskProvider = provider.tasks.getTask(scopeId); if (!taskProvider) return err({ type: 'not_ssh' as const }); - const workspace = workspaceRegistry.get(taskProvider.workspaceId); + const workspaceId = provider.tasks.getWorkspaceId(scopeId) ?? ''; + const workspace = workspaceRegistry.get(workspaceId); if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); const remotePaths = await Promise.all( diff --git a/src/main/core/tasks/getTaskSettings.ts b/src/main/core/tasks/getTaskSettings.ts index 4f81bf2ac3..1e329d6c1b 100644 --- a/src/main/core/tasks/getTaskSettings.ts +++ b/src/main/core/tasks/getTaskSettings.ts @@ -1,16 +1,20 @@ +import { projectManager } from '@main/core/projects/project-manager'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/task-settings'; -import { resolveTask } from '@main/core/projects/utils'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; export async function getTaskSettings(projectId: string, taskId: string): Promise { - const task = resolveTask(projectId, taskId); - if (!task) { + const project = projectManager.getProject(projectId); + if (!project?.tasks.getTask(taskId)) { throw new Error(`Task ${taskId} not found or not provisioned`); } - const workspace = workspaceRegistry.get(task.workspaceId); + const workspaceId = project.tasks.getWorkspaceId(taskId); + if (!workspaceId) { + throw new Error(`Workspace ID for task ${taskId} not found`); + } + const workspace = workspaceRegistry.get(workspaceId); if (!workspace) { - throw new Error(`Workspace ${task.workspaceId} not found`); + throw new Error(`Workspace ${workspaceId} not found`); } return getEffectiveTaskSettings({ diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index 748d299b77..b398395b64 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -20,7 +20,7 @@ export async function provisionTask(taskId: string) { const existingTask = project.tasks.getTask(taskId); if (existingTask) { - const wsId = existingTask.workspaceId; + const wsId = project.tasks.getWorkspaceId(taskId) ?? ''; return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId }; } @@ -42,14 +42,16 @@ export async function provisionTask(taskId: string) { throw new Error(`Failed to provision task: ${formatProvisionTaskError(result.error)}`); } - const wsId = result.data.workspaceId; + const { persistData } = result.data; await db .update(tasks) .set({ lastInteractedAt: sql`CURRENT_TIMESTAMP`, - workspaceId: wsId, - workspaceProviderData: result.data.workspaceProviderData ?? null, + workspaceId: persistData.workspaceId, + workspaceProviderData: persistData.workspaceProviderData + ? JSON.stringify(persistData.workspaceProviderData) + : null, }) .where(eq(tasks.id, taskId)); capture('task_provisioned', { @@ -57,5 +59,8 @@ export async function provisionTask(taskId: string) { task_id: task.id, }); - return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId }; + return { + path: workspaceRegistry.get(persistData.workspaceId)?.path ?? '', + workspaceId: persistData.workspaceId, + }; } From 455338c2791ee43c2c234e7e424a9f455e2d016f Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 15:55:01 +0200 Subject: [PATCH 068/263] fix(dependencies): use platform pty resolver for installs --- .../core/dependencies/install-runner.test.ts | 70 ++++++++++++++++++- src/main/core/dependencies/install-runner.ts | 22 ++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/main/core/dependencies/install-runner.test.ts b/src/main/core/dependencies/install-runner.test.ts index 2dda8e4dab..d4fc86cb7d 100644 --- a/src/main/core/dependencies/install-runner.test.ts +++ b/src/main/core/dependencies/install-runner.test.ts @@ -1,5 +1,52 @@ -import { describe, expect, it } from 'vitest'; -import { classifyInstallCommandFailure } from './install-runner'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LocalSpawnOptions } from '@main/core/pty/local-pty'; +import type { Pty } from '@main/core/pty/pty'; +import { classifyInstallCommandFailure, runLocalInstallCommand } from './install-runner'; + +const mocks = vi.hoisted(() => ({ + spawnLocalPty: vi.fn(), + ensureUserBinDirsInPath: vi.fn(), +})); + +vi.mock('@main/core/pty/local-pty', () => ({ + spawnLocalPty: mocks.spawnLocalPty, +})); + +vi.mock('@main/utils/userEnv', () => ({ + ensureUserBinDirsInPath: mocks.ensureUserBinDirsInPath, +})); + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalEnv = { ...process.env }; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +function createSuccessfulPty(): Pty { + return { + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((handler) => handler({ exitCode: 0 })), + }; +} + +beforeEach(() => { + mocks.spawnLocalPty.mockReturnValue(createSuccessfulPty()); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + vi.clearAllMocks(); +}); describe('classifyInstallCommandFailure', () => { it('summarizes permission errors from npm global installs', () => { @@ -32,3 +79,22 @@ describe('classifyInstallCommandFailure', () => { }); }); }); + +describe('runLocalInstallCommand', () => { + it('runs Windows installs through the local PTY platform resolver', async () => { + setPlatform('win32'); + delete process.env.SHELL; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + const result = await runLocalInstallCommand('npm install -g @openai/codex'); + + expect(result.success).toBe(true); + expect(mocks.spawnLocalPty).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'npm install -g @openai/codex'], + cwd: expect.any(String), + } satisfies Partial) + ); + }); +}); diff --git a/src/main/core/dependencies/install-runner.ts b/src/main/core/dependencies/install-runner.ts index 2d86ac65f8..4e85a991c1 100644 --- a/src/main/core/dependencies/install-runner.ts +++ b/src/main/core/dependencies/install-runner.ts @@ -3,6 +3,7 @@ import type { InstallCommandError } from '@shared/dependencies'; import { err, ok, type Result } from '@shared/result'; import { spawnLocalPty } from '@main/core/pty/local-pty'; import type { Pty } from '@main/core/pty/pty'; +import { logLocalPtySpawnWarnings, resolveLocalPtySpawn } from '@main/core/pty/pty-spawn-platform'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; import { log } from '@main/lib/logger'; @@ -61,14 +62,25 @@ function waitForInstallPty(pty: Pty): Promise> export function runLocalInstallCommand( command: string ): Promise> { - const shell = process.env.SHELL ?? '/bin/sh'; + const installId = `install:${crypto.randomUUID()}`; + const resolved = resolveLocalPtySpawn({ + platform: process.platform, + env: process.env, + intent: { + kind: 'run-command', + cwd: os.homedir(), + command: { kind: 'shell-line', commandLine: command }, + }, + }); + logLocalPtySpawnWarnings('DependencyManager', resolved.warnings, { installId }); + let pty: Pty; try { pty = spawnLocalPty({ - id: `install:${crypto.randomUUID()}`, - command: shell, - args: ['-c', command], - cwd: os.homedir(), + id: installId, + command: resolved.command, + args: resolved.args, + cwd: resolved.cwd, env: process.env as Record, cols: 80, rows: 24, From 2b001668fd4789480333df61a0673d781fb0ef04 Mon Sep 17 00:00:00 2001 From: Devin Matte Date: Tue, 28 Apr 2026 10:10:01 -0400 Subject: [PATCH 069/263] fix: raise maxBuffer for branch listing and fetch Node's execFile defaults to a 1 MB stdout buffer. On large monorepos (e.g. dd-source with ~25k refs) `git branch -a --format=...` produces ~2.7 MB and rejects with ERR_CHILD_PROCESS_STDOUT_MAXBUFFER_EXCEEDED, which surfaces in the UI as no local branches and no remote PRs (the PR mapping joins against the branch list). Fetch progress output can hit the same ceiling on stale repos. Pass an explicit 64 MB maxBuffer to git branch -a, the standalone git fetch, and the git fetch inside createBranch(). --- src/main/core/git/impl/git-service.ts | 11 ++++++++--- src/main/core/git/impl/git-utils.ts | 7 +++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/core/git/impl/git-service.ts b/src/main/core/git/impl/git-service.ts index bea5e5fe0c..b04fcb42cd 100644 --- a/src/main/core/git/impl/git-service.ts +++ b/src/main/core/git/impl/git-service.ts @@ -33,13 +33,14 @@ import { ownerFromUrl } from '@shared/pull-requests'; import { err, ok, type Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; import { GIT_EXECUTABLE, type ExecFn } from '@main/core/utils/exec'; -import { GitProvider } from '../types'; +import { type GitProvider } from '../types'; import { CatFileBatch } from './cat-file-batch'; import { computeBaseRef, mapStatus, MAX_DIFF_CONTENT_BYTES, MAX_DIFF_OUTPUT_BYTES, + MAX_REF_LIST_BYTES, parseDiffLines, stripTrailingNewline, } from './git-utils'; @@ -893,6 +894,7 @@ export class GitService implements GitProvider { await this.exec('git', selectedRemote ? ['fetch', selectedRemote] : ['fetch'], { cwd: this.path, + maxBuffer: MAX_REF_LIST_BYTES, }); return ok(); } catch (error: unknown) { @@ -1212,7 +1214,7 @@ export class GitService implements GitProvider { const { stdout } = await this.exec( 'git', ['branch', '-a', '--format=%(refname:short)|%(upstream:short)|%(upstream:track)|%(refname)'], - { cwd: this.path } + { cwd: this.path, maxBuffer: MAX_REF_LIST_BYTES } ); const branches: Branch[] = []; @@ -1346,7 +1348,10 @@ export class GitService implements GitProvider { remote = 'origin' ): Promise> { if (syncWithRemote) { - await this.exec('git', ['fetch', remote], { cwd: this.path }).catch(() => {}); + await this.exec('git', ['fetch', remote], { + cwd: this.path, + maxBuffer: MAX_REF_LIST_BYTES, + }).catch(() => {}); } const base = syncWithRemote ? `${remote}/${from}` : `refs/heads/${from}`; try { diff --git a/src/main/core/git/impl/git-utils.ts b/src/main/core/git/impl/git-utils.ts index 1804b7c32e..fa6a52ab73 100644 --- a/src/main/core/git/impl/git-utils.ts +++ b/src/main/core/git/impl/git-utils.ts @@ -6,6 +6,13 @@ export const MAX_DIFF_CONTENT_BYTES = 512 * 1024; /** Maximum bytes for `git diff` output (larger than content limit due to headers/context). */ export const MAX_DIFF_OUTPUT_BYTES = 10 * 1024 * 1024; +/** + * Maximum bytes for ref-listing / fetch output. Repos with many thousands of refs + * (e.g. monorepos) easily exceed Node's 1 MB default `maxBuffer`, which would otherwise + * cause `git branch -a` and `git fetch` to fail silently with no branches surfaced. + */ +export const MAX_REF_LIST_BYTES = 64 * 1024 * 1024; + /** Headers emitted by `git diff` that should be skipped when parsing hunks. */ const DIFF_HEADER_PREFIXES = [ 'diff ', From 3c24fe8b4db7e092b9b171bee7370b09cf448013 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 16:19:15 +0200 Subject: [PATCH 070/263] fix(utils): read APPDATA case-insensitively --- src/main/utils/userEnv.test.ts | 16 +++++++++++++++- src/main/utils/userEnv.ts | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/utils/userEnv.test.ts b/src/main/utils/userEnv.test.ts index 4bdb3004e3..7c0549d83b 100644 --- a/src/main/utils/userEnv.test.ts +++ b/src/main/utils/userEnv.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { ensureUserBinDirsInPath } from './userEnv'; +import { ensureUserBinDirsInPath, ensureWindowsNpmGlobalBinInPath } from './userEnv'; const originalPath = process.env.PATH; @@ -31,3 +31,17 @@ describe('ensureUserBinDirsInPath', () => { expect(process.env.PATH).toBe([dir, '/usr/bin'].join(path.delimiter)); }); }); + +describe('ensureWindowsNpmGlobalBinInPath', () => { + it('uses APPDATA case-insensitively when prepending npm global bin', () => { + const env: NodeJS.ProcessEnv = { + appdata: 'C:\\Users\\test\\AppData\\Roaming', + Path: 'C:\\Windows\\System32', + }; + + const added = ensureWindowsNpmGlobalBinInPath(env); + + expect(added).toBe('C:\\Users\\test\\AppData\\Roaming\\npm'); + expect(env.Path).toBe('C:\\Users\\test\\AppData\\Roaming\\npm;C:\\Windows\\System32'); + }); +}); diff --git a/src/main/utils/userEnv.ts b/src/main/utils/userEnv.ts index 1c5732a481..ff4680bde5 100644 --- a/src/main/utils/userEnv.ts +++ b/src/main/utils/userEnv.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { log } from '@main/lib/logger'; -import { prependWindowsPathEntry } from './windows-env'; +import { getWindowsEnvValue, prependWindowsPathEntry } from './windows-env'; /** * Keys that must never be overwritten from the shell env capture. @@ -84,7 +84,7 @@ export function ensureUserBinDirsInPath(candidates: string[] = USER_BIN_DIRS): s export function ensureWindowsNpmGlobalBinInPath( env: NodeJS.ProcessEnv = process.env ): string | null { - const appData = env.APPDATA; + const appData = getWindowsEnvValue(env, 'APPDATA'); if (!appData) return null; const npmPath = path.win32.join(appData, 'npm'); From dc3135e11c9c913347ae8dea8483b12e55b58de2 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 16:19:43 +0200 Subject: [PATCH 071/263] fix(agent-hooks): memoize Windows notify script --- .../agent-hooks/agent-notify-command.test.ts | 16 ++++++++++++++++ .../core/agent-hooks/agent-notify-command.ts | 7 +++++++ 2 files changed, 23 insertions(+) create mode 100644 src/main/core/agent-hooks/agent-notify-command.test.ts diff --git a/src/main/core/agent-hooks/agent-notify-command.test.ts b/src/main/core/agent-hooks/agent-notify-command.test.ts new file mode 100644 index 0000000000..9fa71a9a2e --- /dev/null +++ b/src/main/core/agent-hooks/agent-notify-command.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { makeCodexNotifyCommand } from './agent-notify-command'; + +describe('makeCodexNotifyCommand', () => { + it('writes the Windows notify script only once per script path', () => { + const writeFile = vi.fn(); + const mkdir = vi.fn(); + const scriptPath = 'C:\\Temp\\emdash-codex-notify.ps1'; + + makeCodexNotifyCommand({ platform: 'win32', scriptPath, mkdir, writeFile }); + makeCodexNotifyCommand({ platform: 'win32', scriptPath, mkdir, writeFile }); + + expect(mkdir).toHaveBeenCalledTimes(1); + expect(writeFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/core/agent-hooks/agent-notify-command.ts b/src/main/core/agent-hooks/agent-notify-command.ts index 7fcdeafa1f..9b7bf700c1 100644 --- a/src/main/core/agent-hooks/agent-notify-command.ts +++ b/src/main/core/agent-hooks/agent-notify-command.ts @@ -10,6 +10,8 @@ export type CodexNotifyCommandOptions = { scriptPath?: string; }; +const ensuredWindowsCodexNotifyScriptPaths = new Set(); + export function makeClaudeHookCommand(eventType: string): string { return ( 'curl -sf -X POST ' + @@ -59,6 +61,10 @@ function windowsCodexNotifyScript(): string { function ensureWindowsCodexNotifyScript(options: CodexNotifyCommandOptions): string { const platform = options.platform ?? process.platform; const scriptPath = options.scriptPath ?? join(tmpdir(), 'emdash-codex-notify.ps1'); + if (ensuredWindowsCodexNotifyScriptPaths.has(scriptPath)) { + return scriptPath; + } + const scriptDir = platform === 'win32' ? win32.dirname(scriptPath) : dirname(scriptPath); const mkdir = options.mkdir ?? ((path: string) => mkdirSync(path, { recursive: true })); const writeFile = options.writeFile ?? writeFileSync; @@ -66,6 +72,7 @@ function ensureWindowsCodexNotifyScript(options: CodexNotifyCommandOptions): str try { mkdir(scriptDir); writeFile(scriptPath, windowsCodexNotifyScript()); + ensuredWindowsCodexNotifyScriptPaths.add(scriptPath); } catch (err) { log.warn('CodexNotifyCommand: failed to write Windows notify script', { path: scriptPath, From c2fcf2fc530e774fab0b7265667698126d49599a Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 16:24:43 +0200 Subject: [PATCH 072/263] feat: add optional password auth to byoi (for testing) --- src/main/core/projects/impl/local-project-provider.ts | 4 +++- src/main/core/projects/provision-output.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index 553720c0cd..dc8d4d4e2b 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -205,7 +205,9 @@ export async function createLocalProvider( host: output.host, port: output.port ?? 22, username: output.username ?? process.env['USER'], - agent: process.env['SSH_AUTH_SOCK'], + ...(output.password + ? { password: output.password } + : { agent: process.env['SSH_AUTH_SOCK'] }), }); events.emit(taskProvisionProgressChannel, { diff --git a/src/main/core/projects/provision-output.ts b/src/main/core/projects/provision-output.ts index 2437bdd77a..770b057bd2 100644 --- a/src/main/core/projects/provision-output.ts +++ b/src/main/core/projects/provision-output.ts @@ -2,11 +2,12 @@ import z from 'zod'; import { err, ok, type Result } from '@shared/result'; const provisionOutputSchema = z.object({ - id: z.string().optional(), + id: z.string(), host: z.string().min(1, 'Provisioner output must contain a non-empty "host" field').trim(), port: z.number().optional(), username: z.string().optional(), worktreePath: z.string().optional(), + password: z.string().optional(), }); export type ProvisionOutput = z.infer; From 3a03e8c325034bd1e2f8d1cf33f4c5fd1de09b8c Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 16:31:26 +0200 Subject: [PATCH 073/263] chore: create release 1.1.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c54ac98ab1..c4178facba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "emdash", - "version": "1.1.4", + "version": "1.1.5", "description": "A cross-platform Electron app that orchestrates multiple coding agents in parallel", "type": "module", "main": "./out/main/index.js", From cd81fab99dec4f0137d1d1eaf376491033119de0 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 17:53:34 +0200 Subject: [PATCH 074/263] fix: use correct update channel --- src/main/core/updates/update-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/core/updates/update-service.ts b/src/main/core/updates/update-service.ts index 9cc09ed093..586a4ac1c7 100644 --- a/src/main/core/updates/update-service.ts +++ b/src/main/core/updates/update-service.ts @@ -3,7 +3,6 @@ import _electronUpdater, { type UpdateInfo, type Logger as UpdaterLogger, } from 'electron-updater'; -import { UPDATE_CHANNEL } from '@shared/app-identity'; import { updateAvailableEvent, updateCheckingEvent, @@ -15,12 +14,15 @@ import { updateProgressEvent, } from '@shared/events/updateEvents'; import { resolveAppVersion } from '@main/core/app/utils'; +import { env } from '@main/lib/env'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { formatUpdaterError, sanitizeUpdaterLogArgs } from './utils'; const { autoUpdater } = _electronUpdater; +const UPDATE_CHANNEL = env.build.VITE_BUILD === 'canary' ? 'v1-canary' : 'v1-stable'; + const ALLOW_PRERELEASE = false; const ALLOW_DOWNGRADE = false; const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour From 79f8294652674d1d991084bcb9234720dc3d6012 Mon Sep 17 00:00:00 2001 From: Jona Schwarz Date: Tue, 28 Apr 2026 18:39:40 +0200 Subject: [PATCH 075/263] Fix canary runtime app identity --- src/main/core/updates/update-service.ts | 4 +--- src/shared/app-identity.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/core/updates/update-service.ts b/src/main/core/updates/update-service.ts index 586a4ac1c7..9cc09ed093 100644 --- a/src/main/core/updates/update-service.ts +++ b/src/main/core/updates/update-service.ts @@ -3,6 +3,7 @@ import _electronUpdater, { type UpdateInfo, type Logger as UpdaterLogger, } from 'electron-updater'; +import { UPDATE_CHANNEL } from '@shared/app-identity'; import { updateAvailableEvent, updateCheckingEvent, @@ -14,15 +15,12 @@ import { updateProgressEvent, } from '@shared/events/updateEvents'; import { resolveAppVersion } from '@main/core/app/utils'; -import { env } from '@main/lib/env'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { formatUpdaterError, sanitizeUpdaterLogArgs } from './utils'; const { autoUpdater } = _electronUpdater; -const UPDATE_CHANNEL = env.build.VITE_BUILD === 'canary' ? 'v1-canary' : 'v1-stable'; - const ALLOW_PRERELEASE = false; const ALLOW_DOWNGRADE = false; const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour diff --git a/src/shared/app-identity.ts b/src/shared/app-identity.ts index 6408a54fd4..999dd0247f 100644 --- a/src/shared/app-identity.ts +++ b/src/shared/app-identity.ts @@ -1,6 +1,10 @@ -export const APP_ID = 'com.emdash.stable'; -export const PRODUCT_NAME = 'Emdash'; -export const APP_NAME_LOWER = 'emdash'; -export const UPDATE_CHANNEL = 'v1-stable'; -export const ARTIFACT_PREFIX = 'emdash'; +type ImportMetaWithEnv = ImportMeta & { env?: { VITE_BUILD?: string } }; + +const isCanary = (import.meta as ImportMetaWithEnv).env?.VITE_BUILD === 'canary'; + +export const APP_ID = isCanary ? 'com.emdash.canary' : 'com.emdash.stable'; +export const PRODUCT_NAME = isCanary ? 'Emdash Canary' : 'Emdash'; +export const APP_NAME_LOWER = isCanary ? 'emdash-canary' : 'emdash'; +export const UPDATE_CHANNEL = isCanary ? 'v1-canary' : 'v1-stable'; +export const ARTIFACT_PREFIX = isCanary ? 'emdash-canary' : 'emdash'; export const R2_BASE_URL = 'https://releases.emdash.sh'; From dbdd035e201b33f7cc76b365f5d87bfb985ec7ee Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 19:31:25 +0200 Subject: [PATCH 076/263] feat: add byoi option to modal --- .../projects/impl/local-project-provider.ts | 229 +++--------------- .../projects/impl/ssh-project-provider.ts | 105 +++----- .../core/projects/provision-task-error.ts | 2 +- src/main/core/projects/task-builder.ts | 125 +++++++++- src/main/core/tasks/core.ts | 2 +- .../workspaces/byoi/provision-byoi-task.ts | 157 ++++++++++++ .../byoi}/provision-output.ts | 0 .../create-task-modal/create-task-modal.tsx | 21 +- src/shared/tasks.ts | 4 +- testing/byoi/README.md | 75 ++++++ {docker-ssh => testing/docker-ssh}/dockerfile | 0 .../docker-ssh}/entrypoint.sh | 0 12 files changed, 449 insertions(+), 271 deletions(-) create mode 100644 src/main/core/workspaces/byoi/provision-byoi-task.ts rename src/main/core/{projects => workspaces/byoi}/provision-output.ts (100%) create mode 100644 testing/byoi/README.md rename {docker-ssh => testing/docker-ssh}/dockerfile (100%) rename {docker-ssh => testing/docker-ssh}/entrypoint.sh (100%) diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index dc8d4d4e2b..e8658e62de 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; -import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; import { bareRefName } from '@shared/git-utils'; import type { LocalProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; @@ -17,22 +16,17 @@ import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import { localWorkspaceId, remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { localWorkspaceId } from '@main/core/workspaces/workspace-id'; import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import { quoteShellArg } from '@main/utils/shellEscape'; +import { provisionBYOITask } from '../../workspaces/byoi/provision-byoi-task'; import type { ProjectProvider, ProvisionResult, TaskProvider } from '../project-provider'; -import { parseProvisionOutput } from '../provision-output'; import { LocalProjectSettingsProvider } from '../settings/project-settings'; -import type { ProjectSettings } from '../settings/schema'; -import { buildTaskFromWorkspace } from '../task-builder'; +import { provisionLocalTask } from '../task-builder'; import { TaskProvisionManager } from '../task-provision-manager'; -import { createWorkspaceFactory } from '../workspace-factory'; -import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createLocalProvider( @@ -82,18 +76,24 @@ export async function createLocalProvider( ): Promise { log.debug('LocalProjectProvider: doProvisionTask START', { taskId: task.id }); - const projectSettings = await settings.get(); - const useSsh = - task.workspaceProvider != null - ? task.workspaceProvider === 'ssh' - : projectSettings.workspaceProvider?.type === 'script'; - if (useSsh && projectSettings.workspaceProvider?.type === 'script') { - return doProvisionRemoteTask( + if (task.workspaceProvider === 'byoi') { + const projectSettings = await settings.get(); + if (projectSettings.workspaceProvider?.type !== 'script') { + throw new Error( + 'Task has workspaceProvider=byoi but project has no script provider configured' + ); + } + return provisionBYOITask({ task, conversations, terminals, - projectSettings.workspaceProvider - ); + wpConfig: projectSettings.workspaceProvider, + execFn: localExec, + projectId: project.id, + projectPath: project.path, + settings, + logPrefix: 'LocalProjectProvider[byoi]', + }); } void gitFetchService.fetch(); @@ -101,188 +101,26 @@ export async function createLocalProvider( const workspaceId = localWorkspaceId(project.id, task.taskBranch); - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'resolving-worktree', - message: 'Resolving worktree…', - }); - const workDir = await resolveTaskWorkDir(task, project.path, worktreeService); - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'initialising-workspace', - message: 'Initialising workspace…', - }); - const workspace = await workspaceRegistry.acquire( + const { provisionResult, workspace } = await provisionLocalTask({ + task, + conversations, + terminals, workspaceId, - project.id, - createWorkspaceFactory( - workspaceId, - { kind: 'local' }, - { - task, - workDir, - projectId: project.id, - projectPath: project.path, - settings, - logPrefix: 'LocalProjectProvider', - repository, - fetchService: gitFetchService, - extraHooks: { - onCreate: async (ws) => { - const mainDotGitAbs = path.resolve(project.path, '.git'); - const relativeGitDir = await ws.git.getWorktreeGitDir(mainDotGitAbs); - gitWatcher.registerWorktree(workspaceId, relativeGitDir); - }, - onDestroy: async () => gitWatcher.unregisterWorktree(workspaceId), - }, - } - ) - ); - - let provisionSucceeded = false; - try { - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'starting-sessions', - message: 'Starting sessions…', - }); - const { taskProvider } = await buildTaskFromWorkspace( - task, - workspace, - { kind: 'local' }, - project.id, - project.path, - settings, - { conversations, terminals }, - 'LocalProjectProvider' - ); - log.debug('LocalProjectProvider: doProvisionTask DONE', { taskId: task.id }); - provisionSucceeded = true; - return { taskProvider, persistData: { workspaceId: workspace.id } }; - } finally { - if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); - } - } - } - - async function doProvisionRemoteTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[], - wpConfig: NonNullable - ): Promise { - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'running-provision-script', - message: 'Running provision script…', - }); - - const { stdout } = await localExec('/bin/sh', ['-c', wpConfig.provisionCommand], { - cwd: project.path, - }); - - const parseResult = parseProvisionOutput(stdout); - if (!parseResult.success) { - throw new Error(parseResult.error.message); - } - const output = parseResult.data; - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'connecting', - message: `Connecting to ${output.host}…`, - }); - - const connectionId = `task:${task.id}`; - const proxy = await sshConnectionManager.connectFromConfig(connectionId, { - host: output.host, - port: output.port ?? 22, - username: output.username ?? process.env['USER'], - ...(output.password - ? { password: output.password } - : { agent: process.env['SSH_AUTH_SOCK'] }), - }); - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, + type: { kind: 'local' }, projectId: project.id, - step: 'setting-up-workspace', - message: 'Setting up workspace…', + projectPath: project.path, + settings, + worktreeService, + fetchService: gitFetchService, + repository, + logPrefix: 'LocalProjectProvider', }); - const workDir = output.worktreePath ?? project.path; - const workspaceId = remoteTaskWorkspaceId(output.id ?? task.id); - - const workspace = await workspaceRegistry.acquire( - workspaceId, - project.id, - createWorkspaceFactory( - workspaceId, - { kind: 'ssh', proxy }, - { - task, - workDir, - projectId: project.id, - projectPath: project.path, - settings, - logPrefix: 'LocalProjectProvider[remote]', - extraHooks: { - onDestroy: async () => { - const cmd = output.id - ? `REMOTE_WORKSPACE_ID=${quoteShellArg(output.id)} ${wpConfig.terminateCommand}` - : wpConfig.terminateCommand; - await localExec('/bin/sh', ['-c', cmd], { cwd: project.path }).catch((e) => { - log.warn('LocalProjectProvider: terminate command failed', { error: String(e) }); - }); - await sshConnectionManager.disconnect(connectionId); - }, - onDetach: async () => { - await sshConnectionManager.disconnect(connectionId); - }, - }, - } - ) - ); + const mainDotGitAbs = path.resolve(project.path, '.git'); + const relativeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); + gitWatcher.registerWorktree(workspaceId, relativeGitDir); - let provisionSucceeded = false; - try { - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'starting-sessions', - message: 'Starting sessions…', - }); - const { taskProvider } = await buildTaskFromWorkspace( - task, - workspace, - { kind: 'ssh', proxy }, - project.id, - project.path, - settings, - { conversations, terminals }, - 'LocalProjectProvider[remote]' - ); - log.debug('LocalProjectProvider: doProvisionRemoteTask DONE', { taskId: task.id }); - provisionSucceeded = true; - return { - taskProvider, - persistData: { - workspaceId: workspace.id, - workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, - }, - }; - } finally { - if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); - } - } + return provisionResult; } async function doTeardownTask( @@ -298,6 +136,7 @@ export async function createLocalProvider( await task.terminals.destroyAll(); } await workspaceRegistry.release(workspaceId, mode); + gitWatcher.unregisterWorktree(workspaceId); } async function cleanupDetachedTmuxSessions(taskId: string): Promise { diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 6f67a5066b..bb4540a7b1 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -1,6 +1,5 @@ import path from 'node:path'; import type { Conversation } from '@shared/conversations'; -import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; import { bareRefName } from '@shared/git-utils'; import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; @@ -25,14 +24,12 @@ import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; import { sshWorkspaceId } from '@main/core/workspaces/workspace-id'; import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; -import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; +import { provisionBYOITask } from '../../workspaces/byoi/provision-byoi-task'; import { type ProjectProvider, type ProvisionResult, type TaskProvider } from '../project-provider'; import { SshProjectSettingsProvider } from '../settings/project-settings'; -import { buildTaskFromWorkspace } from '../task-builder'; +import { provisionLocalTask } from '../task-builder'; import { TaskProvisionManager } from '../task-provision-manager'; -import { createWorkspaceFactory } from '../workspace-factory'; -import { resolveTaskWorkDir } from '../worktrees/utils'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createSshProvider( @@ -80,74 +77,52 @@ export async function createSshProvider( ): Promise { log.debug('SshProjectProvider: doProvisionTask START', { taskId: task.id }); + if (task.workspaceProvider === 'byoi') { + const projectSettings = await settings.get(); + if (projectSettings.workspaceProvider?.type !== 'script') { + throw new Error( + 'Task has workspaceProvider=byoi but project has no script provider configured' + ); + } + return provisionBYOITask({ + task, + conversations, + terminals, + wpConfig: projectSettings.workspaceProvider, + execFn: getSshExec(proxy), + projectId: project.id, + projectPath: project.path, + settings, + logPrefix: 'SshProjectProvider[byoi]', + }); + } + void gitFetchService.fetch(); void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - const workspaceId = sshWorkspaceId(project.id, task.taskBranch); - - events.emit(taskProvisionProgressChannel, { - taskId: task.id, + const { provisionResult, buildTaskResult } = await provisionLocalTask({ + task, + conversations, + terminals, + workspaceId: sshWorkspaceId(project.id, task.taskBranch), + type: { kind: 'ssh', proxy }, projectId: project.id, - step: 'resolving-worktree', - message: 'Resolving worktree…', + projectPath: project.path, + settings, + worktreeService, + fetchService: gitFetchService, + repository, + logPrefix: 'SshProjectProvider', }); - const workDir = await resolveTaskWorkDir(task, project.path, worktreeService); - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'initialising-workspace', - message: 'Initialising workspace…', - }); - const workspace = await workspaceRegistry.acquire( - workspaceId, - project.id, - createWorkspaceFactory( - workspaceId, - { kind: 'ssh', proxy }, - { - task, - workDir, - projectId: project.id, - projectPath: project.path, - settings, - logPrefix: 'SshProjectProvider', - repository, - fetchService: gitFetchService, - } - ) + terminalProviders.set(task.id, buildTaskResult.terminalProvider as SshTerminalProvider); + conversationProviders.set( + task.id, + buildTaskResult.conversationProvider as SshConversationProvider ); + log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); - let provisionSucceeded = false; - try { - events.emit(taskProvisionProgressChannel, { - taskId: task.id, - projectId: project.id, - step: 'starting-sessions', - message: 'Starting sessions…', - }); - const { taskProvider, conversationProvider, terminalProvider } = - await buildTaskFromWorkspace( - task, - workspace, - { kind: 'ssh', proxy }, - project.id, - project.path, - settings, - { conversations, terminals }, - 'SshProjectProvider' - ); - - terminalProviders.set(task.id, terminalProvider as SshTerminalProvider); - conversationProviders.set(task.id, conversationProvider as SshConversationProvider); - log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); - provisionSucceeded = true; - return { taskProvider, persistData: { workspaceId: workspace.id } }; - } finally { - if (!provisionSucceeded) { - await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); - } - } + return provisionResult; } async function doTeardownTask( diff --git a/src/main/core/projects/provision-task-error.ts b/src/main/core/projects/provision-task-error.ts index 7af73e56a4..1a379d282e 100644 --- a/src/main/core/projects/provision-task-error.ts +++ b/src/main/core/projects/provision-task-error.ts @@ -2,7 +2,7 @@ import type { ProvisionTaskError, TeardownTaskError } from './project-provider'; import { TimeoutSignal } from './utils'; import type { ServeWorktreeError } from './worktrees/worktree-service'; -export const TASK_TIMEOUT_MS = 60_000; +export const TASK_TIMEOUT_MS = 600000; export const TEARDOWN_SCRIPT_WAIT_MS = 10_000; export function toProvisionError(e: unknown): ProvisionTaskError { diff --git a/src/main/core/projects/task-builder.ts b/src/main/core/projects/task-builder.ts index bbbcacb81a..0388d466b2 100644 --- a/src/main/core/projects/task-builder.ts +++ b/src/main/core/projects/task-builder.ts @@ -1,13 +1,25 @@ import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import type { ConversationProvider } from '@main/core/conversations/types'; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; import type { Workspace } from '@main/core/workspaces/workspace'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import type { TaskProvider } from './project-provider'; +import type { ProvisionResult, TaskProvider } from './project-provider'; import type { ProjectSettingsProvider } from './settings/schema'; -import { buildTaskProviders, resolveTaskEnv, type WorkspaceType } from './workspace-factory'; +import { + buildTaskProviders, + createWorkspaceFactory, + resolveTaskEnv, + type WorkspaceType, +} from './workspace-factory'; +import { resolveTaskWorkDir } from './worktrees/utils'; +import type { WorktreeService } from './worktrees/worktree-service'; export type BuildTaskResult = { taskProvider: TaskProvider; @@ -15,6 +27,115 @@ export type BuildTaskResult = { terminalProvider: TerminalProvider; }; +export type ProvisionLocalTaskParams = { + task: Task; + conversations: Conversation[]; + terminals: Terminal[]; + workspaceId: string; + type: WorkspaceType; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + worktreeService: WorktreeService; + fetchService: GitFetchService; + repository: GitRepositoryService; + logPrefix: string; +}; + +export type ProvisionLocalTaskResult = { + provisionResult: ProvisionResult; + workspace: Workspace; + buildTaskResult: BuildTaskResult; +}; + +/** + * Shared provision scaffolding for tasks whose workspace lives local to the + * repository — either a worktree alongside the repo or the project root itself. + * Works for both local and SSH transports (transport is encoded in `type`). + * + * Returns workspace and buildTaskResult so callers can perform their own + * post-provision setup (e.g. git watcher registration, reconnect map population) + * without lifecycle hook callbacks. + */ +export async function provisionLocalTask( + params: ProvisionLocalTaskParams +): Promise { + const { + task, + conversations, + terminals, + workspaceId, + type, + projectId, + projectPath, + settings, + worktreeService, + fetchService, + repository, + logPrefix, + } = params; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'resolving-worktree', + message: 'Resolving worktree…', + }); + const workDir = await resolveTaskWorkDir(task, projectPath, worktreeService); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'initialising-workspace', + message: 'Initialising workspace…', + }); + const workspace = await workspaceRegistry.acquire( + workspaceId, + projectId, + createWorkspaceFactory(workspaceId, type, { + task, + workDir, + projectId, + projectPath, + settings, + logPrefix, + repository, + fetchService, + }) + ); + + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const buildTaskResult = await buildTaskFromWorkspace( + task, + workspace, + type, + projectId, + projectPath, + settings, + { conversations, terminals }, + logPrefix + ); + log.debug(`${logPrefix}: provisionLocalTask DONE`, { taskId: task.id }); + provisionSucceeded = true; + return { + provisionResult: { taskProvider: buildTaskResult.taskProvider, persistData: { workspaceId } }, + workspace, + buildTaskResult, + }; + } finally { + if (!provisionSucceeded) { + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); + } + } +} + /** * Shared tail of doProvisionTask — builds and hydrates a TaskProvider from * an already-acquired workspace. Works for both local and SSH transports. diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/core.ts index 84c50fcf6e..8ff862c7df 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/core.ts @@ -25,7 +25,7 @@ export function mapTaskRowToTask( updatedAt: row.updatedAt, statusChangedAt: row.statusChangedAt, isPinned: row.isPinned === 1, - workspaceProvider: (row.workspaceProvider as 'local' | 'ssh') ?? undefined, + workspaceProvider: (row.workspaceProvider as 'byoi') ?? undefined, workspaceId: row.workspaceId ?? undefined, workspaceProviderData: row.workspaceProviderData ?? undefined, }; diff --git a/src/main/core/workspaces/byoi/provision-byoi-task.ts b/src/main/core/workspaces/byoi/provision-byoi-task.ts new file mode 100644 index 0000000000..4326e8e505 --- /dev/null +++ b/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -0,0 +1,157 @@ +import type { Conversation } from '@shared/conversations'; +import { taskProvisionProgressChannel } from '@shared/events/taskEvents'; +import type { Task } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import type { ProvisionResult } from '@main/core/projects/project-provider'; +import type { ProjectSettings, ProjectSettingsProvider } from '@main/core/projects/settings/schema'; +import { buildTaskFromWorkspace } from '@main/core/projects/task-builder'; +import { createWorkspaceFactory } from '@main/core/projects/workspace-factory'; +import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import type { ExecFn } from '@main/core/utils/exec'; +import { parseProvisionOutput } from '@main/core/workspaces/byoi/provision-output'; +import { remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; + +export type ProvisionBYOITaskParams = { + task: Task; + conversations: Conversation[]; + terminals: Terminal[]; + /** Workspace provider config read from project settings (`workspaceProvider.type === 'script'`). */ + wpConfig: NonNullable; + /** Exec function for running provision/terminate scripts. Use `getLocalExec()` for local + * projects and `getSshExec(proxy)` for SSH projects so scripts run on the right host. */ + execFn: ExecFn; + projectId: string; + projectPath: string; + settings: ProjectSettingsProvider; + logPrefix: string; +}; + +/** + * Runs the BYOI script-run → SSH-connect → workspace-acquire → build flow. + * Parameterised by `execFn` so both local and SSH project providers can use it: + * - Local project: pass `getLocalExec()` (scripts run on the local machine) + * - SSH project: pass `getSshExec(proxy)` (scripts run on the remote SSH host) + */ +export async function provisionBYOITask(params: ProvisionBYOITaskParams): Promise { + const { + task, + conversations, + terminals, + wpConfig, + execFn, + projectId, + projectPath, + settings, + logPrefix, + } = params; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'running-provision-script', + message: 'Running provision script…', + }); + + const { stdout } = await execFn('/bin/sh', ['-c', wpConfig.provisionCommand], { + cwd: projectPath, + }); + + const parseResult = parseProvisionOutput(stdout); + if (!parseResult.success) { + throw new Error(parseResult.error.message); + } + const output = parseResult.data; + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'connecting', + message: `Connecting to ${output.host}…`, + }); + + const connectionId = `task:${task.id}`; + const proxy = await sshConnectionManager.connectFromConfig(connectionId, { + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env['USER'], + ...(output.password ? { password: output.password } : { agent: process.env['SSH_AUTH_SOCK'] }), + }); + + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'setting-up-workspace', + message: 'Setting up workspace…', + }); + + const workDir = output.worktreePath ?? projectPath; + const workspaceId = remoteTaskWorkspaceId(output.id ?? task.id); + + const workspace = await workspaceRegistry.acquire( + workspaceId, + projectId, + createWorkspaceFactory( + workspaceId, + { kind: 'ssh', proxy }, + { + task, + workDir, + projectId, + projectPath, + settings, + logPrefix, + extraHooks: { + onDestroy: async () => { + const cmd = output.id + ? `REMOTE_WORKSPACE_ID=${quoteShellArg(output.id)} ${wpConfig.terminateCommand}` + : wpConfig.terminateCommand; + await execFn('/bin/sh', ['-c', cmd], { cwd: projectPath }).catch((e) => { + log.warn(`${logPrefix}: terminate command failed`, { error: String(e) }); + }); + await sshConnectionManager.disconnect(connectionId); + }, + onDetach: async () => { + await sshConnectionManager.disconnect(connectionId); + }, + }, + } + ) + ); + + let provisionSucceeded = false; + try { + events.emit(taskProvisionProgressChannel, { + taskId: task.id, + projectId, + step: 'starting-sessions', + message: 'Starting sessions…', + }); + const { taskProvider } = await buildTaskFromWorkspace( + task, + workspace, + { kind: 'ssh', proxy }, + projectId, + projectPath, + settings, + { conversations, terminals }, + logPrefix + ); + log.debug(`${logPrefix}: provisionBYOITask DONE`, { taskId: task.id }); + provisionSucceeded = true; + return { + taskProvider, + persistData: { + workspaceId: workspace.id, + workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, + }, + }; + } finally { + if (!provisionSucceeded) { + await workspaceRegistry.release(workspace.id, 'terminate').catch(() => {}); + } + } +} diff --git a/src/main/core/projects/provision-output.ts b/src/main/core/workspaces/byoi/provision-output.ts similarity index 100% rename from src/main/core/projects/provision-output.ts rename to src/main/core/workspaces/byoi/provision-output.ts diff --git a/src/renderer/features/tasks/create-task-modal/create-task-modal.tsx b/src/renderer/features/tasks/create-task-modal/create-task-modal.tsx index da20962013..cc4f3e4c70 100644 --- a/src/renderer/features/tasks/create-task-modal/create-task-modal.tsx +++ b/src/renderer/features/tasks/create-task-modal/create-task-modal.tsx @@ -1,6 +1,6 @@ import { ChevronRight, FolderOpen } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getPrNumber, isForkPr, type PullRequest } from '@shared/pull-requests'; import { getProjectManagerStore, @@ -9,7 +9,7 @@ import { } from '@renderer/features/projects/stores/project-selectors'; import { ProjectSelector } from '@renderer/features/tasks/create-task-modal/project-selector'; import { useNavigate } from '@renderer/lib/layout/navigation-provider'; -import { BaseModalProps } from '@renderer/lib/modal/modal-provider'; +import { type BaseModalProps } from '@renderer/lib/modal/modal-provider'; import { appState } from '@renderer/lib/stores/app-state'; import { AnimatedHeight } from '@renderer/lib/ui/animated-height'; import { ComboboxTrigger, ComboboxValue } from '@renderer/lib/ui/combobox'; @@ -20,6 +20,7 @@ import { DialogHeader, DialogTitle, } from '@renderer/lib/ui/dialog'; +import { Switch } from '@renderer/lib/ui/switch'; import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group'; import { resolveBranchLikeTaskStrategy, @@ -62,6 +63,8 @@ export const CreateTaskModal = observer(function CreateTaskModal({ }); const [selectedStrategy, setSelectedStrategy] = useState(strategy); const [isTransitioning, setIsTransitioning] = useState(false); + const [useBYOI, setUseBYOI] = useState(false); + useEffect(() => setUseBYOI(false), [selectedProjectId]); const repo = selectedProjectId ? getRepositoryStore(selectedProjectId) : undefined; const defaultBranch = repo?.defaultBranch; const isUnborn = repo?.isUnborn ?? false; @@ -107,7 +110,8 @@ export const CreateTaskModal = observer(function CreateTaskModal({ projectId: selectedProjectId, name: fromBranch.taskName, sourceBranch: fromBranch.selectedBranch, - strategy: taskStrategy, + strategy: useBYOI ? { kind: 'no-worktree' } : taskStrategy, + workspaceProvider: useBYOI ? 'byoi' : undefined, }); break; } @@ -124,8 +128,9 @@ export const CreateTaskModal = observer(function CreateTaskModal({ projectId: selectedProjectId, name: fromIssue.taskName, sourceBranch: fromIssue.selectedBranch, - strategy: taskStrategy, + strategy: useBYOI ? { kind: 'no-worktree' } : taskStrategy, linkedIssue: fromIssue.linkedIssue ?? undefined, + workspaceProvider: useBYOI ? 'byoi' : undefined, }); break; } @@ -148,7 +153,8 @@ export const CreateTaskModal = observer(function CreateTaskModal({ sourceBranch: { type: 'local', branch: reviewBranch }, initialStatus: fromPR.linkedPR.status === 'open' && !fromPR.linkedPR.isDraft ? 'review' : undefined, - strategy: taskStrategy, + strategy: useBYOI ? { kind: 'no-worktree' } : taskStrategy, + workspaceProvider: useBYOI ? 'byoi' : undefined, }); break; } @@ -163,6 +169,7 @@ export const CreateTaskModal = observer(function CreateTaskModal({ fromIssue, fromPR, isUnborn, + useBYOI, navigate, onClose, ]); @@ -203,6 +210,10 @@ export const CreateTaskModal = observer(function CreateTaskModal({ From Pull Request +
+ + Use BYOI infrastructure +
{selectedStrategy === 'from-branch' && ( ; - workspaceProvider?: 'local' | 'ssh'; + workspaceProvider?: 'byoi'; workspaceId?: string; workspaceProviderData?: string; // JSON, BYOI only }; @@ -74,7 +74,7 @@ export type CreateTaskParams = { /** */ initialConversation?: CreateConversationParams; initialStatus?: TaskLifecycleStatus; - workspaceProvider?: 'local' | 'ssh'; + workspaceProvider?: 'byoi'; }; export type CreateTaskError = diff --git a/testing/byoi/README.md b/testing/byoi/README.md new file mode 100644 index 0000000000..5011d96a8b --- /dev/null +++ b/testing/byoi/README.md @@ -0,0 +1,75 @@ +# Emdash BYOI Testing Kit + +A copy-pasteable setup for testing emdash's BYOI (Bring Your Own Infrastructure) feature. +Each task gets its own Docker container running a full Linux dev environment with Node.js, git, tmux, and Claude Code pre-installed. + +## How it works + +1. When you create a task, emdash runs `provision.sh` +2. The script builds a Docker image (first run only), starts a new container, clones your repo into it, and prints a JSON blob +3. Emdash SSH-connects to the container using password auth and opens the workspace at `/home/devuser/workspace` +4. When you terminate the task, emdash runs `terminate.sh` which stops and removes the container + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) running on your machine +- `jq` for JSON output — install with `brew install jq` on macOS + +## Setup + +Copy these files into the root of the repo you want to test with: + +``` +Dockerfile +entrypoint.sh +scripts/ + provision.sh + terminate.sh +emdash.json.example → rename to .emdash.json +``` + +Make the scripts executable: + +```bash +chmod +x scripts/provision.sh scripts/terminate.sh +``` + +Add the project in emdash, then create a task. That's it. + +## First provision + +The first provision builds the Docker image (~2-3 minutes, one-time). Subsequent provisions start a new container and clone the repo (~10-20 seconds). + +## Forwarding API keys + +The provision script forwards API keys from your host environment into the container automatically. Just make sure the relevant variables are set in your shell before emdash runs the provision script: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +export GH_TOKEN=ghp_... +``` + +To add more keys, edit the `docker run` call in `scripts/provision.sh` and the `AGENT_VARS` list in `entrypoint.sh`. + +## Container credentials + +- **Username:** `devuser` +- **Password:** `devpass` +- **Workspace path:** `/home/devuser/workspace` + +Change the password in `Dockerfile` (`devuser:devpass`) and `scripts/provision.sh` (`CONTAINER_PASS`) if needed. + +## Cleanup + +Containers are removed automatically when a task is terminated. To manually clean up any leftover containers: + +```bash +docker ps --filter label=emdash.purpose=byoi-workspace +docker rm -f $(docker ps -aq --filter label=emdash.purpose=byoi-workspace) +``` + +To remove the cached Docker image and force a rebuild on the next provision: + +```bash +docker rmi emdash-byoi-workspace +``` diff --git a/docker-ssh/dockerfile b/testing/docker-ssh/dockerfile similarity index 100% rename from docker-ssh/dockerfile rename to testing/docker-ssh/dockerfile diff --git a/docker-ssh/entrypoint.sh b/testing/docker-ssh/entrypoint.sh similarity index 100% rename from docker-ssh/entrypoint.sh rename to testing/docker-ssh/entrypoint.sh From f4431f7ccb2ef5497f4880f535224888f085262d Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 19:35:01 +0200 Subject: [PATCH 077/263] fix: ssh fs not showing root path --- src/main/core/fs/impl/ssh-fs.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/core/fs/impl/ssh-fs.ts b/src/main/core/fs/impl/ssh-fs.ts index 997771fb29..6729bc562d 100644 --- a/src/main/core/fs/impl/ssh-fs.ts +++ b/src/main/core/fs/impl/ssh-fs.ts @@ -819,7 +819,7 @@ export class SshFileSystem implements FileSystemProvider { // Handle absolute paths (should not escape base) if (normalized.startsWith('/')) { - const resolved = normalized; + const resolved = this.normalizePosixPath(normalized); // Security: ensure resolved path is within remotePath base if (!this.isWithinBase(resolved)) { throw new FileSystemError( @@ -831,8 +831,9 @@ export class SshFileSystem implements FileSystemProvider { return resolved; } - // Join with base path - const fullPath = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); + // Join with base path and normalize away any '.' segments (e.g. when relPath is '.') + const joined = `${this.remotePath}/${normalized}`.replace(/\/+/g, '/'); + const fullPath = this.normalizePosixPath(joined); // Security: ensure path is within basePath if (!this.isWithinBase(fullPath)) { @@ -846,6 +847,18 @@ export class SshFileSystem implements FileSystemProvider { return fullPath; } + /** Remove single-dot segments from a POSIX path (e.g. /a/./b → /a/b). */ + private normalizePosixPath(p: string): string { + const parts = p.split('/'); + const out: string[] = []; + for (const seg of parts) { + if (seg === '.') continue; + out.push(seg); + } + // Re-join and collapse any double slashes introduced by the filter + return out.join('/').replace(/\/+/g, '/') || '/'; + } + /** * Check if a path is within the base directory */ From 5076067869ec743e9125e635e24a098000e3dfec Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 20:01:20 +0200 Subject: [PATCH 078/263] feat: add task connection status indicator and reconnect --- .../projects/impl/ssh-project-provider.ts | 5 ++++- src/main/core/projects/project-provider.ts | 1 + src/main/core/tasks/provisionTask.ts | 7 ++++++- .../workspaces/byoi/provision-byoi-task.ts | 1 + .../features/sidebar/project-item.tsx | 17 +++------------- src/renderer/features/sidebar/task-item.tsx | 13 ++++++++++-- .../tasks/components/task-context-menu.tsx | 8 ++++++++ .../features/tasks/stores/task-manager.ts | 3 ++- src/renderer/features/tasks/stores/task.ts | 12 +++++++---- .../tasks/stores/workspace-registry.ts | 12 +++++++++-- .../features/tasks/stores/workspace.ts | 20 +++++++++++++++++-- src/renderer/features/tasks/task-titlebar.tsx | 8 ++++++-- .../lib/components/connection-status-dot.tsx | 17 ++++++++++++++++ 13 files changed, 95 insertions(+), 29 deletions(-) create mode 100644 src/renderer/lib/components/connection-status-dot.tsx diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index bb4540a7b1..8622588e2d 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -122,7 +122,10 @@ export async function createSshProvider( ); log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); - return provisionResult; + return { + ...provisionResult, + persistData: { ...provisionResult.persistData, sshConnectionId: project.connectionId }, + }; } async function doTeardownTask( diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 0e2cf44dd7..bf5257cd8f 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -29,6 +29,7 @@ export type ProvisionResult = { persistData: { workspaceId: string; workspaceProviderData?: WorkspaceProviderData; + sshConnectionId?: string; }; }; diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index b398395b64..e6d47384fb 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -21,7 +21,11 @@ export async function provisionTask(taskId: string) { if (existingTask) { const wsId = project.tasks.getWorkspaceId(taskId) ?? ''; - return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId }; + return { + path: workspaceRegistry.get(wsId)?.path ?? '', + workspaceId: wsId, + sshConnectionId: undefined, + }; } const [existingTerminals, existingConversations] = await Promise.all([ @@ -62,5 +66,6 @@ export async function provisionTask(taskId: string) { return { path: workspaceRegistry.get(persistData.workspaceId)?.path ?? '', workspaceId: persistData.workspaceId, + sshConnectionId: persistData.sshConnectionId, }; } diff --git a/src/main/core/workspaces/byoi/provision-byoi-task.ts b/src/main/core/workspaces/byoi/provision-byoi-task.ts index 4326e8e505..6e05922fba 100644 --- a/src/main/core/workspaces/byoi/provision-byoi-task.ts +++ b/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -147,6 +147,7 @@ export async function provisionBYOITask(params: ProvisionBYOITaskParams): Promis persistData: { workspaceId: workspace.id, workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, + sshConnectionId: connectionId, }, }; } finally { diff --git a/src/renderer/features/sidebar/project-item.tsx b/src/renderer/features/sidebar/project-item.tsx index e3b6259c01..509591ba11 100644 --- a/src/renderer/features/sidebar/project-item.tsx +++ b/src/renderer/features/sidebar/project-item.tsx @@ -13,7 +13,7 @@ import { observer } from 'mobx-react-lite'; import React, { useCallback, useEffect } from 'react'; import { isUnregisteredProject, - UnregisteredProject, + type UnregisteredProject, } from '@renderer/features/projects/stores/project'; import { getProjectManagerStore, @@ -21,6 +21,7 @@ import { getRepositoryStore, projectViewKind, } from '@renderer/features/projects/stores/project-selectors'; +import { ConnectionStatusDot } from '@renderer/lib/components/connection-status-dot'; import { useNavigate, useParams, @@ -92,14 +93,6 @@ export const SidebarProjectItem = observer(function SidebarProjectItem({ : null; const canReconnect = sshConnectionState !== 'connected'; const ProjectIcon = isSshProject ? FolderInput : FolderClosed; - const isReconnecting = - sshConnectionState === 'connecting' || sshConnectionState === 'reconnecting'; - const sshStateDotClass = - sshConnectionState === 'connected' - ? 'bg-emerald-500' - : isReconnecting - ? 'bg-blue-500' - : 'bg-red-500'; const renderSpinnerWithTooltip = () => { if (!isUnregisteredProject(project)) return null; @@ -157,11 +150,7 @@ export const SidebarProjectItem = observer(function SidebarProjectItem({ {isSshProject ? ( {project.name} - + ) : ( diff --git a/src/renderer/features/sidebar/task-item.tsx b/src/renderer/features/sidebar/task-item.tsx index f662085a91..9b00a50cba 100644 --- a/src/renderer/features/sidebar/task-item.tsx +++ b/src/renderer/features/sidebar/task-item.tsx @@ -3,8 +3,12 @@ import { selectCurrentPr } from '@shared/pull-requests'; import { TaskSidebarAgentStatus } from '@renderer/features/sidebar/task-sidebar-agent-status'; import { TaskContextMenu } from '@renderer/features/tasks/components/task-context-menu'; import { TaskGitDiffStats } from '@renderer/features/tasks/components/task-git-diff-stats'; -import { TaskStore } from '@renderer/features/tasks/stores/task'; -import { getTaskManagerStore, getTaskStore } from '@renderer/features/tasks/stores/task-selectors'; +import { type TaskStore } from '@renderer/features/tasks/stores/task'; +import { + asProvisioned, + getTaskManagerStore, + getTaskStore, +} from '@renderer/features/tasks/stores/task-selectors'; import { useNavigate, useParams, @@ -71,6 +75,10 @@ export const SidebarTaskItem = observer(function SidebarTaskItem({ const canPin = task.state !== 'unregistered'; + const workspace = asProvisioned(task)?.workspace; + const handleReconnect = + workspace?.connectionState != null ? () => workspace.reconnect() : undefined; + return ( void task.setPinned(false)} onRename={handleRename} onArchive={handleArchive} + onReconnect={handleReconnect} onDelete={handleDelete} > void; onArchive: () => void; onRestore?: () => void; + onReconnect?: () => void; onDelete: () => void; } @@ -31,6 +32,7 @@ export function TaskContextMenu({ onRename, onArchive, onRestore, + onReconnect, onDelete, }: TaskContextMenuProps) { return ( @@ -53,6 +55,12 @@ export function TaskContextMenu({ Rename + {onReconnect && ( + + + Reconnect + + )} {!isArchived && ( diff --git a/src/renderer/features/tasks/stores/task-manager.ts b/src/renderer/features/tasks/stores/task-manager.ts index bf478c3686..481b469c8a 100644 --- a/src/renderer/features/tasks/stores/task-manager.ts +++ b/src/renderer/features/tasks/stores/task-manager.ts @@ -284,7 +284,8 @@ export class TaskManagerStore { result.workspaceId, this._settingsStore, this._baseRef, - savedSnapshot as TaskViewSnapshot | undefined + savedSnapshot as TaskViewSnapshot | undefined, + result.sshConnectionId ?? undefined ); current.activate(); } diff --git a/src/renderer/features/tasks/stores/task.ts b/src/renderer/features/tasks/stores/task.ts index b65bc2809b..659d0af085 100644 --- a/src/renderer/features/tasks/stores/task.ts +++ b/src/renderer/features/tasks/stores/task.ts @@ -63,7 +63,8 @@ export class ProvisionedTask { workspaceId: string, settingsStore: ProjectSettingsStore, baseRef: string, - savedSnapshot?: TaskViewSnapshot + savedSnapshot?: TaskViewSnapshot, + sshConnectionId?: string ) { this._taskStore = taskStore; const taskData = taskStore.data as Task; @@ -76,7 +77,8 @@ export class ProvisionedTask { this.workspaceId, taskStore, settingsStore, - baseRef + baseRef, + sshConnectionId ); this.repositoryStore = this.workspace.repository; this.devServers = new DevServerStore(taskData.id, this.workspaceId); @@ -168,7 +170,8 @@ export class TaskStore { workspaceId: string, settingsStore: ProjectSettingsStore, baseRef: string, - savedSnapshot?: TaskViewSnapshot + savedSnapshot?: TaskViewSnapshot, + sshConnectionId?: string ): void { this.data = data; this.provisionedTask = new ProvisionedTask( @@ -177,7 +180,8 @@ export class TaskStore { workspaceId, settingsStore, baseRef, - savedSnapshot + savedSnapshot, + sshConnectionId ); this.state = 'provisioned'; this.phase = null; diff --git a/src/renderer/features/tasks/stores/workspace-registry.ts b/src/renderer/features/tasks/stores/workspace-registry.ts index d188b389cd..ab183d1c2f 100644 --- a/src/renderer/features/tasks/stores/workspace-registry.ts +++ b/src/renderer/features/tasks/stores/workspace-registry.ts @@ -20,7 +20,8 @@ export class WorkspaceRegistryStore { workspaceId: string, taskStore: TaskStore, settingsStore: ProjectSettingsStore, - baseRef: string + baseRef: string, + sshConnectionId?: string ): WorkspaceStore { const key = makeKey(projectId, workspaceId); const existing = this.entries.get(key); @@ -30,7 +31,14 @@ export class WorkspaceRegistryStore { return existing.store; } - const store = new WorkspaceStore(projectId, workspaceId, [taskStore], settingsStore, baseRef); + const store = new WorkspaceStore( + projectId, + workspaceId, + [taskStore], + settingsStore, + baseRef, + sshConnectionId + ); this.entries.set(key, { store, refCount: 1, activated: false }); return store; } diff --git a/src/renderer/features/tasks/stores/workspace.ts b/src/renderer/features/tasks/stores/workspace.ts index 66c933f161..2fb126fd81 100644 --- a/src/renderer/features/tasks/stores/workspace.ts +++ b/src/renderer/features/tasks/stores/workspace.ts @@ -1,6 +1,8 @@ -import { observable } from 'mobx'; +import { computed, observable } from 'mobx'; +import type { ConnectionState } from '@shared/ssh'; import type { ProjectSettingsStore } from '@renderer/features/projects/stores/project-settings-store'; import { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; +import { appState } from '@renderer/lib/stores/app-state'; import { GitStore } from '../diff-view/stores/git-store'; import { FilesStore } from '../editor/stores/files-store'; import { LifecycleScriptsStore } from './lifecycle-scripts'; @@ -10,6 +12,7 @@ import type { TaskStore } from './task'; export class WorkspaceStore { readonly tasks = observable.array(); readonly repository: RepositoryStore; + readonly sshConnectionId: string | undefined; git: GitStore; files: FilesStore; lifecycleScripts: LifecycleScriptsStore; @@ -20,8 +23,10 @@ export class WorkspaceStore { workspaceId: string, initialTasks: TaskStore[], settingsStore: ProjectSettingsStore, - baseRef: string + baseRef: string, + sshConnectionId?: string ) { + this.sshConnectionId = sshConnectionId; this.tasks.replace(initialTasks); this.repository = new RepositoryStore(projectId, settingsStore, baseRef, workspaceId); this.git = new GitStore(projectId, workspaceId, this.repository); @@ -30,6 +35,17 @@ export class WorkspaceStore { this.pr = new PrStore(projectId, workspaceId, this.repository, this.tasks); } + @computed get connectionState(): ConnectionState | null { + if (!this.sshConnectionId) return null; + return appState.sshConnections.stateFor(this.sshConnectionId); + } + + reconnect(): void { + if (this.sshConnectionId) { + void appState.sshConnections.connect(this.sshConnectionId).catch(() => {}); + } + } + addTask(task: TaskStore): void { if (!this.tasks.includes(task)) this.tasks.push(task); } diff --git a/src/renderer/features/tasks/task-titlebar.tsx b/src/renderer/features/tasks/task-titlebar.tsx index bb756ecfe3..7cb94909cf 100644 --- a/src/renderer/features/tasks/task-titlebar.tsx +++ b/src/renderer/features/tasks/task-titlebar.tsx @@ -25,7 +25,8 @@ import { taskViewKind, } from '@renderer/features/tasks/stores/task-selectors'; import { useProvisionedTask, useTaskViewContext } from '@renderer/features/tasks/task-view-context'; -import { RightPanelView } from '@renderer/features/tasks/types'; +import type { RightPanelView } from '@renderer/features/tasks/types'; +import { ConnectionStatusDot } from '@renderer/lib/components/connection-status-dot'; import { OpenInMenu } from '@renderer/lib/components/titlebar/open-in-menu'; import { Titlebar } from '@renderer/lib/components/titlebar/Titlebar'; import { useDelayedBoolean } from '@renderer/lib/hooks/use-delay-boolean'; @@ -125,7 +126,10 @@ const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({ {projectName} / - {taskDisplayName(taskStore)} + + {taskDisplayName(taskStore)} + + diff --git a/src/renderer/lib/components/connection-status-dot.tsx b/src/renderer/lib/components/connection-status-dot.tsx new file mode 100644 index 0000000000..b805943d75 --- /dev/null +++ b/src/renderer/lib/components/connection-status-dot.tsx @@ -0,0 +1,17 @@ +import type { ConnectionState } from '@shared/ssh'; +import { cn } from '@renderer/utils/utils'; + +export function ConnectionStatusDot({ state }: { state: ConnectionState | null }) { + if (!state) return null; + return ( + + ); +} From bbaa3bd51e01d624b5b2a597472159f75e9b658c Mon Sep 17 00:00:00 2001 From: David Konopka Date: Tue, 28 Apr 2026 20:13:26 +0200 Subject: [PATCH 079/263] chore: clean up migrations --- ...mium_azazel.sql => 0007_bent_spitfire.sql} | 1 + drizzle/0007_secret_boomer.sql | 1 - drizzle/meta/0007_snapshot.json | 16 +- drizzle/meta/0008_snapshot.json | 1291 ----------------- drizzle/meta/_journal.json | 11 +- 5 files changed, 18 insertions(+), 1302 deletions(-) rename drizzle/{0008_premium_azazel.sql => 0007_bent_spitfire.sql} (62%) delete mode 100644 drizzle/0007_secret_boomer.sql delete mode 100644 drizzle/meta/0008_snapshot.json diff --git a/drizzle/0008_premium_azazel.sql b/drizzle/0007_bent_spitfire.sql similarity index 62% rename from drizzle/0008_premium_azazel.sql rename to drizzle/0007_bent_spitfire.sql index 694f2dc9d8..444b07d462 100644 --- a/drizzle/0008_premium_azazel.sql +++ b/drizzle/0007_bent_spitfire.sql @@ -1,2 +1,3 @@ +ALTER TABLE `tasks` ADD `workspace_provider` text;--> statement-breakpoint ALTER TABLE `tasks` ADD `workspace_id` text;--> statement-breakpoint ALTER TABLE `tasks` ADD `workspace_provider_data` text; \ No newline at end of file diff --git a/drizzle/0007_secret_boomer.sql b/drizzle/0007_secret_boomer.sql deleted file mode 100644 index ebeae7a81d..0000000000 --- a/drizzle/0007_secret_boomer.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `tasks` ADD `workspace_provider` text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json index affb5099fb..cd92f77abb 100644 --- a/drizzle/meta/0007_snapshot.json +++ b/drizzle/meta/0007_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "d6fa5a7a-51ad-49fb-8c73-7b3c35e074a3", + "id": "68b5cc95-67aa-4078-94fa-c10caad8d9eb", "prevId": "79109ea2-737d-48d0-b5c6-ec2ceb4b0d3c", "tables": { "app_secrets": { @@ -1155,6 +1155,20 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provider_data": { + "name": "workspace_provider_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": { diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json deleted file mode 100644 index edeeb588eb..0000000000 --- a/drizzle/meta/0008_snapshot.json +++ /dev/null @@ -1,1291 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "cde091fd-ff9f-4606-a76d-48364c5f9e6e", - "prevId": "d6fa5a7a-51ad-49fb-8c73-7b3c35e074a3", - "tables": { - "app_secrets": { - "name": "app_secrets", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "secret": { - "name": "secret", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "idx_app_secrets_key": { - "name": "idx_app_secrets_key", - "columns": ["key"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "app_settings": { - "name": "app_settings", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_app_settings_key": { - "name": "idx_app_settings_key", - "columns": ["key"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "conversations": { - "name": "conversations", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "task_id": { - "name": "task_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_conversations_task_id": { - "name": "idx_conversations_task_id", - "columns": ["task_id"], - "isUnique": false - } - }, - "foreignKeys": { - "conversations_project_id_projects_id_fk": { - "name": "conversations_project_id_projects_id_fk", - "tableFrom": "conversations", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "conversations_task_id_tasks_id_fk": { - "name": "conversations_task_id_tasks_id_fk", - "tableFrom": "conversations", - "tableTo": "tasks", - "columnsFrom": ["task_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "editor_buffers": { - "name": "editor_buffers", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "idx_editor_buffers_workspace_file": { - "name": "idx_editor_buffers_workspace_file", - "columns": ["workspace_id", "file_path"], - "isUnique": false - } - }, - "foreignKeys": { - "editor_buffers_project_id_projects_id_fk": { - "name": "editor_buffers_project_id_projects_id_fk", - "tableFrom": "editor_buffers", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "kv": { - "name": "kv", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_kv_key": { - "name": "idx_kv_key", - "columns": ["key"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "messages": { - "name": "messages", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sender": { - "name": "sender", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "timestamp": { - "name": "timestamp", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "idx_messages_conversation_id": { - "name": "idx_messages_conversation_id", - "columns": ["conversation_id"], - "isUnique": false - }, - "idx_messages_timestamp": { - "name": "idx_messages_timestamp", - "columns": ["timestamp"], - "isUnique": false - } - }, - "foreignKeys": { - "messages_conversation_id_conversations_id_fk": { - "name": "messages_conversation_id_conversations_id_fk", - "tableFrom": "messages", - "tableTo": "conversations", - "columnsFrom": ["conversation_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "project_remotes": { - "name": "project_remotes", - "columns": { - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "remote_name": { - "name": "remote_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "remote_url": { - "name": "remote_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "project_remotes_project_id_projects_id_fk": { - "name": "project_remotes_project_id_projects_id_fk", - "tableFrom": "project_remotes", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "project_remotes_project_id_remote_name_pk": { - "columns": ["project_id", "remote_name"], - "name": "project_remotes_project_id_remote_name_pk" - } - }, - "uniqueConstraints": {} - }, - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "workspace_provider": { - "name": "workspace_provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'local'" - }, - "base_ref": { - "name": "base_ref", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "ssh_connection_id": { - "name": "ssh_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_projects_path": { - "name": "idx_projects_path", - "columns": ["path"], - "isUnique": true - }, - "idx_projects_ssh_connection_id": { - "name": "idx_projects_ssh_connection_id", - "columns": ["ssh_connection_id"], - "isUnique": false - } - }, - "foreignKeys": { - "projects_ssh_connection_id_ssh_connections_id_fk": { - "name": "projects_ssh_connection_id_ssh_connections_id_fk", - "tableFrom": "projects", - "tableTo": "ssh_connections", - "columnsFrom": ["ssh_connection_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "pull_request_assignees": { - "name": "pull_request_assignees", - "columns": { - "pull_request_url": { - "name": "pull_request_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "idx_pra_pull_request_url": { - "name": "idx_pra_pull_request_url", - "columns": ["pull_request_url"], - "isUnique": false - }, - "idx_pra_user_id": { - "name": "idx_pra_user_id", - "columns": ["user_id"], - "isUnique": false - } - }, - "foreignKeys": { - "pull_request_assignees_pull_request_url_pull_requests_url_fk": { - "name": "pull_request_assignees_pull_request_url_pull_requests_url_fk", - "tableFrom": "pull_request_assignees", - "tableTo": "pull_requests", - "columnsFrom": ["pull_request_url"], - "columnsTo": ["url"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "pull_request_assignees_user_id_pull_request_users_user_id_fk": { - "name": "pull_request_assignees_user_id_pull_request_users_user_id_fk", - "tableFrom": "pull_request_assignees", - "tableTo": "pull_request_users", - "columnsFrom": ["user_id"], - "columnsTo": ["user_id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pull_request_assignees_pull_request_url_user_id_pk": { - "columns": ["pull_request_url", "user_id"], - "name": "pull_request_assignees_pull_request_url_user_id_pk" - } - }, - "uniqueConstraints": {} - }, - "pull_request_checks": { - "name": "pull_request_checks", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "pull_request_url": { - "name": "pull_request_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "commit_sha": { - "name": "commit_sha", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "conclusion": { - "name": "conclusion", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "details_url": { - "name": "details_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "completed_at": { - "name": "completed_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workflow_name": { - "name": "workflow_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "app_name": { - "name": "app_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "app_logo_url": { - "name": "app_logo_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "idx_prc_pull_request_url": { - "name": "idx_prc_pull_request_url", - "columns": ["pull_request_url"], - "isUnique": false - } - }, - "foreignKeys": { - "pull_request_checks_pull_request_url_pull_requests_url_fk": { - "name": "pull_request_checks_pull_request_url_pull_requests_url_fk", - "tableFrom": "pull_request_checks", - "tableTo": "pull_requests", - "columnsFrom": ["pull_request_url"], - "columnsTo": ["url"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "pull_request_labels": { - "name": "pull_request_labels", - "columns": { - "pull_request_id": { - "name": "pull_request_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "idx_prl_name": { - "name": "idx_prl_name", - "columns": ["name"], - "isUnique": false - } - }, - "foreignKeys": { - "pull_request_labels_pull_request_id_pull_requests_url_fk": { - "name": "pull_request_labels_pull_request_id_pull_requests_url_fk", - "tableFrom": "pull_request_labels", - "tableTo": "pull_requests", - "columnsFrom": ["pull_request_id"], - "columnsTo": ["url"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "pull_request_labels_pull_request_id_name_pk": { - "columns": ["pull_request_id", "name"], - "name": "pull_request_labels_pull_request_id_name_pk" - } - }, - "uniqueConstraints": {} - }, - "pull_request_users": { - "name": "pull_request_users", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_name": { - "name": "user_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_updated_at": { - "name": "user_updated_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_created_at": { - "name": "user_created_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "pull_requests": { - "name": "pull_requests", - "columns": { - "url": { - "name": "url", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'github'" - }, - "repository_url": { - "name": "repository_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "base_ref_name": { - "name": "base_ref_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "base_ref_oid": { - "name": "base_ref_oid", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "head_repository_url": { - "name": "head_repository_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "head_ref_name": { - "name": "head_ref_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "head_ref_oid": { - "name": "head_ref_oid", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'open'" - }, - "is_draft": { - "name": "is_draft", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "author_user_id": { - "name": "author_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "additions": { - "name": "additions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deletions": { - "name": "deletions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "changed_files": { - "name": "changed_files", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "commit_count": { - "name": "commit_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "mergeable_status": { - "name": "mergeable_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "merge_state_status": { - "name": "merge_state_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "review_decision": { - "name": "review_decision", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pull_request_created_at": { - "name": "pull_request_created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "pull_request_updated_at": { - "name": "pull_request_updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_pull_requests_url": { - "name": "idx_pull_requests_url", - "columns": ["url"], - "isUnique": true - }, - "idx_pull_requests_repository_url": { - "name": "idx_pull_requests_repository_url", - "columns": ["repository_url"], - "isUnique": false - }, - "idx_pull_requests_head_repository_url": { - "name": "idx_pull_requests_head_repository_url", - "columns": ["head_repository_url"], - "isUnique": false - } - }, - "foreignKeys": { - "pull_requests_author_user_id_pull_request_users_user_id_fk": { - "name": "pull_requests_author_user_id_pull_request_users_user_id_fk", - "tableFrom": "pull_requests", - "tableTo": "pull_request_users", - "columnsFrom": ["author_user_id"], - "columnsTo": ["user_id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "ssh_connections": { - "name": "ssh_connections", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host": { - "name": "host", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "port": { - "name": "port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 22 - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_type": { - "name": "auth_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'agent'" - }, - "private_key_path": { - "name": "private_key_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "use_agent": { - "name": "use_agent", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_ssh_connections_name": { - "name": "idx_ssh_connections_name", - "columns": ["name"], - "isUnique": true - }, - "idx_ssh_connections_host": { - "name": "idx_ssh_connections_host", - "columns": ["host"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "tasks": { - "name": "tasks", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_branch": { - "name": "source_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "task_branch": { - "name": "task_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "linked_issue": { - "name": "linked_issue", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "archived_at": { - "name": "archived_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "last_interacted_at": { - "name": "last_interacted_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_changed_at": { - "name": "status_changed_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "is_pinned": { - "name": "is_pinned", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "workspace_provider": { - "name": "workspace_provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "workspace_provider_data": { - "name": "workspace_provider_data", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "idx_tasks_project_id": { - "name": "idx_tasks_project_id", - "columns": ["project_id"], - "isUnique": false - } - }, - "foreignKeys": { - "tasks_project_id_projects_id_fk": { - "name": "tasks_project_id_projects_id_fk", - "tableFrom": "tasks", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "terminals": { - "name": "terminals", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "task_id": { - "name": "task_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ssh": { - "name": "ssh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - }, - "updated_at": { - "name": "updated_at", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" - } - }, - "indexes": { - "idx_terminals_task_id": { - "name": "idx_terminals_task_id", - "columns": ["task_id"], - "isUnique": false - } - }, - "foreignKeys": { - "terminals_project_id_projects_id_fk": { - "name": "terminals_project_id_projects_id_fk", - "tableFrom": "terminals", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminals_task_id_tasks_id_fk": { - "name": "terminals_task_id_tasks_id_fk", - "tableFrom": "terminals", - "tableTo": "tasks", - "columnsFrom": ["task_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - } - }, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 60da07cfbc..236853131a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -54,15 +54,8 @@ { "idx": 7, "version": "6", - "when": 1777378774526, - "tag": "0007_secret_boomer", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1777382026422, - "tag": "0008_premium_azazel", + "when": 1777399834705, + "tag": "0007_bent_spitfire", "breakpoints": true } ] From 1a1249748c1e22af193ea7635ff4e760bcfa9000 Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Tue, 28 Apr 2026 17:05:29 -0700 Subject: [PATCH 080/263] Use Apache 2.0 license metadata --- LICENSE.md | 223 ++++++++++++++++++++++++++++++++++++++++++++++----- flake.nix | 2 +- package.json | 1 + 3 files changed, 204 insertions(+), 22 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index b020b7b722..d645695673 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,202 @@ -MIT License - -Copyright (c) 2025 General Action, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/flake.nix b/flake.nix index 37917e95c4..b39da79770 100644 --- a/flake.nix +++ b/flake.nix @@ -179,7 +179,7 @@ EOF meta = { description = "Emdash – multi-agent orchestration desktop app"; homepage = "https://emdash.sh"; - license = lib.licenses.mit; + license = lib.licenses.asl20; platforms = [ "x86_64-linux" ]; }; } diff --git a/package.json b/package.json index c4178facba..96043d1585 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "emdash", "version": "1.1.5", "description": "A cross-platform Electron app that orchestrates multiple coding agents in parallel", + "license": "Apache-2.0", "type": "module", "main": "./out/main/index.js", "packageManager": "pnpm@10.28.2", From fa9ba23f1b805f2f2baf1d414ebc0a11179bfd53 Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Tue, 28 Apr 2026 17:05:39 -0700 Subject: [PATCH 081/263] Refresh README and contributor docs --- .node-version | 3 +-- CONTRIBUTING.md | 43 +++++++++++++++++++---------------- README.md | 11 +++++---- agents/quickstart.md | 2 +- agents/risky-areas/updater.md | 3 ++- agents/workflows/testing.md | 4 ++-- 6 files changed, 37 insertions(+), 29 deletions(-) diff --git a/.node-version b/.node-version index 57c15c67c7..d845d9d88d 100644 --- a/.node-version +++ b/.node-version @@ -1,2 +1 @@ -22.20.0 - +24.14.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96e9dae4af..77acf9c23b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for your interest in contributing! We favor small, focused PRs and clear Prerequisites -- **Node.js 24.0.0+ (recommended: 24.14.0)** and Git +- **Node.js 24.0.0+ (recommended: 24.14.0)**, **pnpm 10.28.0+**, and Git - Optional (recommended for end‑to‑end testing): - GitHub CLI (`brew install gh`; then `gh auth login`) - At least one supported coding agent CLI (see docs for list) @@ -28,10 +28,11 @@ pnpm run d pnpm install pnpm run dev -# Type checking, lint, build - pnpm run typecheck - pnpm run lint - pnpm run build +# Format, lint, type check, and test +pnpm run format +pnpm run lint +pnpm run typecheck +pnpm run test ``` Tip: During development, the renderer hot‑reloads. Changes to the Electron main process (files in `src/main`) require a restart of the dev app. @@ -61,13 +62,13 @@ Tip: During development, the renderer hot‑reloads. Changes to the Electron mai 3. Run checks locally ``` -pnpm run format # Format code with Prettier (required) +pnpm run format # Format code with Prettier (required) +pnpm run lint # ESLint pnpm run typecheck # TypeScript type checking -pnpm run lint # ESLint -pnpm run build # Build both main and renderer +pnpm run test # Vitest test suite ``` -Pre-commit hooks run automatically via Husky + lint-staged. On each commit, staged files are auto-formatted with Prettier and linted with ESLint. You don't need to remember to run these manually. Type checking and tests run in CI only since they need the full project context and are slower to execute. +Pre-commit hooks run automatically via Husky + lint-staged. On each commit, staged files are auto-formatted with Prettier and linted with ESLint. Run the full local gate before opening or merging a PR. If you need to skip the hook for a work-in-progress commit, use `git commit --no-verify`. The checks will still run in CI when you open a PR. @@ -98,9 +99,9 @@ TypeScript + ESLint + Prettier Pre-commit hooks handle formatting and linting automatically on staged files. For full-project checks you can run them manually: - `pnpm run format` -- format all files with Prettier -- `pnpm run typecheck` -- TypeScript type checking (whole project) - `pnpm run lint` -- ESLint across all files -- `pnpm exec vitest run` -- run the test suite +- `pnpm run typecheck` -- TypeScript type checking (whole project) +- `pnpm run test` -- run the test suite Electron main (Node side) @@ -159,19 +160,23 @@ This automatically: 2. Creates a git commit with the version number (e.g., `"0.2.10"`) 3. Creates a git tag (e.g., `v0.2.10`) -Then push to trigger the CI/CD pipeline. +Then push the commit and tag. Production release builds are dispatched from GitHub Actions. ### What happens next -Two GitHub Actions workflows trigger on version tags: +The release pipeline is split across these GitHub Actions workflows: -**macOS Release** (`.github/workflows/release.yml`): -1. Builds the TypeScript and Vite bundles -2. Signs the app with Apple Developer ID -3. Notarizes via Apple's notary service -4. Creates a GitHub Release with DMG artifacts for arm64 and x64 +**Production Release** (`.github/workflows/release-prod.yml`): +1. Builds Linux, Windows, and macOS packages +2. Signs Windows builds when Azure Trusted Signing secrets are configured +3. Signs, verifies, notarizes, and staples macOS DMGs and ZIPs +4. Uploads release artifacts to Cloudflare R2 **Linux/Nix Build** (`.github/workflows/nix-build.yml`): 1. Computes the correct dependency hash from `pnpm-lock.yaml` 2. Builds the x86_64-linux package via Nix flake -3. Pushes build artifacts to Cachix +3. Pushes build artifacts to Cachix and uploads the Nix artifact when available + +**Canary Release** (`.github/workflows/release-canary.yml`): +1. Builds Linux, Windows, and macOS packages with the canary config +2. Publishes artifacts to the `v1-canary` R2 channel diff --git a/README.md b/README.md index bff639682a..1c8ef3080b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Emdash is a provider-agnostic desktop app that lets you run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH on a remote machine. We call it an Agentic Development Environment (ADE). -Emdash supports 23 CLI agents, including Claude Code, Qwen Code, Hermes Agent, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. +Emdash supports 23 CLI agents, including Claude Code, Qwen Code, Devin, Amp, and Codex. Users can directly pass issues and tickets from Linear, GitHub, Jira, GitLab, Forgejo, or Plain to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. **Develop on remote servers via SSH** @@ -93,11 +93,11 @@ Emdash currently supports 23 CLI providers, and we are adding new ones regularly | [Codex](https://github.com/openai/codex) | ✅ Supported | npm install -g @openai/codex | | [Continue](https://docs.continue.dev/guides/cli) | ✅ Supported | npm i -g @continuedev/cli | | [Cursor](https://cursor.com/cli) | ✅ Supported | curl https://cursor.com/install -fsS | bash | +| [Devin](https://cli.devin.ai/docs) | ✅ Supported | curl -fsSL https://cli.devin.ai/install.sh | bash | | [Droid](https://docs.factory.ai/cli/getting-started/quickstart) | ✅ Supported | curl -fsSL https://app.factory.ai/cli | sh | | [Gemini](https://github.com/google-gemini/gemini-cli) | ✅ Supported | npm install -g @google/gemini-cli | | [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) | ✅ Supported | npm install -g @github/copilot | | [Goose](https://block.github.io/goose/docs/quickstart/) | ✅ Supported | curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash | -| [Hermes Agent](https://hermes-agent.nousresearch.com/docs/) | ✅ Supported | curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash | | [Kilocode](https://kilo.ai/docs/cli) | ✅ Supported | npm install -g @kilocode/cli | | [Kimi](https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html) | ✅ Supported | uv tool install kimi-cli | | [Kiro (AWS)](https://kiro.dev/docs/cli/) | ✅ Supported | curl -fsSL https://cli.kiro.dev/install | bash | @@ -109,13 +109,16 @@ Emdash currently supports 23 CLI providers, and we are adding new ones regularly ### Issues -Emdash allows you to pass tickets straight from Linear, GitHub, or Jira to your coding agent. +Emdash allows you to pass issues, tickets, and support threads straight to your coding agent. | Tool | Status | Authentication | | ----------- | ------ | ----------- | | [Linear](https://linear.app) | ✅ Supported | Connect with a Linear API key. | | [Jira](https://www.atlassian.com/software/jira) | ✅ Supported | Provide your site URL, email, and Atlassian API token. | -| [GitHub Issues](https://docs.github.com/en/issues) | ✅ Supported | Authenticate via GitHub CLI (`gh auth login`). | +| [GitHub Issues](https://docs.github.com/en/issues) | ✅ Supported | Connect your GitHub account or authenticate via GitHub CLI (`gh auth login`). | +| [GitLab Issues](https://docs.gitlab.com/user/project/issues/) | ✅ Supported | Provide your GitLab instance URL and a personal access token with `read_api` scope. | +| [Forgejo Issues](https://forgejo.org/) | ✅ Supported | Provide your Forgejo instance URL and API token. | +| [Plain Threads](https://www.plain.com/) | ✅ Supported | Connect with a Plain API key. | # Contributing diff --git a/agents/quickstart.md b/agents/quickstart.md index 22643e52f9..b962f42731 100644 --- a/agents/quickstart.md +++ b/agents/quickstart.md @@ -25,7 +25,7 @@ pnpm run reset pnpm run format pnpm run lint pnpm run typecheck -pnpm test run +pnpm run test ``` ## Docs Commands diff --git a/agents/risky-areas/updater.md b/agents/risky-areas/updater.md index 7830a7f268..cf360acd0e 100644 --- a/agents/risky-areas/updater.md +++ b/agents/risky-areas/updater.md @@ -6,7 +6,8 @@ - `src/main/core/updates/controller.ts` - `build/` - `package.json` -- `.github/workflows/release.yml` +- `.github/workflows/release-prod.yml` +- `.github/workflows/release-canary.yml` - `.github/workflows/windows-beta-build.yml` - `.github/workflows/nix-build.yml` diff --git a/agents/workflows/testing.md b/agents/workflows/testing.md index 39b7c270f5..dd40ee4d99 100644 --- a/agents/workflows/testing.md +++ b/agents/workflows/testing.md @@ -31,8 +31,8 @@ pnpm run test - `.github/workflows/code-consistency-check.yml` currently enforces: - `pnpm run format:check` - `pnpm run typecheck` - - `pnpm exec vitest run` -- Lint is still expected locally even though it is not enabled in that workflow yet. + - `pnpm run lint` +- Tests are still expected locally before merging even though they are not enabled in that workflow yet. ## Focused Validation From 4645e8b81a0ab6fae3a46871a020fddcf49793ec Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Tue, 28 Apr 2026 17:09:23 -0700 Subject: [PATCH 082/263] Add copyright notice to license --- LICENSE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LICENSE.md b/LICENSE.md index d645695673..52225c98d0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,3 +1,5 @@ +Copyright 2026 General Action, Inc. + Apache License Version 2.0, January 2004 From 15158b0184f5492978435921e8d4ff77d8758b33 Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Tue, 28 Apr 2026 17:52:31 -0700 Subject: [PATCH 083/263] Readd Hermes agent provider --- README.md | 5 +++-- agents/integrations/providers.md | 4 ++-- src/assets/images/hermesagent.jpg | Bin 0 -> 27780 bytes src/main/core/pty/pty-env.ts | 2 ++ src/renderer/lib/providers/meta.ts | 2 ++ src/shared/agent-provider-registry.ts | 20 ++++++++++++++++++++ 6 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/assets/images/hermesagent.jpg diff --git a/README.md b/README.md index bff639682a..62aae2695c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Emdash is a provider-agnostic desktop app that lets you run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH on a remote machine. We call it an Agentic Development Environment (ADE). -Emdash supports 23 CLI agents, including Claude Code, Qwen Code, Hermes Agent, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. +Emdash supports 24 CLI agents, including Claude Code, Qwen Code, Hermes Agent, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. **Develop on remote servers via SSH** @@ -79,7 +79,7 @@ Connect to remote machines via SSH/SFTP to work with remote codebases. Emdash su ### Supported CLI Providers -Emdash currently supports 23 CLI providers, and we are adding new ones regularly. If you miss one, let us know or create a PR. +Emdash currently supports 24 CLI providers, and we are adding new ones regularly. If you miss one, let us know or create a PR. | CLI Provider | Status | Install | | ----------- | ------ | ----------- | @@ -93,6 +93,7 @@ Emdash currently supports 23 CLI providers, and we are adding new ones regularly | [Codex](https://github.com/openai/codex) | ✅ Supported | npm install -g @openai/codex | | [Continue](https://docs.continue.dev/guides/cli) | ✅ Supported | npm i -g @continuedev/cli | | [Cursor](https://cursor.com/cli) | ✅ Supported | curl https://cursor.com/install -fsS | bash | +| [Devin](https://cli.devin.ai/docs) | ✅ Supported | curl -fsSL https://cli.devin.ai/install.sh | bash | | [Droid](https://docs.factory.ai/cli/getting-started/quickstart) | ✅ Supported | curl -fsSL https://app.factory.ai/cli | sh | | [Gemini](https://github.com/google-gemini/gemini-cli) | ✅ Supported | npm install -g @google/gemini-cli | | [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) | ✅ Supported | npm install -g @github/copilot | diff --git a/agents/integrations/providers.md b/agents/integrations/providers.md index eabd67a7d9..e1ec4e10b6 100644 --- a/agents/integrations/providers.md +++ b/agents/integrations/providers.md @@ -6,9 +6,9 @@ - `src/main/core/dependencies/dependency-manager.ts` - `src/main/core/pty/` -## Current Providers (22) +## Current Providers (24) -codex, claude, qwen, droid, gemini, cursor, copilot, amp, opencode, charm, auggie, goose, kimi, kilocode, kiro, rovo, cline, continue, codebuff, mistral, pi, autohand +codex, claude, devin, qwen, droid, gemini, cursor, copilot, amp, opencode, hermes, charm, auggie, goose, kimi, kilocode, kiro, rovo, cline, continue, codebuff, mistral, pi, autohand ## Provider Metadata Includes diff --git a/src/assets/images/hermesagent.jpg b/src/assets/images/hermesagent.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5edec869f14db9bf0198de1ffcd0b9c25792a741 GIT binary patch literal 27780 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 literal 0 HcmV?d00001 diff --git a/src/main/core/pty/pty-env.ts b/src/main/core/pty/pty-env.ts index 415283e490..1bc2ab5393 100644 --- a/src/main/core/pty/pty-env.ts +++ b/src/main/core/pty/pty-env.ts @@ -36,6 +36,8 @@ export const AGENT_ENV_VARS = [ 'NO_PROXY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', + 'OPENROUTER_API_KEY', + 'OPENROUTER_BASE_URL', ] as const; const DISPLAY_ENV_VARS = [ diff --git a/src/renderer/lib/providers/meta.ts b/src/renderer/lib/providers/meta.ts index 0d926f5819..bafe295934 100644 --- a/src/renderer/lib/providers/meta.ts +++ b/src/renderer/lib/providers/meta.ts @@ -13,6 +13,7 @@ import factorydroidIcon from '@/assets/images/droid.svg?raw'; import geminiIcon from '@/assets/images/gemini.png'; import ghcopilotIcon from '@/assets/images/gh-copilot.svg?raw'; import gooseIcon from '@/assets/images/goose.png'; +import hermesIcon from '@/assets/images/hermesagent.jpg'; import kilocodeIcon from '@/assets/images/kilocode.png'; import kimiIcon from '@/assets/images/kimi.png'; import kiroIcon from '@/assets/images/kiro.png'; @@ -39,6 +40,7 @@ const ICONS: Record = { 'devin.png': devinIcon, 'gh-copilot.svg': ghcopilotIcon, 'goose.png': gooseIcon, + 'hermesagent.jpg': hermesIcon, 'kimi.png': kimiIcon, 'kilocode.png': kilocodeIcon, 'kiro.png': kiroIcon, diff --git a/src/shared/agent-provider-registry.ts b/src/shared/agent-provider-registry.ts index 76fd659be5..4874c8eeed 100644 --- a/src/shared/agent-provider-registry.ts +++ b/src/shared/agent-provider-registry.ts @@ -9,6 +9,7 @@ export const AGENT_PROVIDER_IDS = [ 'copilot', 'amp', 'opencode', + 'hermes', 'charm', 'auggie', 'goose', @@ -222,6 +223,25 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ invertInDark: true, terminalOnly: true, }, + { + id: 'hermes', + name: 'Hermes Agent', + description: + 'Nous Research terminal agent with interactive chat, model-provider routing, skills, and session workflows.', + docUrl: 'https://hermes-agent.nousresearch.com/docs/', + installCommand: + 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash', + commands: ['hermes'], + versionArgs: ['--version'], + cli: 'hermes', + autoApproveFlag: '--yolo', + initialPromptFlag: '', + useKeystrokeInjection: true, + resumeFlag: '--continue', + icon: 'hermesagent.jpg', + alt: 'Hermes Agent CLI', + terminalOnly: true, + }, { id: 'copilot', name: 'GitHub Copilot', From 74fb9307b3660ee7bc79c8fb9ab3490cb5d34adf Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Tue, 28 Apr 2026 17:57:23 -0700 Subject: [PATCH 084/263] Add Hermes to renderer agent config --- src/renderer/utils/agentConfig.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/utils/agentConfig.ts b/src/renderer/utils/agentConfig.ts index 8cd147abfc..e6623f5f83 100644 --- a/src/renderer/utils/agentConfig.ts +++ b/src/renderer/utils/agentConfig.ts @@ -14,6 +14,7 @@ import factoryLogoSvg from '../../assets/images/droid.svg?raw'; import geminiLogo from '../../assets/images/gemini.png'; import copilotLogoSvg from '../../assets/images/gh-copilot.svg?raw'; import gooseLogo from '../../assets/images/goose.png'; +import hermesLogo from '../../assets/images/hermesagent.jpg'; import kilocodeLogo from '../../assets/images/kilocode.png'; import kimiLogo from '../../assets/images/kimi.png'; import kiroLogo from '../../assets/images/kiro.png'; @@ -44,6 +45,7 @@ export const agentConfig: Record = { pi: { name: 'Pi', logo: piLogo, alt: 'Pi CLI' }, autohand: { name: 'Autohand Code', logo: autohandLogoSvg, alt: 'Autohand Code CLI', isSvg: true }, opencode: { name: 'OpenCode', logo: opencodeLogo, alt: 'OpenCode', invertInDark: true }, + hermes: { name: 'Hermes Agent', logo: hermesLogo, alt: 'Hermes Agent CLI' }, auggie: { name: 'Auggie', logo: augmentLogoSvg, alt: 'Auggie CLI', isSvg: true }, goose: { name: 'Goose', logo: gooseLogo, alt: 'Goose CLI' }, kimi: { name: 'Kimi', logo: kimiLogo, alt: 'Kimi CLI' }, From 7e2477e0d12b92a5cdb0f2711fe532869b613f63 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:18:39 +0200 Subject: [PATCH 085/263] Fix MCP modal save clone error --- src/renderer/lib/modal/modal-store.test.ts | 35 ++++++++++++++++++++++ src/renderer/lib/modal/modal-store.ts | 6 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/renderer/lib/modal/modal-store.test.ts diff --git a/src/renderer/lib/modal/modal-store.test.ts b/src/renderer/lib/modal/modal-store.test.ts new file mode 100644 index 0000000000..8ad047c3ec --- /dev/null +++ b/src/renderer/lib/modal/modal-store.test.ts @@ -0,0 +1,35 @@ +import { isObservableArray, isObservableObject } from 'mobx'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { modalStore } from './modal-store'; + +describe('modalStore', () => { + afterEach(() => { + modalStore.activeModalId = null; + modalStore.activeModalArgs = null; + modalStore.closeGuardActive = false; + }); + + it('keeps modal args as plain reference data for IPC payloads', () => { + const server = { + name: 'datadog', + transport: 'stdio' as const, + command: 'npx', + args: ['-y', '@datadog/mcp-server'], + env: { DATADOG_API_KEY: 'from-shell' }, + providers: ['claude'], + }; + const args = { + mode: { type: 'edit' as const, server }, + providers: [{ id: 'claude', name: 'Claude Code', installed: true, supportsHttp: true }], + onSave: vi.fn(), + }; + + modalStore.setModal('mcpServerModal', args); + + expect(modalStore.activeModalArgs).toBe(args); + expect(isObservableObject(modalStore.activeModalArgs?.mode)).toBe(false); + expect(isObservableObject(server)).toBe(false); + expect(isObservableArray(server.providers)).toBe(false); + expect(() => structuredClone(server)).not.toThrow(); + }); +}); diff --git a/src/renderer/lib/modal/modal-store.ts b/src/renderer/lib/modal/modal-store.ts index 33ddbbc06b..18237f2f54 100644 --- a/src/renderer/lib/modal/modal-store.ts +++ b/src/renderer/lib/modal/modal-store.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, observable } from 'mobx'; class ModalStore { activeModalId: string | null = null; @@ -7,7 +7,9 @@ class ModalStore { closeGuardActive = false; constructor() { - makeAutoObservable(this); + makeAutoObservable(this, { + activeModalArgs: observable.ref, + }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any From fd60a51f1adbeab9cf8d59ae713a1c97411b7002 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 12:39:26 +0200 Subject: [PATCH 086/263] chore: clean up project manager --- .../projects/impl/local-project-provider.ts | 17 +- .../projects/impl/ssh-project-provider.ts | 3 +- .../core/projects/operations/openProject.ts | 18 +- src/main/core/projects/project-manager.ts | 175 ++++++------------ .../core/projects/task-provision-manager.ts | 128 +++++-------- src/main/core/projects/utils.ts | 34 ++++ .../core/pull-requests/pr-sync-scheduler.ts | 12 +- src/main/lib/hookable.ts | 46 +++++ src/main/lib/lifecycle-map.ts | 111 +++++++++++ src/main/lib/logger.ts | 2 + src/shared/result.ts | 22 +++ 11 files changed, 353 insertions(+), 215 deletions(-) create mode 100644 src/main/lib/hookable.ts create mode 100644 src/main/lib/lifecycle-map.ts diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index c107b30dd3..e44696161c 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -8,7 +8,6 @@ import { makePtySessionId } from '@shared/ptySessionId'; import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GitFetchService } from '@main/core/git/git-fetch-service'; import { GitWatcherService } from '@main/core/git/git-watcher-service'; import { GitService } from '@main/core/git/impl/git-service'; @@ -32,19 +31,15 @@ import { localWorkspaceId } from '@main/core/workspaces/workspace-id'; import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; +import { LocalWorktreeHost } from '../worktrees/hosts/local-worktree-host'; -export async function createLocalProvider( - project: LocalProject, - rootFs: FileSystemProvider -): Promise { - const settings = new LocalProjectSettingsProvider( - project.path, - bareRefName(project.baseRef), - rootFs - ); +export async function createLocalProvider(project: LocalProject): Promise { + const settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); await fs.promises.mkdir(worktreePoolPath, { recursive: true }); + const worktreeHost = await LocalWorktreeHost.create({ allowedRoots: [project.path] }); + const localFs = new LocalFileSystem(project.path); const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); const repoGit = new GitService(project.path, gitExec, localFs); @@ -54,7 +49,7 @@ export async function createLocalProvider( repoPath: project.path, projectSettings: settings, exec: gitExec, - rootFs, + host: worktreeHost, }); const gitWatcher = new GitWatcherService(project.id, project.path); void gitWatcher.start(); diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 8332037c36..8164dd14a0 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -30,6 +30,7 @@ import { type ProjectProvider, type ProvisionResult, type TaskProvider } from '. import { SshProjectSettingsProvider } from '../settings/project-settings'; import { provisionLocalTask } from '../task-builder'; import { TaskProvisionManager } from '../task-provision-manager'; +import { SshWorktreeHost } from '../worktrees/hosts/ssh-worktree-host'; import { WorktreeService } from '../worktrees/worktree-service'; export async function createSshProvider( @@ -60,7 +61,7 @@ export async function createSshProvider( repoPath: project.path, projectSettings: settings, exec: gitExec, - rootFs, + host: worktreeHost, }); const gitFetchService = new GitFetchService( repoGit, diff --git a/src/main/core/projects/operations/openProject.ts b/src/main/core/projects/operations/openProject.ts index a17e17464e..a47f7ebde8 100644 --- a/src/main/core/projects/operations/openProject.ts +++ b/src/main/core/projects/operations/openProject.ts @@ -1,7 +1,21 @@ import type { OpenProjectError } from '@shared/projects'; -import type { Result } from '@shared/result'; +import { err, ok, type Result } from '@shared/result'; import { projectManager } from '@main/core/projects/project-manager'; +import { checkIsValidDirectory } from '../path-utils'; +import { getProjectById } from './getProjects'; export async function openProject(projectId: string): Promise> { - return projectManager.openProjectById(projectId); + const project = await getProjectById(projectId); + if (!project) return err({ type: 'error', message: `Project not found: ${projectId}` }); + if (project.type === 'local' && !checkIsValidDirectory(project.path)) { + return err({ type: 'path-not-found', path: project.path }); + } + const result = await projectManager.openProject(project); + if (!result.success) { + if (project.type === 'ssh') { + return err({ type: 'ssh-disconnected', connectionId: project.connectionId }); + } + return err({ type: 'error', message: result.error.message }); + } + return ok(); } diff --git a/src/main/core/projects/project-manager.ts b/src/main/core/projects/project-manager.ts index 0523918f85..f7bf122097 100644 --- a/src/main/core/projects/project-manager.ts +++ b/src/main/core/projects/project-manager.ts @@ -1,171 +1,104 @@ -import type { - LocalProject, - OpenProjectError, - ProjectBootstrapStatus, - SshProject, -} from '@shared/projects'; +import type { LocalProject, ProjectBootstrapStatus, SshProject } from '@shared/projects'; import { err, ok, type Result } from '@shared/result'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; import { SshFileSystem } from '../fs/impl/ssh-fs'; -import { getProjectById, getProjects } from '../projects/operations/getProjects'; import { sshConnectionManager } from '../ssh/ssh-connection-manager'; import { createLocalProvider } from './impl/local-project-provider'; import { createSshProvider } from './impl/ssh-project-provider'; -import { checkIsValidDirectory } from './path-utils'; import type { ProjectProvider } from './project-provider'; import { TimeoutSignal, withTimeout } from './utils'; -const PROVIDER_TIMEOUT_MS = 60_000; +const SSH_PROVIDER_TIMEOUT_MS = 60_000; +const LOCAL_PROVIDER_TIMEOUT_MS = 20_000; +const TEARDOWN_PROVIDER_TIMEOUT_MS = 60_000; -type ProjectLifecycleHook = (projectId: string) => void | Promise; - -type ProviderError = { - type: 'error'; - message: string; -}; - -type TimeoutError = { - type: 'timeout'; - message: string; - timeout: number; +export type ProjectManagerHooks = { + projectOpened: (projectId: string) => void | Promise; + projectClosed: (projectId: string) => void | Promise; }; -type InitializeProviderError = TimeoutError | ProviderError; -type TeardownProviderError = TimeoutError | ProviderError; +type ProviderLifecycleError = + | { type: 'timeout'; message: string; timeout: number } + | { type: 'error'; message: string }; -function toInitError(e: unknown): InitializeProviderError { +function toInitError(e: unknown): ProviderLifecycleError { if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; return { type: 'error', message: e instanceof Error ? e.message : String(e) }; } -function toTeardownError(e: unknown): TeardownProviderError { +function toTeardownError(e: unknown): ProviderLifecycleError { if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; return { type: 'error', message: e instanceof Error ? e.message : String(e) }; } -class ProjectManager { - private initializingProviders = new Map< - string, - Promise> - >(); - private providers = new Map(); - private tearingDownProviders = new Map>>(); - private initializationErrors = new Map(); - private _onProjectOpenedHooks: ProjectLifecycleHook[] = []; - private _onProjectClosedHooks: ProjectLifecycleHook[] = []; +class ProjectManager implements Hookable { + private readonly _lifecycle = new LifecycleMap(); + private readonly _hooks = new HookCore((name, e) => + log.error(`ProjectManager: ${String(name)} hook error`, e) + ); - async initialize(): Promise { - const allProjects = await getProjects(); - - await Promise.allSettled( - allProjects.map(async (project) => { - await this.openProject(project); - }) - ); + on(name: K, handler: ProjectManagerHooks[K]) { + return this._hooks.on(name, handler); } async openProject( project: LocalProject | SshProject - ): Promise> { - if (this.providers.has(project.id)) return ok(this.providers.get(project.id)!); - if (this.initializingProviders.has(project.id)) - return this.initializingProviders.get(project.id)!; - - const promise = withTimeout(createProvider(project), PROVIDER_TIMEOUT_MS) - .then((provider) => { - this.providers.set(project.id, provider); - this.initializingProviders.delete(project.id); - this._fireHooks(this._onProjectOpenedHooks, project.id, 'onProjectOpened'); + ): Promise> { + return this._lifecycle.provision(project.id, async () => { + try { + const provider = await withTimeout( + createProvider(project), + project.type === 'ssh' ? SSH_PROVIDER_TIMEOUT_MS : LOCAL_PROVIDER_TIMEOUT_MS + ); + this._hooks.callHookBackground('projectOpened', project.id); return ok(provider); - }) - .catch((e) => { + } catch (e) { const initError = toInitError(e); - this.initializationErrors.set(project.id, initError); - this.initializingProviders.delete(project.id); log.error('ProjectManager: error during project initialization', { projectId: project.id, ...initError, }); - return err(initError); - }); - - this.initializingProviders.set(project.id, promise); - return promise; - } - - async closeProject(projectId: string): Promise> { - if (this.tearingDownProviders.has(projectId)) return this.tearingDownProviders.get(projectId)!; - const provider = this.providers.get(projectId); - if (!provider) return ok(); - - const promise = withTimeout(provider.cleanup(), PROVIDER_TIMEOUT_MS) - .then(() => ok()) - .catch((e) => { - const error = toTeardownError(e); - log.error('ProjectManager: error during project teardown', { projectId, ...error }); - return err(error); - }) - .finally(() => { - this.providers.delete(projectId); - this.tearingDownProviders.delete(projectId); - this._fireHooks(this._onProjectClosedHooks, projectId, 'onProjectClosed'); - }); - - this.tearingDownProviders.set(projectId, promise); - return promise; - } - - registerOnProjectOpened(hook: ProjectLifecycleHook): void { - this._onProjectOpenedHooks.push(hook); - } - - registerOnProjectClosed(hook: ProjectLifecycleHook): void { - this._onProjectClosedHooks.push(hook); + return err(initError); + } + }); } - private _fireHooks(hooks: ProjectLifecycleHook[], projectId: string, name: string): void { - for (const hook of hooks) { - Promise.resolve(hook(projectId)).catch((e) => - log.error(`ProjectManager: ${name} hook error`, { projectId, error: String(e) }) - ); - } + async closeProject(projectId: string): Promise> { + return ( + this._lifecycle.teardown( + projectId, + async (provider) => { + try { + await withTimeout(provider.cleanup(), TEARDOWN_PROVIDER_TIMEOUT_MS); + return ok(); + } catch (e) { + const error = toTeardownError(e); + log.error('ProjectManager: error during project teardown', { projectId, ...error }); + return err(error); + } + }, + () => this._hooks.callHookBackground('projectClosed', projectId) + ) ?? ok() + ); } getProject(projectId: string): ProjectProvider | undefined { - return this.providers.get(projectId); + return this._lifecycle.get(projectId); } getProjectBootstrapStatus(projectId: string): ProjectBootstrapStatus { - if (this.providers.has(projectId)) return { status: 'ready' }; - if (this.initializingProviders.has(projectId)) return { status: 'bootstrapping' }; - const initError = this.initializationErrors.get(projectId); - if (initError) return { status: 'error', message: initError.message }; - return { status: 'not-started' }; - } - - async openProjectById(projectId: string): Promise> { - const project = await getProjectById(projectId); - if (!project) return err({ type: 'error', message: `Project not found: ${projectId}` }); - if (project.type === 'local' && !checkIsValidDirectory(project.path)) { - return err({ type: 'path-not-found', path: project.path }); - } - const result = await this.openProject(project); - if (!result.success) { - if (project.type === 'ssh') { - return err({ type: 'ssh-disconnected', connectionId: project.connectionId }); - } - return err({ type: 'error', message: result.error.message }); - } - return ok(); + return this._lifecycle.bootstrapStatus(projectId, (e) => e.message); } async shutdown(): Promise { - const ids = Array.from(this.providers.keys()); + const ids = Array.from(this._lifecycle.keys()); await Promise.allSettled(ids.map((id) => this.closeProject(id))); } } -async function createProvider(project: LocalProject | SshProject): Promise { +async function createProvider(project: LocalProject | SshProject) { if (project.type === 'ssh') { const proxy = await sshConnectionManager.connect(project.connectionId); const rootFs = new SshFileSystem(proxy, '/'); diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts index 7a43c337b8..eed1dbc7e6 100644 --- a/src/main/core/projects/task-provision-manager.ts +++ b/src/main/core/projects/task-provision-manager.ts @@ -2,6 +2,7 @@ import type { Conversation } from '@shared/conversations'; import { err, ok, type Result } from '@shared/result'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; +import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; import type { TeardownMode } from '../workspaces/workspace-registry'; import type { @@ -31,14 +32,7 @@ type DetachedCleanupFn = (taskId: string) => Promise; export type TeardownAllOpts = { mode: TeardownMode }; export class TaskProvisionManager { - private readonly _tasks = new Map(); - private readonly _workspaceIds = new Map(); - private readonly _provisioningTasks = new Map< - string, - Promise> - >(); - private readonly _tearingDownTasks = new Map>>(); - private readonly _bootstrapErrors = new Map(); + private readonly _lifecycle = new LifecycleMap(); constructor( private readonly logPrefix: string, @@ -53,106 +47,84 @@ export class TaskProvisionManager { conversations: Conversation[], terminals: Terminal[] ): Promise> { - const existing = this._tasks.get(task.id); - if (existing) { - const workspaceId = this._workspaceIds.get(task.id) ?? ''; - return ok({ taskProvider: existing, persistData: { workspaceId } }); - } - - const inFlight = this._provisioningTasks.get(task.id); - if (inFlight) return inFlight; - - const promise = withTimeout(this.provisionFn(task, conversations, terminals), TASK_TIMEOUT_MS) - .then(({ taskProvider, persistData }) => { - this._tasks.set(task.id, taskProvider); - this._workspaceIds.set(task.id, persistData.workspaceId); - this._provisioningTasks.delete(task.id); - return ok({ taskProvider, persistData }); - }) - .catch((e) => { + return this._lifecycle.provision(task.id, async () => { + try { + const result = await withTimeout( + this.provisionFn(task, conversations, terminals), + TASK_TIMEOUT_MS + ); + return ok(result); + } catch (e) { const provisionError = toProvisionError(e); - this._bootstrapErrors.set(task.id, provisionError); - this._provisioningTasks.delete(task.id); log.error(`${this.logPrefix}: failed to provision task`, { taskId: task.id, error: String(e), }); return err(provisionError); - }); - - this._provisioningTasks.set(task.id, promise); - return promise; + } + }); } getTask(taskId: string): TaskProvider | undefined { - return this._tasks.get(taskId); + return this._lifecycle.get(taskId)?.taskProvider; } getWorkspaceId(taskId: string): string | undefined { - return this._workspaceIds.get(taskId); + return this._lifecycle.get(taskId)?.persistData.workspaceId; } getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - if (this._tasks.has(taskId)) return { status: 'ready' }; - if (this._provisioningTasks.has(taskId)) return { status: 'bootstrapping' }; - const bootstrapError = this._bootstrapErrors.get(taskId); - if (bootstrapError) - return { status: 'error', message: formatProvisionTaskError(bootstrapError) }; - return { status: 'not-started' }; + return this._lifecycle.bootstrapStatus(taskId, formatProvisionTaskError); } async teardownTask( taskId: string, mode: TeardownMode = 'terminate' ): Promise> { - const inFlight = this._tearingDownTasks.get(taskId); - if (inFlight) return inFlight; - - const task = this._tasks.get(taskId); - if (!task) { - await this.detachedCleanupFn(taskId); - return ok(); - } - - const workspaceId = this._workspaceIds.get(taskId) ?? ''; - const promise = withTimeout(this.teardownFn(task, workspaceId, mode), TASK_TIMEOUT_MS) - .then(() => ok()) - .catch(async (e) => { - log.error(`${this.logPrefix}: failed to teardown task`, { - taskId, - error: String(e), - }); - await this.detachedCleanupFn(taskId).catch((cleanupError) => { - log.warn(`${this.logPrefix}: fallback cleanup failed`, { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - }) - .finally(() => { - this._tasks.delete(taskId); - this._workspaceIds.delete(taskId); - this._tearingDownTasks.delete(taskId); - this.onTeardownFinally?.(taskId); - }); - - this._tearingDownTasks.set(taskId, promise); - return promise; + return ( + this._lifecycle.teardown( + taskId, + async (provisionResult) => { + const { taskProvider, persistData } = provisionResult; + try { + await withTimeout( + this.teardownFn(taskProvider, persistData.workspaceId, mode), + TASK_TIMEOUT_MS + ); + return ok(); + } catch (e) { + log.error(`${this.logPrefix}: failed to teardown task`, { + taskId, + error: String(e), + }); + await this.detachedCleanupFn(taskId).catch((cleanupError) => { + log.warn(`${this.logPrefix}: fallback cleanup failed`, { + taskId, + error: String(cleanupError), + }); + }); + return err(toTeardownError(e)); + } + }, + () => this.onTeardownFinally?.(taskId) + ) ?? (await this.detachedCleanupFn(taskId).then(() => ok())) + ); } async teardownAll(opts: TeardownAllOpts): Promise { if (opts.mode === 'detach') { await Promise.all( - Array.from(this._tasks.values()).map((task) => - Promise.all([task.conversations.detachAll(), task.terminals.detachAll()]) + Array.from(this._lifecycle.values()).map((r) => + Promise.all([ + r.taskProvider.conversations.detachAll(), + r.taskProvider.terminals.detachAll(), + ]) ) ); - this._tasks.clear(); - this._workspaceIds.clear(); + this._lifecycle.clearActive(); } else { await Promise.all( - Array.from(this._tasks.keys()).map((id) => this.teardownTask(id, 'terminate')) + Array.from(this._lifecycle.keys()).map((id) => this.teardownTask(id, 'terminate')) ); } } diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index 77100ee21b..f2680e6375 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -22,3 +22,37 @@ export function withTimeout(promise: Promise, ms: number): Promise { }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } + +export type TimeoutError = { + type: 'timeout'; + scope: T; + timeout: number; + message?: string; +}; + +export function timeoutError( + scope: T, + timeout: number, + message?: string +): TimeoutError { + return { + type: 'timeout', + scope, + timeout, + message, + }; +} + +export type AbortError = { + type: 'abort'; + scope: T; + message?: string; +}; + +export function abortError(scope: T, message?: string): AbortError { + return { + type: 'abort', + scope, + message, + }; +} diff --git a/src/main/core/pull-requests/pr-sync-scheduler.ts b/src/main/core/pull-requests/pr-sync-scheduler.ts index 748ae46b33..4c25428974 100644 --- a/src/main/core/pull-requests/pr-sync-scheduler.ts +++ b/src/main/core/pull-requests/pr-sync-scheduler.ts @@ -18,10 +18,18 @@ export class PrSyncScheduler { private readonly _intervals = new Map[]>(); /** Per-project set of known GitHub remote URLs (for cleanup on unmount). */ private readonly _projectRemoteUrls = new Map(); + private _unsubscribes: Array<() => void> = []; initialize(): void { - projectManager.registerOnProjectOpened((id) => this.onProjectMounted(id)); - projectManager.registerOnProjectClosed((id) => this.onProjectUnmounted(id)); + this._unsubscribes = [ + projectManager.on('projectOpened', (id) => this.onProjectMounted(id)), + projectManager.on('projectClosed', (id) => this.onProjectUnmounted(id)), + ]; + } + + dispose(): void { + for (const unsub of this._unsubscribes) unsub(); + this._unsubscribes = []; } // ── Project lifecycle ────────────────────────────────────────────────────── diff --git a/src/main/lib/hookable.ts b/src/main/lib/hookable.ts new file mode 100644 index 0000000000..2a068a66a4 --- /dev/null +++ b/src/main/lib/hookable.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HookSchema = Record void | Promise>; + +export interface Hookable { + on(name: K, handler: T[K]): () => void; +} + +export class HookCore implements Hookable { + private readonly _hooks = new Map>(); + + constructor(private readonly onError: (name: keyof T, error: unknown) => void) {} + + /** + * @param name - The name of the hook to register the handler for. + * @param handler - The handler to register. + * @returns A function to unregister the handler. + */ + on(name: K, handler: T[K]) { + if (!this._hooks.has(name)) this._hooks.set(name, new Set()); + this._hooks.get(name)!.add(handler); + return () => this._hooks.get(name)?.delete(handler); + } + + async callHook(name: K, ...args: Parameters): Promise { + for (const handler of this._hooks.get(name) ?? []) { + await (handler as (...a: unknown[]) => unknown)(...args); + } + } + + callHookSync(name: K, ...args: Parameters): void { + for (const handler of this._hooks.get(name) ?? []) { + const result = (handler as (...a: unknown[]) => unknown)(...args); + if (result instanceof Promise) { + throw new TypeError(`Hook "${String(name)}" returned a Promise in a sync context`); + } + } + } + + callHookBackground(name: K, ...args: Parameters): void { + for (const handler of this._hooks.get(name) ?? []) { + Promise.resolve((handler as (...a: unknown[]) => unknown)(...args)).catch((e) => + this.onError(name, { error: String(e) }) + ); + } + } +} diff --git a/src/main/lib/lifecycle-map.ts b/src/main/lib/lifecycle-map.ts new file mode 100644 index 0000000000..3e4895ffc6 --- /dev/null +++ b/src/main/lib/lifecycle-map.ts @@ -0,0 +1,111 @@ +import { err, ok, type Result } from '@shared/result'; + +export type LifecycleStatus = + | { status: 'ready' } + | { status: 'bootstrapping' } + | { status: 'error'; message: string } + | { status: 'not-started' }; + +/** + * Manages the lifecycle state machine for a collection of async resources. + * + * Encapsulates four maps (active, in-flight provision, in-flight teardown, errors) + * and provides deduplicated provision/teardown with a consistent bootstrap status query. + * + * Callers own timeout, error conversion, and logging — only the state transitions + * and deduplication logic live here. + */ +export class LifecycleMap { + private readonly _active = new Map(); + private readonly _provisioning = new Map>>(); + private readonly _tearingDown = new Map>>(); + private readonly _errors = new Map(); + + get(id: string): T | undefined { + return this._active.get(id); + } + + has(id: string): boolean { + return this._active.has(id); + } + + keys(): IterableIterator { + return this._active.keys(); + } + + values(): IterableIterator { + return this._active.values(); + } + + /** Clears the active map without running any teardown callbacks. Use for bulk detach operations. */ + clearActive(): void { + this._active.clear(); + } + + bootstrapStatus(id: string, formatError: (e: E) => string): LifecycleStatus { + if (this._active.has(id)) return { status: 'ready' }; + if (this._provisioning.has(id)) return { status: 'bootstrapping' }; + const error = this._errors.get(id); + if (error) return { status: 'error', message: formatError(error) }; + return { status: 'not-started' }; + } + + /** + * Provisions a resource with deduplication. + * - If already active, returns the existing value immediately. + * - If already in-flight, returns the existing promise. + * - Otherwise calls `run`, stores the result, and returns it. + */ + provision(id: string, run: () => Promise>): Promise> { + const existing = this._active.get(id); + if (existing !== undefined) return Promise.resolve(ok(existing)); + + const inFlight = this._provisioning.get(id); + if (inFlight) return inFlight; + + const promise = run().then((result) => { + if (result.success) this._active.set(id, result.data); + else this._errors.set(id, result.error); + this._provisioning.delete(id); + return result; + }); + + this._provisioning.set(id, promise); + return promise; + } + + /** + * Tears down a resource with deduplication. + * - If already tearing down, returns the existing promise. + * - If not found in the active map, returns `null` — caller decides what to do. + * - Otherwise calls `run`, cleans up maps, calls `onFinally`, and returns the result. + * + * The teardown error type `TE` is independent of the provision error type `E`, + * since provision and teardown may surface different error variants. + */ + teardown( + id: string, + run: (value: T) => Promise>, + onFinally?: () => void + ): Promise> | null { + const inFlight = this._tearingDown.get(id) as Promise> | undefined; + if (inFlight) return inFlight; + + const value = this._active.get(id); + if (value === undefined) return null; + + const promise = run(value) + .then((result) => { + if (!result.success) return err(result.error); + return ok(); + }) + .finally(() => { + this._active.delete(id); + this._tearingDown.delete(id); + onFinally?.(); + }); + + this._tearingDown.set(id, promise as Promise>); + return promise; + } +} diff --git a/src/main/lib/logger.ts b/src/main/lib/logger.ts index ffad11dca6..6f2adb3647 100644 --- a/src/main/lib/logger.ts +++ b/src/main/lib/logger.ts @@ -4,3 +4,5 @@ export const log = createLogger({ envLevel: process.env.LOG_LEVEL, debugFlag: process.argv.includes('--debug-logs'), }); + +export type Logger = ReturnType; diff --git a/src/shared/result.ts b/src/shared/result.ts index 60ba69279a..843af1239b 100644 --- a/src/shared/result.ts +++ b/src/shared/result.ts @@ -4,3 +4,25 @@ export type Result = Ok | Err; export const ok = (data: T = undefined as T): Ok => ({ success: true, data }); export const err = (error: E): Err => ({ success: false, error }); + +export type BaseError = { + type: T; + message?: M; + cause?: unknown; +}; + +export function withAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + +export function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms); + promise.then(resolve, reject).finally(() => clearTimeout(timer)); + }); +} From 1de67262998e30940c35e063c83c4c7d1ebfd367 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 13:18:13 +0200 Subject: [PATCH 087/263] chore: cleanup project lifecycle --- src/main/core/git/git-watcher-registry.ts | 27 +++++++++++ src/main/core/projects/create-provider.ts | 14 ++++++ .../projects/impl/local-project-provider.ts | 11 ++--- .../projects/impl/ssh-project-provider.ts | 40 ++-------------- src/main/core/projects/project-manager.ts | 18 ++----- src/main/core/projects/project-provider.ts | 1 + .../core/projects/task-provision-manager.ts | 47 +++++++++---------- src/main/core/projects/workspace-factory.ts | 6 ++- .../terminals/impl/ssh-terminal-provider.ts | 28 +++++++++-- .../workspaces/byoi/provision-byoi-task.ts | 4 +- src/main/index.ts | 2 + 11 files changed, 108 insertions(+), 90 deletions(-) create mode 100644 src/main/core/git/git-watcher-registry.ts create mode 100644 src/main/core/projects/create-provider.ts diff --git a/src/main/core/git/git-watcher-registry.ts b/src/main/core/git/git-watcher-registry.ts new file mode 100644 index 0000000000..cb2bda262e --- /dev/null +++ b/src/main/core/git/git-watcher-registry.ts @@ -0,0 +1,27 @@ +import { projectManager } from '../projects/project-manager'; +import { GitWatcherService } from './git-watcher-service'; + +export class GitWatcherRegistry { + private readonly _watchers = new Map(); + + initialize(): void { + projectManager.on('projectOpened', (projectId, provider) => { + if (provider.type !== 'local') return; + const watcher = new GitWatcherService(projectId, provider.repoPath); + void watcher.start(); + this._watchers.set(projectId, watcher); + }); + projectManager.on('projectClosed', (projectId) => { + const watcher = this._watchers.get(projectId); + if (!watcher) return; + void watcher.stop(); + this._watchers.delete(projectId); + }); + } + + get(projectId: string): GitWatcherService | undefined { + return this._watchers.get(projectId); + } +} + +export const gitWatcherRegistry = new GitWatcherRegistry(); diff --git a/src/main/core/projects/create-provider.ts b/src/main/core/projects/create-provider.ts new file mode 100644 index 0000000000..c76959a6bb --- /dev/null +++ b/src/main/core/projects/create-provider.ts @@ -0,0 +1,14 @@ +import type { LocalProject, SshProject } from '@shared/projects'; +import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import { SshFileSystem } from '../fs/impl/ssh-fs'; +import { createLocalProvider } from './impl/local-project-provider'; +import { createSshProvider } from './impl/ssh-project-provider'; + +export async function createProvider(project: LocalProject | SshProject) { + if (project.type === 'ssh') { + const proxy = await sshConnectionManager.connect(project.connectionId); + const rootFs = new SshFileSystem(proxy, '/'); + return createSshProvider(project, rootFs, proxy); + } + return createLocalProvider(project); +} diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index e44696161c..ef18c0a4d7 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -9,7 +9,7 @@ import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { GitWatcherService } from '@main/core/git/git-watcher-service'; +import { gitWatcherRegistry } from '@main/core/git/git-watcher-registry'; import { GitService } from '@main/core/git/impl/git-service'; import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; @@ -51,9 +51,6 @@ export async function createLocalProvider(project: LocalProject): Promise (await githubConnectionService.getToken()) !== null @@ -117,7 +114,7 @@ export async function createLocalProvider(project: LocalProject): Promise { @@ -157,6 +154,7 @@ export async function createLocalProvider(project: LocalProject): Promise { configChangeUnsub(); gitFetchService.stop(); - await gitWatcher.stop(); const projectSettings = await settings.get(); const mode = projectSettings.tmux ? 'detach' : 'terminate'; await taskManager.teardownAll({ mode }); diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts index 8164dd14a0..8cbf327c92 100644 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ b/src/main/core/projects/impl/ssh-project-provider.ts @@ -5,7 +5,6 @@ import type { SshProject } from '@shared/projects'; import { makePtySessionId } from '@shared/ptySessionId'; import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; -import type { SshConversationProvider } from '@main/core/conversations/impl/ssh-conversation'; import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { FileSystemProvider } from '@main/core/fs/types'; import { GitFetchService } from '@main/core/git/git-fetch-service'; @@ -20,7 +19,6 @@ import { type SshConnectionEvent, } from '@main/core/ssh/ssh-connection-manager'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; -import type { SshTerminalProvider } from '@main/core/terminals/impl/ssh-terminal-provider'; import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; import { sshWorkspaceId } from '@main/core/workspaces/workspace-id'; import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; @@ -69,9 +67,6 @@ export async function createSshProvider( ); gitFetchService.start(); - const conversationProviders = new Map(); - const terminalProviders = new Map(); - async function doProvisionTask( task: Task, conversations: Conversation[], @@ -102,12 +97,12 @@ export async function createSshProvider( void gitFetchService.fetch(); void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - const { provisionResult, buildTaskResult } = await provisionLocalTask({ + const { provisionResult } = await provisionLocalTask({ task, conversations, terminals, workspaceId: sshWorkspaceId(project.id, task.taskBranch), - type: { kind: 'ssh', proxy }, + type: { kind: 'ssh', proxy, connectionId: project.connectionId }, projectId: project.id, projectPath: project.path, settings, @@ -117,11 +112,6 @@ export async function createSshProvider( logPrefix: 'SshProjectProvider', }); - terminalProviders.set(task.id, buildTaskResult.terminalProvider as SshTerminalProvider); - conversationProviders.set( - task.id, - buildTaskResult.conversationProvider as SshConversationProvider - ); log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); return { @@ -160,35 +150,12 @@ export async function createSshProvider( 'SshProjectProvider', doProvisionTask, doTeardownTask, - cleanupDetachedTmuxSessions, - (taskId) => { - conversationProviders.delete(taskId); - terminalProviders.delete(taskId); - } + cleanupDetachedTmuxSessions ); - async function rehydrateTerminals(): Promise { - await Promise.all( - Array.from(terminalProviders.values()).map((provider) => - provider.rehydrate().catch((e: unknown) => { - log.error('SshEnvironmentProvider: rehydrateTerminals failed for a provider', { - error: String(e), - }); - }) - ) - ); - } - function handleConnectionEvent(evt: SshConnectionEvent): void { if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { void gitFetchService.fetch(); - rehydrateTerminals().catch((e: unknown) => { - log.error('SshProjectProvider: rehydrateTerminals failed after reconnect', { - projectId: project.id, - connectionId: project.connectionId, - error: String(e), - }); - }); } } @@ -196,6 +163,7 @@ export async function createSshProvider( return { type: 'ssh', + repoPath: project.path, settings, repository, fs: projectFs, diff --git a/src/main/core/projects/project-manager.ts b/src/main/core/projects/project-manager.ts index f7bf122097..433e28d496 100644 --- a/src/main/core/projects/project-manager.ts +++ b/src/main/core/projects/project-manager.ts @@ -3,10 +3,7 @@ import { err, ok, type Result } from '@shared/result'; import { HookCore, type Hookable } from '@main/lib/hookable'; import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; -import { SshFileSystem } from '../fs/impl/ssh-fs'; -import { sshConnectionManager } from '../ssh/ssh-connection-manager'; -import { createLocalProvider } from './impl/local-project-provider'; -import { createSshProvider } from './impl/ssh-project-provider'; +import { createProvider } from './create-provider'; import type { ProjectProvider } from './project-provider'; import { TimeoutSignal, withTimeout } from './utils'; @@ -15,7 +12,7 @@ const LOCAL_PROVIDER_TIMEOUT_MS = 20_000; const TEARDOWN_PROVIDER_TIMEOUT_MS = 60_000; export type ProjectManagerHooks = { - projectOpened: (projectId: string) => void | Promise; + projectOpened: (projectId: string, provider: ProjectProvider) => void | Promise; projectClosed: (projectId: string) => void | Promise; }; @@ -52,7 +49,7 @@ class ProjectManager implements Hookable { createProvider(project), project.type === 'ssh' ? SSH_PROVIDER_TIMEOUT_MS : LOCAL_PROVIDER_TIMEOUT_MS ); - this._hooks.callHookBackground('projectOpened', project.id); + this._hooks.callHookBackground('projectOpened', project.id, provider); return ok(provider); } catch (e) { const initError = toInitError(e); @@ -98,13 +95,4 @@ class ProjectManager implements Hookable { } } -async function createProvider(project: LocalProject | SshProject) { - if (project.type === 'ssh') { - const proxy = await sshConnectionManager.connect(project.connectionId); - const rootFs = new SshFileSystem(proxy, '/'); - return createSshProvider(project, rootFs, proxy); - } - return createLocalProvider(project); -} - export const projectManager = new ProjectManager(); diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index bf5257cd8f..717a70dc25 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -44,6 +44,7 @@ export interface TaskProvider { export interface ProjectProvider { readonly type: string; + readonly repoPath: string; readonly settings: ProjectSettingsProvider; readonly repository: GitRepositoryService; readonly fs: FileSystemProvider; diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts index eed1dbc7e6..0fe9e49d80 100644 --- a/src/main/core/projects/task-provision-manager.ts +++ b/src/main/core/projects/task-provision-manager.ts @@ -38,8 +38,7 @@ export class TaskProvisionManager { private readonly logPrefix: string, private readonly provisionFn: ProvisionFn, private readonly teardownFn: TeardownFn, - private readonly detachedCleanupFn: DetachedCleanupFn, - private readonly onTeardownFinally?: (taskId: string) => void + private readonly detachedCleanupFn: DetachedCleanupFn ) {} async provisionTask( @@ -82,32 +81,28 @@ export class TaskProvisionManager { mode: TeardownMode = 'terminate' ): Promise> { return ( - this._lifecycle.teardown( - taskId, - async (provisionResult) => { - const { taskProvider, persistData } = provisionResult; - try { - await withTimeout( - this.teardownFn(taskProvider, persistData.workspaceId, mode), - TASK_TIMEOUT_MS - ); - return ok(); - } catch (e) { - log.error(`${this.logPrefix}: failed to teardown task`, { + this._lifecycle.teardown(taskId, async (provisionResult) => { + const { taskProvider, persistData } = provisionResult; + try { + await withTimeout( + this.teardownFn(taskProvider, persistData.workspaceId, mode), + TASK_TIMEOUT_MS + ); + return ok(); + } catch (e) { + log.error(`${this.logPrefix}: failed to teardown task`, { + taskId, + error: String(e), + }); + await this.detachedCleanupFn(taskId).catch((cleanupError) => { + log.warn(`${this.logPrefix}: fallback cleanup failed`, { taskId, - error: String(e), + error: String(cleanupError), }); - await this.detachedCleanupFn(taskId).catch((cleanupError) => { - log.warn(`${this.logPrefix}: fallback cleanup failed`, { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - } - }, - () => this.onTeardownFinally?.(taskId) - ) ?? (await this.detachedCleanupFn(taskId).then(() => ok())) + }); + return err(toTeardownError(e)); + } + }) ?? (await this.detachedCleanupFn(taskId).then(() => ok())) ); } diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/projects/workspace-factory.ts index 5873943264..f680a1dcc2 100644 --- a/src/main/core/projects/workspace-factory.ts +++ b/src/main/core/projects/workspace-factory.ts @@ -23,7 +23,9 @@ import type { ProjectSettingsProvider } from './settings/schema'; import { getEffectiveTaskSettings } from './settings/task-settings'; import { TimeoutSignal, withTimeout } from './utils'; -export type WorkspaceType = { kind: 'local' } | { kind: 'ssh'; proxy: SshClientProxy }; +export type WorkspaceType = + | { kind: 'local' } + | { kind: 'ssh'; proxy: SshClientProxy; connectionId: string }; type WorkspaceFactoryContext = { task: Pick; @@ -102,6 +104,7 @@ export function createWorkspaceFactory( shellSetup, exec, proxy: type.proxy, + connectionId: type.connectionId, taskEnvVars: bootstrapTaskEnvVars, }) : new LocalTerminalProvider({ @@ -251,6 +254,7 @@ export function buildTaskProviders( shellSetup: opts.shellSetup, exec, proxy: type.proxy, + connectionId: type.connectionId, taskEnvVars: opts.taskEnvVars, }), }; diff --git a/src/main/core/terminals/impl/ssh-terminal-provider.ts b/src/main/core/terminals/impl/ssh-terminal-provider.ts index a40ad0675d..06d8c0c6fe 100644 --- a/src/main/core/terminals/impl/ssh-terminal-provider.ts +++ b/src/main/core/terminals/impl/ssh-terminal-provider.ts @@ -1,17 +1,21 @@ import type { GeneralSessionConfig } from '@shared/general-session'; import { makePtySessionId } from '@shared/ptySessionId'; -import { Terminal } from '@shared/terminals'; -import { Pty } from '@main/core/pty/pty'; +import type { Terminal } from '@shared/terminals'; +import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; import { type LifecycleScriptSpawnRequest, type TerminalProvider, } from '@main/core/terminals/terminal-provider'; -import { ExecFn } from '@main/core/utils/exec'; +import type { ExecFn } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { wireTerminalDevServerWatcher } from '../dev-server-watcher'; @@ -39,6 +43,8 @@ export class SshTerminalProvider implements TerminalProvider { private readonly shellSetup?: string; private readonly exec: ExecFn; private readonly proxy: SshClientProxy; + private readonly connectionId: string; + private readonly _handleReconnect: (evt: SshConnectionEvent) => void; constructor({ projectId, @@ -49,6 +55,7 @@ export class SshTerminalProvider implements TerminalProvider { shellSetup, exec, proxy, + connectionId, }: { projectId: string; scopeId: string; @@ -58,6 +65,7 @@ export class SshTerminalProvider implements TerminalProvider { shellSetup?: string; exec: ExecFn; proxy: SshClientProxy; + connectionId: string; }) { this.projectId = projectId; this.scopeId = scopeId; @@ -67,6 +75,19 @@ export class SshTerminalProvider implements TerminalProvider { this.shellSetup = shellSetup; this.exec = exec; this.proxy = proxy; + this.connectionId = connectionId; + this._handleReconnect = (evt: SshConnectionEvent) => { + if (evt.type === 'reconnected' && evt.connectionId === this.connectionId) { + this.rehydrate().catch((e: unknown) => { + log.error('SshTerminalProvider: rehydrate failed after reconnect', { + scopeId: this.scopeId, + connectionId: this.connectionId, + error: String(e), + }); + }); + } + }; + sshConnectionManager.on('connection-event', this._handleReconnect); } async spawnTerminal( @@ -227,6 +248,7 @@ export class SshTerminalProvider implements TerminalProvider { } async destroyAll(): Promise { + sshConnectionManager.off('connection-event', this._handleReconnect); const sessionIds = Array.from(this.knownSessionIds); await this.detachAll(); if (this.tmux) { diff --git a/src/main/core/workspaces/byoi/provision-byoi-task.ts b/src/main/core/workspaces/byoi/provision-byoi-task.ts index 6e05922fba..2d8603d321 100644 --- a/src/main/core/workspaces/byoi/provision-byoi-task.ts +++ b/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -96,7 +96,7 @@ export async function provisionBYOITask(params: ProvisionBYOITaskParams): Promis projectId, createWorkspaceFactory( workspaceId, - { kind: 'ssh', proxy }, + { kind: 'ssh', proxy, connectionId }, { task, workDir, @@ -133,7 +133,7 @@ export async function provisionBYOITask(params: ProvisionBYOITaskParams): Promis const { taskProvider } = await buildTaskFromWorkspace( task, workspace, - { kind: 'ssh', proxy }, + { kind: 'ssh', proxy, connectionId }, projectId, projectPath, settings, diff --git a/src/main/index.ts b/src/main/index.ts index fdba933579..b3f776a78a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import { agentHookService } from './core/agent-hooks/agent-hook-service'; import { appService } from './core/app/service'; import { localDependencyManager } from './core/dependencies/dependency-manager'; import { editorBufferService } from './core/editor/editor-buffer-service'; +import { gitWatcherRegistry } from './core/git/git-watcher-registry'; import { githubConnectionService } from './core/github/services/github-connection-service'; import { projectManager } from './core/projects/project-manager'; import { prSyncScheduler } from './core/pull-requests/pr-sync-scheduler'; @@ -88,6 +89,7 @@ app.whenReady().then(async () => { log.warn('telemetry init failed:', e); } + gitWatcherRegistry.initialize(); prSyncScheduler.initialize(); appService.initialize(); appSettingsService.initialize(); From 84f2b6bad771599cc1f8021805b1e795ca489d1b Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 14:18:52 +0200 Subject: [PATCH 088/263] chore: refactor to global task manager --- src/main/core/git/git-watcher-registry.ts | 8 + .../projects/impl/local-project-provider.ts | 116 +------- .../projects/impl/ssh-project-provider.ts | 110 +------ .../core/projects/operations/deleteProject.ts | 3 +- src/main/core/projects/project-provider.ts | 12 +- src/main/core/projects/task-manager.ts | 276 ++++++++++++++++++ src/main/core/projects/utils.ts | 6 +- src/main/core/pty/controller.ts | 9 +- src/main/core/tasks/archiveTask.ts | 5 +- src/main/core/tasks/createTask.ts | 3 +- src/main/core/tasks/deleteTask.ts | 3 +- src/main/core/tasks/getBootstrapStatus.ts | 9 +- src/main/core/tasks/getTaskSettings.ts | 12 +- src/main/core/tasks/provisionTask.ts | 12 +- src/main/core/tasks/teardownTask.ts | 8 +- 15 files changed, 349 insertions(+), 243 deletions(-) create mode 100644 src/main/core/projects/task-manager.ts diff --git a/src/main/core/git/git-watcher-registry.ts b/src/main/core/git/git-watcher-registry.ts index cb2bda262e..0ebca983e1 100644 --- a/src/main/core/git/git-watcher-registry.ts +++ b/src/main/core/git/git-watcher-registry.ts @@ -1,4 +1,5 @@ import { projectManager } from '../projects/project-manager'; +import { taskManager } from '../projects/task-manager'; import { GitWatcherService } from './git-watcher-service'; export class GitWatcherRegistry { @@ -17,6 +18,13 @@ export class GitWatcherRegistry { void watcher.stop(); this._watchers.delete(projectId); }); + taskManager.hooks.on('task:provisioned', ({ projectId, workspaceId, worktreeGitDir }) => { + if (!worktreeGitDir) return; + this._watchers.get(projectId)?.registerWorktree(workspaceId, worktreeGitDir); + }); + taskManager.hooks.on('task:torn-down', ({ projectId, workspaceId }) => { + this._watchers.get(projectId)?.unregisterWorktree(workspaceId); + }); } get(projectId: string): GitWatcherService | undefined { diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts index ef18c0a4d7..11a5c77d78 100644 --- a/src/main/core/projects/impl/local-project-provider.ts +++ b/src/main/core/projects/impl/local-project-provider.ts @@ -1,36 +1,21 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { Conversation } from '@shared/conversations'; import { gitRefChangedChannel } from '@shared/events/gitEvents'; import { bareRefName } from '@shared/git-utils'; import type { LocalProject } from '@shared/projects'; -import { makePtySessionId } from '@shared/ptySessionId'; -import type { Task } from '@shared/tasks'; -import type { Terminal } from '@shared/terminals'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { gitWatcherRegistry } from '@main/core/git/git-watcher-registry'; import { GitService } from '@main/core/git/impl/git-service'; import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import type { - ProjectProvider, - ProvisionResult, - TaskProvider, -} from '@main/core/projects/project-provider'; +import type { ProjectProvider } from '@main/core/projects/project-provider'; import { LocalProjectSettingsProvider } from '@main/core/projects/settings/project-settings'; -import { provisionLocalTask } from '@main/core/projects/task-builder'; -import { TaskProvisionManager } from '@main/core/projects/task-provision-manager'; import { WorktreeService } from '@main/core/projects/worktrees/worktree-service'; -import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import { provisionBYOITask } from '@main/core/workspaces/byoi/provision-byoi-task'; -import { localWorkspaceId } from '@main/core/workspaces/workspace-id'; -import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; -import { log } from '@main/lib/logger'; +import { taskManager } from '../task-manager'; import { LocalWorktreeHost } from '../worktrees/hosts/local-worktree-host'; export async function createLocalProvider(project: LocalProject): Promise { @@ -65,100 +50,17 @@ export async function createLocalProvider(project: LocalProject): Promise { - log.debug('LocalProjectProvider: doProvisionTask START', { taskId: task.id }); - - if (task.workspaceProvider === 'byoi') { - const projectSettings = await settings.get(); - if (projectSettings.workspaceProvider?.type !== 'script') { - throw new Error( - 'Task has workspaceProvider=byoi but project has no script provider configured' - ); - } - return provisionBYOITask({ - task, - conversations, - terminals, - wpConfig: projectSettings.workspaceProvider, - execFn: localExec, - projectId: project.id, - projectPath: project.path, - settings, - logPrefix: 'LocalProjectProvider[byoi]', - }); - } - - void gitFetchService.fetch(); - void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - - const workspaceId = localWorkspaceId(project.id, task.taskBranch); - - const { provisionResult, workspace } = await provisionLocalTask({ - task, - conversations, - terminals, - workspaceId, - type: { kind: 'local' }, - projectId: project.id, - projectPath: project.path, - settings, - worktreeService, - fetchService: gitFetchService, - repository, - logPrefix: 'LocalProjectProvider', - }); - - const mainDotGitAbs = path.resolve(project.path, '.git'); - const relativeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); - gitWatcherRegistry.get(project.id)?.registerWorktree(workspaceId, relativeGitDir); - - return provisionResult; - } - - async function doTeardownTask( - task: TaskProvider, - workspaceId: string, - mode: TeardownMode - ): Promise { - if (mode === 'detach') { - await task.conversations.detachAll(); - await task.terminals.detachAll(); - } else { - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - } - await workspaceRegistry.release(workspaceId, mode); - gitWatcherRegistry.get(project.id)?.unregisterWorktree(workspaceId); - } - - async function cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(project.id, taskId); - const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(project.id, taskId, leafId) - ); - await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(localExec, makeTmuxSessionName(sessionId))) - ); - } - - const taskManager = new TaskProvisionManager( - 'LocalProjectProvider', - doProvisionTask, - doTeardownTask, - cleanupDetachedTmuxSessions - ); - return { type: 'local', + projectId: project.id, repoPath: project.path, + exec: localExec, settings, repository, fs: localFs, - tasks: taskManager, + worktreeService, + gitFetchService, + workspaceType: { kind: 'local' }, getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), removeTaskWorktree: async (taskBranch) => { const worktreePath = await worktreeService.getWorktree(taskBranch); @@ -173,7 +75,7 @@ export async function createLocalProvider(project: LocalProject): Promise { - log.debug('SshProjectProvider: doProvisionTask START', { taskId: task.id }); - - if (task.workspaceProvider === 'byoi') { - const projectSettings = await settings.get(); - if (projectSettings.workspaceProvider?.type !== 'script') { - throw new Error( - 'Task has workspaceProvider=byoi but project has no script provider configured' - ); - } - return provisionBYOITask({ - task, - conversations, - terminals, - wpConfig: projectSettings.workspaceProvider, - execFn: getSshExec(proxy), - projectId: project.id, - projectPath: project.path, - settings, - logPrefix: 'SshProjectProvider[byoi]', - }); - } - - void gitFetchService.fetch(); - void prSyncScheduler.onTaskProvisioned(project.id, task.taskBranch); - - const { provisionResult } = await provisionLocalTask({ - task, - conversations, - terminals, - workspaceId: sshWorkspaceId(project.id, task.taskBranch), - type: { kind: 'ssh', proxy, connectionId: project.connectionId }, - projectId: project.id, - projectPath: project.path, - settings, - worktreeService, - fetchService: gitFetchService, - repository, - logPrefix: 'SshProjectProvider', - }); - - log.debug('SshProjectProvider: doProvisionTask DONE', { taskId: task.id }); - - return { - ...provisionResult, - persistData: { ...provisionResult.persistData, sshConnectionId: project.connectionId }, - }; - } - - async function doTeardownTask( - task: TaskProvider, - workspaceId: string, - mode: TeardownMode - ): Promise { - if (mode === 'detach') { - await task.conversations.detachAll(); - await task.terminals.detachAll(); - } else { - await task.conversations.destroyAll(); - await task.terminals.destroyAll(); - } - await workspaceRegistry.release(workspaceId, mode); - } - - async function cleanupDetachedTmuxSessions(taskId: string): Promise { - const { conversationIds, terminalIds } = await getTaskSessionLeafIds(project.id, taskId); - const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => - makePtySessionId(project.id, taskId, leafId) - ); - const sshExec = getSshExec(proxy); - await Promise.all( - sessionIds.map((sessionId) => killTmuxSession(sshExec, makeTmuxSessionName(sessionId))) - ); - } - - const taskManager = new TaskProvisionManager( - 'SshProjectProvider', - doProvisionTask, - doTeardownTask, - cleanupDetachedTmuxSessions - ); - function handleConnectionEvent(evt: SshConnectionEvent): void { if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { void gitFetchService.fetch(); @@ -163,11 +67,15 @@ export async function createSshProvider( return { type: 'ssh', + projectId: project.id, repoPath: project.path, + exec, settings, repository, fs: projectFs, - tasks: taskManager, + worktreeService, + gitFetchService, + workspaceType: { kind: 'ssh', proxy, connectionId: project.connectionId }, getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), removeTaskWorktree: async (taskBranch) => { const worktreePath = await worktreeService.getWorktree(taskBranch); @@ -182,7 +90,7 @@ export async function createSshProvider( sshConnectionManager.off('connection-event', handleConnectionEvent); const projectSettings = await settings.get(); const mode = projectSettings.tmux ? 'detach' : 'terminate'; - await taskManager.teardownAll({ mode }); + await taskManager.teardownAllForProject(project.id, mode); await workspaceRegistry.releaseAllForProject(project.id, mode); }, }; diff --git a/src/main/core/projects/operations/deleteProject.ts b/src/main/core/projects/operations/deleteProject.ts index 0acab53e9b..9561f4d8bc 100644 --- a/src/main/core/projects/operations/deleteProject.ts +++ b/src/main/core/projects/operations/deleteProject.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/projects/task-manager'; import { prSyncEngine } from '@main/core/pull-requests/pr-sync-engine'; import { getTasks } from '@main/core/tasks/getTasks'; import { viewStateService } from '@main/core/view-state/view-state-service'; @@ -12,7 +13,7 @@ export async function deleteProject(id: string): Promise { if (provider) { const projectTasks = await getTasks(id); await Promise.allSettled([ - ...projectTasks.map((t) => provider.tasks.teardownTask(t.id)), + ...projectTasks.map((t) => taskManager.teardownTask(t.id)), ...projectTasks.map((t) => viewStateService.del(`task:${t.id}`)), ]); } diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 717a70dc25..5e24e4824a 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -2,11 +2,14 @@ import type { Branch, FetchError } from '@shared/git'; import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; +import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { ExecFn } from '@main/core/utils/exec'; import type { ConversationProvider } from '../conversations/types'; import type { GitRepositoryService } from '../git/repository-service'; import type { TerminalProvider } from '../terminals/terminal-provider'; import type { ProjectSettingsProvider } from './settings/schema'; -import type { TaskProvisionManager } from './task-provision-manager'; +import type { WorkspaceType } from './workspace-factory'; +import type { WorktreeService } from './worktrees/worktree-service'; export type ProvisionTaskError = | { type: 'timeout'; message: string; timeout: number } @@ -30,6 +33,7 @@ export type ProvisionResult = { workspaceId: string; workspaceProviderData?: WorkspaceProviderData; sshConnectionId?: string; + worktreeGitDir?: string; }; }; @@ -44,11 +48,15 @@ export interface TaskProvider { export interface ProjectProvider { readonly type: string; + readonly projectId: string; readonly repoPath: string; + readonly exec: ExecFn; readonly settings: ProjectSettingsProvider; readonly repository: GitRepositoryService; readonly fs: FileSystemProvider; - readonly tasks: TaskProvisionManager; + readonly worktreeService: WorktreeService; + readonly gitFetchService: GitFetchService; + readonly workspaceType: WorkspaceType; getRemoteState(): Promise; getWorktreeForBranch(branchName: string): Promise; removeTaskWorktree(taskBranch: string): Promise; diff --git a/src/main/core/projects/task-manager.ts b/src/main/core/projects/task-manager.ts new file mode 100644 index 0000000000..bcf6601d2b --- /dev/null +++ b/src/main/core/projects/task-manager.ts @@ -0,0 +1,276 @@ +import path from 'node:path'; +import type { Conversation } from '@shared/conversations'; +import { makePtySessionId } from '@shared/ptySessionId'; +import { err, ok, type Result } from '@shared/result'; +import type { Task, TaskBootstrapStatus } from '@shared/tasks'; +import type { Terminal } from '@shared/terminals'; +import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; +import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; +import type { ExecFn } from '@main/core/utils/exec'; +import { provisionBYOITask } from '@main/core/workspaces/byoi/provision-byoi-task'; +import { localWorkspaceId, sshWorkspaceId } from '@main/core/workspaces/workspace-id'; +import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/workspace-registry'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { LifecycleMap } from '@main/lib/lifecycle-map'; +import { log } from '@main/lib/logger'; +import type { + ProjectProvider, + ProvisionResult, + ProvisionTaskError, + TaskProvider, + TeardownTaskError, +} from './project-provider'; +import { + formatProvisionTaskError, + TASK_TIMEOUT_MS, + toProvisionError, + toTeardownError, +} from './provision-task-error'; +import { provisionLocalTask } from './task-builder'; +import { withTimeout } from './utils'; + +type StoredTask = ProvisionResult & { projectId: string; exec: ExecFn }; + +export type TaskManagerHooks = { + 'task:provisioned': (info: { + projectId: string; + taskId: string; + workspaceId: string; + worktreeGitDir?: string; + }) => void | Promise; + 'task:torn-down': (info: { + projectId: string; + taskId: string; + workspaceId: string; + }) => void | Promise; +}; + +async function executeProvision( + provider: ProjectProvider, + task: Task, + conversations: Conversation[], + terminals: Terminal[] +): Promise { + if (task.workspaceProvider === 'byoi') { + const projectSettings = await provider.settings.get(); + if (projectSettings.workspaceProvider?.type !== 'script') { + throw new Error( + 'Task has workspaceProvider=byoi but project has no script provider configured' + ); + } + return provisionBYOITask({ + task, + conversations, + terminals, + wpConfig: projectSettings.workspaceProvider, + execFn: provider.exec, + projectId: provider.projectId, + projectPath: provider.repoPath, + settings: provider.settings, + logPrefix: `${provider.type}ProjectProvider[byoi]`, + }); + } + + void provider.gitFetchService.fetch(); + void prSyncScheduler.onTaskProvisioned(provider.projectId, task.taskBranch); + + const workspaceId = + provider.workspaceType.kind === 'local' + ? localWorkspaceId(provider.projectId, task.taskBranch) + : sshWorkspaceId(provider.projectId, task.taskBranch); + + const { provisionResult, workspace } = await provisionLocalTask({ + task, + conversations, + terminals, + workspaceId, + type: provider.workspaceType, + projectId: provider.projectId, + projectPath: provider.repoPath, + settings: provider.settings, + worktreeService: provider.worktreeService, + fetchService: provider.gitFetchService, + repository: provider.repository, + logPrefix: `${provider.type}ProjectProvider`, + }); + + if (provider.workspaceType.kind === 'local') { + const mainDotGitAbs = path.resolve(provider.repoPath, '.git'); + const worktreeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); + return { + ...provisionResult, + persistData: { ...provisionResult.persistData, worktreeGitDir }, + }; + } + + return { + ...provisionResult, + persistData: { + ...provisionResult.persistData, + sshConnectionId: provider.workspaceType.connectionId, + }, + }; +} + +async function executeTeardown( + task: TaskProvider, + workspaceId: string, + mode: TeardownMode +): Promise { + if (mode === 'detach') { + await task.conversations.detachAll(); + await task.terminals.detachAll(); + } else { + await task.conversations.destroyAll(); + await task.terminals.destroyAll(); + } + await workspaceRegistry.release(workspaceId, mode); +} + +async function cleanupDetachedSessions( + projectId: string, + taskId: string, + exec: ExecFn +): Promise { + const { conversationIds, terminalIds } = await getTaskSessionLeafIds(projectId, taskId); + const sessionIds = [...conversationIds, ...terminalIds].map((leafId) => + makePtySessionId(projectId, taskId, leafId) + ); + await Promise.all( + sessionIds.map((sessionId) => killTmuxSession(exec, makeTmuxSessionName(sessionId))) + ); +} + +class TaskManager { + private readonly _lifecycle = new LifecycleMap(); + private readonly _tasksByProject = new Map>(); + private readonly _hooks = new HookCore((name, e) => + log.error(`TaskManager: ${String(name)} hook error`, e) + ); + + readonly hooks: Hookable = this._hooks; + + async provisionTask( + provider: ProjectProvider, + task: Task, + conversations: Conversation[], + terminals: Terminal[] + ): Promise> { + return this._lifecycle.provision(task.id, async () => { + try { + const result = await withTimeout( + executeProvision(provider, task, conversations, terminals), + TASK_TIMEOUT_MS + ); + const stored: StoredTask = { + ...result, + projectId: provider.projectId, + exec: provider.exec, + }; + + const byProject = this._tasksByProject.get(provider.projectId) ?? new Set(); + byProject.add(task.id); + this._tasksByProject.set(provider.projectId, byProject); + + this._hooks.callHookBackground('task:provisioned', { + projectId: provider.projectId, + taskId: task.id, + workspaceId: result.persistData.workspaceId, + worktreeGitDir: result.persistData.worktreeGitDir, + }); + + return ok(stored); + } catch (e) { + const provisionError = toProvisionError(e); + log.error('TaskManager: failed to provision task', { + taskId: task.id, + projectId: provider.projectId, + error: String(e), + }); + return err(provisionError); + } + }); + } + + async teardownTask( + taskId: string, + mode: TeardownMode = 'terminate' + ): Promise> { + // Pre-capture stored task so the onFinally closure has access to hook data. + const stored = this._lifecycle.get(taskId); + + const result = this._lifecycle.teardown( + taskId, + async ({ taskProvider, persistData, projectId, exec }) => { + try { + await withTimeout( + executeTeardown(taskProvider, persistData.workspaceId, mode), + TASK_TIMEOUT_MS + ); + return ok(); + } catch (e) { + log.error('TaskManager: failed to teardown task', { taskId, error: String(e) }); + await cleanupDetachedSessions(projectId, taskId, exec).catch((cleanupError) => { + log.warn('TaskManager: fallback cleanup failed', { + taskId, + error: String(cleanupError), + }); + }); + return err(toTeardownError(e)); + } + }, + () => { + if (!stored) return; + this._tasksByProject.get(stored.projectId)?.delete(taskId); + this._hooks.callHookBackground('task:torn-down', { + projectId: stored.projectId, + taskId, + workspaceId: stored.persistData.workspaceId, + }); + } + ); + + return result ?? ok(); + } + + async teardownAllForProject(projectId: string, mode: TeardownMode): Promise { + const taskIds = Array.from(this._tasksByProject.get(projectId) ?? []); + if (mode === 'detach') { + // Detach sessions but leave workspaces alive; provider.cleanup() will call + // workspaceRegistry.releaseAllForProject to handle workspace teardown. + await Promise.all( + taskIds.flatMap((id) => { + const stored = this._lifecycle.get(id); + if (!stored) return []; + return [ + stored.taskProvider.conversations.detachAll(), + stored.taskProvider.terminals.detachAll(), + ]; + }) + ); + // Remove entries from lifecycle maps without running workspace teardown. + this._tasksByProject.delete(projectId); + await Promise.all( + taskIds.map((id) => this._lifecycle.teardown(id, async () => ok()) ?? Promise.resolve(ok())) + ); + } else { + // teardownTask handles _tasksByProject cleanup in onFinally. + await Promise.all(taskIds.map((id) => this.teardownTask(id, 'terminate'))); + } + } + + getTask(taskId: string): TaskProvider | undefined { + return this._lifecycle.get(taskId)?.taskProvider; + } + + getWorkspaceId(taskId: string): string | undefined { + return this._lifecycle.get(taskId)?.persistData.workspaceId; + } + + getBootstrapStatus(taskId: string): TaskBootstrapStatus { + return this._lifecycle.bootstrapStatus(taskId, formatProvisionTaskError); + } +} + +export const taskManager = new TaskManager(); diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index f2680e6375..e02ab846a2 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -1,8 +1,8 @@ import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { projectManager } from './project-manager'; +import { taskManager } from './task-manager'; -export function resolveTask(projectId: string, taskId: string) { - return projectManager.getProject(projectId)?.tasks.getTask(taskId) ?? null; +export function resolveTask(_projectId: string, taskId: string) { + return taskManager.getTask(taskId) ?? null; } export function resolveWorkspace(_projectId: string, workspaceId: string) { diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index ef34f78e79..c2561a5656 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; import { log } from '@main/lib/logger'; -import { projectManager } from '../projects/project-manager'; +import { taskManager } from '../projects/task-manager'; import { workspaceRegistry } from '../workspaces/workspace-registry'; import { ptySessionRegistry } from './pty-session-registry'; @@ -69,13 +69,10 @@ export const ptyController = createRPCController({ const [projectId, scopeId] = args.sessionId.split(':'); if (!projectId || !scopeId) return err({ type: 'invalid_session' as const }); - const provider = projectManager.getProject(projectId); - if (!provider) return err({ type: 'not_ssh' as const }); - - const taskProvider = provider.tasks.getTask(scopeId); + const taskProvider = taskManager.getTask(scopeId); if (!taskProvider) return err({ type: 'not_ssh' as const }); - const workspaceId = provider.tasks.getWorkspaceId(scopeId) ?? ''; + const workspaceId = taskManager.getWorkspaceId(scopeId) ?? ''; const workspace = workspaceRegistry.get(workspaceId); if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); diff --git a/src/main/core/tasks/archiveTask.ts b/src/main/core/tasks/archiveTask.ts index fdc2ccc60b..39a47cc3c2 100644 --- a/src/main/core/tasks/archiveTask.ts +++ b/src/main/core/tasks/archiveTask.ts @@ -1,5 +1,6 @@ import { and, eq, isNull, sql } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/projects/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; @@ -24,14 +25,14 @@ export async function archiveTask(projectId: string, taskId: string): Promise { if (!teardownResult.success) { log.warn('archiveTask: teardown failed', { taskId, error: teardownResult.error.message }); } }) - .catch((e) => { + .catch((e: unknown) => { log.warn('archiveTask: teardown failed', { taskId, error: String(e) }); }); diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/createTask.ts index fa7bae7355..7ac08f70e4 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/createTask.ts @@ -9,6 +9,7 @@ import type { TaskLifecycleStatus, } from '@shared/tasks'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/projects/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; @@ -211,7 +212,7 @@ export async function createTask( const task = mapTaskRowToTask(taskRow, prs); - const provisionResult = await project.tasks.provisionTask(task, [], []); + const provisionResult = await taskManager.provisionTask(project, task, [], []); if (!provisionResult.success) { return err(mapProvisionError(provisionResult.error)); } diff --git a/src/main/core/tasks/deleteTask.ts b/src/main/core/tasks/deleteTask.ts index 0593e8d7b9..a628c79045 100644 --- a/src/main/core/tasks/deleteTask.ts +++ b/src/main/core/tasks/deleteTask.ts @@ -1,5 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/projects/task-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; @@ -14,7 +15,7 @@ export async function deleteTask(projectId: string, taskId: string): Promise { + const teardownResult = await taskManager.teardownTask(taskId, 'terminate').catch((e) => { log.warn('deleteTask: teardown failed', { taskId, error: String(e) }); return null; }); diff --git a/src/main/core/tasks/getBootstrapStatus.ts b/src/main/core/tasks/getBootstrapStatus.ts index 0ff35cde4a..e2e91751a1 100644 --- a/src/main/core/tasks/getBootstrapStatus.ts +++ b/src/main/core/tasks/getBootstrapStatus.ts @@ -1,15 +1,12 @@ import type { TaskBootstrapStatus } from '@shared/tasks'; -import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/projects/task-manager'; import { log } from '@main/lib/logger'; export async function getBootstrapStatus( - projectId: string, + _projectId: string, taskId: string ): Promise { - const project = projectManager.getProject(projectId); - if (!project) throw new Error(`Project not found: ${projectId}`); - - const status = project.tasks.getTaskBootstrapStatus(taskId); + const status = taskManager.getBootstrapStatus(taskId); log.debug('getBootstrapStatus', { taskId, status: status.status }); return status; } diff --git a/src/main/core/tasks/getTaskSettings.ts b/src/main/core/tasks/getTaskSettings.ts index 1e329d6c1b..401015061f 100644 --- a/src/main/core/tasks/getTaskSettings.ts +++ b/src/main/core/tasks/getTaskSettings.ts @@ -1,14 +1,16 @@ -import { projectManager } from '@main/core/projects/project-manager'; import type { ProjectSettings } from '@main/core/projects/settings/schema'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/task-settings'; +import { taskManager } from '@main/core/projects/task-manager'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -export async function getTaskSettings(projectId: string, taskId: string): Promise { - const project = projectManager.getProject(projectId); - if (!project?.tasks.getTask(taskId)) { +export async function getTaskSettings( + _projectId: string, + taskId: string +): Promise { + if (!taskManager.getTask(taskId)) { throw new Error(`Task ${taskId} not found or not provisioned`); } - const workspaceId = project.tasks.getWorkspaceId(taskId); + const workspaceId = taskManager.getWorkspaceId(taskId); if (!workspaceId) { throw new Error(`Workspace ID for task ${taskId} not found`); } diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index e6d47384fb..60a2ac0d66 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -2,6 +2,7 @@ import { eq, sql } from 'drizzle-orm'; import { mapConversationRowToConversation } from '@main/core/conversations/utils'; import { projectManager } from '@main/core/projects/project-manager'; import { formatProvisionTaskError } from '@main/core/projects/provision-task-error'; +import { taskManager } from '@main/core/projects/task-manager'; import { mapTerminalRowToTerminal } from '@main/core/terminals/core'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; @@ -17,10 +18,10 @@ export async function provisionTask(taskId: string) { const project = projectManager.getProject(task.projectId); if (!project) throw new Error(`Project not found: ${task.projectId}`); - const existingTask = project.tasks.getTask(taskId); + const existingTask = taskManager.getTask(taskId); if (existingTask) { - const wsId = project.tasks.getWorkspaceId(taskId) ?? ''; + const wsId = taskManager.getWorkspaceId(taskId) ?? ''; return { path: workspaceRegistry.get(wsId)?.path ?? '', workspaceId: wsId, @@ -41,7 +42,12 @@ export async function provisionTask(taskId: string) { .then((rows) => rows.map((r) => mapConversationRowToConversation(r, true))), ]); - const result = await project.tasks.provisionTask(task, existingConversations, existingTerminals); + const result = await taskManager.provisionTask( + project, + task, + existingConversations, + existingTerminals + ); if (!result.success) { throw new Error(`Failed to provision task: ${formatProvisionTaskError(result.error)}`); } diff --git a/src/main/core/tasks/teardownTask.ts b/src/main/core/tasks/teardownTask.ts index 53a9f22c86..0fc89fb9b4 100644 --- a/src/main/core/tasks/teardownTask.ts +++ b/src/main/core/tasks/teardownTask.ts @@ -1,7 +1,5 @@ -import { projectManager } from '../projects/project-manager'; +import { taskManager } from '../projects/task-manager'; -export async function teardownTask(projectId: string, taskId: string) { - const project = projectManager.getProject(projectId); - if (!project) throw new Error(`Project not found: ${projectId}`); - return await project.tasks.teardownTask(taskId, 'terminate'); +export async function teardownTask(_projectId: string, taskId: string) { + return await taskManager.teardownTask(taskId, 'terminate'); } From 6fa3b4888b17985241259eab91d767eae7a7e7b8 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 14:46:50 +0200 Subject: [PATCH 089/263] chore: refactor project provider --- src/main/core/projects/create-provider.ts | 157 +++++++++++++++++- .../projects/impl/local-project-provider.ts | 82 --------- .../projects/impl/ssh-project-provider.ts | 104 ------------ src/main/core/projects/project-provider.ts | 90 ++++++++-- .../core/projects/provision-task-error.ts | 11 +- src/main/core/projects/task-manager.ts | 18 +- .../core/projects/task-provision-manager.ts | 126 -------------- src/main/core/tasks/createTask.ts | 2 +- 8 files changed, 240 insertions(+), 350 deletions(-) delete mode 100644 src/main/core/projects/impl/local-project-provider.ts delete mode 100644 src/main/core/projects/impl/ssh-project-provider.ts delete mode 100644 src/main/core/projects/task-provision-manager.ts diff --git a/src/main/core/projects/create-provider.ts b/src/main/core/projects/create-provider.ts index c76959a6bb..a98c4cffda 100644 --- a/src/main/core/projects/create-provider.ts +++ b/src/main/core/projects/create-provider.ts @@ -1,14 +1,157 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { gitRefChangedChannel } from '@shared/events/gitEvents'; +import { bareRefName } from '@shared/git-utils'; import type { LocalProject, SshProject } from '@shared/projects'; -import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; -import { SshFileSystem } from '../fs/impl/ssh-fs'; -import { createLocalProvider } from './impl/local-project-provider'; -import { createSshProvider } from './impl/ssh-project-provider'; +import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; +import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; +import { GitFetchService } from '@main/core/git/git-fetch-service'; +import { GitService } from '@main/core/git/impl/git-service'; +import { GitRepositoryService } from '@main/core/git/repository-service'; +import { githubConnectionService } from '@main/core/github/services/github-connection-service'; +import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; +import { + sshConnectionManager, + type SshConnectionEvent, +} from '@main/core/ssh/ssh-connection-manager'; +import { getGitLocalExec, getGitSshExec, getLocalExec, getSshExec } from '@main/core/utils/exec'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import { ProjectProvider } from './project-provider'; +import { + LocalProjectSettingsProvider, + SshProjectSettingsProvider, +} from './settings/project-settings'; +import { LocalWorktreeHost } from './worktrees/hosts/local-worktree-host'; +import { SshWorktreeHost } from './worktrees/hosts/ssh-worktree-host'; +import { WorktreeService } from './worktrees/worktree-service'; -export async function createProvider(project: LocalProject | SshProject) { +const hasGitHubToken = async (): Promise => + (await githubConnectionService.getToken()) !== null; + +export async function createProvider(project: LocalProject | SshProject): Promise { if (project.type === 'ssh') { + return createSshProvider(project); + } + return createLocalProvider(project); +} + +async function createLocalProvider(project: LocalProject): Promise { + const localFs = new LocalFileSystem(project.path); + const exec = getLocalExec(); + const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); + const settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); + const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); + await fs.promises.mkdir(worktreePoolPath, { recursive: true }); + const worktreeHost = await LocalWorktreeHost.create({ allowedRoots: [project.path] }); + + const transport = { + kind: 'local' as const, + defaultWorkspaceType: { kind: 'local' as const }, + exec, + gitExec, + fs: localFs, + settings, + worktreeHost, + worktreePoolPath, + }; + + const repoGit = new GitService(project.path, gitExec, localFs); + const repository = new GitRepositoryService(repoGit, settings); + const worktreeService = new WorktreeService({ + worktreePoolPath, + repoPath: project.path, + projectSettings: settings, + exec: gitExec, + host: worktreeHost, + }); + const gitFetchService = new GitFetchService(repoGit, hasGitHubToken); + gitFetchService.start(); + + const unsub = events.on(gitRefChangedChannel, (p) => { + if (p.projectId === project.id && p.kind === 'config') { + void prSyncScheduler.onRemoteChanged(project.id); + } + }); + const dispose = () => unsub(); + + return new ProjectProvider( + project.id, + project.path, + transport, + repository, + worktreeService, + gitFetchService, + dispose + ); +} + +async function createSshProvider(project: SshProject): Promise { + try { const proxy = await sshConnectionManager.connect(project.connectionId); const rootFs = new SshFileSystem(proxy, '/'); - return createSshProvider(project, rootFs, proxy); + const projectFs = new SshFileSystem(proxy, project.path); + const exec = getSshExec(proxy); + const gitExec = getGitSshExec(proxy, () => githubConnectionService.getToken()); + + const settings = new SshProjectSettingsProvider( + projectFs, + bareRefName(project.baseRef), + rootFs, + project.path, + exec + ); + const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); + const worktreeHost = new SshWorktreeHost(rootFs); + await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); + + const transport = { + kind: 'ssh' as const, + defaultWorkspaceType: { kind: 'ssh' as const, proxy, connectionId: project.connectionId }, + exec, + gitExec, + fs: projectFs, + settings, + worktreeHost, + worktreePoolPath, + }; + + // SSH: disable local-filesystem git operations (CatFileBatch and streaming status) + const repoGit = new GitService(project.path, gitExec, projectFs, false); + const repository = new GitRepositoryService(repoGit, settings); + const worktreeService = new WorktreeService({ + worktreePoolPath, + repoPath: project.path, + projectSettings: settings, + exec: gitExec, + host: worktreeHost, + }); + const gitFetchService = new GitFetchService(repoGit, hasGitHubToken); + gitFetchService.start(); + + // Wire reconnect handler now that fetchService is in scope — no deferred injection needed. + const handler = (evt: SshConnectionEvent) => { + if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { + void gitFetchService.fetch(); + } + }; + sshConnectionManager.on('connection-event', handler); + const dispose = () => sshConnectionManager.off('connection-event', handler); + + return new ProjectProvider( + project.id, + project.path, + transport, + repository, + worktreeService, + gitFetchService, + dispose + ); + } catch (error) { + log.warn('createSshProvider: SSH connection failed', { + projectId: project.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; } - return createLocalProvider(project); } diff --git a/src/main/core/projects/impl/local-project-provider.ts b/src/main/core/projects/impl/local-project-provider.ts deleted file mode 100644 index 11a5c77d78..0000000000 --- a/src/main/core/projects/impl/local-project-provider.ts +++ /dev/null @@ -1,82 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { gitRefChangedChannel } from '@shared/events/gitEvents'; -import { bareRefName } from '@shared/git-utils'; -import type { LocalProject } from '@shared/projects'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { GitService } from '@main/core/git/impl/git-service'; -import { GitRepositoryService } from '@main/core/git/repository-service'; -import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import type { ProjectProvider } from '@main/core/projects/project-provider'; -import { LocalProjectSettingsProvider } from '@main/core/projects/settings/project-settings'; -import { WorktreeService } from '@main/core/projects/worktrees/worktree-service'; -import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; -import { getGitLocalExec, getLocalExec } from '@main/core/utils/exec'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { events } from '@main/lib/events'; -import { taskManager } from '../task-manager'; -import { LocalWorktreeHost } from '../worktrees/hosts/local-worktree-host'; - -export async function createLocalProvider(project: LocalProject): Promise { - const settings = new LocalProjectSettingsProvider(project.path, bareRefName(project.baseRef)); - const worktreePoolPath = path.join(await settings.getWorktreeDirectory(), project.name); - await fs.promises.mkdir(worktreePoolPath, { recursive: true }); - - const worktreeHost = await LocalWorktreeHost.create({ allowedRoots: [project.path] }); - - const localFs = new LocalFileSystem(project.path); - const gitExec = getGitLocalExec(() => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, localFs); - const repository = new GitRepositoryService(repoGit, settings); - const worktreeService = new WorktreeService({ - worktreePoolPath, - repoPath: project.path, - projectSettings: settings, - exec: gitExec, - host: worktreeHost, - }); - const gitFetchService = new GitFetchService( - repoGit, - async () => (await githubConnectionService.getToken()) !== null - ); - gitFetchService.start(); - - const configChangeUnsub = events.on(gitRefChangedChannel, (p) => { - if (p.projectId === project.id && p.kind === 'config') { - void prSyncScheduler.onRemoteChanged(project.id); - } - }); - - const localExec = getLocalExec(); - - return { - type: 'local', - projectId: project.id, - repoPath: project.path, - exec: localExec, - settings, - repository, - fs: localFs, - worktreeService, - gitFetchService, - workspaceType: { kind: 'local' }, - getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), - removeTaskWorktree: async (taskBranch) => { - const worktreePath = await worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await worktreeService.removeWorktree(worktreePath); - } - }, - fetch: () => gitFetchService.fetch(), - getRemoteState: () => repository.getRemoteState(), - cleanup: async () => { - configChangeUnsub(); - gitFetchService.stop(); - const projectSettings = await settings.get(); - const mode = projectSettings.tmux ? 'detach' : 'terminate'; - await taskManager.teardownAllForProject(project.id, mode); - await workspaceRegistry.releaseAllForProject(project.id, mode); - }, - }; -} diff --git a/src/main/core/projects/impl/ssh-project-provider.ts b/src/main/core/projects/impl/ssh-project-provider.ts deleted file mode 100644 index 45ca08a92f..0000000000 --- a/src/main/core/projects/impl/ssh-project-provider.ts +++ /dev/null @@ -1,104 +0,0 @@ -import path from 'node:path'; -import { bareRefName } from '@shared/git-utils'; -import type { SshProject } from '@shared/projects'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; -import { GitFetchService } from '@main/core/git/git-fetch-service'; -import { GitService } from '@main/core/git/impl/git-service'; -import { GitRepositoryService } from '@main/core/git/repository-service'; -import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import type { SshClientProxy } from '@main/core/ssh/ssh-client-proxy'; -import { - sshConnectionManager, - type SshConnectionEvent, -} from '@main/core/ssh/ssh-connection-manager'; -import { getGitSshExec, getSshExec } from '@main/core/utils/exec'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { log } from '@main/lib/logger'; -import { type ProjectProvider } from '../project-provider'; -import { SshProjectSettingsProvider } from '../settings/project-settings'; -import { taskManager } from '../task-manager'; -import { SshWorktreeHost } from '../worktrees/hosts/ssh-worktree-host'; -import { WorktreeService } from '../worktrees/worktree-service'; - -export async function createSshProvider( - project: SshProject, - rootFs: FileSystemProvider, - proxy: SshClientProxy -): Promise { - try { - const projectFs = new SshFileSystem(proxy, project.path); - const exec = getSshExec(proxy); - - const settings = new SshProjectSettingsProvider( - projectFs, - bareRefName(project.baseRef), - rootFs, - project.path, - exec - ); - const worktreePoolPath = path.posix.join(await settings.getWorktreeDirectory(), project.name); - const worktreeHost = new SshWorktreeHost(rootFs); - await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); - - const gitExec = getGitSshExec(proxy, () => githubConnectionService.getToken()); - const repoGit = new GitService(project.path, gitExec, projectFs, false); - const repository = new GitRepositoryService(repoGit, settings); - const worktreeService = new WorktreeService({ - worktreePoolPath, - repoPath: project.path, - projectSettings: settings, - exec: gitExec, - host: worktreeHost, - }); - const gitFetchService = new GitFetchService( - repoGit, - async () => (await githubConnectionService.getToken()) !== null - ); - gitFetchService.start(); - - function handleConnectionEvent(evt: SshConnectionEvent): void { - if (evt.type === 'reconnected' && evt.connectionId === project.connectionId) { - void gitFetchService.fetch(); - } - } - - sshConnectionManager.on('connection-event', handleConnectionEvent); - - return { - type: 'ssh', - projectId: project.id, - repoPath: project.path, - exec, - settings, - repository, - fs: projectFs, - worktreeService, - gitFetchService, - workspaceType: { kind: 'ssh', proxy, connectionId: project.connectionId }, - getWorktreeForBranch: (branch) => worktreeService.getWorktree(branch), - removeTaskWorktree: async (taskBranch) => { - const worktreePath = await worktreeService.getWorktree(taskBranch); - if (worktreePath) { - await worktreeService.removeWorktree(worktreePath); - } - }, - fetch: () => gitFetchService.fetch(), - getRemoteState: () => repository.getRemoteState(), - cleanup: async () => { - gitFetchService.stop(); - sshConnectionManager.off('connection-event', handleConnectionEvent); - const projectSettings = await settings.get(); - const mode = projectSettings.tmux ? 'detach' : 'terminate'; - await taskManager.teardownAllForProject(project.id, mode); - await workspaceRegistry.releaseAllForProject(project.id, mode); - }, - }; - } catch (error) { - log.warn('createSshProvider: SSH connection failed', { - projectId: project.id, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 5e24e4824a..55c2d71b30 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -3,24 +3,17 @@ import type { ProjectRemoteState } from '@shared/projects'; import type { Result } from '@shared/result'; import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitFetchService } from '@main/core/git/git-fetch-service'; +import type { GitRepositoryService } from '@main/core/git/repository-service'; import type { ExecFn } from '@main/core/utils/exec'; +import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import type { ConversationProvider } from '../conversations/types'; -import type { GitRepositoryService } from '../git/repository-service'; import type { TerminalProvider } from '../terminals/terminal-provider'; import type { ProjectSettingsProvider } from './settings/schema'; +import { taskManager } from './task-manager'; import type { WorkspaceType } from './workspace-factory'; +import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import type { WorktreeService } from './worktrees/worktree-service'; -export type ProvisionTaskError = - | { type: 'timeout'; message: string; timeout: number } - | { type: 'branch-not-found'; branch: string } - | { type: 'worktree-setup-failed'; branch: string; message?: string } - | { type: 'error'; message: string }; - -export type TeardownTaskError = - | { type: 'timeout'; message: string; timeout: number } - | { type: 'error'; message: string }; - export type WorkspaceProviderData = { provisionCommand: string; terminateCommand: string; @@ -46,7 +39,23 @@ export interface TaskProvider { readonly terminals: TerminalProvider; } -export interface ProjectProvider { +/** + * Transport-specific dependencies: the only things that differ between local and SSH. + * Pure data — no lifecycle methods. + */ +export type ProjectProviderTransport = { + readonly kind: string; + readonly defaultWorkspaceType: WorkspaceType; + readonly exec: ExecFn; + /** Used by WorktreeService and GitService construction in create-provider.ts. */ + readonly gitExec: ExecFn; + readonly fs: FileSystemProvider; + readonly settings: ProjectSettingsProvider; + readonly worktreeHost: WorktreeHost; + readonly worktreePoolPath: string; +}; + +export class ProjectProvider { readonly type: string; readonly projectId: string; readonly repoPath: string; @@ -56,10 +65,55 @@ export interface ProjectProvider { readonly fs: FileSystemProvider; readonly worktreeService: WorktreeService; readonly gitFetchService: GitFetchService; - readonly workspaceType: WorkspaceType; - getRemoteState(): Promise; - getWorktreeForBranch(branchName: string): Promise; - removeTaskWorktree(taskBranch: string): Promise; - fetch(): Promise>; - cleanup(): Promise; + /** Workspace type for standard worktree tasks. BYOI tasks use their own remote workspace type. */ + readonly defaultWorkspaceType: WorkspaceType; + + constructor( + projectId: string, + repoPath: string, + transport: ProjectProviderTransport, + repository: GitRepositoryService, + worktreeService: WorktreeService, + gitFetchService: GitFetchService, + private readonly _dispose: () => void + ) { + this.type = transport.kind; + this.projectId = projectId; + this.repoPath = repoPath; + this.exec = transport.exec; + this.settings = transport.settings; + this.fs = transport.fs; + this.repository = repository; + this.worktreeService = worktreeService; + this.gitFetchService = gitFetchService; + this.defaultWorkspaceType = transport.defaultWorkspaceType; + } + + getRemoteState(): Promise { + return this.repository.getRemoteState(); + } + + getWorktreeForBranch(branchName: string): Promise { + return this.worktreeService.getWorktree(branchName); + } + + async removeTaskWorktree(taskBranch: string): Promise { + const worktreePath = await this.worktreeService.getWorktree(taskBranch); + if (worktreePath) { + await this.worktreeService.removeWorktree(worktreePath); + } + } + + fetch(): Promise> { + return this.gitFetchService.fetch(); + } + + async cleanup(): Promise { + this._dispose(); + this.gitFetchService.stop(); + const projectSettings = await this.settings.get(); + const mode = projectSettings.tmux ? 'detach' : 'terminate'; + await taskManager.teardownAllForProject(this.projectId, mode); + await workspaceRegistry.releaseAllForProject(this.projectId, mode); + } } diff --git a/src/main/core/projects/provision-task-error.ts b/src/main/core/projects/provision-task-error.ts index 1a379d282e..1abd5ff997 100644 --- a/src/main/core/projects/provision-task-error.ts +++ b/src/main/core/projects/provision-task-error.ts @@ -1,10 +1,19 @@ -import type { ProvisionTaskError, TeardownTaskError } from './project-provider'; import { TimeoutSignal } from './utils'; import type { ServeWorktreeError } from './worktrees/worktree-service'; export const TASK_TIMEOUT_MS = 600000; export const TEARDOWN_SCRIPT_WAIT_MS = 10_000; +export type ProvisionTaskError = + | { type: 'timeout'; message: string; timeout: number } + | { type: 'branch-not-found'; branch: string } + | { type: 'worktree-setup-failed'; branch: string; message?: string } + | { type: 'error'; message: string }; + +export type TeardownTaskError = + | { type: 'timeout'; message: string; timeout: number } + | { type: 'error'; message: string }; + export function toProvisionError(e: unknown): ProvisionTaskError { if (isProvisionTaskError(e)) return e; if (e instanceof TimeoutSignal) return { type: 'timeout', message: e.message, timeout: e.ms }; diff --git a/src/main/core/projects/task-manager.ts b/src/main/core/projects/task-manager.ts index bcf6601d2b..f70f0e7fba 100644 --- a/src/main/core/projects/task-manager.ts +++ b/src/main/core/projects/task-manager.ts @@ -14,18 +14,14 @@ import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/work import { HookCore, type Hookable } from '@main/lib/hookable'; import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; -import type { - ProjectProvider, - ProvisionResult, - ProvisionTaskError, - TaskProvider, - TeardownTaskError, -} from './project-provider'; +import type { ProjectProvider, ProvisionResult, TaskProvider } from './project-provider'; import { formatProvisionTaskError, TASK_TIMEOUT_MS, toProvisionError, toTeardownError, + type ProvisionTaskError, + type TeardownTaskError, } from './provision-task-error'; import { provisionLocalTask } from './task-builder'; import { withTimeout } from './utils'; @@ -76,7 +72,7 @@ async function executeProvision( void prSyncScheduler.onTaskProvisioned(provider.projectId, task.taskBranch); const workspaceId = - provider.workspaceType.kind === 'local' + provider.defaultWorkspaceType.kind === 'local' ? localWorkspaceId(provider.projectId, task.taskBranch) : sshWorkspaceId(provider.projectId, task.taskBranch); @@ -85,7 +81,7 @@ async function executeProvision( conversations, terminals, workspaceId, - type: provider.workspaceType, + type: provider.defaultWorkspaceType, projectId: provider.projectId, projectPath: provider.repoPath, settings: provider.settings, @@ -95,7 +91,7 @@ async function executeProvision( logPrefix: `${provider.type}ProjectProvider`, }); - if (provider.workspaceType.kind === 'local') { + if (provider.defaultWorkspaceType.kind === 'local') { const mainDotGitAbs = path.resolve(provider.repoPath, '.git'); const worktreeGitDir = await workspace.git.getWorktreeGitDir(mainDotGitAbs); return { @@ -108,7 +104,7 @@ async function executeProvision( ...provisionResult, persistData: { ...provisionResult.persistData, - sshConnectionId: provider.workspaceType.connectionId, + sshConnectionId: provider.defaultWorkspaceType.connectionId, }, }; } diff --git a/src/main/core/projects/task-provision-manager.ts b/src/main/core/projects/task-provision-manager.ts deleted file mode 100644 index 0fe9e49d80..0000000000 --- a/src/main/core/projects/task-provision-manager.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { Conversation } from '@shared/conversations'; -import { err, ok, type Result } from '@shared/result'; -import type { Task, TaskBootstrapStatus } from '@shared/tasks'; -import type { Terminal } from '@shared/terminals'; -import { LifecycleMap } from '@main/lib/lifecycle-map'; -import { log } from '@main/lib/logger'; -import type { TeardownMode } from '../workspaces/workspace-registry'; -import type { - ProvisionResult, - ProvisionTaskError, - TaskProvider, - TeardownTaskError, -} from './project-provider'; -import { - formatProvisionTaskError, - TASK_TIMEOUT_MS, - toProvisionError, - toTeardownError, -} from './provision-task-error'; -import { withTimeout } from './utils'; - -type ProvisionFn = ( - task: Task, - conversations: Conversation[], - terminals: Terminal[] -) => Promise; - -type TeardownFn = (task: TaskProvider, workspaceId: string, mode: TeardownMode) => Promise; - -type DetachedCleanupFn = (taskId: string) => Promise; - -export type TeardownAllOpts = { mode: TeardownMode }; - -export class TaskProvisionManager { - private readonly _lifecycle = new LifecycleMap(); - - constructor( - private readonly logPrefix: string, - private readonly provisionFn: ProvisionFn, - private readonly teardownFn: TeardownFn, - private readonly detachedCleanupFn: DetachedCleanupFn - ) {} - - async provisionTask( - task: Task, - conversations: Conversation[], - terminals: Terminal[] - ): Promise> { - return this._lifecycle.provision(task.id, async () => { - try { - const result = await withTimeout( - this.provisionFn(task, conversations, terminals), - TASK_TIMEOUT_MS - ); - return ok(result); - } catch (e) { - const provisionError = toProvisionError(e); - log.error(`${this.logPrefix}: failed to provision task`, { - taskId: task.id, - error: String(e), - }); - return err(provisionError); - } - }); - } - - getTask(taskId: string): TaskProvider | undefined { - return this._lifecycle.get(taskId)?.taskProvider; - } - - getWorkspaceId(taskId: string): string | undefined { - return this._lifecycle.get(taskId)?.persistData.workspaceId; - } - - getTaskBootstrapStatus(taskId: string): TaskBootstrapStatus { - return this._lifecycle.bootstrapStatus(taskId, formatProvisionTaskError); - } - - async teardownTask( - taskId: string, - mode: TeardownMode = 'terminate' - ): Promise> { - return ( - this._lifecycle.teardown(taskId, async (provisionResult) => { - const { taskProvider, persistData } = provisionResult; - try { - await withTimeout( - this.teardownFn(taskProvider, persistData.workspaceId, mode), - TASK_TIMEOUT_MS - ); - return ok(); - } catch (e) { - log.error(`${this.logPrefix}: failed to teardown task`, { - taskId, - error: String(e), - }); - await this.detachedCleanupFn(taskId).catch((cleanupError) => { - log.warn(`${this.logPrefix}: fallback cleanup failed`, { - taskId, - error: String(cleanupError), - }); - }); - return err(toTeardownError(e)); - } - }) ?? (await this.detachedCleanupFn(taskId).then(() => ok())) - ); - } - - async teardownAll(opts: TeardownAllOpts): Promise { - if (opts.mode === 'detach') { - await Promise.all( - Array.from(this._lifecycle.values()).map((r) => - Promise.all([ - r.taskProvider.conversations.detachAll(), - r.taskProvider.terminals.detachAll(), - ]) - ) - ); - this._lifecycle.clearActive(); - } else { - await Promise.all( - Array.from(this._lifecycle.keys()).map((id) => this.teardownTask(id, 'terminate')) - ); - } - } -} diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/createTask.ts index 7ac08f70e4..61b568601f 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/createTask.ts @@ -14,7 +14,7 @@ import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; import { createConversation } from '../conversations/createConversation'; -import type { ProvisionTaskError } from '../projects/project-provider'; +import type { ProvisionTaskError } from '../projects/provision-task-error'; import { prQueryService } from '../pull-requests/pr-query-service'; import { appSettingsService } from '../settings/settings-service'; import { mapTaskRowToTask } from './core'; From fd294c734f7c3b86918b19269bff0c65e04c72a3 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 14:56:19 +0200 Subject: [PATCH 090/263] chore: cleanup --- src/main/core/git/git-watcher-registry.ts | 2 +- .../core/projects/operations/deleteProject.ts | 4 +-- .../operations/updateProjectSettings.ts | 2 +- src/main/core/projects/project-provider.ts | 4 +-- src/main/core/projects/utils.ts | 2 +- src/main/core/projects/worktrees/utils.ts | 2 +- src/main/core/pty/controller.ts | 2 +- src/main/core/tasks/controller.ts | 26 +++++++++---------- src/main/core/tasks/getBootstrapStatus.ts | 12 --------- src/main/core/tasks/getTaskSettings.ts | 26 ------------------- .../generateTaskName.test.ts | 0 .../{ => name-generation}/generateTaskName.ts | 0 .../tasks/{ => operations}/archiveTask.ts | 2 +- .../core/tasks/{ => operations}/createTask.ts | 16 ++++++------ .../core/tasks/{ => operations}/deleteTask.ts | 2 +- .../core/tasks/{ => operations}/getTasks.ts | 4 +-- .../{ => operations}/getWorkspaceSettings.ts | 0 .../core/tasks/{ => operations}/renameTask.ts | 2 +- .../tasks/{ => operations}/restoreTask.ts | 0 .../tasks/{ => operations}/setTaskPinned.ts | 0 .../tasks/{ => operations}/teardownTask.ts | 2 +- .../{ => operations}/updateLinkedIssue.ts | 2 +- .../{ => operations}/updateTaskStatus.ts | 2 +- .../provision-task-error.ts | 4 +-- src/main/core/tasks/provisionTask.ts | 6 ++--- .../core/{projects => tasks}/task-builder.ts | 10 +++---- .../core/{projects => tasks}/task-manager.ts | 4 +-- .../core/tasks/{core.ts => utils/utils.ts} | 2 +- .../workspaces/byoi/provision-byoi-task.ts | 4 +-- .../workspace-factory.ts | 8 +++--- 30 files changed, 56 insertions(+), 96 deletions(-) delete mode 100644 src/main/core/tasks/getBootstrapStatus.ts delete mode 100644 src/main/core/tasks/getTaskSettings.ts rename src/main/core/tasks/{ => name-generation}/generateTaskName.test.ts (100%) rename src/main/core/tasks/{ => name-generation}/generateTaskName.ts (100%) rename src/main/core/tasks/{ => operations}/archiveTask.ts (96%) rename src/main/core/tasks/{ => operations}/createTask.ts (94%) rename src/main/core/tasks/{ => operations}/deleteTask.ts (97%) rename src/main/core/tasks/{ => operations}/getTasks.ts (92%) rename src/main/core/tasks/{ => operations}/getWorkspaceSettings.ts (100%) rename src/main/core/tasks/{ => operations}/renameTask.ts (95%) rename src/main/core/tasks/{ => operations}/restoreTask.ts (100%) rename src/main/core/tasks/{ => operations}/setTaskPinned.ts (100%) rename src/main/core/tasks/{ => operations}/teardownTask.ts (71%) rename src/main/core/tasks/{ => operations}/updateLinkedIssue.ts (94%) rename src/main/core/tasks/{ => operations}/updateTaskStatus.ts (93%) rename src/main/core/{projects => tasks}/provision-task-error.ts (94%) rename src/main/core/{projects => tasks}/task-builder.ts (94%) rename src/main/core/{projects => tasks}/task-manager.ts (99%) rename src/main/core/tasks/{core.ts => utils/utils.ts} (95%) rename src/main/core/{projects => workspaces}/workspace-factory.ts (97%) diff --git a/src/main/core/git/git-watcher-registry.ts b/src/main/core/git/git-watcher-registry.ts index 0ebca983e1..a6e7eb6c2d 100644 --- a/src/main/core/git/git-watcher-registry.ts +++ b/src/main/core/git/git-watcher-registry.ts @@ -1,5 +1,5 @@ import { projectManager } from '../projects/project-manager'; -import { taskManager } from '../projects/task-manager'; +import { taskManager } from '../tasks/task-manager'; import { GitWatcherService } from './git-watcher-service'; export class GitWatcherRegistry { diff --git a/src/main/core/projects/operations/deleteProject.ts b/src/main/core/projects/operations/deleteProject.ts index 9561f4d8bc..0495862093 100644 --- a/src/main/core/projects/operations/deleteProject.ts +++ b/src/main/core/projects/operations/deleteProject.ts @@ -1,8 +1,8 @@ import { eq } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; -import { taskManager } from '@main/core/projects/task-manager'; import { prSyncEngine } from '@main/core/pull-requests/pr-sync-engine'; -import { getTasks } from '@main/core/tasks/getTasks'; +import { getTasks } from '@main/core/tasks/operations/getTasks'; +import { taskManager } from '@main/core/tasks/task-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; diff --git a/src/main/core/projects/operations/updateProjectSettings.ts b/src/main/core/projects/operations/updateProjectSettings.ts index 3d6e350313..aee069284d 100644 --- a/src/main/core/projects/operations/updateProjectSettings.ts +++ b/src/main/core/projects/operations/updateProjectSettings.ts @@ -1,7 +1,7 @@ import type { UpdateProjectSettingsError } from '@shared/projects'; import { err, type Result } from '@shared/result'; import { projectManager } from '../project-manager'; -import { ProjectSettings } from '../settings/schema'; +import type { ProjectSettings } from '../settings/schema'; export async function updateProjectSettings( projectId: string, diff --git a/src/main/core/projects/project-provider.ts b/src/main/core/projects/project-provider.ts index 55c2d71b30..8d0be21275 100644 --- a/src/main/core/projects/project-provider.ts +++ b/src/main/core/projects/project-provider.ts @@ -7,10 +7,10 @@ import type { GitRepositoryService } from '@main/core/git/repository-service'; import type { ExecFn } from '@main/core/utils/exec'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import type { ConversationProvider } from '../conversations/types'; +import { taskManager } from '../tasks/task-manager'; import type { TerminalProvider } from '../terminals/terminal-provider'; +import type { WorkspaceType } from '../workspaces/workspace-factory'; import type { ProjectSettingsProvider } from './settings/schema'; -import { taskManager } from './task-manager'; -import type { WorkspaceType } from './workspace-factory'; import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import type { WorktreeService } from './worktrees/worktree-service'; diff --git a/src/main/core/projects/utils.ts b/src/main/core/projects/utils.ts index e02ab846a2..4efc098ce2 100644 --- a/src/main/core/projects/utils.ts +++ b/src/main/core/projects/utils.ts @@ -1,5 +1,5 @@ import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { taskManager } from './task-manager'; +import { taskManager } from '../tasks/task-manager'; export function resolveTask(_projectId: string, taskId: string) { return taskManager.getTask(taskId) ?? null; diff --git a/src/main/core/projects/worktrees/utils.ts b/src/main/core/projects/worktrees/utils.ts index 6f83514cb1..68548e106c 100644 --- a/src/main/core/projects/worktrees/utils.ts +++ b/src/main/core/projects/worktrees/utils.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import type { Task } from '@shared/tasks'; import type { FileSystemProvider } from '@main/core/fs/types'; -import { mapWorktreeErrorToProvisionError } from '../provision-task-error'; +import { mapWorktreeErrorToProvisionError } from '../../tasks/provision-task-error'; import type { WorktreeService } from './worktree-service'; export const ensureLocalWorktreeDirectory = ({ diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index c2561a5656..3575fa6b2d 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import { createRPCController } from '@shared/ipc/rpc'; import { err, ok } from '@shared/result'; import { log } from '@main/lib/logger'; -import { taskManager } from '../projects/task-manager'; +import { taskManager } from '../tasks/task-manager'; import { workspaceRegistry } from '../workspaces/workspace-registry'; import { ptySessionRegistry } from './pty-session-registry'; diff --git a/src/main/core/tasks/controller.ts b/src/main/core/tasks/controller.ts index e686861276..9c46455bba 100644 --- a/src/main/core/tasks/controller.ts +++ b/src/main/core/tasks/controller.ts @@ -1,18 +1,17 @@ import { createRPCController } from '@shared/ipc/rpc'; -import { archiveTask } from './archiveTask'; -import { createTask } from './createTask'; -import { deleteTask } from './deleteTask'; -import { generateTaskName } from './generateTaskName'; -import { getBootstrapStatus } from './getBootstrapStatus'; -import { getTasks } from './getTasks'; -import { getWorkspaceSettings } from './getWorkspaceSettings'; +import { generateTaskName } from './name-generation/generateTaskName'; +import { archiveTask } from './operations/archiveTask'; +import { createTask } from './operations/createTask'; +import { deleteTask } from './operations/deleteTask'; +import { getTasks } from './operations/getTasks'; +import { getWorkspaceSettings } from './operations/getWorkspaceSettings'; +import { renameTask } from './operations/renameTask'; +import { restoreTask } from './operations/restoreTask'; +import { setTaskPinned } from './operations/setTaskPinned'; +import { teardownTask } from './operations/teardownTask'; +import { updateLinkedIssue } from './operations/updateLinkedIssue'; +import { updateTaskStatus } from './operations/updateTaskStatus'; import { provisionTask } from './provisionTask'; -import { renameTask } from './renameTask'; -import { restoreTask } from './restoreTask'; -import { setTaskPinned } from './setTaskPinned'; -import { teardownTask } from './teardownTask'; -import { updateLinkedIssue } from './updateLinkedIssue'; -import { updateTaskStatus } from './updateTaskStatus'; export const taskController = createRPCController({ createTask, @@ -24,7 +23,6 @@ export const taskController = createRPCController({ renameTask, provisionTask, teardownTask, - getBootstrapStatus, getWorkspaceSettings, updateLinkedIssue, updateTaskStatus, diff --git a/src/main/core/tasks/getBootstrapStatus.ts b/src/main/core/tasks/getBootstrapStatus.ts deleted file mode 100644 index e2e91751a1..0000000000 --- a/src/main/core/tasks/getBootstrapStatus.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TaskBootstrapStatus } from '@shared/tasks'; -import { taskManager } from '@main/core/projects/task-manager'; -import { log } from '@main/lib/logger'; - -export async function getBootstrapStatus( - _projectId: string, - taskId: string -): Promise { - const status = taskManager.getBootstrapStatus(taskId); - log.debug('getBootstrapStatus', { taskId, status: status.status }); - return status; -} diff --git a/src/main/core/tasks/getTaskSettings.ts b/src/main/core/tasks/getTaskSettings.ts deleted file mode 100644 index 401015061f..0000000000 --- a/src/main/core/tasks/getTaskSettings.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ProjectSettings } from '@main/core/projects/settings/schema'; -import { getEffectiveTaskSettings } from '@main/core/projects/settings/task-settings'; -import { taskManager } from '@main/core/projects/task-manager'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; - -export async function getTaskSettings( - _projectId: string, - taskId: string -): Promise { - if (!taskManager.getTask(taskId)) { - throw new Error(`Task ${taskId} not found or not provisioned`); - } - const workspaceId = taskManager.getWorkspaceId(taskId); - if (!workspaceId) { - throw new Error(`Workspace ID for task ${taskId} not found`); - } - const workspace = workspaceRegistry.get(workspaceId); - if (!workspace) { - throw new Error(`Workspace ${workspaceId} not found`); - } - - return getEffectiveTaskSettings({ - projectSettings: workspace.settings, - taskFs: workspace.fs, - }); -} diff --git a/src/main/core/tasks/generateTaskName.test.ts b/src/main/core/tasks/name-generation/generateTaskName.test.ts similarity index 100% rename from src/main/core/tasks/generateTaskName.test.ts rename to src/main/core/tasks/name-generation/generateTaskName.test.ts diff --git a/src/main/core/tasks/generateTaskName.ts b/src/main/core/tasks/name-generation/generateTaskName.ts similarity index 100% rename from src/main/core/tasks/generateTaskName.ts rename to src/main/core/tasks/name-generation/generateTaskName.ts diff --git a/src/main/core/tasks/archiveTask.ts b/src/main/core/tasks/operations/archiveTask.ts similarity index 96% rename from src/main/core/tasks/archiveTask.ts rename to src/main/core/tasks/operations/archiveTask.ts index 39a47cc3c2..a391838953 100644 --- a/src/main/core/tasks/archiveTask.ts +++ b/src/main/core/tasks/operations/archiveTask.ts @@ -1,6 +1,6 @@ import { and, eq, isNull, sql } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; -import { taskManager } from '@main/core/projects/task-manager'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { log } from '@main/lib/logger'; diff --git a/src/main/core/tasks/createTask.ts b/src/main/core/tasks/operations/createTask.ts similarity index 94% rename from src/main/core/tasks/createTask.ts rename to src/main/core/tasks/operations/createTask.ts index 61b568601f..f79550ea2b 100644 --- a/src/main/core/tasks/createTask.ts +++ b/src/main/core/tasks/operations/createTask.ts @@ -9,17 +9,17 @@ import type { TaskLifecycleStatus, } from '@shared/tasks'; import { projectManager } from '@main/core/projects/project-manager'; -import { taskManager } from '@main/core/projects/task-manager'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; -import { createConversation } from '../conversations/createConversation'; -import type { ProvisionTaskError } from '../projects/provision-task-error'; -import { prQueryService } from '../pull-requests/pr-query-service'; -import { appSettingsService } from '../settings/settings-service'; -import { mapTaskRowToTask } from './core'; -import { resolveTaskBranchName } from './resolveTaskBranchName'; -import { toStoredBranch } from './stored-branch'; +import { createConversation } from '../../conversations/createConversation'; +import { prQueryService } from '../../pull-requests/pr-query-service'; +import { appSettingsService } from '../../settings/settings-service'; +import type { ProvisionTaskError } from '../provision-task-error'; +import { resolveTaskBranchName } from '../resolveTaskBranchName'; +import { toStoredBranch } from '../stored-branch'; +import { mapTaskRowToTask } from '../utils/utils'; function mapProvisionError(error: ProvisionTaskError): CreateTaskError { switch (error.type) { diff --git a/src/main/core/tasks/deleteTask.ts b/src/main/core/tasks/operations/deleteTask.ts similarity index 97% rename from src/main/core/tasks/deleteTask.ts rename to src/main/core/tasks/operations/deleteTask.ts index a628c79045..ab43e6e8a3 100644 --- a/src/main/core/tasks/deleteTask.ts +++ b/src/main/core/tasks/operations/deleteTask.ts @@ -1,6 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; -import { taskManager } from '@main/core/projects/task-manager'; +import { taskManager } from '@main/core/tasks/task-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; diff --git a/src/main/core/tasks/getTasks.ts b/src/main/core/tasks/operations/getTasks.ts similarity index 92% rename from src/main/core/tasks/getTasks.ts rename to src/main/core/tasks/operations/getTasks.ts index 8b028410af..2dfeb9a41d 100644 --- a/src/main/core/tasks/getTasks.ts +++ b/src/main/core/tasks/operations/getTasks.ts @@ -1,8 +1,8 @@ import { and, count, desc, eq, inArray } from 'drizzle-orm'; -import { Task } from '@shared/tasks'; +import { type Task } from '@shared/tasks'; import { db } from '@main/db/client'; import { conversations, tasks } from '@main/db/schema'; -import { mapTaskRowToTask } from './core'; +import { mapTaskRowToTask } from '../utils/utils'; export async function getTasks(projectId?: string): Promise { const rows = projectId diff --git a/src/main/core/tasks/getWorkspaceSettings.ts b/src/main/core/tasks/operations/getWorkspaceSettings.ts similarity index 100% rename from src/main/core/tasks/getWorkspaceSettings.ts rename to src/main/core/tasks/operations/getWorkspaceSettings.ts diff --git a/src/main/core/tasks/renameTask.ts b/src/main/core/tasks/operations/renameTask.ts similarity index 95% rename from src/main/core/tasks/renameTask.ts rename to src/main/core/tasks/operations/renameTask.ts index fb7975b701..a2fce225a6 100644 --- a/src/main/core/tasks/renameTask.ts +++ b/src/main/core/tasks/operations/renameTask.ts @@ -2,7 +2,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { projectManager } from '@main/core/projects/project-manager'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; -import { appSettingsService } from '../settings/settings-service'; +import { appSettingsService } from '../../settings/settings-service'; export async function renameTask( projectId: string, diff --git a/src/main/core/tasks/restoreTask.ts b/src/main/core/tasks/operations/restoreTask.ts similarity index 100% rename from src/main/core/tasks/restoreTask.ts rename to src/main/core/tasks/operations/restoreTask.ts diff --git a/src/main/core/tasks/setTaskPinned.ts b/src/main/core/tasks/operations/setTaskPinned.ts similarity index 100% rename from src/main/core/tasks/setTaskPinned.ts rename to src/main/core/tasks/operations/setTaskPinned.ts diff --git a/src/main/core/tasks/teardownTask.ts b/src/main/core/tasks/operations/teardownTask.ts similarity index 71% rename from src/main/core/tasks/teardownTask.ts rename to src/main/core/tasks/operations/teardownTask.ts index 0fc89fb9b4..7c84841e82 100644 --- a/src/main/core/tasks/teardownTask.ts +++ b/src/main/core/tasks/operations/teardownTask.ts @@ -1,4 +1,4 @@ -import { taskManager } from '../projects/task-manager'; +import { taskManager } from '../task-manager'; export async function teardownTask(_projectId: string, taskId: string) { return await taskManager.teardownTask(taskId, 'terminate'); diff --git a/src/main/core/tasks/updateLinkedIssue.ts b/src/main/core/tasks/operations/updateLinkedIssue.ts similarity index 94% rename from src/main/core/tasks/updateLinkedIssue.ts rename to src/main/core/tasks/operations/updateLinkedIssue.ts index ff36a42ca2..f77ed5e71b 100644 --- a/src/main/core/tasks/updateLinkedIssue.ts +++ b/src/main/core/tasks/operations/updateLinkedIssue.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm'; -import { Issue } from '@shared/tasks'; +import { type Issue } from '@shared/tasks'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; diff --git a/src/main/core/tasks/updateTaskStatus.ts b/src/main/core/tasks/operations/updateTaskStatus.ts similarity index 93% rename from src/main/core/tasks/updateTaskStatus.ts rename to src/main/core/tasks/operations/updateTaskStatus.ts index 4b89ac7fc8..aadab545b3 100644 --- a/src/main/core/tasks/updateTaskStatus.ts +++ b/src/main/core/tasks/operations/updateTaskStatus.ts @@ -1,5 +1,5 @@ import { eq, sql } from 'drizzle-orm'; -import { TaskLifecycleStatus } from '@shared/tasks'; +import { type TaskLifecycleStatus } from '@shared/tasks'; import { db } from '@main/db/client'; import { tasks } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; diff --git a/src/main/core/projects/provision-task-error.ts b/src/main/core/tasks/provision-task-error.ts similarity index 94% rename from src/main/core/projects/provision-task-error.ts rename to src/main/core/tasks/provision-task-error.ts index 1abd5ff997..b021db2f45 100644 --- a/src/main/core/projects/provision-task-error.ts +++ b/src/main/core/tasks/provision-task-error.ts @@ -1,5 +1,5 @@ -import { TimeoutSignal } from './utils'; -import type { ServeWorktreeError } from './worktrees/worktree-service'; +import { TimeoutSignal } from '../projects/utils'; +import type { ServeWorktreeError } from '../projects/worktrees/worktree-service'; export const TASK_TIMEOUT_MS = 600000; export const TEARDOWN_SCRIPT_WAIT_MS = 10_000; diff --git a/src/main/core/tasks/provisionTask.ts b/src/main/core/tasks/provisionTask.ts index 60a2ac0d66..e9d811fa6d 100644 --- a/src/main/core/tasks/provisionTask.ts +++ b/src/main/core/tasks/provisionTask.ts @@ -1,14 +1,14 @@ import { eq, sql } from 'drizzle-orm'; import { mapConversationRowToConversation } from '@main/core/conversations/utils'; import { projectManager } from '@main/core/projects/project-manager'; -import { formatProvisionTaskError } from '@main/core/projects/provision-task-error'; -import { taskManager } from '@main/core/projects/task-manager'; +import { formatProvisionTaskError } from '@main/core/tasks/provision-task-error'; +import { taskManager } from '@main/core/tasks/task-manager'; import { mapTerminalRowToTerminal } from '@main/core/terminals/core'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; import { conversations, tasks, terminals } from '@main/db/schema'; import { capture } from '@main/lib/telemetry'; -import { mapTaskRowToTask } from './core'; +import { mapTaskRowToTask } from './utils/utils'; export async function provisionTask(taskId: string) { const [row] = await db.select().from(tasks).where(eq(tasks.id, taskId)); diff --git a/src/main/core/projects/task-builder.ts b/src/main/core/tasks/task-builder.ts similarity index 94% rename from src/main/core/projects/task-builder.ts rename to src/main/core/tasks/task-builder.ts index 0388d466b2..eab74a31d2 100644 --- a/src/main/core/projects/task-builder.ts +++ b/src/main/core/tasks/task-builder.ts @@ -10,16 +10,16 @@ import type { Workspace } from '@main/core/workspaces/workspace'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; -import type { ProvisionResult, TaskProvider } from './project-provider'; -import type { ProjectSettingsProvider } from './settings/schema'; +import type { ProvisionResult, TaskProvider } from '../projects/project-provider'; +import type { ProjectSettingsProvider } from '../projects/settings/schema'; +import { resolveTaskWorkDir } from '../projects/worktrees/utils'; +import type { WorktreeService } from '../projects/worktrees/worktree-service'; import { buildTaskProviders, createWorkspaceFactory, resolveTaskEnv, type WorkspaceType, -} from './workspace-factory'; -import { resolveTaskWorkDir } from './worktrees/utils'; -import type { WorktreeService } from './worktrees/worktree-service'; +} from '../workspaces/workspace-factory'; export type BuildTaskResult = { taskProvider: TaskProvider; diff --git a/src/main/core/projects/task-manager.ts b/src/main/core/tasks/task-manager.ts similarity index 99% rename from src/main/core/projects/task-manager.ts rename to src/main/core/tasks/task-manager.ts index f70f0e7fba..b47e942676 100644 --- a/src/main/core/projects/task-manager.ts +++ b/src/main/core/tasks/task-manager.ts @@ -14,7 +14,8 @@ import { workspaceRegistry, type TeardownMode } from '@main/core/workspaces/work import { HookCore, type Hookable } from '@main/lib/hookable'; import { LifecycleMap } from '@main/lib/lifecycle-map'; import { log } from '@main/lib/logger'; -import type { ProjectProvider, ProvisionResult, TaskProvider } from './project-provider'; +import type { ProjectProvider, ProvisionResult, TaskProvider } from '../projects/project-provider'; +import { withTimeout } from '../projects/utils'; import { formatProvisionTaskError, TASK_TIMEOUT_MS, @@ -24,7 +25,6 @@ import { type TeardownTaskError, } from './provision-task-error'; import { provisionLocalTask } from './task-builder'; -import { withTimeout } from './utils'; type StoredTask = ProvisionResult & { projectId: string; exec: ExecFn }; diff --git a/src/main/core/tasks/core.ts b/src/main/core/tasks/utils/utils.ts similarity index 95% rename from src/main/core/tasks/core.ts rename to src/main/core/tasks/utils/utils.ts index 8ff862c7df..41e6a8cfae 100644 --- a/src/main/core/tasks/core.ts +++ b/src/main/core/tasks/utils/utils.ts @@ -1,7 +1,7 @@ import type { PullRequest } from '@shared/pull-requests'; import type { Issue, Task, TaskLifecycleStatus } from '@shared/tasks'; import type { TaskRow } from '@main/db/schema'; -import { fromStoredBranch } from './stored-branch'; +import { fromStoredBranch } from '../stored-branch'; export function mapTaskRowToTask( row: TaskRow, diff --git a/src/main/core/workspaces/byoi/provision-byoi-task.ts b/src/main/core/workspaces/byoi/provision-byoi-task.ts index 2d8603d321..7ad124cdb1 100644 --- a/src/main/core/workspaces/byoi/provision-byoi-task.ts +++ b/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -4,11 +4,11 @@ import type { Task } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import type { ProvisionResult } from '@main/core/projects/project-provider'; import type { ProjectSettings, ProjectSettingsProvider } from '@main/core/projects/settings/schema'; -import { buildTaskFromWorkspace } from '@main/core/projects/task-builder'; -import { createWorkspaceFactory } from '@main/core/projects/workspace-factory'; import { sshConnectionManager } from '@main/core/ssh/ssh-connection-manager'; +import { buildTaskFromWorkspace } from '@main/core/tasks/task-builder'; import type { ExecFn } from '@main/core/utils/exec'; import { parseProvisionOutput } from '@main/core/workspaces/byoi/provision-output'; +import { createWorkspaceFactory } from '@main/core/workspaces/workspace-factory'; import { remoteTaskWorkspaceId } from '@main/core/workspaces/workspace-id'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { events } from '@main/lib/events'; diff --git a/src/main/core/projects/workspace-factory.ts b/src/main/core/workspaces/workspace-factory.ts similarity index 97% rename from src/main/core/projects/workspace-factory.ts rename to src/main/core/workspaces/workspace-factory.ts index f680a1dcc2..62afe4bf1c 100644 --- a/src/main/core/projects/workspace-factory.ts +++ b/src/main/core/workspaces/workspace-factory.ts @@ -18,10 +18,10 @@ import type { Workspace } from '@main/core/workspaces/workspace'; import { LifecycleScriptService } from '@main/core/workspaces/workspace-lifecycle-service'; import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-registry'; import { log } from '@main/lib/logger'; -import { TEARDOWN_SCRIPT_WAIT_MS } from './provision-task-error'; -import type { ProjectSettingsProvider } from './settings/schema'; -import { getEffectiveTaskSettings } from './settings/task-settings'; -import { TimeoutSignal, withTimeout } from './utils'; +import type { ProjectSettingsProvider } from '../projects/settings/schema'; +import { getEffectiveTaskSettings } from '../projects/settings/task-settings'; +import { TimeoutSignal, withTimeout } from '../projects/utils'; +import { TEARDOWN_SCRIPT_WAIT_MS } from '../tasks/provision-task-error'; export type WorkspaceType = | { kind: 'local' } From 8ea95b111bbfc9161a7b748b789684c94c137d64 Mon Sep 17 00:00:00 2001 From: David Konopka Date: Wed, 29 Apr 2026 15:13:52 +0200 Subject: [PATCH 091/263] fix: prs not showing on origin switch --- src/main/core/git/git-watcher-registry.ts | 138 ++++++++++++++++-- src/main/core/git/git-watcher-service.ts | 126 ---------------- src/main/core/projects/create-provider.ts | 10 +- .../core/pull-requests/pr-sync-scheduler.ts | 18 ++- src/main/core/tasks/task-manager.ts | 6 +- 5 files changed, 137 insertions(+), 161 deletions(-) delete mode 100644 src/main/core/git/git-watcher-service.ts diff --git a/src/main/core/git/git-watcher-registry.ts b/src/main/core/git/git-watcher-registry.ts index a6e7eb6c2d..902ed30258 100644 --- a/src/main/core/git/git-watcher-registry.ts +++ b/src/main/core/git/git-watcher-registry.ts @@ -1,34 +1,144 @@ +import path from 'node:path'; +import parcelWatcher from '@parcel/watcher'; +import { + gitRefChangedChannel, + gitWorkspaceChangedChannel, + type GitRefChange, +} from '@shared/events/gitEvents'; +import { branchRef, remoteRef, toRefString, type GitObjectRef } from '@shared/git'; +import { events } from '@main/lib/events'; +import { HookCore, type Hookable } from '@main/lib/hookable'; +import { log } from '@main/lib/logger'; import { projectManager } from '../projects/project-manager'; import { taskManager } from '../tasks/task-manager'; -import { GitWatcherService } from './git-watcher-service'; -export class GitWatcherRegistry { - private readonly _watchers = new Map(); +export type GitWatcherHooks = { + 'ref:changed': (change: GitRefChange) => void | Promise; +}; + +class GitWatcherRegistry implements Hookable { + private readonly _hooks = new HookCore((name, e) => + log.error(`GitWatcherRegistry: ${String(name)} hook error`, e) + ); + private readonly _subscriptions = new Map(); + /** + * Per-project worktree registry. + * projectId → (workspaceId → relativeGitDir) + */ + private readonly _worktrees = new Map>(); + + on(name: K, handler: GitWatcherHooks[K]) { + return this._hooks.on(name, handler); + } initialize(): void { + // IPC bridge: forward all ref changes to the renderer. + this._hooks.on('ref:changed', (change) => events.emit(gitRefChangedChannel, change)); + projectManager.on('projectOpened', (projectId, provider) => { if (provider.type !== 'local') return; - const watcher = new GitWatcherService(projectId, provider.repoPath); - void watcher.start(); - this._watchers.set(projectId, watcher); + void this._startWatching(projectId, provider.repoPath); }); + projectManager.on('projectClosed', (projectId) => { - const watcher = this._watchers.get(projectId); - if (!watcher) return; - void watcher.stop(); - this._watchers.delete(projectId); + void this._stopWatching(projectId); }); + taskManager.hooks.on('task:provisioned', ({ projectId, workspaceId, worktreeGitDir }) => { if (!worktreeGitDir) return; - this._watchers.get(projectId)?.registerWorktree(workspaceId, worktreeGitDir); + this._worktrees.get(projectId)?.set(workspaceId, worktreeGitDir); }); + taskManager.hooks.on('task:torn-down', ({ projectId, workspaceId }) => { - this._watchers.get(projectId)?.unregisterWorktree(workspaceId); + this._worktrees.get(projectId)?.delete(workspaceId); }); } - get(projectId: string): GitWatcherService | undefined { - return this._watchers.get(projectId); + private async _startWatching(projectId: string, repoPath: string): Promise { + const gitDir = path.join(repoPath, '.git'); + this._worktrees.set(projectId, new Map()); + try { + const sub = await parcelWatcher.subscribe(gitDir, (_err, rawEvents) => { + if (_err) return; + let emitLocal = false; + let emitRemote = false; + let emitConfig = false; + const changedLocalByKey = new Map(); + const changedRemoteByKey = new Map(); + + const worktrees = this._worktrees.get(projectId) ?? new Map(); + + for (const e of rawEvents) { + const rel = path.relative(gitDir, e.path).replace(/\\/g, '/'); + + // Project-level ref changes + if (rel.startsWith('refs/heads/')) { + const branch = rel.slice('refs/heads/'.length); + const r = branchRef({ type: 'local', branch }); + changedLocalByKey.set(toRefString(r), r); + emitLocal = true; + } else if (rel === 'HEAD') { + emitLocal = true; + } + if (rel.startsWith('refs/remotes/')) { + const full = rel.slice('refs/remotes/'.length); + const idx = full.indexOf('/'); + if (idx > 0) { + const r = remoteRef(full.slice(0, idx), full.slice(idx + 1)); + changedRemoteByKey.set(toRefString(r), r); + } + emitRemote = true; + } + if (rel === 'packed-refs') { + emitLocal = true; + emitRemote = true; + } + if (rel === 'config') emitConfig = true; + + // Workspace-level index/HEAD changes (renderer-only, direct IPC emit) + for (const [workspaceId, relGitDir] of worktrees) { + const prefix = relGitDir ? `${relGitDir}/` : ''; + if (rel === `${prefix}index`) { + events.emit(gitWorkspaceChangedChannel, { projectId, workspaceId, kind: 'index' }); + } + if (rel === `${prefix}HEAD`) { + events.emit(gitWorkspaceChangedChannel, { projectId, workspaceId, kind: 'head' }); + } + } + } + + if (emitLocal) { + const changedRefs = + changedLocalByKey.size > 0 ? [...changedLocalByKey.values()] : undefined; + this._hooks.callHookBackground('ref:changed', { + projectId, + kind: 'local-refs', + changedRefs, + }); + } + if (emitRemote) { + const changedRefs = + changedRemoteByKey.size > 0 ? [...changedRemoteByKey.values()] : undefined; + this._hooks.callHookBackground('ref:changed', { + projectId, + kind: 'remote-refs', + changedRefs, + }); + } + if (emitConfig) { + this._hooks.callHookBackground('ref:changed', { projectId, kind: 'config' }); + } + }); + this._subscriptions.set(projectId, sub); + } catch { + // Subscription failed (e.g. project path removed or .git directory missing). + } + } + + private async _stopWatching(projectId: string): Promise { + await this._subscriptions.get(projectId)?.unsubscribe(); + this._subscriptions.delete(projectId); + this._worktrees.delete(projectId); } } diff --git a/src/main/core/git/git-watcher-service.ts b/src/main/core/git/git-watcher-service.ts deleted file mode 100644 index d8279d1a3b..0000000000 --- a/src/main/core/git/git-watcher-service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import path from 'node:path'; -import parcelWatcher from '@parcel/watcher'; -import { gitRefChangedChannel, gitWorkspaceChangedChannel } from '@shared/events/gitEvents'; -import { branchRef, remoteRef, toRefString, type GitObjectRef } from '@shared/git'; -import { events } from '@main/lib/events'; - -export class GitWatcherService { - private sub: parcelWatcher.AsyncSubscription | null = null; - - /** - * Registered worktrees. Maps workspaceId → git-dir path relative to the - * repo's .git directory (from `GitService.getWorktreeGitDir`). - * Main workspace → '' - * Linked worktree → e.g. 'worktrees/' - */ - private readonly _worktrees = new Map(); - - constructor( - private readonly projectId: string, - private readonly repoPath: string - ) {} - - /** - * Register a workspace so that index/HEAD changes inside its git dir are - * emitted as gitWorkspaceChangedChannel events. - * - * @param workspaceId The renderer-side workspace key. - * @param relativeGitDir Path of the worktree's git dir relative to .git/. - * Pass '' for the main worktree; 'worktrees/' for linked worktrees. - */ - registerWorktree(workspaceId: string, relativeGitDir: string): void { - this._worktrees.set(workspaceId, relativeGitDir); - } - - unregisterWorktree(workspaceId: string): void { - this._worktrees.delete(workspaceId); - } - - async start(): Promise { - const gitDir = path.join(this.repoPath, '.git'); - try { - this.sub = await parcelWatcher.subscribe(gitDir, (_err, rawEvents) => { - if (_err) return; - let emitLocal = false; - let emitRemote = false; - let emitConfig = false; - const changedLocalByKey = new Map(); - const changedRemoteByKey = new Map(); - for (const e of rawEvents) { - const rel = path.relative(gitDir, e.path).replace(/\\/g, '/'); - // Project-level ref changes - if (rel.startsWith('refs/heads/')) { - const branch = rel.slice('refs/heads/'.length); - const r = branchRef({ type: 'local', branch }); - changedLocalByKey.set(toRefString(r), r); - emitLocal = true; - } else if (rel === 'HEAD') { - emitLocal = true; - } - if (rel.startsWith('refs/remotes/')) { - const full = rel.slice('refs/remotes/'.length); - const idx = full.indexOf('/'); - if (idx > 0) { - const r = remoteRef(full.slice(0, idx), full.slice(idx + 1)); - changedRemoteByKey.set(toRefString(r), r); - } - emitRemote = true; - } - if (rel === 'packed-refs') { - emitLocal = true; - emitRemote = true; - } - if (rel === 'config') emitConfig = true; - - // Workspace-level index/HEAD changes - for (const [workspaceId, relGitDir] of this._worktrees) { - const prefix = relGitDir ? `${relGitDir}/` : ''; - if (rel === `${prefix}index`) { - events.emit(gitWorkspaceChangedChannel, { - projectId: this.projectId, - workspaceId, - kind: 'index', - }); - } - // HEAD but not refs/heads/* (that's a branch pointer update, not a checkout) - if (rel === `${prefix}HEAD`) { - events.emit(gitWorkspaceChangedChannel, { - projectId: this.projectId, - workspaceId, - kind: 'head', - }); - } - } - } - if (emitLocal) { - const changedRefs = - changedLocalByKey.size > 0 ? [...changedLocalByKey.values()] : undefined; - events.emit(gitRefChangedChannel, { - projectId: this.projectId, - kind: 'local-refs', - changedRefs, - }); - } - if (emitRemote) { - const changedRefs = - changedRemoteByKey.size > 0 ? [...changedRemoteByKey.values()] : undefined; - events.emit(gitRefChangedChannel, { - projectId: this.projectId, - kind: 'remote-refs', - changedRefs, - }); - } - if (emitConfig) { - events.emit(gitRefChangedChannel, { projectId: this.projectId, kind: 'config' }); - } - }); - } catch { - // Subscription failed (e.g. project path removed or .git directory missing). - } - } - - async stop(): Promise { - await this.sub?.unsubscribe(); - this.sub = null; - } -} diff --git a/src/main/core/projects/create-provider.ts b/src/main/core/projects/create-provider.ts index a98c4cffda..de09fead06 100644 --- a/src/main/core/projects/create-provider.ts +++ b/src/main/core/projects/create-provider.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import { gitRefChangedChannel } from '@shared/events/gitEvents'; import { bareRefName } from '@shared/git-utils'; import type { LocalProject, SshProject } from '@shared/projects'; import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; @@ -9,13 +8,11 @@ import { GitFetchService } from '@main/core/git/git-fetch-service'; import { GitService } from '@main/core/git/impl/git-service'; import { GitRepositoryService } from '@main/core/git/repository-service'; import { githubConnectionService } from '@main/core/github/services/github-connection-service'; -import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { sshConnectionManager, type SshConnectionEvent, } from '@main/core/ssh/ssh-connection-manager'; import { getGitLocalExec, getGitSshExec, getLocalExec, getSshExec } from '@main/core/utils/exec'; -import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { ProjectProvider } from './project-provider'; import { @@ -68,12 +65,7 @@ async function createLocalProvider(project: LocalProject): Promise { - if (p.projectId === project.id && p.kind === 'config') { - void prSyncScheduler.onRemoteChanged(project.id); - } - }); - const dispose = () => unsub(); + const dispose = () => {}; return new ProjectProvider( project.id, diff --git a/src/main/core/pull-requests/pr-sync-scheduler.ts b/src/main/core/pull-requests/pr-sync-scheduler.ts index 4c25428974..6c1c9170e7 100644 --- a/src/main/core/pull-requests/pr-sync-scheduler.ts +++ b/src/main/core/pull-requests/pr-sync-scheduler.ts @@ -1,6 +1,8 @@ import { eq } from 'drizzle-orm'; +import { gitWatcherRegistry } from '@main/core/git/git-watcher-registry'; import { isGitHubUrl, normalizeGitHubUrl } from '@main/core/github/services/utils'; import { projectManager } from '@main/core/projects/project-manager'; +import { taskManager } from '@main/core/tasks/task-manager'; import { db } from '@main/db/client'; import { projectRemotes } from '@main/db/schema'; import { log } from '@main/lib/logger'; @@ -24,6 +26,12 @@ export class PrSyncScheduler { this._unsubscribes = [ projectManager.on('projectOpened', (id) => this.onProjectMounted(id)), projectManager.on('projectClosed', (id) => this.onProjectUnmounted(id)), + taskManager.hooks.on('task:provisioned', ({ projectId, taskBranch }) => { + void this.onTaskProvisioned(projectId, taskBranch); + }), + gitWatcherRegistry.on('ref:changed', (p) => { + if (p.kind === 'config') void this.onRemoteChanged(p.projectId); + }), ]; } @@ -32,8 +40,6 @@ export class PrSyncScheduler { this._unsubscribes = []; } - // ── Project lifecycle ────────────────────────────────────────────────────── - async onProjectMounted(projectId: string): Promise { log.info('PrSyncScheduler: onProjectMounted', { projectId }); const remoteUrls = await this._syncAndGetGitHubRemotes(projectId); @@ -91,10 +97,6 @@ export class PrSyncScheduler { } } - async onPushCompleted(projectId: string, taskBranch: string): Promise { - return this.onTaskProvisioned(projectId, taskBranch); - } - // ── Remote config change ─────────────────────────────────────────────────── async onRemoteChanged(projectId: string): Promise { @@ -177,8 +179,8 @@ export class PrSyncScheduler { .limit(1); if (!rows[0]?.identifier) return null; - const n = parseInt(rows[0].identifier.replace('#', ''), 10); - return isNaN(n) ? null : n; + const n = Number.parseInt(rows[0].identifier.replace('#', ''), 10); + return Number.isNaN(n) ? null : n; } } diff --git a/src/main/core/tasks/task-manager.ts b/src/main/core/tasks/task-manager.ts index b47e942676..ec2410d86d 100644 --- a/src/main/core/tasks/task-manager.ts +++ b/src/main/core/tasks/task-manager.ts @@ -5,7 +5,6 @@ import { err, ok, type Result } from '@shared/result'; import type { Task, TaskBootstrapStatus } from '@shared/tasks'; import type { Terminal } from '@shared/terminals'; import { killTmuxSession, makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; -import { prSyncScheduler } from '@main/core/pull-requests/pr-sync-scheduler'; import { getTaskSessionLeafIds } from '@main/core/tasks/session-targets'; import type { ExecFn } from '@main/core/utils/exec'; import { provisionBYOITask } from '@main/core/workspaces/byoi/provision-byoi-task'; @@ -32,6 +31,7 @@ export type TaskManagerHooks = { 'task:provisioned': (info: { projectId: string; taskId: string; + taskBranch: string | undefined; workspaceId: string; worktreeGitDir?: string; }) => void | Promise; @@ -68,9 +68,6 @@ async function executeProvision( }); } - void provider.gitFetchService.fetch(); - void prSyncScheduler.onTaskProvisioned(provider.projectId, task.taskBranch); - const workspaceId = provider.defaultWorkspaceType.kind === 'local' ? localWorkspaceId(provider.projectId, task.taskBranch) @@ -172,6 +169,7 @@ class TaskManager { this._hooks.callHookBackground('task:provisioned', { projectId: provider.projectId, taskId: task.id, + taskBranch: task.taskBranch, workspaceId: result.persistData.workspaceId, worktreeGitDir: result.persistData.worktreeGitDir, }); From 554e0134e55ff740916afebc057cd82de304f5e6 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:39:20 +0200 Subject: [PATCH 092/263] fix: prevent settings borders from clipping --- .../settings-view/project-settings-form.tsx | 13 ++++++++----- .../features/settings/components/SettingsPage.tsx | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx index c0103ee127..0060de954c 100644 --- a/src/renderer/features/projects/components/settings-view/project-settings-form.tsx +++ b/src/renderer/features/projects/components/settings-view/project-settings-form.tsx @@ -123,7 +123,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ const baseline = useMemo( () => settingsToForm(initial, configuredRemote, remotes), - // eslint-disable-next-line react-hooks/exhaustive-deps + [initial, configuredRemote, remotes] ); const [form, setForm] = useState(baseline); @@ -171,9 +171,12 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({ } return ( -
-

Project Settings

-
+
+

Project Settings

+
Preserve patterns @@ -344,7 +347,7 @@ export const ProjectSettingsForm = observer(function ProjectSettingsForm({
-
+