From 151afda317eb64d7670dea6ed2b6ba727f1a533e Mon Sep 17 00:00:00 2001 From: YiJie Date: Sun, 2 Aug 2026 02:22:22 +0800 Subject: [PATCH 1/4] fix: explain and enforce worktree derivation eligibility (#192) --- .../chat/git-controls/ChatGitControls.tsx | 5 +- .../chat/git-controls/GitWorktreeDropdown.tsx | 33 +++++-- .../git-controls/use-chat-git-controls.ts | 2 +- apps/client/src/resources/locales/en.json | 9 ++ apps/client/src/resources/locales/zh.json | 9 ++ apps/server/__tests__/routes/sessions.spec.ts | 47 ++++++++- .../services/session-workspace.spec.ts | 95 +++++++++++++++++++ apps/server/src/routes/sessions.ts | 7 +- apps/server/src/services/session/workspace.ts | 72 +++++++++++++- packages/types/src/session.ts | 15 +++ 10 files changed, 274 insertions(+), 20 deletions(-) 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..b5cfcc02f 100644 --- a/apps/server/__tests__/routes/sessions.spec.ts +++ b/apps/server/__tests__/routes/sessions.spec.ts @@ -22,7 +22,12 @@ 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 +} from '#~/services/session/workspace.js' import { disposeTerminalSession } from '#~/services/terminal/index.js' vi.mock('#~/db/index.js', () => ({ @@ -92,10 +97,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 +137,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 +166,38 @@ 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('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..8393c1561 100644 --- a/apps/server/__tests__/services/session-workspace.spec.ts +++ b/apps/server/__tests__/services/session-workspace.spec.ts @@ -186,6 +186,101 @@ 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('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..1bf3af9ea 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,10 +576,10 @@ 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) => { diff --git a/apps/server/src/services/session/workspace.ts b/apps/server/src/services/session/workspace.ts index 631da243b..a43462696 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, @@ -139,6 +144,47 @@ 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 resolveRepositoryDirectoryName = (repositoryRoot: string, fallback: string) => { const segments = repositoryRoot .split(/[\\/]+/) @@ -453,12 +499,30 @@ 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' + ) } try { diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 863f207a9..fbe8e7304 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -9,6 +9,20 @@ export type SessionMessageBranchAction = 'fork' | 'recall' | 'edit' export type SessionWorkspaceKind = 'managed_worktree' | 'shared_workspace' | 'external_workspace' export type SessionWorkspaceState = 'provisioning' | 'ready' | 'deleting' | 'deleted' | 'broken' export type SessionWorkspaceCleanupPolicy = 'delete_on_session_delete' | 'retain' +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 +} + export type SessionQueuedMessageMode = 'steer' | 'next' export type SessionCreationProgressStatus = 'running' | 'success' | 'error' | 'skipped' export type SessionCreationProgressPhase = 'worktree' | 'environment' | 'workspace' @@ -197,4 +211,5 @@ export interface SessionWorkspace { createdAt: number updatedAt: number deletedAt?: number + worktreeDerivation?: SessionWorktreeDerivationEligibility } From abf853af7ce31da34feeabe22dc25e05fa3b8c43 Mon Sep 17 00:00:00 2001 From: YiJie Date: Mon, 3 Aug 2026 19:22:45 +0800 Subject: [PATCH 2/4] fix(session): close worktree derivation runtime race --- .../chat-git-worktree-dropdown.spec.tsx | 108 ++++++++++++++++++ apps/server/__tests__/routes/sessions.spec.ts | 19 ++- .../services/session-workspace.spec.ts | 92 +++++++++++++++ apps/server/src/routes/sessions.ts | 5 +- apps/server/src/services/session/workspace.ts | 64 ++++++++++- 5 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx 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..1536597ca --- /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('GitWorktreeDropdown', () => { + 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/server/__tests__/routes/sessions.spec.ts b/apps/server/__tests__/routes/sessions.spec.ts index b5cfcc02f..6298498a0 100644 --- a/apps/server/__tests__/routes/sessions.spec.ts +++ b/apps/server/__tests__/routes/sessions.spec.ts @@ -26,7 +26,8 @@ import { createSessionManagedWorktree, provisionSessionWorkspace, resolveSessionWorkspace, - resolveSessionWorkspaceWithDerivationEligibility + resolveSessionWorkspaceWithDerivationEligibility, + transferSessionWorkspaceToLocal } from '#~/services/session/workspace.js' import { disposeTerminalSession } from '#~/services/terminal/index.js' @@ -198,6 +199,22 @@ describe('sessionsRouter', () => { 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 8393c1561..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() @@ -281,6 +282,97 @@ describe('session workspace service', () => { 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 1bf3af9ea..e3a58efc0 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -584,9 +584,8 @@ export function sessionsRouter(): Router { 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 a43462696..6557f4d2e 100644 --- a/apps/server/src/services/session/workspace.ts +++ b/apps/server/src/services/session/workspace.ts @@ -48,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) @@ -185,6 +186,45 @@ const getWorktreeDerivationEligibility = async ( 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(/[\\/]+/) @@ -275,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, { @@ -319,6 +360,7 @@ const buildManagedWorkspace = async ( worktreePath }) throwIfAborted(signal, 'Workspace provision cancelled') + beforeCreateWorktree?.() await addGitWorktree({ branch: branchName, cwd: repositoryRoot, @@ -525,8 +567,24 @@ export const createSessionManagedWorktree = async (sessionId: string) => { ) } + 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 @@ -540,6 +598,8 @@ export const createSessionManagedWorktree = async (sessionId: string) => { }, 'session_workspace_not_repository' ) + } finally { + sessionWorktreeDerivationReservations.delete(sessionId) } } From 85b40a8b8d802d492d3d37cab0c89c7d8148b0e0 Mon Sep 17 00:00:00 2001 From: YiJie Date: Tue, 4 Aug 2026 05:54:58 +0800 Subject: [PATCH 3/4] fix(types): satisfy worktree derivation policy gates --- .../chat-git-worktree-dropdown.spec.tsx | 2 +- changelog/0.1.0-rc.0/types.md | 3 +++ packages/types/src/index.ts | 1 + .../types/src/session-worktree-derivation.ts | 13 +++++++++++++ packages/types/src/session.ts | 17 +---------------- 5 files changed, 19 insertions(+), 17 deletions(-) create mode 100644 changelog/0.1.0-rc.0/types.md create mode 100644 packages/types/src/session-worktree-derivation.ts diff --git a/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx b/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx index 1536597ca..b382e1d2e 100644 --- a/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx +++ b/apps/client/__tests__/chat-git-worktree-dropdown.spec.tsx @@ -76,7 +76,7 @@ const renderDropdown = async (eligible: boolean) => { }) } -describe('GitWorktreeDropdown', () => { +describe('git worktree dropdown', () => { beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) container = document.createElement('div') 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 fbe8e7304..9796b8eda 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,28 +1,14 @@ 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' export type SessionWorkspaceKind = 'managed_worktree' | 'shared_workspace' | 'external_workspace' export type SessionWorkspaceState = 'provisioning' | 'ready' | 'deleting' | 'deleted' | 'broken' export type SessionWorkspaceCleanupPolicy = 'delete_on_session_delete' | 'retain' -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 -} - export type SessionQueuedMessageMode = 'steer' | 'next' export type SessionCreationProgressStatus = 'running' | 'success' | 'error' | 'skipped' export type SessionCreationProgressPhase = 'worktree' | 'environment' | 'workspace' @@ -38,7 +24,6 @@ export type SessionCreationProgressStep = | 'environment_skipped' | 'workspace_ready' | 'workspace_failed' - export interface SessionCreationProgressEvent { phase: SessionCreationProgressPhase step: SessionCreationProgressStep From 2d83335015b9e75b965d960cb1cd8529cf96b42c Mon Sep 17 00:00:00 2001 From: YiJie Date: Tue, 4 Aug 2026 06:46:12 +0800 Subject: [PATCH 4/4] docs(pr): add issue 192 eligibility evidence --- .../223/issue-192-enabled-eligible.jpeg | Bin 0 -> 7932 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-screenshots/223/issue-192-enabled-eligible.jpeg 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 0000000000000000000000000000000000000000..4d6f9b6eddcfe0c4192c20b650d3ada2bf96af2e GIT binary patch literal 7932 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 WPB%|qw$j;71BPgN8n@hNFaHZGgRvd} literal 0 HcmV?d00001