diff --git a/.github/pr-screenshots/223/issue-192-enabled-eligible.jpeg b/.github/pr-screenshots/223/issue-192-enabled-eligible.jpeg new file mode 100644 index 000000000..4d6f9b6ed Binary files /dev/null and b/.github/pr-screenshots/223/issue-192-enabled-eligible.jpeg differ diff --git a/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx b/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx new file mode 100644 index 000000000..b382e1d2e --- /dev/null +++ b/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import type { Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { GitWorktreeDropdown } from '#~/components/chat/git-controls/GitWorktreeDropdown' + +vi.mock('antd', () => ({ + Button: ({ children, type: _type, ...props }: ButtonHTMLAttributes & { type?: string }) => ( + + ), + Dropdown: ({ children, popupRender }: { children: ReactNode; popupRender: () => ReactNode }) => ( + <>{children}{popupRender()} + ), + Switch: () => + ), + OverlayActionRow: ({ children }: { children: ReactNode }) =>
{children}
, + OverlayPanel: ({ children }: { children: ReactNode }) =>
{children}
, + OverlaySearchRow: () => +})) + +vi.mock( + '#~/components/chat/sender/@components/mobile-select-drawer/SenderMobileSelectDrawer', + () => ({ + SenderMobileSelectBreadcrumbs: () => null, + SenderMobileSelectDrawer: ({ children }: { children: ReactNode }) =>
{children}
+ }) +) + +let container: HTMLDivElement +let root: Root + +const renderDropdown = async (eligible: boolean) => { + await act(async () => { + root.render( + undefined, + onTransferToLocal: () => undefined + }} + onOpenChange={() => undefined} + /> + ) + }) +} + +describe('git worktree dropdown', () => { + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + }) + + it('keeps Create Worktree mounted and explains external-runtime recovery', async () => { + await renderDropdown(false) + + const createWorktree = [...container.querySelectorAll('button')] + .find(button => button.textContent?.includes('chat.sessionWorkspaceMenuCreateWorktree')) + + expect(createWorktree).toBeDefined() + expect(createWorktree?.disabled).toBe(true) + expect(createWorktree?.getAttribute('title')).toBe('chat.sessionWorkspaceDerivationDisabled.external_runtime') + expect(container.textContent).toContain('chat.sessionWorkspaceDerivationDisabled.external_runtime') + + await renderDropdown(true) + + expect(createWorktree?.disabled).toBe(false) + expect(container.textContent).not.toContain('chat.sessionWorkspaceDerivationDisabled.external_runtime') + }) +}) diff --git a/apps/client/src/components/chat/git-controls/ChatGitControls.tsx b/apps/client/src/components/chat/git-controls/ChatGitControls.tsx index aa6395a18..bc2ab5577 100644 --- a/apps/client/src/components/chat/git-controls/ChatGitControls.tsx +++ b/apps/client/src/components/chat/git-controls/ChatGitControls.tsx @@ -42,10 +42,7 @@ export function ChatGitControls({ mode={{ type: 'session', isBusy: git.isBusy, - canCreateManagedWorktree: git.repoState?.available === true && - git.workspace != null && - git.workspace.kind !== 'managed_worktree' && - (git.workspace.worktreePath == null || git.workspace.worktreePath.trim() === ''), + worktreeDerivation: git.workspace?.worktreeDerivation, canTransferToLocal: git.workspace?.kind === 'managed_worktree', onCreateManagedWorktree: git.handleCreateManagedWorktree, onTransferToLocal: git.handleTransferWorkspaceToLocal diff --git a/apps/client/src/components/chat/git-controls/GitWorktreeDropdown.tsx b/apps/client/src/components/chat/git-controls/GitWorktreeDropdown.tsx index 41bae8b1f..d3d1ef94c 100644 --- a/apps/client/src/components/chat/git-controls/GitWorktreeDropdown.tsx +++ b/apps/client/src/components/chat/git-controls/GitWorktreeDropdown.tsx @@ -4,7 +4,12 @@ import { Button, Dropdown, Switch } from 'antd' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { GitWorktreeSummary, SessionWorkspace } from '@oneworks/types' +import type { + GitWorktreeSummary, + SessionWorkspace, + SessionWorktreeDerivationDisabledReason, + SessionWorktreeDerivationEligibility +} from '@oneworks/types' import { OverlayAction, OverlayActionRow, OverlayPanel, OverlaySearchRow } from '#~/components/overlay' @@ -24,12 +29,20 @@ interface DraftWorktreeMenuMode { interface SessionWorktreeMenuMode { type: 'session' isBusy: boolean - canCreateManagedWorktree: boolean + worktreeDerivation?: SessionWorktreeDerivationEligibility canTransferToLocal: boolean onCreateManagedWorktree: () => void onTransferToLocal: () => void } +const getWorktreeDerivationDisabledReason = ( + reason: SessionWorktreeDerivationDisabledReason | undefined, + t: (key: string) => string +) => { + if (reason == null) return undefined + return t(`chat.sessionWorkspaceDerivationDisabled.${reason}`) +} + const getWorkspaceKindIcon = (kind: SessionWorkspace['kind']) => { switch (kind) { case 'managed_worktree': @@ -256,16 +269,24 @@ export function GitWorktreeDropdown({ )} - {mode.type === 'session' && mode.canCreateManagedWorktree && ( + {mode.type === 'session' && ( add - - {t('chat.sessionWorkspaceMenuCreateWorktree')} + + + {t('chat.sessionWorkspaceMenuCreateWorktree')} + + {mode.worktreeDerivation?.eligible === false && ( + + {getWorktreeDerivationDisabledReason(mode.worktreeDerivation.disabledReason, t)} + + )} diff --git a/apps/client/src/components/chat/git-controls/use-chat-git-controls.ts b/apps/client/src/components/chat/git-controls/use-chat-git-controls.ts index a207ae4ba..d2023bdc2 100644 --- a/apps/client/src/components/chat/git-controls/use-chat-git-controls.ts +++ b/apps/client/src/components/chat/git-controls/use-chat-git-controls.ts @@ -48,7 +48,7 @@ export function useChatGitControls(sessionId: string) { const { data: workspaceData, mutate: mutateWorkspaceData } = useSWR<{ workspace: SessionWorkspace }>( ['session-workspace', sessionId], () => getSessionWorkspace(sessionId), - { revalidateOnFocus: false } + { refreshInterval: 5_000, revalidateOnFocus: false } ) const { data: repoState, mutate: mutateRepoState } = useSWR( ['session-git-state', sessionId], diff --git a/apps/client/src/resources/locales/en.json b/apps/client/src/resources/locales/en.json index f95b63ec7..e5baccf34 100644 --- a/apps/client/src/resources/locales/en.json +++ b/apps/client/src/resources/locales/en.json @@ -2784,6 +2784,15 @@ "sessionWorkspaceMenuLaunchInWorktree": "Start in worktree mode", "sessionWorkspaceMenuTransferToLocal": "Transfer to local", "sessionWorkspaceMenuCreateWorktree": "Create worktree", + "sessionWorkspaceDerivationDisabled": { + "already_managed_worktree": "This session already uses a managed worktree.", + "workspace_unavailable": "Wait for the session workspace to become ready.", + "external_runtime": "External-runtime sessions cannot change worktrees.", + "not_repository": "Open a session in a Git repository to create a worktree.", + "git_not_installed": "Install Git on the server running this workspace.", + "repository_unavailable": "The server cannot inspect this workspace's repository.", + "dirty_worktree": "Commit, stash, or discard local changes before creating a worktree." + }, "sessionWorkspaceDraftCreateWorktreeEnabled": "New sessions get an isolated managed worktree.", "sessionWorkspaceDraftCreateWorktreeDisabled": "New sessions reuse the shared workspace directly.", "sessionWorkspaceDraftCreateBranchLabel": "New branch: {{branch}}", diff --git a/apps/client/src/resources/locales/zh.json b/apps/client/src/resources/locales/zh.json index fa71a4181..7b0abde1a 100644 --- a/apps/client/src/resources/locales/zh.json +++ b/apps/client/src/resources/locales/zh.json @@ -2785,6 +2785,15 @@ "sessionWorkspaceMenuLaunchInWorktree": "以工作树模式启动", "sessionWorkspaceMenuTransferToLocal": "转移到本地", "sessionWorkspaceMenuCreateWorktree": "创建工作树", + "sessionWorkspaceDerivationDisabled": { + "already_managed_worktree": "当前会话已经使用托管工作树。", + "workspace_unavailable": "请等待会话工作区就绪后再试。", + "external_runtime": "外部运行端会话不能切换工作树。", + "not_repository": "请在 Git 项目中打开会话后再创建工作树。", + "git_not_installed": "请先在运行此工作区的服务端安装 Git。", + "repository_unavailable": "服务端暂时无法检查此工作区的 Git 仓库。", + "dirty_worktree": "请先提交、暂存或丢弃本地改动,再创建工作树。" + }, "sessionWorkspaceDraftCreateWorktreeEnabled": "新会话会自动拿到一个隔离的托管 worktree。", "sessionWorkspaceDraftCreateWorktreeDisabled": "新会话会直接复用当前共享工作区。", "sessionWorkspaceDraftCreateBranchLabel": "新分支:{{branch}}", diff --git a/apps/server/__tests__/routes/sessions.spec.ts b/apps/server/__tests__/routes/sessions.spec.ts index 2ed7cea71..6298498a0 100644 --- a/apps/server/__tests__/routes/sessions.spec.ts +++ b/apps/server/__tests__/routes/sessions.spec.ts @@ -22,7 +22,13 @@ import { updateAndNotifySession } from '#~/services/session/index.js' import { notifySessionUpdated } from '#~/services/session/runtime.js' -import { provisionSessionWorkspace, resolveSessionWorkspace } from '#~/services/session/workspace.js' +import { + createSessionManagedWorktree, + provisionSessionWorkspace, + resolveSessionWorkspace, + resolveSessionWorkspaceWithDerivationEligibility, + transferSessionWorkspaceToLocal +} from '#~/services/session/workspace.js' import { disposeTerminalSession } from '#~/services/terminal/index.js' vi.mock('#~/db/index.js', () => ({ @@ -92,10 +98,11 @@ vi.mock('#~/services/session/runtime.js', () => ({ })) vi.mock('#~/services/session/workspace.js', () => ({ - createSessionManagedWorktree: vi.fn(), + createSessionManagedWorktree: vi.fn().mockResolvedValue(undefined), deleteSessionWorkspace: vi.fn(), provisionSessionWorkspace: vi.fn(), resolveSessionWorkspace: vi.fn(), + resolveSessionWorkspaceWithDerivationEligibility: vi.fn(), resolveSessionWorkspaceFolder: vi.fn(), transferSessionWorkspaceToLocal: vi.fn() })) @@ -131,6 +138,11 @@ describe('sessionsRouter', () => { sessionId: 'session-branch', workspaceFolder: '/workspace/root' } as any) + vi.mocked(resolveSessionWorkspaceWithDerivationEligibility).mockResolvedValue({ + sessionId: 'session-branch', + workspaceFolder: '/workspace/root', + worktreeDerivation: { eligible: true } + } as any) }) it('returns a single session by id', () => { @@ -155,6 +167,54 @@ describe('sessionsRouter', () => { expect(ctx.body).toEqual({ session }) }) + it('returns worktree derivation eligibility with the session workspace', async () => { + const handleGetWorkspace = findRouteHandler('/:id/workspace', 'GET') + const ctx = { + params: { id: 'session-derivation' }, + body: undefined + } + + await handleGetWorkspace(ctx) + + expect(resolveSessionWorkspaceWithDerivationEligibility).toHaveBeenCalledWith('session-derivation') + expect(ctx.body).toEqual({ + workspace: expect.objectContaining({ worktreeDerivation: { eligible: true } }) + }) + }) + + it('does not terminate an already-managed session when worktree derivation is rejected', async () => { + const error = Object.assign(new Error('already managed'), { + code: 'session_workspace_derivation_unavailable' + }) + vi.mocked(createSessionManagedWorktree).mockRejectedValueOnce(error) + const handleCreateWorktree = findRouteHandler('/:id/workspace/create-worktree', 'POST') + const ctx = { + params: { id: 'session-managed' }, + body: undefined + } + + await expect(handleCreateWorktree(ctx)).rejects.toBe(error) + + expect(killSession).not.toHaveBeenCalled() + expect(disposeTerminalSession).not.toHaveBeenCalled() + }) + + it('returns current worktree derivation eligibility after transferring to local', async () => { + const handleTransferToLocal = findRouteHandler('/:id/workspace/transfer-local', 'POST') + const ctx = { + params: { id: 'session-transfer' }, + body: undefined + } + + await handleTransferToLocal(ctx) + + expect(transferSessionWorkspaceToLocal).toHaveBeenCalledWith('session-transfer') + expect(resolveSessionWorkspaceWithDerivationEligibility).toHaveBeenCalledWith('session-transfer') + expect(ctx.body).toEqual({ + workspace: expect.objectContaining({ worktreeDerivation: { eligible: true } }) + }) + }) + it('triggers native project history import', async () => { const result = { importedEvents: 2, diff --git a/apps/server/__tests__/services/session-workspace.spec.ts b/apps/server/__tests__/services/session-workspace.spec.ts index 3c3db2f5d..bae3d7665 100644 --- a/apps/server/__tests__/services/session-workspace.spec.ts +++ b/apps/server/__tests__/services/session-workspace.spec.ts @@ -96,6 +96,7 @@ describe('session workspace service', () => { vi.doUnmock('node:fs/promises') vi.doUnmock('node:process') vi.doUnmock('#~/services/safe-regular-file-update.js') + vi.doUnmock('@oneworks/utils') process.env.__ONEWORKS_PROJECT_WORKSPACE_FOLDER__ = previousWorkspaceEnv process.env.__ONEWORKS_PROJECT_PRIMARY_WORKSPACE_FOLDER__ = previousPrimaryWorkspaceEnv db.close() @@ -186,6 +187,192 @@ describe('session workspace service', () => { }) }) + it('reports why a session cannot be derived into a managed worktree', async () => { + const { + createSessionManagedWorktree, + resolveSessionWorkspaceWithDerivationEligibility + } = await import('#~/services/session/workspace.js') + const session = db.createSession('Eligibility', 'sess-eligibility') + + db.upsertSessionWorkspace({ + sessionId: session.id, + kind: 'shared_workspace', + workspaceFolder: workspaceRoot, + cleanupPolicy: 'retain', + state: 'ready' + }) + + await expect(resolveSessionWorkspaceWithDerivationEligibility(session.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: true } + }) + + await writeFile(path.join(workspaceRoot, 'uncommitted.txt'), 'dirty\n', 'utf8') + await expect(resolveSessionWorkspaceWithDerivationEligibility(session.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: false, disabledReason: 'dirty_worktree' } + }) + await expect(createSessionManagedWorktree(session.id)).rejects.toMatchObject({ + code: 'session_workspace_derivation_unavailable' + }) + + await rm(path.join(workspaceRoot, 'uncommitted.txt')) + await expect(resolveSessionWorkspaceWithDerivationEligibility(session.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: true } + }) + }) + + it('reports non-repository, session-type, and runtime derivation limits', async () => { + const { + createSessionManagedWorktree, + resolveSessionWorkspaceWithDerivationEligibility + } = await import('#~/services/session/workspace.js') + const nonGitRoot = await mkdtemp(path.join(os.tmpdir(), 'ow-session-workspace-non-git-')) + const nonGitSession = db.createSession('Non Git', 'sess-non-git') + const externalSession = db.createSession('External runtime', 'sess-external-runtime') + const managedSession = db.createSession('Managed', 'sess-managed') + const unavailableSession = db.createSession('Unavailable', 'sess-unavailable') + + db.upsertSessionWorkspace({ + sessionId: nonGitSession.id, + kind: 'shared_workspace', + workspaceFolder: nonGitRoot, + cleanupPolicy: 'retain', + state: 'ready' + }) + db.upsertSessionWorkspace({ + sessionId: externalSession.id, + kind: 'shared_workspace', + workspaceFolder: workspaceRoot, + cleanupPolicy: 'retain', + state: 'ready' + }) + db.updateSessionRuntimeState(externalSession.id, { runtimeKind: 'external' }) + db.upsertSessionWorkspace({ + sessionId: managedSession.id, + kind: 'managed_worktree', + workspaceFolder: workspaceRoot, + worktreePath: workspaceRoot, + cleanupPolicy: 'delete_on_session_delete', + state: 'ready' + }) + db.upsertSessionWorkspace({ + sessionId: unavailableSession.id, + kind: 'shared_workspace', + workspaceFolder: workspaceRoot, + cleanupPolicy: 'retain', + state: 'broken' + }) + + await expect(resolveSessionWorkspaceWithDerivationEligibility(nonGitSession.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: false, disabledReason: 'not_repository' } + }) + await expect(resolveSessionWorkspaceWithDerivationEligibility(externalSession.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: false, disabledReason: 'external_runtime' } + }) + await expect(resolveSessionWorkspaceWithDerivationEligibility(managedSession.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: false, disabledReason: 'already_managed_worktree' } + }) + await expect(createSessionManagedWorktree(managedSession.id)).rejects.toMatchObject({ + code: 'session_workspace_derivation_unavailable' + }) + await expect(resolveSessionWorkspaceWithDerivationEligibility(unavailableSession.id)).resolves.toMatchObject({ + worktreeDerivation: { eligible: false, disabledReason: 'workspace_unavailable' } + }) + expect(db.getSessionWorkspace(unavailableSession.id)?.state).toBe('broken') + + await rm(nonGitRoot, { recursive: true, force: true }) + }) + + it('rejects worktree derivation when an external runtime arrives during preflight', async () => { + let releasePreflight: (() => void) | undefined + const preflightStarted = new Promise(resolve => { + releasePreflight = resolve + }) + let notifyPreflightStarted: (() => void) | undefined + const preflightIsRunning = new Promise(resolve => { + notifyPreflightStarted = resolve + }) + const utils = await vi.importActual('@oneworks/utils') + vi.doMock('@oneworks/utils', () => ({ + ...utils, + runGitCommand: vi.fn(async (args: string[]) => { + if (args[0] === 'status') { + notifyPreflightStarted?.() + await preflightStarted + return { stdout: '', stderr: '' } + } + return await utils.runGitCommand(args, workspaceRoot) + }) + })) + + const { createSessionManagedWorktree } = await import('#~/services/session/workspace.js') + const session = db.createSession('Racing external runtime', 'sess-runtime-race') + db.upsertSessionWorkspace({ + sessionId: session.id, + kind: 'shared_workspace', + workspaceFolder: workspaceRoot, + cleanupPolicy: 'retain', + state: 'ready' + }) + + const creating = createSessionManagedWorktree(session.id) + await preflightIsRunning + db.updateSessionRuntimeState(session.id, { runtimeKind: 'external' }) + releasePreflight?.() + + await expect(creating).rejects.toMatchObject({ + code: 'session_workspace_derivation_unavailable', + details: { reason: 'external_runtime' } + }) + expect(db.getSessionWorkspace(session.id)).toMatchObject({ + kind: 'shared_workspace', + workspaceFolder: workspaceRoot + }) + }) + + it('rejects worktree derivation when an external runtime arrives after reservation', async () => { + let releaseHeadRef: (() => void) | undefined + const headRefCanResolve = new Promise(resolve => { + releaseHeadRef = resolve + }) + let notifyHeadRefStarted: (() => void) | undefined + const headRefIsPending = new Promise(resolve => { + notifyHeadRefStarted = resolve + }) + const addGitWorktree = vi.fn() + const utils = await vi.importActual('@oneworks/utils') + vi.doMock('@oneworks/utils', () => ({ + ...utils, + addGitWorktree, + resolveGitCurrentBranch: vi.fn().mockResolvedValue(''), + resolveGitHeadRef: vi.fn(async () => { + notifyHeadRefStarted?.() + await headRefCanResolve + return 'HEAD' + }) + })) + + const { createSessionManagedWorktree } = await import('#~/services/session/workspace.js') + const session = db.createSession('Reserved runtime race', 'sess-reserved-runtime-race') + db.upsertSessionWorkspace({ + sessionId: session.id, + kind: 'shared_workspace', + workspaceFolder: workspaceRoot, + cleanupPolicy: 'retain', + state: 'ready' + }) + + const creating = createSessionManagedWorktree(session.id) + await headRefIsPending + db.updateSessionRuntimeState(session.id, { runtimeKind: 'external' }) + releaseHeadRef?.() + + await expect(creating).rejects.toMatchObject({ + code: 'session_workspace_derivation_unavailable', + details: { reason: 'external_runtime' } + }) + expect(addGitWorktree).not.toHaveBeenCalled() + }) + it('does not attach a worktree environment to an explicitly shared workspace', async () => { const { provisionSessionWorkspace } = await import('#~/services/session/workspace.js') db.createSession('Shared Env', 'sess-shared-env') diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 14aa55fba..e3a58efc0 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -68,6 +68,7 @@ import { provisionSessionWorkspace, resolveSessionWorkspace, resolveSessionWorkspaceFolder, + resolveSessionWorkspaceWithDerivationEligibility, transferSessionWorkspaceToLocal } from '#~/services/session/workspace.js' import { disposeTerminalSession } from '#~/services/terminal/index.js' @@ -515,7 +516,7 @@ export function sessionsRouter(): Router { router.get('/:id/workspace', async (ctx) => { const { id } = ctx.params as { id: string } ctx.body = { - workspace: await resolveSessionWorkspace(id) + workspace: await resolveSessionWorkspaceWithDerivationEligibility(id) } }) @@ -575,17 +576,16 @@ export function sessionsRouter(): Router { router.post('/:id/workspace/create-worktree', async (ctx) => { const { id } = ctx.params as { id: string } - const workspace = await createSessionManagedWorktree(id) + await createSessionManagedWorktree(id) killSession(id, { recordWorkspaceChanges: false }) disposeTerminalSession(id) - ctx.body = { workspace } + ctx.body = { workspace: await resolveSessionWorkspaceWithDerivationEligibility(id) } }) router.post('/:id/workspace/transfer-local', async (ctx) => { const { id } = ctx.params as { id: string } - ctx.body = { - workspace: await transferSessionWorkspaceToLocal(id) - } + await transferSessionWorkspaceToLocal(id) + ctx.body = { workspace: await resolveSessionWorkspaceWithDerivationEligibility(id) } }) router.patch('/:id', (ctx) => { diff --git a/apps/server/src/services/session/workspace.ts b/apps/server/src/services/session/workspace.ts index 631da243b..6557f4d2e 100644 --- a/apps/server/src/services/session/workspace.ts +++ b/apps/server/src/services/session/workspace.ts @@ -6,7 +6,12 @@ import { env as processEnv } from 'node:process' import type { WSEvent } from '@oneworks/core' import { resolvePrimaryWorkspaceFolder } from '@oneworks/register/dotenv' -import type { SessionCreationProgressEvent, SessionInfo } from '@oneworks/types' +import type { + SessionCreationProgressEvent, + SessionInfo, + SessionWorkspace, + SessionWorktreeDerivationEligibility +} from '@oneworks/types' import { PROJECT_WORKSPACE_FOLDER_ENV, addGitWorktree, @@ -43,6 +48,7 @@ interface ProvisionSessionWorkspaceOptions { } const DEFAULT_CLEANUP_POLICY: SessionWorkspaceCleanupPolicy = 'delete_on_session_delete' +const sessionWorktreeDerivationReservations = new Set() const isRecord = (value: unknown): value is Record => ( value != null && typeof value === 'object' && !Array.isArray(value) @@ -139,6 +145,86 @@ const getSessionOrThrow = (sessionId: string) => { return session } +const getWorktreeDerivationEligibility = async ( + sessionId: string, + workspace: SessionWorkspace +): Promise => { + if (workspace.kind === 'managed_worktree') { + return { eligible: false, disabledReason: 'already_managed_worktree' } + } + + if (workspace.state !== 'ready') { + return { eligible: false, disabledReason: 'workspace_unavailable' } + } + + if (getDb().getSessionRuntimeState(sessionId)?.runtimeKind === 'external') { + return { eligible: false, disabledReason: 'external_runtime' } + } + + let repositoryRoot: string + try { + repositoryRoot = await resolveGitRepositoryRoot(workspace.workspaceFolder) + } catch (error) { + if (isGitMissingError(error)) { + return { eligible: false, disabledReason: 'git_not_installed' } + } + if (isGitNotRepositoryError(error)) { + return { eligible: false, disabledReason: 'not_repository' } + } + return { eligible: false, disabledReason: 'repository_unavailable' } + } + + try { + const { stdout } = await runGitCommand(['status', '--short'], repositoryRoot) + if (stdout.trim() !== '') { + return { eligible: false, disabledReason: 'dirty_worktree' } + } + } catch { + return { eligible: false, disabledReason: 'repository_unavailable' } + } + + return { eligible: true } +} + +const reserveSessionWorktreeDerivation = (sessionId: string) => { + const workspace = getDb().getSessionWorkspace(sessionId) + if (workspace == null || workspace.kind === 'managed_worktree' || workspace.state !== 'ready') { + return { eligible: false as const, disabledReason: 'workspace_unavailable' as const } + } + + if (getDb().getSessionRuntimeState(sessionId)?.runtimeKind === 'external') { + return { eligible: false as const, disabledReason: 'external_runtime' as const } + } + + if (sessionWorktreeDerivationReservations.has(sessionId)) { + return { eligible: false as const, disabledReason: 'workspace_unavailable' as const } + } + + sessionWorktreeDerivationReservations.add(sessionId) + return { eligible: true as const, workspace } +} + +const assertSessionWorktreeDerivationReservation = (sessionId: string) => { + const workspace = getDb().getSessionWorkspace(sessionId) + const unavailable = !sessionWorktreeDerivationReservations.has(sessionId) || + workspace == null || + workspace.kind === 'managed_worktree' || + workspace.state !== 'ready' + const disabledReason = getDb().getSessionRuntimeState(sessionId)?.runtimeKind === 'external' + ? 'external_runtime' + : unavailable + ? 'workspace_unavailable' + : undefined + + if (disabledReason != null) { + throw conflict( + 'Session workspace cannot be derived into a managed worktree', + { sessionId, reason: disabledReason }, + 'session_workspace_derivation_unavailable' + ) + } +} + const resolveRepositoryDirectoryName = (repositoryRoot: string, fallback: string) => { const segments = repositoryRoot .split(/[\\/]+/) @@ -229,7 +315,8 @@ const buildManagedWorkspace = async ( workspaceFolder: string, worktreeEnvironment?: string, onProgress?: ProvisionSessionWorkspaceOptions['onProgress'], - signal?: AbortSignal + signal?: AbortSignal, + beforeCreateWorktree?: () => void ) => { throwIfAborted(signal, 'Workspace provision cancelled') await emitProvisionProgress(onProgress, { @@ -273,6 +360,7 @@ const buildManagedWorkspace = async ( worktreePath }) throwIfAborted(signal, 'Workspace provision cancelled') + beforeCreateWorktree?.() await addGitWorktree({ branch: branchName, cwd: repositoryRoot, @@ -453,16 +541,50 @@ export const resolveSessionWorkspaceFolder = async (sessionId: string) => { return workspace.workspaceFolder } +const resolveSessionWorkspaceForDerivation = async (sessionId: string) => { + return getDb().getSessionWorkspace(sessionId) ?? await resolveSessionWorkspace(sessionId) +} + +export const resolveSessionWorkspaceWithDerivationEligibility = async (sessionId: string) => { + getSessionOrThrow(sessionId) + const workspace = await resolveSessionWorkspaceForDerivation(sessionId) + return { + ...workspace, + worktreeDerivation: await getWorktreeDerivationEligibility(sessionId, workspace) + } +} + export const createSessionManagedWorktree = async (sessionId: string) => { getSessionOrThrow(sessionId) - const existing = await resolveSessionWorkspace(sessionId) - if (existing.kind === 'managed_worktree') { - return existing + const existing = await resolveSessionWorkspaceForDerivation(sessionId) + const derivationEligibility = await getWorktreeDerivationEligibility(sessionId, existing) + if (!derivationEligibility.eligible) { + throw conflict( + 'Session workspace cannot be derived into a managed worktree', + { sessionId, reason: derivationEligibility.disabledReason }, + 'session_workspace_derivation_unavailable' + ) + } + + const reservation = reserveSessionWorktreeDerivation(sessionId) + if (!reservation.eligible) { + throw conflict( + 'Session workspace cannot be derived into a managed worktree', + { sessionId, reason: reservation.disabledReason }, + 'session_workspace_derivation_unavailable' + ) } try { - return await buildManagedWorkspace(sessionId, existing.workspaceFolder, existing.worktreeEnvironment) + return await buildManagedWorkspace( + sessionId, + reservation.workspace.workspaceFolder, + reservation.workspace.worktreeEnvironment, + undefined, + undefined, + () => assertSessionWorktreeDerivationReservation(sessionId) + ) } catch (error) { if (!isGitMissingError(error) && !isGitNotRepositoryError(error)) { throw error @@ -476,6 +598,8 @@ export const createSessionManagedWorktree = async (sessionId: string) => { }, 'session_workspace_not_repository' ) + } finally { + sessionWorktreeDerivationReservations.delete(sessionId) } } diff --git a/changelog/0.1.0-rc.0/types.md b/changelog/0.1.0-rc.0/types.md new file mode 100644 index 000000000..325ee6a10 --- /dev/null +++ b/changelog/0.1.0-rc.0/types.md @@ -0,0 +1,3 @@ +# @oneworks/types 0.1.0-rc.0 + +- Add the session worktree-derivation eligibility contract for clients to present safe worktree creation availability. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 53bc6ed58..64f70c1ce 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -25,6 +25,7 @@ export * from './native-plugin' export * from './plugin' export * from './project' export * from './session' +export * from './session-worktree-derivation' export * from './standalone-route' export * from './task' export * from './terminal' diff --git a/packages/types/src/session-worktree-derivation.ts b/packages/types/src/session-worktree-derivation.ts new file mode 100644 index 000000000..b2ea97778 --- /dev/null +++ b/packages/types/src/session-worktree-derivation.ts @@ -0,0 +1,13 @@ +export type SessionWorktreeDerivationDisabledReason = + | 'already_managed_worktree' + | 'workspace_unavailable' + | 'external_runtime' + | 'not_repository' + | 'git_not_installed' + | 'repository_unavailable' + | 'dirty_worktree' + +export interface SessionWorktreeDerivationEligibility { + eligible: boolean + disabledReason?: SessionWorktreeDerivationDisabledReason +} diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 863f207a9..9796b8eda 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,8 +1,8 @@ import type { EffortLevel } from './common' import type { ChatMessageContent } from './message' +import type { SessionWorktreeDerivationEligibility } from './session-worktree-derivation' export type SessionStatus = 'running' | 'completed' | 'failed' | 'terminated' | 'waiting_input' - export type SessionPermissionMode = 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions' export type SessionPromptType = 'spec' | 'entity' | 'workspace' export type SessionMessageBranchAction = 'fork' | 'recall' | 'edit' @@ -24,7 +24,6 @@ export type SessionCreationProgressStep = | 'environment_skipped' | 'workspace_ready' | 'workspace_failed' - export interface SessionCreationProgressEvent { phase: SessionCreationProgressPhase step: SessionCreationProgressStep @@ -197,4 +196,5 @@ export interface SessionWorkspace { createdAt: number updatedAt: number deletedAt?: number + worktreeDerivation?: SessionWorktreeDerivationEligibility }