From 03c828eece972565ece4b2ae09ef80200a0a1df9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:37:54 -0800 Subject: [PATCH] Run setup scripts for project group folder workspaces (#41744) * Add setup scripts for project group folder workspaces * Align setup regression fixtures and catalog read validation * Target the Advanced disclosure in group setup regression * test: initialize project groups in worktree slice fixture * Preserve group setup startup input across paired hosts * Check paired group startup regression in cross-layer project * Restore group setup settings when saving fails * Wait for group agents to accept startup input * Type the group startup readiness fixture foreground state * Give group startup fixture mocks portable public types * Reuse worktree startup for group folder setup * Keep example shell regression while removing obsolete observer tests * Complete launch runtime fixture PTY options * Exercise terminal startup failure and worktree draft paste in parity tests * Preserve repository type defaults for setup commands --- config/tsconfig.tc.web.json | 1 + config/tsconfig.test.pane-launch.json | 1 + config/tsconfig.web.json | 1 + config/workspace-record-read-baseline.txt | 2 +- .../agent-launch/agent-launch-executor.ts | 17 +- .../agent-launch-surface-factories.ts | 5 +- .../ipc/repos-execution-host-catalog.test.ts | 28 ++ src/main/ipc/repos.ts | 3 +- ...lder-workspace-attachment-handlers.test.ts | 1 + .../ipc/repos/folder-workspace-handlers.ts | 47 +- src/main/ipc/repos/project-group-handlers.ts | 16 +- src/main/ipc/repos/repo-ipc-arg-schemas.ts | 8 + .../ipc/repos/repo-ipc-wsl-move-fence.test.ts | 1 + ...istence-folder-scan-project-groups.test.ts | 37 ++ .../project-group-operations.ts | 3 + .../runtime-host-service-calls.ts | 6 + .../construction/runtime-host-services.ts | 6 +- .../launch/setup-completion-signal.test.ts | 37 ++ .../runtime-project-group-contract.ts | 52 ++ .../runtime-project-group-controller.ts | 101 ++-- .../runtime-repository-command-surface.ts | 2 + .../runtime-repository-hooks-commands.ts | 13 +- ...ent-launch-folder-workspace-create.test.ts | 13 + .../agent-launch-folder-workspace-creation.ts | 63 ++- .../agent-launch-worktree-creation.ts | 2 +- .../agent-launch/agent-launch.test-fixture.ts | 35 +- .../projects/project-group-rpc-methods.ts | 8 + .../methods/workspaces/folder-workspace.ts | 6 +- .../created-worktree-approved-setup.ts | 32 +- .../runtime-group-folder-setup-host.ts | 105 ++++ .../runtime-group-folder-setup.test.ts | 253 ++++++++++ .../creation/runtime-group-folder-setup.ts | 160 +++++++ .../creation/runtime-local-worktree-setup.ts | 4 +- .../workspaces/creation/workspace-creation.ts | 33 +- .../managed-worktree-create-startup.ts | 2 +- ...runtime-local-worktree-terminal-startup.ts | 4 +- .../runtime-worktree-agent-startup.test.ts | 2 - .../startup/runtime-worktree-agent-startup.ts | 4 +- .../runtime-worktree-terminal-provisioning.ts | 2 + .../created-worktree-setup-trust.ts | 8 +- .../workspace-hooks/effective-hook-config.ts | 15 +- .../folder-setup-runner-files.ts | 5 +- src/main/workspace-hooks/hooks.ts | 11 +- .../workspace-hooks/setup-hook-env-vars.ts | 8 +- .../workspace-hooks/worktree-runner-script.ts | 8 +- .../api/projects/project-groups-bridge.ts | 1 + src/preload/api/projects/repository-api.ts | 2 + src/preload/api/workspaces/worktree-api.ts | 14 +- .../settings-project-section-renderer.tsx | 166 ++++--- .../settings/page/settings-view-model.test.ts | 37 ++ .../settings/page/settings-view-model.ts | 8 +- .../settings/page/use-settings-store-model.ts | 3 + .../settings/repository/RepositoryPane.tsx | 1 + .../hooks/ProjectGroupHooksSection.test.tsx | 452 ++++++++++++++++++ .../hooks/ProjectGroupHooksSection.tsx | 144 ++++++ .../hooks/RepositoryHookPolicySettings.tsx | 3 + .../hooks/RepositoryHooksSection.tsx | 132 +++-- .../hooks/RepositoryHooksYamlStatus.tsx | 62 ++- .../repository-git-hooks-search-entries.ts | 75 +-- .../hooks/repository-hook-settings-draft.ts | 13 +- .../use-repository-hook-settings-draft.ts | 2 +- ...pace-composer-submit-host-platform.test.ts | 247 +++++++++- .../folder-workspace-composer-submit-input.ts | 24 +- .../folder-workspace-composer-submit.ts | 151 +++--- .../folder-workspace-source-selection.ts | 30 +- .../folder-workspace-startup-payload.ts | 83 ++++ .../composer-state/composer-card-props.ts | 22 +- .../composer-setup-card-props.ts | 26 + .../composer-state/composer-store-actions.ts | 17 +- .../composer-submit-orchestration.ts | 2 + .../composer-target-input-contracts.ts | 2 + .../composer-state/composer-target-state.ts | 2 + .../composer-state/derived-composer-state.ts | 6 + .../src/hooks/composer-state/derived-model.ts | 2 + .../folder-submit-orchestration.ts | 65 +++ .../use-project-group-setup.test.tsx | 222 +++++++++ .../composer-state/use-project-group-setup.ts | 161 +++++++ .../settings-navigation-build-options.ts | 2 + .../settings-navigation-remote-sections.ts | 18 + .../use-supported-group-hook-settings.ts | 69 +++ .../hooks/useSettingsNavigationMetadata.ts | 8 + src/renderer/src/i18n/locales/en.json | 9 + .../folder-workspace-initial-terminal.ts | 36 ++ .../worktree-initial-terminal-seeding.ts | 28 -- .../worktree-setup-issue-command-queue.ts | 1 + .../activation/worktree-activation-outcome.ts | 3 + .../activation/worktree-activation.ts | 14 +- .../creation/setup/folder-setup-support.ts | 49 +- .../creation/setup/workspace-setup-trust.ts | 18 +- .../launch-work-item-direct-test-fixture.ts | 1 + ...on-structured-session-host-decline.test.ts | 2 +- .../repo-hooks/ensure-hooks-confirmed.ts | 52 +- .../repo-hooks/hook-owner-resolution.ts | 48 ++ .../repo-hooks/runtime-hooks-client.ts | 19 + .../folder-workspace-catalog.ts | 11 + .../folder-workspace-create-catalog.test.ts | 132 +++++ .../folder-workspace-creation.ts | 73 +++ .../folder-workspace-mutations.ts | 57 +-- ...r-workspace-owner-routed-mutations.test.ts | 10 + .../project-group-catalog-actions.ts | 19 +- .../project-group-hook-trust.test.ts | 42 ++ .../project-group-removal-state.ts | 18 +- .../src/store/repos/repo-catalog-merge.ts | 3 +- src/renderer/src/store/repos/repo-state.ts | 20 +- .../ui/ui-slice-hydration-sanitizers.ts | 3 +- .../create/created-worktree-setup-trust.ts | 9 +- .../worktrees/worktrees-slice-test-harness.ts | 1 + .../folder-workspace-create.ts | 36 ++ src/shared/project-group-hooks.ts | 12 + src/shared/project-group-setup-example.ts | 20 + src/shared/project-group-types.ts | 4 +- src/shared/project-groups.ts | 10 + .../rpc-contract/projects/repo-params.ts | 6 + .../rpc-params-catalog.generated.ts | 2 + .../workspaces/folder-workspace-params.ts | 33 ++ src/shared/workspace-hook-owner.ts | 50 ++ src/shared/workspace-hook-settings-schema.ts | 9 + src/shared/worktree-runtime-capabilities.ts | 4 + src/shared/worktree/creation/launch-types.ts | 1 + .../e2e/folder-workspace-palette-wake.spec.ts | 2 +- .../e2e/paired-cli-agent-launch.unit.test.ts | 5 + tests/e2e/paired-skill-installation.spec.ts | 2 +- tests/e2e/pdf-find-counter.spec.ts | 2 +- ...urce-manager-end-tabless-terminals.spec.ts | 2 +- .../resource-manager-folder-labels.spec.ts | 2 +- ...sidebar-folder-keyboard-navigation.spec.ts | 6 +- tests/e2e/sidebar-sort-stamps-startup.spec.ts | 2 +- tests/e2e/sidebar-workspace-rows.spec.ts | 2 +- ...ree-jump-palette-folder-workspaces.spec.ts | 6 +- .../worktree-lineage-toggle-shortcut.spec.ts | 2 +- tests/e2e/worktree-scroll-to-current.spec.ts | 2 +- tests/e2e/wsl-move-fence.spec.ts | 2 +- 132 files changed, 3671 insertions(+), 676 deletions(-) create mode 100644 src/main/runtime/repositories/runtime-project-group-contract.ts create mode 100644 src/main/runtime/workspaces/creation/runtime-group-folder-setup-host.ts create mode 100644 src/main/runtime/workspaces/creation/runtime-group-folder-setup.test.ts create mode 100644 src/main/runtime/workspaces/creation/runtime-group-folder-setup.ts create mode 100644 src/renderer/src/components/settings/page/settings-view-model.test.ts create mode 100644 src/renderer/src/components/settings/repository/hooks/ProjectGroupHooksSection.test.tsx create mode 100644 src/renderer/src/components/settings/repository/hooks/ProjectGroupHooksSection.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-startup-payload.ts create mode 100644 src/renderer/src/hooks/composer-state/composer-setup-card-props.ts create mode 100644 src/renderer/src/hooks/composer-state/use-project-group-setup.test.tsx create mode 100644 src/renderer/src/hooks/composer-state/use-project-group-setup.ts create mode 100644 src/renderer/src/hooks/use-supported-group-hook-settings.ts create mode 100644 src/renderer/src/lib/workspace/activation/initial-surface/folder-workspace-initial-terminal.ts create mode 100644 src/renderer/src/runtime/repo-hooks/hook-owner-resolution.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-create-catalog.test.ts create mode 100644 src/renderer/src/store/folder-workspaces/folder-workspace-creation.ts create mode 100644 src/renderer/src/store/project-groups/project-group-hook-trust.test.ts create mode 100644 src/shared/folder-workspaces/folder-workspace-create.ts create mode 100644 src/shared/project-group-hooks.ts create mode 100644 src/shared/project-group-setup-example.ts create mode 100644 src/shared/workspace-hook-owner.ts create mode 100644 src/shared/workspace-hook-settings-schema.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 0714d5d3490..5d0498d5163 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.web.json", "exclude": [ + "../src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts", "../src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts", "../src/shared/**/*.test.ts", diff --git a/config/tsconfig.test.pane-launch.json b/config/tsconfig.test.pane-launch.json index cff73d0a79e..a6772914789 100644 --- a/config/tsconfig.test.pane-launch.json +++ b/config/tsconfig.test.pane-launch.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.web.json", "include": [ + "../src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts", "../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts", "../src/renderer/src/runtime/observed-provider-accounts.test.tsx", "../src/renderer/src/components/status-bar/ClaudeSwitcherMenu.observation.test.tsx", diff --git a/config/tsconfig.web.json b/config/tsconfig.web.json index 7b7b09455b2..f8aec1e1774 100644 --- a/config/tsconfig.web.json +++ b/config/tsconfig.web.json @@ -11,6 +11,7 @@ "../src/shared/**/*" ], "exclude": [ + "../src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts", "../src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.test.tsx", "../src/renderer/src/runtime/provider-accounts/observations/provider-account-observation.test-fixture.ts" ], diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt index 3efc1f265d9..3658cbbf35f 100644 --- a/config/workspace-record-read-baseline.txt +++ b/config/workspace-record-read-baseline.txt @@ -50,7 +50,7 @@ 2 src/renderer/src/runtime/sync-runtime-graph/sync-key.ts # reads a passed-in copy 3 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts # store writer 7 src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts # store writer -5 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts # store writer +4 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts # store writer 1 src/renderer/src/store/project-groups/project-group-removal-state.ts # store writer 4 src/renderer/src/store/repos/converted-ssh-host-rows.ts # store writer 5 src/renderer/src/store/repos/repo-catalog-merge.ts # store writer diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index f79870c2187..c9bc8f0e35f 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -100,7 +100,8 @@ export async function openAgentLaunch( } const launch = - intent.target.kind === 'create-worktree' && preflight.mode === 'structured' + (intent.target.kind === 'create-worktree' && preflight.mode === 'structured') || + intent.target.kind === 'create-folder-workspace' ? beginCreatedWorkspaceLaunch(runtime, intent.agent) : undefined let placed: Awaited> | undefined @@ -233,14 +234,14 @@ async function resolveWorkspace( throw new Error('agent_launch_workspace_factory_required') } execution.onStage?.('worktree_create') - if (intent.target.kind === 'create-folder-workspace') { - if (!workspaces.createFolderWorkspace) { - throw new Error('agent_launch_workspace_factory_required') - } - const created = await workspaces.createFolderWorkspace({ create: intent.target.create }) - return { ...created, startupTerminalHandle: undefined } + const createWorkspace = + intent.target.kind === 'create-folder-workspace' + ? workspaces.createFolderWorkspace?.bind(workspaces) + : workspaces.createWorktree.bind(workspaces) + if (!createWorkspace) { + throw new Error('agent_launch_workspace_factory_required') } - const created = await workspaces.createWorktree({ + const created = await createWorkspace({ // Reserved fields must come from the launch intent, not a stale create payload. create: withoutReservedAgentCreateFields(intent.target.create), ...(onWorkspaceCreated ? { onWorkspaceCreated } : {}), diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 26ac68e08fd..401fdf2a246 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -123,10 +123,7 @@ export type AgentLaunchWorkspaceFactory = { }> /** Leave a usable workspace when chat creation has an indeterminate outcome. */ createRecoveryShell?: (worktreeId: string) => Promise - /** Folders have no startup terminal; the executor launches their agent after creation. */ - createFolderWorkspace?(args: { - create: Readonly> - }): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }> + createFolderWorkspace?: AgentLaunchWorkspaceFactory['createWorktree'] } /** The options a terminal leg starts with: a caller's saved chat choice applies to it too. */ diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index d8d9f9961d5..7b3a9e04d1a 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -57,6 +57,7 @@ vi.mock('../workspaces/creation/worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) +import { ipcMain } from 'electron' import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/command-runner/host-routing/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/connection/authority/ssh-provider-authority' @@ -91,6 +92,33 @@ describe('projectGroups IPC validation', () => { registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) + it('re-registers group hook inspection and subsequent handlers when a window reopens', () => { + const registered = new Map unknown>() + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + if (registered.has(channel)) { + throw new Error(`Handler already registered: ${channel}`) + } + registered.set(channel, handler) + }) + vi.mocked(ipcMain.removeHandler).mockImplementation((channel) => { + registered.delete(channel) + }) + const runtime = { checkProjectGroupHooks: vi.fn(() => ({ status: 'ok' })) } + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registration harness supplies only the window, store and runtime ports invoked by these handlers. + registerRepoHandlers(mockWindow as never, mockStore as never, runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registration harness supplies only the window, store and runtime ports invoked by these handlers. + registerRepoHandlers(mockWindow as never, mockStore as never, runtime as never) + expect(registered.get('projectGroups:checkHooks')?.(null, { groupId: 'group-1' })).toEqual({ + status: 'ok' + }) + expect(registered.get('projectGroups:list')?.()).toEqual([]) + expect(registered.has('folderWorkspaces:create')).toBe(true) + } finally { + vi.mocked(ipcMain.removeHandler).mockReset() + } + }) + describe('while a WSL move fences the local repo-1', () => { let userDataPath: string beforeEach(() => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 24448a20635..31447eb27a6 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -35,6 +35,7 @@ export function registerRepoHandlers( ipcMain.removeHandler('projectHostSetups:setupExistingFolder') ipcMain.removeHandler('projectHostSetups:update') ipcMain.removeHandler('projectHostSetups:delete') + ipcMain.removeHandler('projectGroups:checkHooks') ipcMain.removeHandler('projectGroups:list') ipcMain.removeHandler('projectGroups:create') ipcMain.removeHandler('projectGroups:update') @@ -76,7 +77,7 @@ export function registerRepoHandlers( } registerProjectHostSetupHandlers(mainWindow, store, metadata) registerRepoCreationHandlers(mainWindow, store, metadata.enqueueRepositoryMetadata) - registerProjectGroupHandlers(mainWindow, store) + registerProjectGroupHandlers(mainWindow, store, runtime) registerFolderWorkspaceHandlers(mainWindow, store, runtime) registerNestedRepoImportHandler(mainWindow, store, metadata.enqueueRepositoryMetadata) registerRepoUpdateHandler(mainWindow, store) diff --git a/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts b/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts index c13c35e34fa..cac5e3cd83f 100644 --- a/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts +++ b/src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts @@ -57,6 +57,7 @@ describe('folder workspace attachment desktop IPC', () => { }) const workspace = store.createFolderWorkspace({ projectGroupId: group.id }) registerFolderWorkspaceHandlers(new BrowserWindow(), store, { + createFolderWorkspace: vi.fn(), deleteFolderWorkspace: vi.fn(async () => ({ deleted: true })) }) expect( diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 715c9fe2de0..7dfe2ea5a07 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -17,7 +17,6 @@ import { parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' import { visibleFolderWorkspaces } from '../../persistence/migrating-orcad-catalog/source/catalog/orcad-source-visibility' -import { createFolderWorkspaceWithPluginHooks } from '../../plugins/hooks/workspace-create-plugin-hook' import { isLocalWslMoveRow, shouldDropWslMoveProjectUpdate @@ -26,7 +25,7 @@ import { export function registerFolderWorkspaceHandlers( mainWindow: BrowserWindow, store: Store, - runtime: Pick + runtime: Pick ): void { ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => visibleFolderWorkspaces(store)) @@ -39,38 +38,18 @@ export function registerFolderWorkspaceHandlers( return getFolderWorkspacePathStatus(store, args) }) - ipcMain.handle( - 'folderWorkspaces:create', - async (_event, rawArgs: unknown): Promise => { - const args = parseProjectGroupIpcArgs( - FolderWorkspaceCreateArgs, - rawArgs, - 'invalid_folder_workspace_create_args' - ) - const projectGroups = store.getProjectGroups() - const group = projectGroups.find((entry) => entry.id === args.projectGroupId) - const folderPath = - typeof args.folderPath === 'string' && args.folderPath.trim().length > 0 - ? args.folderPath - : group?.parentPath - if (!group || !folderPath) { - throw new Error('folder_workspace_project_group_not_found') - } - const status = await getFolderWorkspacePathStatusForPath({ - folderPath, - projectGroupId: group.id, - connectionId: args.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: store.getRepos() - }) - assertFolderWorkspacePathUsable(status) - const workspace = await createFolderWorkspaceWithPluginHooks(args, (request) => - store.createFolderWorkspace({ ...request, creatorProvenance: { kind: 'host' } }) - ) - notifyReposChanged(mainWindow) - return workspace - } - ) + ipcMain.handle('folderWorkspaces:create', async (_event, rawArgs: unknown) => { + const args = parseProjectGroupIpcArgs( + FolderWorkspaceCreateArgs, + rawArgs, + 'invalid_folder_workspace_create_args' + ) + return runtime.createFolderWorkspace({ + ...args, + rendererOwnsSetup: true, + creatorProvenance: { kind: 'host' } + }) + }) ipcMain.handle( 'folderWorkspaces:update', diff --git a/src/main/ipc/repos/project-group-handlers.ts b/src/main/ipc/repos/project-group-handlers.ts index 17e89cc2575..8acb6f21850 100644 --- a/src/main/ipc/repos/project-group-handlers.ts +++ b/src/main/ipc/repos/project-group-handlers.ts @@ -1,3 +1,5 @@ +import { ProjectGroupHooksCheck } from '../../../shared/rpc-contract/projects/repo-params' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { type BrowserWindow, ipcMain } from 'electron' import type { Store } from '../../persistence/loading-store/store' import type { Repo } from '../../../shared/repo-types' @@ -21,7 +23,19 @@ import { import { activeNestedRepoScans, runNestedRepoScanForIpc } from './nested-repo-scan-ipc' import { visibleProjectGroups } from '../../persistence/migrating-orcad-catalog/source/catalog/orcad-source-visibility' -export function registerProjectGroupHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerProjectGroupHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: Pick +): void { + ipcMain.handle('projectGroups:checkHooks', (_event, rawArgs: unknown) => { + const args = parseProjectGroupIpcArgs( + ProjectGroupHooksCheck, + rawArgs, + 'invalid_project_group_hooks_check_args' + ) + return runtime.checkProjectGroupHooks(args.groupId, { background: args.background }) + }) ipcMain.handle('projectGroups:list', () => visibleProjectGroups(store)) ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => { diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index 8e3d46c8b44..2107cae1531 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -1,3 +1,4 @@ +import { WorkspaceHookSettingsSchema } from '../../../shared/workspace-hook-settings-schema' import { z } from 'zod' import { isTuiAgent } from '../../../shared/agent-launch/catalog/tui-agent-config' import { TaskSourceContextSchema } from '../../../shared/work-items/task-source-context-schema' @@ -6,6 +7,10 @@ import { WorkspaceAttachmentsSchema } from '../../../shared/workspace-attachment import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-attachments/workspace-linked-item-source-context' import { DiffCommentSchema } from '../../../shared/diff-comment-schema' import { normalizeExecutionHostId } from '../../../shared/execution-host' +import { + FolderWorkspaceHookCreateShape, + assertFolderWorkspaceStartup +} from '../../../shared/rpc-contract/workspaces/folder-workspace-params' export const ProjectGroupCreateArgs = z.object({ name: z.string().min(1), @@ -18,6 +23,7 @@ export const ProjectGroupCreateArgs = z.object({ export const ProjectGroupUpdateArgs = z.object({ groupId: z.string().min(1), updates: z.object({ + hookSettings: WorkspaceHookSettingsSchema.optional(), name: z.string().optional(), isCollapsed: z.boolean().optional(), tabOrder: z.number().finite().optional(), @@ -145,6 +151,7 @@ function assertFolderWorkspaceLinkedSourceContextMatch( export const FolderWorkspaceCreateArgs = z .object({ + ...FolderWorkspaceHookCreateShape, projectGroupId: z.string().min(1), name: z.string().optional(), folderPath: z.string().nullable().optional(), @@ -157,6 +164,7 @@ export const FolderWorkspaceCreateArgs = z pendingFirstAgentMessageRename: z.boolean().optional() }) .superRefine(assertFolderWorkspaceLinkedSourceContextMatch) + .superRefine(assertFolderWorkspaceStartup) export const FolderWorkspaceUpdateArgs = z.object({ folderWorkspaceId: z.string().min(1), diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts index f1c4874ba5a..3c9115edd6b 100644 --- a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -79,6 +79,7 @@ describe('folderWorkspaces:update under a WSL move', () => { cutover('move-1', 'source-fenced', {}, { folderWorkspaceIds: [workspace.id] }) ) registerFolderWorkspaceHandlers(new BrowserWindow(), store, { + createFolderWorkspace: vi.fn(), deleteFolderWorkspace: vi.fn(async () => ({ deleted: true })) }) diff --git a/src/main/persistence/tracking-repos/persistence-folder-scan-project-groups.test.ts b/src/main/persistence/tracking-repos/persistence-folder-scan-project-groups.test.ts index d5ca41c75a5..9f9a4e2fe53 100644 --- a/src/main/persistence/tracking-repos/persistence-folder-scan-project-groups.test.ts +++ b/src/main/persistence/tracking-repos/persistence-folder-scan-project-groups.test.ts @@ -42,6 +42,43 @@ describe('Flat folder-scan project groups adaptation', () => { rmSync(testState.dir, { recursive: true, force: true }) }) + it('retains old group defaults and locally configured hooks through restart', async () => { + const store = createStore() + const old = store.createProjectGroup({ + name: 'Old', + parentPath: testState.dir, + createdFrom: 'folder-scan' + }) + const configured = store.createProjectGroup({ + name: 'Configured', + parentPath: testState.dir, + createdFrom: 'folder-scan' + }) + store.updateProjectGroup(configured.id, { + hookSettings: { + mode: 'auto', + scripts: { setup: 'echo setup', archive: 'echo archive' }, + commandSourcePolicy: 'local-only', + setupRunPolicy: 'ask', + setupAgentStartupPolicy: 'wait-for-setup' + } + }) + store.flush() + await closeTestStores() + const reloaded = createStore() + expect( + reloaded.getProjectGroups().find((group) => group.id === old.id)?.hookSettings + ).toBeUndefined() + expect( + reloaded.getProjectGroups().find((group) => group.id === configured.id)?.hookSettings + ).toEqual({ + mode: 'auto', + scripts: { setup: 'echo setup', archive: 'echo archive' }, + commandSourcePolicy: 'local-only', + setupRunPolicy: 'ask', + setupAgentStartupPolicy: 'wait-for-setup' + }) + }) it.each([false, true])( 'preserves saved order through a Store restart (folder repo: %s)', async (includeFolder) => { diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 4ee2fc9aa87..699b79c9065 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -121,6 +121,9 @@ export class ProjectGroupPersistenceOperations { if (updates.color !== undefined) { group.color = typeof updates.color === 'string' ? updates.color : null } + if (updates.hookSettings !== undefined) { + group.hookSettings = updates.hookSettings + } group.updatedAt = Date.now() } diff --git a/src/main/runtime/construction/runtime-host-service-calls.ts b/src/main/runtime/construction/runtime-host-service-calls.ts index 3d8fecf87b8..c8949b07f32 100644 --- a/src/main/runtime/construction/runtime-host-service-calls.ts +++ b/src/main/runtime/construction/runtime-host-service-calls.ts @@ -1,3 +1,4 @@ +import type { RuntimeProjectGroupDependencies } from '../repositories/runtime-project-group-contract' import type { WorkspaceSelectorCandidate, WorkspaceSelectorOptions @@ -111,3 +112,8 @@ export type ResolveTerminalWorkspaceLaunchScopeResult = ReturnType< export type SelectorArgs = [selector: string, options?: WorkspaceSelectorOptions] export type SelectorResult = Promise export type CandidateResult = Promise + +export type FinishGroupFolderCreation = NonNullable< + RuntimeProjectGroupDependencies['finishFolderCreation'] +> +export type AcquireFileWatcherRemoval = RuntimeProjectGroupDependencies['acquireFileWatcherRemoval'] diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 3b972285a01..735ea322179 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -44,9 +44,8 @@ import { teardownFolderWorkspacePtys } from '../workspaces/removal/folder-worksp import { countRepoTerminalsNotKnownExited } from '../workspaces/removal/project-removal-impact' export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTerminalDrivers { - declare acquireFileWatcherRemoval: ConstructorParameters< - typeof RuntimeProjectGroupController - >[0]['acquireFileWatcherRemoval'] + protected abstract finishGroupFolderCreation: Calls.FinishGroupFolderCreation + declare acquireFileWatcherRemoval: Calls.AcquireFileWatcherRemoval protected abstract tryGetWorkspaceSessionHostIdForWorktree( worktreeId: string ): ExecutionHostId | null @@ -290,6 +289,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW }) protected readonly projectGroups = new RuntimeProjectGroupController({ + finishFolderCreation: (...args) => this.finishGroupFolderCreation(...args), getStore: () => this.store, resolveRepo: (...args) => this.managedWorktreeQueries.resolveRepoForConnection(...args), notifyReposChanged: () => this.notifyReposChanged(), diff --git a/src/main/runtime/orchestration/workers/launch/setup-completion-signal.test.ts b/src/main/runtime/orchestration/workers/launch/setup-completion-signal.test.ts index 03dc5c39013..8b124977add 100644 --- a/src/main/runtime/orchestration/workers/launch/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/workers/launch/setup-completion-signal.test.ts @@ -18,3 +18,40 @@ describe('orchestration setup completion signal', () => { expect(onComplete).toHaveBeenCalledWith(17) }) }) + +it.skipIf(process.platform === 'win32')( + 'runs the generated POSIX example with the owning managed CLI name and quoted arguments', + async () => { + const { mkdtempSync, readFileSync, rmSync, writeFileSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const { runProcessSync } = await import('@orca/process-host') + const { projectGroupSetupExample } = + await import('../../../../../shared/project-group-setup-example') + const root = mkdtempSync(join(tmpdir(), 'orca-group-cli-example-')) + const cli = join(root, 'managed cli') + const receipt = join(root, 'arguments') + try { + writeFileSync(cli, '#!/bin/bash\nprintf "%s\\n" "$@" > "$CLI_RECEIPT"\n', { mode: 0o755 }) + const result = runProcessSync({ + program: 'bash', + args: [ + '-c', + projectGroupSetupExample([{ id: 'repo-id', displayName: 'Services' }], 'linux') + ], + env: { + ...process.env, + ORCA_CLI_COMMAND: cli, + ORCA_WORKSPACE_NAME: 'task & spaced', + CLI_RECEIPT: receipt + } + }) + expect(result.code).toBe(0) + expect(readFileSync(receipt, 'utf8')).toBe( + 'worktree\ncreate\n--repo\nid:repo-id\n--name\ntask & spaced\n--json\n' + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) diff --git a/src/main/runtime/repositories/runtime-project-group-contract.ts b/src/main/runtime/repositories/runtime-project-group-contract.ts new file mode 100644 index 00000000000..2cba1208f9e --- /dev/null +++ b/src/main/runtime/repositories/runtime-project-group-contract.ts @@ -0,0 +1,52 @@ +import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachments/workspace-attachment-mutation' +import type { FolderWorkspaceCreateResult } from '../../../shared/folder-workspaces/folder-workspace-create' +import type { RuntimeGroupFolderCreateInput } from '../workspaces/creation/runtime-group-folder-setup' +import type { FolderWorkspace } from '../../../shared/folder-workspaces/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import type { RuntimeStore } from '../construction/runtime-store-contract' +import type { RuntimeWorktreeLifecycleEvent } from '../workspaces/lifecycle/worktree-lifecycle-event' + +export type RuntimeProjectGroupDependencies = { + getStore: () => RuntimeStore | null + // `connectionId` narrows twins by their SSH target; undefined keeps the plain selector. + resolveRepo: (selector: string, connectionId?: string | null) => Promise + notifyReposChanged: () => void + resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null + teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise + acquireFileWatcherRemoval: ( + path: string, + connectionId?: string + ) => Promise<{ finish(removed: boolean): Promise }> + cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise + emitWorktreeLifecycle: (event: RuntimeWorktreeLifecycleEvent) => void + finishFolderCreation?: ( + input: RuntimeGroupFolderCreateInput, + group: ProjectGroup, + workspace: FolderWorkspace + ) => Promise +} + +export type FolderWorkspaceUpdates = Partial< + Pick< + FolderWorkspace, + | 'name' + | 'folderPath' + | 'linkedTask' + | 'linkedItems' + | 'linkedTaskSourceContext' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'lastActivityAt' + | 'diffComments' + > +> & + WorkspaceAttachmentMutation diff --git a/src/main/runtime/repositories/runtime-project-group-controller.ts b/src/main/runtime/repositories/runtime-project-group-controller.ts index 1be8e84cb87..5c4ae0768fe 100644 --- a/src/main/runtime/repositories/runtime-project-group-controller.ts +++ b/src/main/runtime/repositories/runtime-project-group-controller.ts @@ -1,4 +1,14 @@ -import type { WorkspaceAttachmentMutation } from '../../../shared/workspace-attachments/workspace-attachment-mutation' +import type { + RuntimeProjectGroupDependencies, + FolderWorkspaceUpdates +} from './runtime-project-group-contract' +export type { RuntimeProjectGroupDependencies } from './runtime-project-group-contract' +import { projectGroupHookOwner } from '../../../shared/workspace-hook-owner' +import { RuntimeRepositoryHooksCommands } from './runtime-repository-hooks-commands' +import { getEffectiveSetupRunPolicy } from '../../workspace-hooks/effective-hook-config' +import { getSetupConfig } from '../../../shared/orca-hook-setup-config' +import type { FolderWorkspaceCreateResult } from '../../../shared/folder-workspaces/folder-workspace-create' +import type { RuntimeGroupFolderCreateInput } from '../workspaces/creation/runtime-group-folder-setup' import { refuseWslHostWindowsDrivePath } from '../terminal/launch/wsl-host-windows-drive-guard' import type { FolderWorkspace } from '../../../shared/folder-workspaces/folder-workspace-types' import type { @@ -18,8 +28,6 @@ import { getFolderWorkspacePathStatusForPath } from '../../project-groups/folder-workspace-path-status' import { FolderWorkspaceCreateRefusedError } from '../../project-groups/folder-workspace-create-refusal' -import type { RuntimeStore } from '../construction/runtime-store-contract' -import type { RuntimeWorktreeLifecycleEvent } from '../workspaces/lifecycle/worktree-lifecycle-event' import { runtimeVisibleFolderWorkspaces, runtimeVisibleProjectGroups @@ -36,45 +44,6 @@ import { shouldDropWslMoveProjectUpdate } from '../../persistence/moving-wsl-projects/quiescence/wsl-move-scope-guard' -export type RuntimeProjectGroupDependencies = { - getStore: () => RuntimeStore | null - // `connectionId` narrows twins by their SSH target; undefined keeps the plain selector. - resolveRepo: (selector: string, connectionId?: string | null) => Promise - notifyReposChanged: () => void - resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null - teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise - acquireFileWatcherRemoval: ( - path: string, - connectionId?: string - ) => Promise<{ finish(removed: boolean): Promise }> - cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise - emitWorktreeLifecycle: (event: RuntimeWorktreeLifecycleEvent) => void -} - -type FolderWorkspaceUpdates = Partial< - Pick< - FolderWorkspace, - | 'name' - | 'folderPath' - | 'linkedTask' - | 'linkedItems' - | 'linkedTaskSourceContext' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'lastActivityAt' - | 'diffComments' - > -> & - WorkspaceAttachmentMutation - export class RuntimeProjectGroupController { constructor(private readonly deps: RuntimeProjectGroupDependencies) {} @@ -95,6 +64,24 @@ export class RuntimeProjectGroupController { return runtimeVisibleFolderWorkspaces(this.deps.getStore()) } + async checkGroupHooks(groupId: string, options: { background?: boolean } = {}) { + const group = this.listGroups().find((entry) => entry.id === groupId) + const owner = group && projectGroupHookOwner(group) + if (!owner) { + throw new Error('folder_workspace_project_group_not_found') + } + const checked = await new RuntimeRepositoryHooksCommands({ + resolveRepo: async () => owner, + readCreateSourceHooks: async () => undefined + }).checkRepoHooks(groupId, options) + return { + ...checked, + hookSettings: owner.hookSettings, + setupRunPolicy: getEffectiveSetupRunPolicy(owner), + setupConfig: getSetupConfig(owner, checked.hooks) + } + } + async createGroup(input: { name: string parentPath?: string | null @@ -173,18 +160,9 @@ export class RuntimeProjectGroupController { return moved } - async createFolderWorkspace(input: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - creatorProvenance?: FolderWorkspace['creatorProvenance'] - linkedTask?: FolderWorkspace['linkedTask'] - linkedItems?: FolderWorkspace['linkedItems'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }): Promise { + async createFolderWorkspace( + input: RuntimeGroupFolderCreateInput + ): Promise { // Every refusal below comes before the store write, so it proves nothing was created. const store = this.deps.getStore() if (!store?.createFolderWorkspace) { @@ -221,8 +199,21 @@ export class RuntimeProjectGroupController { creatorProvenance: request.creatorProvenance ?? { kind: 'host' } }) ) + input.onWorkspaceCreated?.(`folder:${workspace.id}`) this.deps.notifyReposChanged() - return workspace + try { + return ( + (await this.deps.finishFolderCreation?.(input, group, workspace)) ?? { + folderWorkspace: workspace + } + ) + } catch (error) { + console.warn('[hooks] group workspace startup failed:', error) + return { + folderWorkspace: workspace, + warning: 'Setup or the agent could not start. Open the workspace and try again.' + } + } } async getFolderPathStatus( diff --git a/src/main/runtime/repositories/runtime-repository-command-surface.ts b/src/main/runtime/repositories/runtime-repository-command-surface.ts index b57328ef114..446ec82eb8e 100644 --- a/src/main/runtime/repositories/runtime-repository-command-surface.ts +++ b/src/main/runtime/repositories/runtime-repository-command-surface.ts @@ -48,6 +48,7 @@ export type RuntimeRepositoryCommandSurface = { listFolderWorkspaces: RuntimeProjectGroupController['listFolderWorkspaces'] listVisibleProjectGroups: RuntimeProjectGroupController['listVisibleGroups'] listVisibleFolderWorkspaces: RuntimeProjectGroupController['listVisibleFolderWorkspaces'] + checkProjectGroupHooks: RuntimeProjectGroupController['checkGroupHooks'] createProjectGroup: RuntimeProjectGroupController['createGroup'] updateProjectGroup: RuntimeProjectGroupController['updateGroup'] updateProjectGroups: RuntimeProjectGroupController['updateGroups'] @@ -131,6 +132,7 @@ export function installRuntimeRepositoryCommandSurface( listFolderWorkspaces: groups.listFolderWorkspaces.bind(groups), listVisibleProjectGroups: groups.listVisibleGroups.bind(groups), listVisibleFolderWorkspaces: groups.listVisibleFolderWorkspaces.bind(groups), + checkProjectGroupHooks: groups.checkGroupHooks.bind(groups), createProjectGroup: groups.createGroup.bind(groups), updateProjectGroup: groups.updateGroup.bind(groups), updateProjectGroups: groups.updateGroups.bind(groups), diff --git a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts index c5c8016f0a4..e5be18b7fbe 100644 --- a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts @@ -2,6 +2,7 @@ import { getRepoDialableSshTargetId, isRepoOnCatalogHost } from '../../../shared import { scopeHooksToRepoKind } from '../../../shared/folder-workspace-hooks' import { windowsFsPath } from '../../wsl/filesystem/wsl-unc-drive-fs-path' import { readFile } from 'node:fs/promises' +import type { WorkspaceHookOwner } from '../../../shared/workspace-hook-owner' import type { Repo } from '../../../shared/repo-types' import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types' import { @@ -21,16 +22,16 @@ import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gat import { hashOrcaHookTrustContent } from '../../workspace-hooks/created-worktree-setup-trust' import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source' -type RuntimeRepositoryHooksCommandsDeps = { - resolveRepo: (selector: string) => Promise +type RuntimeRepositoryHooksCommandsDeps = { + resolveRepo: (selector: string) => Promise readCreateSourceHooks: ( - repo: Repo, + repo: TRepo, source: SetupHooksCreateSource ) => Promise<{ hooks: OrcaHooks | null } | undefined> } -export class RuntimeRepositoryHooksCommands { - constructor(private readonly deps: RuntimeRepositoryHooksCommandsDeps) {} +export class RuntimeRepositoryHooksCommands { + constructor(private readonly deps: RuntimeRepositoryHooksCommandsDeps) {} async getRepoHooks(repoSelector: string) { const repo = await this.deps.resolveRepo(repoSelector) @@ -140,7 +141,7 @@ function hookTrust(scriptContent: string): { contentHash: string; scriptContent: } function archiveTrust( - repo: Repo, + repo: WorkspaceHookOwner, sharedHooks: OrcaHooks | null ): { contentHash: string; scriptContent: string } | null { const localArchive = repo.hookSettings?.scripts.archive?.trim() diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-create.test.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-create.test.ts index 50e65ff72c3..b5e951e8936 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-create.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-create.test.ts @@ -88,6 +88,19 @@ describe('a replayed folder launch', () => { expect(result).toMatchObject({ worktreeId: 'folder:fw-new', outcome: { kind: 'terminal' } }) }) + it('does not duplicate a terminal when the requested startup fails', async () => { + const runtime = folderRuntime({ settings: {} }) + const original = runtime.createFolderWorkspace.getMockImplementation()! + runtime.createFolderWorkspace.mockImplementationOnce(async (input) => ({ + ...(await original(input)), + startupTerminal: undefined + })) + await expect(replay(runtime)).rejects.toThrow() + expect(runtime.createFolderWorkspace).toHaveBeenCalledOnce() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(createStructuredSession).not.toHaveBeenCalled() + }) + it('records a create refused before anything was stored as failed, and replays that', async () => { const first = folderRuntime({ settings: {} }) first.createFolderWorkspace.mockRejectedValueOnce( diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-creation.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-creation.ts index 9d7b2bd8afc..6fbd07c22dd 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch-folder-workspace-creation.ts @@ -1,11 +1,11 @@ -/** Folder creation is metadata-only: no Git, setup or startup terminal; launch its agent as in an existing folder workspace. */ - +import type { RuntimeTerminalCreate } from '../../../../../shared/runtime-terminal-create-contract' import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent' import type { AgentLaunchWorkspaceFactory } from '../../../../agent-launch/agent-launch-surface-factories' import type { RpcContext } from '../../core' import { resolveRpcWorkspaceCreatorProvenance } from '../../callers/workspace-creator-context' import type { AgentLaunchParams } from '../../../../../shared/rpc-contract/agent-launch/agent-launch-params' +import { toAgentLaunchPreferences } from '../../../../../shared/agent-launch/choices/agent-launch-preferences' type FolderWorkspaceCreateParams = Extract< AgentLaunchParams['target'], @@ -16,18 +16,65 @@ export function agentLaunchFolderWorkspaceCreator( context: RpcContext, agent: TuiAgent ): AgentLaunchWorkspaceFactory['createFolderWorkspace'] { - return async ({ create }) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`, and the intent copies the parsed payload unchanged. + return async ({ + create, + startupAgent, + promisedAgentChat, + onWorkspaceCreated, + startupPrompt, + agentArgs, + cwd, + launchSource, + paneKey, + options, + accountEnv + }) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: AgentLaunch validated this payload before selecting its create factory. const params = create as FolderWorkspaceCreateParams - const workspace = await context.runtime.createFolderWorkspace({ + let startupTerminalCreation: RuntimeTerminalCreate | undefined + let promptRodeLaunchCommand = false + const result = await context.runtime.createFolderWorkspace({ ...params, + signal: context.signal, creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), - createdWithAgent: agent + createdWithAgent: agent, + startupAgent, + ...(promisedAgentChat ? { agentLaunchMode: 'structured' as const } : {}), + startupPrompt, + startupAgentArgs: agentArgs, + startupCwd: cwd, + launchSource, + startupPaneKey: paneKey, + startupLaunchPreferences: startupAgent + ? toAgentLaunchPreferences(options, startupAgent) + : undefined, + startupAccountEnv: accountEnv ? { ...accountEnv } : undefined, + onWorkspaceCreated, + onStartupTerminalCreated: (terminal) => { + startupTerminalCreation = terminal + }, + onStartupPromptCarry: (carried) => { + promptRodeLaunchCommand = carried + } }) - // Use the existing-target resolver so workspace id and connection match. + if (startupAgent && !result.startupTerminal?.spawned) { + throw new Error( + `Could not start ${agent}. Setup or agent startup did not complete. Open the workspace, check Setup and try again.` + ) + } + const workspace = result.folderWorkspace const scope = await context.runtime.showTerminalWorkspaceLaunchScope( `id:${folderWorkspaceKey(workspace.id)}` ) - return { worktreeId: scope.id, workspacePath: scope.path, connectionId: scope.connectionId } + return { + worktreeId: scope.id, + workspacePath: scope.path, + connectionId: scope.connectionId, + startupTerminalHandle: result.startupTerminal?.handle, + startupTerminalCreation, + startupTerminalPaneKey: result.startupTerminal?.paneKey ?? undefined, + promptRodeLaunchCommand, + warning: result.warning + } } } diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch-worktree-creation.ts index 034884d740d..5811ffd4f76 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch-worktree-creation.ts @@ -159,7 +159,7 @@ function withoutHostActivation(params: WorktreeCreateParams): WorktreeCreatePara } } -async function waitForStructuredSetup( +export async function waitForStructuredSetup( runtime: Pick, receipt: Awaited>['setupReceipt'] ): Promise { diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch.test-fixture.ts index b683c316c48..1c761a0d44c 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch.test-fixture.ts @@ -1,3 +1,4 @@ +import type { FolderWorkspaceCreateResult } from '../../../../../shared/folder-workspaces/folder-workspace-create' /** Use one runtime stub for RPC and replay tests so they exercise the same host surface. */ import { vi } from 'vitest' @@ -103,7 +104,39 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } ), showRepo: vi.fn(async () => ({ id: 'repo-1' })), - createFolderWorkspace: vi.fn(async (_input: Record) => ({ id: 'fw-new' })), + createFolderWorkspace: vi.fn( + async (input: Record): Promise => { + reportPromptCarry(options, input.onStartupPromptCarry, input.startupPrompt) + const terminal = { + handle: 'term_folder_first', + ...(options.startupTerminalPaneKey ? { paneKey: options.startupTerminalPaneKey } : {}) + } + if (input.startupAgent && options.startupTerminalPaneKey) { + handlesByPaneKey.set(options.startupTerminalPaneKey, terminal.handle) + } + if (input.startupAgent && typeof input.onStartupTerminalCreated === 'function') { + input.onStartupTerminalCreated(terminal) + } + return { + folderWorkspace: { + id: 'fw-new', + projectGroupId: 'group-1', + name: 'notes', + folderPath: '/tmp/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + }, + ...(input.startupAgent ? { startupTerminal: { spawned: true, ...terminal } } : {}) + } + } + ), createManagedWorktree: vi.fn(async (args: Record) => { reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) if (args.startupAgent && options.startupTerminalPaneKey) { diff --git a/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts b/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts index b004f2774be..4ba0e209454 100644 --- a/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/projects/project-group-rpc-methods.ts @@ -1,6 +1,7 @@ import { defineMethod } from '../../core' import { projectRepoVisibilityForClient } from '../../workspace-client-views/repo-visibility-projection' import { + ProjectGroupHooksCheck, ProjectGroupCreate, ProjectGroupImportNested, ProjectGroupMoveProject, @@ -11,6 +12,13 @@ import { } from '../../../../../shared/rpc-contract/projects/repo-params' export const PROJECT_GROUP_METHODS = [ + defineMethod({ + name: 'projectGroup.checkHooks', + permission: 'workspace', + params: ProjectGroupHooksCheck, + handler: (params, { runtime }) => + runtime.checkProjectGroupHooks(params.groupId, { background: params.background }) + }), defineMethod({ name: 'projectGroup.list', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/workspaces/folder-workspace.ts b/src/main/runtime/rpc/methods/workspaces/folder-workspace.ts index 4eaaec576ee..184fa64bfc2 100644 --- a/src/main/runtime/rpc/methods/workspaces/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/workspaces/folder-workspace.ts @@ -20,12 +20,12 @@ export const FOLDER_WORKSPACE_METHODS = [ name: 'folderWorkspace.create', permission: 'workspace', params: FolderWorkspaceCreate, - handler: async (params, context) => ({ - folderWorkspace: await context.runtime.createFolderWorkspace({ + handler: async (params, context) => + context.runtime.createFolderWorkspace({ ...params, + signal: context.signal, creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) }) - }) }), defineMethod({ name: 'folderWorkspace.update', diff --git a/src/main/runtime/workspaces/creation/created-worktree-approved-setup.ts b/src/main/runtime/workspaces/creation/created-worktree-approved-setup.ts index 7147cc3b6c3..81cb66a3f2b 100644 --- a/src/main/runtime/workspaces/creation/created-worktree-approved-setup.ts +++ b/src/main/runtime/workspaces/creation/created-worktree-approved-setup.ts @@ -1,3 +1,5 @@ +import { projectGroupIdFromRepoId } from '../../../../shared/folder-workspaces/folder-workspace-worktree' +import { projectGroupHookOwner } from '../../../../shared/workspace-hook-owner' import { isFolderRepo } from '../../../../shared/repo-kind' import { isRepoOnCatalogHost } from '../../../../shared/execution-host' import { scopeHooksToRepoKind } from '../../../../shared/folder-workspace-hooks' @@ -23,9 +25,11 @@ import type { WorktreeTerminalProvisioningArgs } from '../startup/runtime-worktr type ApprovedSetupRuntime = { showManagedWorktree: ( - selector: string + selector: string, + requiredHostId?: undefined, + options?: { matchFolders?: boolean } ) => Promise> - requireStore: () => Pick + requireStore: () => Pick provisionManagedWorktreeTerminals: (args: WorktreeTerminalProvisioningArgs) => Promise } @@ -39,13 +43,21 @@ export async function runApprovedCreatedWorktreeSetup( approvedSetupHash: string, options: { wslUserAct?: true } = {} ): Promise { - const worktree = await runtime.showManagedWorktree(worktreeSelector) + const worktree = await runtime.showManagedWorktree(worktreeSelector, undefined, { + matchFolders: true + }) const store = runtime.requireStore() - const repo = store.getRepo(worktree.repoId) - if (!repo || (isFolderRepo(repo) && !isRepoOnCatalogHost(repo))) { + const groupId = projectGroupIdFromRepoId(worktree.repoId) + const group = groupId + ? store.getProjectGroups?.().find((entry) => entry.id === groupId) + : undefined + const storedRepo = groupId ? undefined : store.getRepo(worktree.repoId) + const repo = group ? projectGroupHookOwner(group) : storedRepo + const hooksRoot = group ? group.parentPath : worktree.path + if (!repo || !hooksRoot || (isFolderRepo(repo) && !isRepoOnCatalogHost(repo))) { return { status: 'not_configured' } } - const yamlHooks = scopeHooksToRepoKind(repo, loadHooks(worktree.path)) + const yamlHooks = scopeHooksToRepoKind(repo, loadHooks(hooksRoot)) const mismatch = checkCreatedWorktreeSetupTrust(repo, yamlHooks, 'run', { approvedSetupHash }) if (mismatch) { return { status: 'mismatch', setupTrustMismatch: mismatch } @@ -54,12 +66,12 @@ export async function runApprovedCreatedWorktreeSetup( const setup = setupScript ? await createSetupRunnerScript( repo, - worktree.path, + hooksRoot, setupScript, - getLocalProjectWorktreeGitOptions(store, repo), + storedRepo ? getLocalProjectWorktreeGitOptions(store, storedRepo) : undefined, resolveSetupRunnerShell(store.getSettings()), yamlHooks?.setupAgentStartupPolicy, - { worktreeId: worktree.id, displayName: worktree.displayName } + { worktreeId: worktree.id, displayName: worktree.displayName, workspacePath: worktree.path } ) : undefined // The create already opened the command-less default tabs. @@ -71,7 +83,7 @@ export async function runApprovedCreatedWorktreeSetup( worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath: worktree.path, - ...(setup ? { setup } : {}), + ...(setup ? { setup: { ...setup, ...(group ? { cwd: hooksRoot } : {}) } } : {}), ...(defaultTabs ? { defaultTabs } : {}), hasStartupTerminal: true, setupCommandPlatform: getSetupRunnerCommandPlatformForPath( diff --git a/src/main/runtime/workspaces/creation/runtime-group-folder-setup-host.ts b/src/main/runtime/workspaces/creation/runtime-group-folder-setup-host.ts new file mode 100644 index 00000000000..b176e81aed5 --- /dev/null +++ b/src/main/runtime/workspaces/creation/runtime-group-folder-setup-host.ts @@ -0,0 +1,105 @@ +import { OrcaRuntimeWithGetWorktreeTerminalProvisioningHost } from '../startup/workspace-terminal-provisioning' +import { + prepareGroupFolderCreation, + groupFolderCreationIsUserAct, + type RuntimeGroupFolderCreateInput +} from './runtime-group-folder-setup' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import type { RuntimeLocalWorktreeTerminalPorts } from '../startup/runtime-local-worktree-terminal-startup' +import { wslUserAct } from '../startup/workspace-create-activation' +import { + buildWorktreeStartupForAgent, + buildWorktreeStartupForDraft +} from '../startup/runtime-worktree-agent-startup' +import { resolveStartupAgentLaunchRuntime } from '../../terminal/launch/agent-launch-scope-runtime' +import { projectGroupHookOwner } from '../../../../shared/workspace-hook-owner' + +export abstract class OrcaRuntimeWithGroupFolderSetup extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { + protected finishGroupFolderCreation = async ( + input: RuntimeGroupFolderCreateInput, + group: ProjectGroup, + workspace: FolderWorkspace + ) => { + const store = this.requireStore() + const worktree = this.folderWorkspaceToResolvedWorktree(workspace) + const activation: { wslUserAct?: true } = groupFolderCreationIsUserAct(input) + ? { wslUserAct: true } + : {} + const scope = await this.resolveTerminalWorkspaceLaunchScope( + `id:${worktree.id}`, + undefined, + activation, + worktree, + workspace + ) + const owner = projectGroupHookOwner(group) + return prepareGroupFolderCreation({ + input, + group, + workspace, + settings: store.getSettings(), + ports: this.localWorktreeTerminalPorts(activation, () => {}, worktree), + wait: (handle, signal) => this.waitForSetupTerminalCompletion(handle, signal), + startupBuilders: { + agent: (agent, prompt, launchPreferences, inputs) => + buildWorktreeStartupForAgent({ + repo: owner ?? { + path: workspace.folderPath, + connectionId: workspace.connectionId, + executionHostId: workspace.executionHostId + }, + agent, + prompt, + launchPreferences, + ...inputs, + settings: store.getSettings(), + getLaunchRuntime: (requested) => + resolveStartupAgentLaunchRuntime(store, scope, requested), + toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences) + }), + draft: (draft, requestedAgent, launchSource, inputs) => + buildWorktreeStartupForDraft({ + repo: owner ?? { + path: workspace.folderPath, + connectionId: workspace.connectionId, + executionHostId: workspace.executionHostId + }, + draft, + requestedAgent, + originalAgent: inputs?.createdWithAgent ?? requestedAgent, + agentArgs: inputs?.startupAgentArgs, + sessionOptions: + inputs?.startupSessionOptions ?? + this.toAgentSessionOptions(inputs?.startupLaunchPreferences), + launchSource, + settings: store.getSettings(), + getLaunchRuntime: (agent) => resolveStartupAgentLaunchRuntime(store, scope, agent) + }) + } + }) + } + protected localWorktreeTerminalPorts( + args: { navigation?: RuntimeNavigationTarget; wslUserAct?: true }, + activate: RuntimeLocalWorktreeTerminalPorts['activate'], + worktree?: Worktree + ): RuntimeLocalWorktreeTerminalPorts { + return { + canSpawn: Boolean(this.ptyController?.spawn), + provisionInBackground: () => this.shouldProvisionWorktreeInBackground(args.navigation), + createTerminal: (selector, options) => + worktree + ? this.createTerminal(selector, { ...options, ...wslUserAct(args) }, worktree) + : this.createTerminal(selector, { ...options, ...wslUserAct(args) }), + pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), + sendFollowup: (handle, followup, terminal) => { + void this.deliverStartupFollowup(handle, followup, { terminalCreation: terminal }) + }, + provision: (options) => + this.provisionManagedWorktreeTerminals({ ...options, ...wslUserAct(args) }, worktree), + activate + } + } +} diff --git a/src/main/runtime/workspaces/creation/runtime-group-folder-setup.test.ts b/src/main/runtime/workspaces/creation/runtime-group-folder-setup.test.ts new file mode 100644 index 00000000000..f4cfb950e02 --- /dev/null +++ b/src/main/runtime/workspaces/creation/runtime-group-folder-setup.test.ts @@ -0,0 +1,253 @@ +import * as wslGate from '../../../wsl/filesystem/wsl-unc-access-gate' +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '@orca/process-host' +import { getDefaultRepoHookSettings, getDefaultSettings } from '../../../../shared/constants' +import { createProjectGroup } from '../../../../shared/project-groups' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' +import type { FolderWorkspaceCreateInput } from '../../../../shared/folder-workspaces/folder-workspace-create' +import { removeFolderSetupRunnerFiles } from '../../../workspace-hooks/folder-setup-runner-files' +import { prepareGroupFolderCreation } from './runtime-group-folder-setup' +import { OrcaRuntimeWithGroupFolderSetup } from './runtime-group-folder-setup-host' +import { provisionWorktreeTerminals } from '../startup/runtime-worktree-terminal-provisioning' +import type { RuntimeLocalWorktreeTerminalPorts } from '../startup/runtime-local-worktree-terminal-startup' + +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) +vi.mock('../startup/workspace-terminal-provisioning', () => ({ + OrcaRuntimeWithGetWorktreeTerminalProvisioningHost: class {} +})) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-group-setup-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + rmSync(root, { recursive: true, force: true }) +}) + +function fixture(input: Partial = {}) { + const group: ProjectGroup = { + ...createProjectGroup({ + name: 'Services', + parentPath: root, + createdFrom: 'folder-scan', + tabOrder: 0 + }), + id: 'services', + hookSettings: { + ...getDefaultRepoHookSettings(), + scripts: { + setup: + 'printf "%s|%s|%s|%s" "$PWD" "$ORCA_ROOT_PATH" "$ORCA_WORKSPACE_ID" "$ORCA_WORKSPACE_NAME" > "$ORCA_ROOT_PATH/ran"', + archive: '' + }, + setupRunPolicy: 'run-by-default', + setupAgentStartupPolicy: 'wait-for-setup', + commandSourcePolicy: 'local-only' + } + } + const workspace: FolderWorkspace = { + id: 'task-a', + projectGroupId: group.id, + name: 'Repair API', + folderPath: join(root, 'other'), + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + const createTerminal = vi.fn(async () => ({ + handle: 'setup-handle', + ptyId: 'setup-pty', + worktreeId: 'folder:task-a', + title: 'Setup' + })) + const ports: RuntimeLocalWorktreeTerminalPorts = { + canSpawn: true, + createTerminal, + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + activate: vi.fn(), + provision: (args) => + provisionWorktreeTerminals( + { + canSpawn: () => true, + createTerminal, + setTabTitle: async () => {}, + setTabColor: async () => {}, + getPtyId: () => 'setup-pty', + recordSetupCompletionToken: vi.fn() + }, + args + ) + } + const buildStartup = vi.fn() + const wait = vi.fn(async (): Promise<{ exitCode: number | null }> => ({ exitCode: 0 })) + const args = { + input: { projectGroupId: group.id, ...input }, + group, + workspace, + settings: getDefaultSettings(root), + ports, + startupBuilders: { agent: buildStartup, draft: vi.fn() }, + wait + } + return { args, createTerminal, buildStartup, wait } +} + +describe('group folder setup lifecycle', () => { + it.skipIf(process.platform === 'win32')( + 'uses the group root and folder identity and isolates sibling runner lifetimes', + async () => { + const { args } = fixture() + const first = await prepareGroupFolderCreation({ + ...args, + input: { ...args.input, rendererOwnsSetup: true } + }) + const second = await prepareGroupFolderCreation({ + ...args, + workspace: { ...args.workspace, id: 'task-b' }, + input: { ...args.input, rendererOwnsSetup: true } + }) + expect(first.setup?.cwd).toBe(root) + expect(first.setup?.envVars).toMatchObject({ + ORCA_WORKSPACE_ID: 'folder:task-a', + ORCA_WORKTREE_ID: 'folder:task-a', + ORCA_WORKSPACE_NAME: 'Repair API', + ORCA_WORKTREE_PATH: join(root, 'other') + }) + if (!first.setup || !second.setup) { + throw new Error('Expected prepared setup runners') + } + expect(dirname(first.setup.runnerScriptPath)).not.toBe(dirname(second.setup.runnerScriptPath)) + runProcessSync({ + program: 'bash', + args: [first.setup.runnerScriptPath], + cwd: first.setup.cwd, + env: { ...process.env, ...first.setup.envVars } + }) + expect(readFileSync(join(root, 'ran'), 'utf8')).toBe( + `${root}|${root}|folder:task-a|Repair API` + ) + await removeFolderSetupRunnerFiles(root, 'folder:task-a') + runProcessSync({ + program: 'bash', + args: [second.setup.runnerScriptPath], + cwd: second.setup.cwd, + env: { ...process.env, ...second.setup.envVars } + }) + expect(readFileSync(join(root, 'ran'), 'utf8')).toBe( + `${root}|${root}|folder:task-b|Repair API` + ) + } + ) + + it('waits for structured Setup through the worktree completion observer', async () => { + const { args, wait, createTerminal, buildStartup } = fixture({ agentLaunchMode: 'structured' }) + let complete: ((value: { exitCode: number | null }) => void) | undefined + wait.mockImplementation( + () => + new Promise((resolve) => { + complete = resolve + }) + ) + const creating = prepareGroupFolderCreation(args) + await vi.waitFor(() => expect(wait).toHaveBeenCalled()) + expect(createTerminal).toHaveBeenCalledOnce() + expect(createTerminal.mock.calls[0]).toMatchObject([ + 'id:folder:task-a', + { title: 'Setup', cwd: root } + ]) + expect(buildStartup).not.toHaveBeenCalled() + let settled = false + void creating.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + complete?.({ exitCode: 0 }) + expect(await creating).toMatchObject({ + folderWorkspace: { id: 'task-a' } + }) + }) + + it('keeps the worktree structured launch behavior after Setup fails', async () => { + const { args, wait } = fixture({ agentLaunchMode: 'structured' }) + wait.mockResolvedValue({ exitCode: 1 }) + expect(await prepareGroupFolderCreation(args)).toMatchObject({ + folderWorkspace: { id: 'task-a' } + }) + expect(wait).toHaveBeenCalledOnce() + }) + + it('lets an explicit agent create reach a stopped distro with no configured hooks', async () => { + const blocked = vi + .spyOn(wslGate, 'blockedWslDistroForPath') + .mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' }) + const { args } = fixture({ agentLaunchMode: 'structured' }) + delete args.group.hookSettings + expect(await prepareGroupFolderCreation(args)).toMatchObject({ + folderWorkspace: { id: 'task-a' } + }) + blocked.mockRestore() + }) + + it('carries explicit creation through host scope admission and provisioning', async () => { + const inputs: Partial[] = [ + { setupDecision: 'inherit' }, + { agentLaunchMode: 'structured' }, + {} + ] + for (const input of inputs) { + const { args } = fixture(input) + const resolveScope = vi.fn(async (..._args: unknown[]) => ({})) + const provision = vi.fn(async (..._args: unknown[]) => ({ + setupSpawned: false, + setupTerminalHandle: null + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked base is empty; these are every port reached by the production finish method. + const runtime = Object.assign(Reflect.construct(OrcaRuntimeWithGroupFolderSetup, []), { + requireStore: () => ({ getSettings: () => args.settings }), + folderWorkspaceToResolvedWorktree: () => ({ id: 'folder:task-a' }), + resolveTerminalWorkspaceLaunchScope: resolveScope, + ptyController: { spawn: vi.fn() }, + shouldProvisionWorktreeInBackground: () => true, + provisionManagedWorktreeTerminals: provision + }) as { + finishGroupFolderCreation: ( + input: typeof args.input, + group: ProjectGroup, + workspace: FolderWorkspace + ) => Promise + } + await runtime.finishGroupFolderCreation(args.input, args.group, args.workspace) + const expected = + 'setupDecision' in input || 'agentLaunchMode' in input ? { wslUserAct: true } : {} + expect(resolveScope.mock.calls[0]?.[2]).toEqual(expected) + expect(provision.mock.calls[0]?.[0]).toMatchObject(expected) + } + }) + + it('skips unapproved YAML for legacy requests but still returns a usable workspace', async () => { + const { args, wait } = fixture({ agentLaunchMode: 'structured' }) + delete args.group.hookSettings + writeFileSync( + join(root, 'orca.yaml'), + 'setupRunPolicy: run-by-default\nsetupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: echo shared\n' + ) + expect(await prepareGroupFolderCreation(args)).toMatchObject({ + folderWorkspace: { id: 'task-a' } + }) + expect(wait).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/workspaces/creation/runtime-group-folder-setup.ts b/src/main/runtime/workspaces/creation/runtime-group-folder-setup.ts new file mode 100644 index 00000000000..f604b6d1f4c --- /dev/null +++ b/src/main/runtime/workspaces/creation/runtime-group-folder-setup.ts @@ -0,0 +1,160 @@ +import { blockedWslDistroForPath } from '../../../wsl/filesystem/wsl-unc-access-gate' +import type { + FolderWorkspaceCreateInput, + FolderWorkspaceCreateResult +} from '../../../../shared/folder-workspaces/folder-workspace-create' +import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { projectGroupHookOwner } from '../../../../shared/workspace-hook-owner' +import { isRepoOnCatalogHost } from '../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { + prepareRuntimeLocalWorktreeSetup, + buildRuntimeSetupReceipt, + resolveRuntimeSetupDecision +} from './runtime-local-worktree-setup' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import type { RuntimeStore } from '../../construction/runtime-store-contract' +import { + startRuntimeLocalWorktreeTerminals, + type RuntimeLocalWorktreeTerminalPorts +} from '../startup/runtime-local-worktree-terminal-startup' +import { resolveManagedWorktreeCreateStartup } from '../startup/managed-worktree-create-startup' +import { waitForStructuredSetup } from '../../rpc/methods/agent-launch/agent-launch-worktree-creation' + +export type RuntimeGroupFolderCreateInput = FolderWorkspaceCreateInput & + Pick< + RuntimeManagedWorktreeCreateArgs, + | 'startupCwd' + | 'startupPaneKey' + | 'startupAccountEnv' + | 'onStartupTerminalCreated' + | 'onStartupPromptCarry' + | 'onWorkspaceCreated' + > & { + creatorProvenance?: FolderWorkspace['creatorProvenance'] + rendererOwnsSetup?: boolean + signal?: AbortSignal + } + +export function groupFolderCreationIsUserAct(input: RuntimeGroupFolderCreateInput): boolean { + return Boolean( + input.rendererOwnsSetup || + input.startupAgent || + input.startupCommand || + input.agentLaunchMode || + input.setupDecision !== undefined + ) +} + +export async function prepareGroupFolderCreation(args: { + input: RuntimeGroupFolderCreateInput + group: ProjectGroup + workspace: FolderWorkspace + settings: ReturnType + ports: RuntimeLocalWorktreeTerminalPorts + startupBuilders: Parameters[2] + wait: (handle: string, signal: AbortSignal) => Promise<{ exitCode: number | null }> +}): Promise { + const { input, workspace, group } = args + const result: FolderWorkspaceCreateResult = { folderWorkspace: workspace } + const owner = projectGroupHookOwner(group) + const skip = resolveRuntimeSetupDecision(input) === 'skip' + let prepared: Awaited> | undefined + if (owner && isRepoOnCatalogHost(owner) && !skip) { + try { + if ( + !groupFolderCreationIsUserAct(input) && + input.setupDecision !== 'run' && + (await blockedWslDistroForPath(owner.path)) + ) { + throw new Error('The group’s WSL distro is not running.') + } + prepared = await prepareRuntimeLocalWorktreeSetup({ + request: input, + repo: owner, + worktreePath: owner.path, + identity: { + worktreeId: folderWorkspaceKey(workspace.id), + displayName: workspace.name, + workspacePath: workspace.folderPath + }, + settings: args.settings, + runtimeTarget: undefined, + shouldUseSetupRunner: true + }) + if (prepared.setup) { + prepared.setup = { ...prepared.setup, cwd: owner.path } + } + result.warning = prepared.warning + result.setupTrustMismatch = prepared.setupTrustMismatch + } catch (error) { + console.warn('[hooks] group setup preparation failed:', error) + result.warning = 'Setup did not run. Check the group’s workspace hooks and try Setup again.' + } + } + if (input.rendererOwnsSetup && input.agentLaunchMode !== 'structured') { + result.setup = prepared?.setup + return result + } + const request = { + ...input, + ...(input.startupCommand + ? { + startup: { + command: input.startupCommand, + env: input.startupEnv, + launchConfig: input.startupLaunchConfig, + startupCommandDelivery: input.startupCommandDelivery + } + } + : {}), + startupLaunchSource: input.launchSource, + awaitTerminalProvisioning: true, + observeSetupCompletion: input.agentLaunchMode === 'structured', + setupOnly: input.agentLaunchMode === 'structured' + } + const built = await resolveManagedWorktreeCreateStartup( + request, + args.settings.disabledTuiAgents, + args.startupBuilders + ) + const started = await startRuntimeLocalWorktreeTerminals({ + request, + repo: { id: group.id }, + worktree: folderWorkspaceToWorktree(workspace), + setup: prepared?.setup, + startup: built.effectiveStartup, + startupFollowup: built.effectiveStartupFollowup, + draftPaste: built.effectiveDraftPaste, + startupLaunchRuntime: built.startupLaunchRuntime, + createdWithAgent: built.effectiveCreatedWithAgent, + promisedAgentChat: input.agentLaunchMode === 'structured', + ports: args.ports + }) + // Host-owned provisioning is never retried by renderer activation. + result.setup = undefined + result.warning = started.warning ?? result.warning + if (prepared) { + result.setupReceipt = buildRuntimeSetupReceipt( + prepared, + started.didSpawnSetup, + started.setupTerminalHandle + ) + } + if (started.didSpawnStartup) { + result.startupTerminal = { + spawned: true, + handle: started.startupTerminalHandle ?? undefined, + tabId: started.startupTerminalTabId ?? undefined, + paneKey: started.startupTerminalPaneKey, + ptyId: started.startupTerminalPtyId, + surface: 'background' + } + } + if (input.agentLaunchMode === 'structured') { + await waitForStructuredSetup({ waitForSetupTerminalCompletion: args.wait }, result.setupReceipt) + } + return result +} diff --git a/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts index 48ac76b6585..94798789c60 100644 --- a/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts +++ b/src/main/runtime/workspaces/creation/runtime-local-worktree-setup.ts @@ -3,7 +3,7 @@ import type { SetupDecision, SetupTrustMismatch } from '../../../../shared/worktree/create-types' -import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../../../shared/workspace-hook-owner' import { loadHooks, runHook } from '../../../workspace-hooks/hooks' import { createSetupRunnerScript, @@ -37,7 +37,7 @@ export async function prepareRuntimeLocalWorktreeSetup(args: { RuntimeManagedWorktreeCreateArgs, 'runHooks' | 'setupDecision' | 'approvedSetupHash' | 'approvedSetupHashes' > - repo: Repo + repo: WorkspaceHookOwner worktreePath: string settings: ReturnType runtimeTarget: { wslDistro?: string } | undefined diff --git a/src/main/runtime/workspaces/creation/workspace-creation.ts b/src/main/runtime/workspaces/creation/workspace-creation.ts index 51a626f9184..bba831ab9ee 100644 --- a/src/main/runtime/workspaces/creation/workspace-creation.ts +++ b/src/main/runtime/workspaces/creation/workspace-creation.ts @@ -6,7 +6,7 @@ import { } from '../lineage/runtime-worktree-lineage-resolution' import { RuntimeLineageError, LINEAGE_PARENT_AMBIGUOUS } from '../lineage/runtime-lineage-error' import type { Repo } from '../../../../shared/repo-types' -import { OrcaRuntimeWithGetWorktreeTerminalProvisioningHost } from '../startup/workspace-terminal-provisioning' +import { OrcaRuntimeWithGroupFolderSetup } from './runtime-group-folder-setup-host' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' import { isFolderRepo } from '../../../../shared/repo-kind' @@ -15,8 +15,6 @@ import { resolveWorktreeCreateRoute } from '../../../workspaces/creation/worktre import { ExecutionHostNotDispatchableError } from '../../../providers/execution-host-provider-dispatch' import { SshHostNotServedError } from '../../../providers/ssh-host-not-served-error' import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' -import type { RuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import type { Worktree } from '../../../../shared/worktree/types' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../../../workspaces/creation/preparation/worktree-create-preparation' import { @@ -25,14 +23,10 @@ import { } from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../../../ipc/worktrees/removal/registered-worktree-roots-scoped-invalidation' import { startRuntimeLocalWorktreeTerminals } from '../startup/runtime-local-worktree-terminal-startup' -import type { RuntimeLocalWorktreeTerminalPorts } from '../startup/runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../../../workspaces/creation/workspace-create-telemetry' import type { RuntimeWorkspaceCreateEvents } from '../../../workspaces/creation/workspace-create-telemetry' -import { - createdWorkspaceActivationCallbacks, - wslUserAct -} from '../startup/workspace-create-activation' +import { createdWorkspaceActivationCallbacks } from '../startup/workspace-create-activation' import { assertWslMoveRepoWritable } from '../../../persistence/moving-wsl-projects/quiescence/wsl-move-scope-guard' import { resolveManagedWorktreeCreateStartup } from '../startup/managed-worktree-create-startup' import type { WorkspaceCreationHandle } from '../lifecycle/workspace-lifecycle-creations' @@ -42,7 +36,7 @@ import { applyWorkspaceCreatePluginHook } from '../../../plugins/hooks/workspace import { withProjectWorktreeCreate } from '../../../workspaces/creation/worktree-create-in-flight' import { createdWorktreeLifecycleIdentity } from '../lifecycle/worktree-lifecycle-event' -export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { +export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGroupFolderSetup { abstract resolveLineageForWorktreeCreate( input?: WorktreeLineageInput, childRepo?: Repo @@ -295,25 +289,4 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi this.authoritativeWindowId !== null || Boolean(startup) || Boolean(this.ptyController?.spawn) ) } - - private localWorktreeTerminalPorts( - args: { navigation?: RuntimeNavigationTarget; wslUserAct?: true }, - activate: RuntimeLocalWorktreeTerminalPorts['activate'], - worktree?: Worktree - ): RuntimeLocalWorktreeTerminalPorts { - return { - canSpawn: Boolean(this.ptyController?.spawn), - provisionInBackground: () => this.shouldProvisionWorktreeInBackground(args.navigation), - createTerminal: (selector, options) => - worktree - ? this.createTerminal(selector, { ...options, ...wslUserAct(args) }, worktree) - : this.createTerminal(selector, { ...options, ...wslUserAct(args) }), - pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), - sendFollowup: (handle, followup, terminal) => - void this.deliverStartupFollowup(handle, followup, { terminalCreation: terminal }), - provision: (options) => - this.provisionManagedWorktreeTerminals({ ...options, ...wslUserAct(args) }, worktree), - activate - } - } } diff --git a/src/main/runtime/workspaces/startup/managed-worktree-create-startup.ts b/src/main/runtime/workspaces/startup/managed-worktree-create-startup.ts index d6c1085c1fb..81a4272318f 100644 --- a/src/main/runtime/workspaces/startup/managed-worktree-create-startup.ts +++ b/src/main/runtime/workspaces/startup/managed-worktree-create-startup.ts @@ -22,7 +22,7 @@ type StartupBuilders = { /** What the new workspace's first terminal launches, refusing a disabled agent. */ export async function resolveManagedWorktreeCreateStartup( - args: RuntimeManagedWorktreeCreateArgs, + args: Omit, disabledTuiAgents: Parameters[1], build: StartupBuilders ) { diff --git a/src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup.ts index 747092f059b..4e47d0e228e 100644 --- a/src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/workspaces/startup/runtime-local-worktree-terminal-startup.ts @@ -63,8 +63,8 @@ export type RuntimeLocalWorktreeTerminalStartupResult = { } export async function startRuntimeLocalWorktreeTerminals(args: { - request: RuntimeManagedWorktreeCreateArgs - repo: Repo + request: Omit + repo: Pick worktree: Worktree setup?: CreateWorktreeResult['setup'] defaultTabs?: CreateWorktreeResult['defaultTabs'] diff --git a/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.test.ts b/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.test.ts index 7f494faf51f..858b5affd0d 100644 --- a/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.test.ts @@ -207,8 +207,6 @@ describe('buildWorktreeStartupForAgent extra agent args', () => { })) const resolved = await resolveWorktreeCreateAgentStartup( { - repoSelector: 'repo-1', - name: 'Review', startupAgent: 'codex', startupAccountEnv: { CODEX_HOME: '/home/u/.codex-b' } }, diff --git a/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.ts b/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.ts index c8d447d76d7..4d1fb2fd397 100644 --- a/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/workspaces/startup/runtime-worktree-agent-startup.ts @@ -39,7 +39,7 @@ type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & { } type StartupEnvironment = { - repo: Repo + repo: Pick settings: ReturnType /** Resolve the execution host's quoting/spawn fact, or the repair error that refuses startup. */ getLaunchRuntime: (agent: TuiAgent) => ReturnType @@ -257,7 +257,7 @@ export async function buildWorktreeStartupForAgent( } export async function resolveWorktreeCreateAgentStartup( - args: RuntimeManagedWorktreeCreateArgs, + args: Omit, build: ( agent: TuiAgent, prompt: string | undefined, diff --git a/src/main/runtime/workspaces/startup/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/workspaces/startup/runtime-worktree-terminal-provisioning.ts index 8e1e8c5bb4e..9acf279efaa 100644 --- a/src/main/runtime/workspaces/startup/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/workspaces/startup/runtime-worktree-terminal-provisioning.ts @@ -11,6 +11,7 @@ export type WorktreeProvisionTerminalOptions = { /** The pane main already wrote (layout-owner build): the terminal starts in it. */ tabId?: string leafId?: string + cwd?: string title?: string command?: string env?: Record @@ -242,6 +243,7 @@ export async function provisionWorktreeTerminals( ) const env = { ...args.setup.envVars, ...observed?.env } const setupTerminal = await host.createTerminal(args.worktreeSelector, { + ...(args.setup.cwd ? { cwd: args.setup.cwd } : {}), title: host.getSetupTerminalTitle?.() ?? 'Setup', command, env, diff --git a/src/main/workspace-hooks/created-worktree-setup-trust.ts b/src/main/workspace-hooks/created-worktree-setup-trust.ts index 1d591ee5bf5..3ad539825fe 100644 --- a/src/main/workspace-hooks/created-worktree-setup-trust.ts +++ b/src/main/workspace-hooks/created-worktree-setup-trust.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { resolveHookCommandSourcePolicy } from '../../shared/hook-command-source-policy' import { getSetupTrustContent } from '../../shared/orca-hook-setup-config' import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../shared/workspace-hook-owner' import type { SetupDecision, SetupTrustMismatch } from '../../shared/worktree/create-types' import type { ApprovedSetupHashes } from '../../shared/worktree/creation/approved-setup-hashes' import type { WorktreeDefaultTabsLaunch } from '../../shared/worktree/creation/launch-types' @@ -18,7 +18,7 @@ export function hashOrcaHookTrustContent(content: string): string { * Undefined means run as decided: no approval was sent (older client), nothing shared would run, or it matches. */ export function checkCreatedWorktreeSetupTrust( - repo: Repo, + repo: WorkspaceHookOwner, createdYamlHooks: OrcaHooks | null, decision: SetupDecision | undefined, approved: ApprovedSetupHashes @@ -44,7 +44,7 @@ export function checkCreatedWorktreeSetupTrust( * existed, so it needs an approval or an explicit run rather than starting unseen. */ export function folderSharedSetupLacksApproval( - repo: Repo, + repo: WorkspaceHookOwner, createdYamlHooks: OrcaHooks | null, decision: SetupDecision | undefined, approved: ApprovedSetupHashes @@ -61,7 +61,7 @@ export function folderSharedSetupLacksApproval( return policy !== 'local-only' && Boolean(getSetupTrustContent(createdYamlHooks)) } -function wouldRunSetup(repo: Repo, decision: SetupDecision | undefined): boolean { +function wouldRunSetup(repo: WorkspaceHookOwner, decision: SetupDecision | undefined): boolean { try { return shouldRunSetupForCreate(repo, decision) } catch { diff --git a/src/main/workspace-hooks/effective-hook-config.ts b/src/main/workspace-hooks/effective-hook-config.ts index c7114f54b77..0129811a5d2 100644 --- a/src/main/workspace-hooks/effective-hook-config.ts +++ b/src/main/workspace-hooks/effective-hook-config.ts @@ -5,7 +5,7 @@ import type { OrcaHooks, SetupRunPolicy } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../shared/workspace-hook-owner' import type { SetupDecision } from '../../shared/worktree/create-types' import type { WorktreeDefaultTabsLaunch } from '../../shared/worktree/creation/launch-types' @@ -29,7 +29,7 @@ function getEffectiveHookScript( } export function getEffectiveHooksFromConfig( - repo: Repo, + repo: WorkspaceHookOwner, yamlHooks: OrcaHooks | null ): OrcaHooks | null { const localSetup = repo.hookSettings?.scripts.setup @@ -57,11 +57,14 @@ export function getEffectiveHooksFromConfig( } } -export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy { +export function getEffectiveSetupRunPolicy(repo: WorkspaceHookOwner): SetupRunPolicy { return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy! } -export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'inherit'): boolean { +export function shouldRunSetupForCreate( + repo: WorkspaceHookOwner, + decision: SetupDecision = 'inherit' +): boolean { if (decision === 'run') { return true } @@ -79,7 +82,7 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i /** Unattended creates (orchestration workers) cannot ask, so only an explicit project skip turns setup off. */ export function resolveUnattendedSetupDecision( - repo: Repo, + repo: WorkspaceHookOwner, requested?: SetupDecision ): Extract { if (requested === 'run' || requested === 'skip') { @@ -90,7 +93,7 @@ export function resolveUnattendedSetupDecision( export function getDefaultTabsLaunch( hooks: OrcaHooks | null, - repo: Repo, + repo: WorkspaceHookOwner, decision: SetupDecision = 'inherit' ): WorktreeDefaultTabsLaunch | undefined { const tabs = hooks?.defaultTabs ?? [] diff --git a/src/main/workspace-hooks/folder-setup-runner-files.ts b/src/main/workspace-hooks/folder-setup-runner-files.ts index 0986d8ed473..b1fcd5504d8 100644 --- a/src/main/workspace-hooks/folder-setup-runner-files.ts +++ b/src/main/workspace-hooks/folder-setup-runner-files.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id' @@ -8,6 +8,9 @@ const FOLDER_RUNNER_DIR = '.orca' // One directory per workspace, so removal deletes it without listing its siblings' runners. function runnerDirName(worktreeId: string): string { + if (worktreeId.startsWith('folder:')) { + return `setup-runner-group-${createHash('sha256').update(worktreeId).digest('hex')}` + } const separatorIdx = worktreeId.lastIndexOf(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) const instance = separatorIdx === -1 diff --git a/src/main/workspace-hooks/hooks.ts b/src/main/workspace-hooks/hooks.ts index 0b5c0177188..7d5af5bd10a 100644 --- a/src/main/workspace-hooks/hooks.ts +++ b/src/main/workspace-hooks/hooks.ts @@ -14,7 +14,7 @@ import { runWslProcess } from '../wsl/execution/wsl-runner' import { windowsFsPath } from '../wsl/filesystem/wsl-unc-drive-fs-path' import type { HookRuntimeTarget } from './hook-runtime-target' import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../shared/workspace-hook-owner' import type { ProjectExecutionRuntimeResolution } from '../../shared/execution-host/project-execution-runtime' import { spawn } from 'node:child_process' import { @@ -147,13 +147,16 @@ export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean { } } -export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null { +export function getEffectiveHooks( + repo: WorkspaceHookOwner, + worktreePath?: string +): OrcaHooks | null { const hooksRoot = worktreePath ?? repo.path return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot)) } export function getSetupCommandSource( - repo: Repo, + repo: WorkspaceHookOwner, worktreePath?: string ): { source: 'yaml' | 'local' | 'both'; command: string } | null { const hooksRoot = worktreePath ?? repo.path @@ -183,7 +186,7 @@ export function getSetupCommandSource( export function runHook( hookName: 'setup' | 'archive', cwd: string, - repo: Repo, + repo: WorkspaceHookOwner, hooksPath?: string, projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, options: { identity?: HookWorkspaceIdentity; timeoutMs?: number; hooks?: OrcaHooks } = {} diff --git a/src/main/workspace-hooks/setup-hook-env-vars.ts b/src/main/workspace-hooks/setup-hook-env-vars.ts index 81211369da7..52a6bdb3a44 100644 --- a/src/main/workspace-hooks/setup-hook-env-vars.ts +++ b/src/main/workspace-hooks/setup-hook-env-vars.ts @@ -1,6 +1,6 @@ import { getRuntimePathBasename } from '../../shared/cross-platform-path' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/shell/launch/terminal-git-credential-guard' -import type { Repo } from '../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../shared/workspace-hook-owner' import { isFolderRepo } from '../../shared/repo-kind' /** The workspace a hook runs for; folder workspaces share one directory, so the path can't name them. */ @@ -8,6 +8,7 @@ export type HookWorkspaceIdentity = { worktreeId: string /** Names a folder workspace; a worktree's name is its directory's. */ displayName?: string + workspacePath?: string } // Path-valued keys only, so WSL and posix-shell launches translate them; names are not paths. @@ -20,10 +21,11 @@ export const SETUP_RUNNER_PATH_ENV_KEYS = [ ] as const export function getSetupEnvVars( - repo: Pick, + repo: Pick, worktreePath: string, identity?: HookWorkspaceIdentity ): Record { + worktreePath = identity?.workspacePath ?? worktreePath const workspaceName = identity?.displayName && isFolderRepo(repo) ? identity.displayName @@ -45,7 +47,7 @@ export function getSetupEnvVars( } export function getSetupRunnerEnvVars( - repo: Repo, + repo: WorkspaceHookOwner, worktreePath: string, identity?: HookWorkspaceIdentity ): Record { diff --git a/src/main/workspace-hooks/worktree-runner-script.ts b/src/main/workspace-hooks/worktree-runner-script.ts index 62ed9b16523..072bcbf917c 100644 --- a/src/main/workspace-hooks/worktree-runner-script.ts +++ b/src/main/workspace-hooks/worktree-runner-script.ts @@ -31,7 +31,7 @@ import { ignoreFolderSetupRunners, newFolderSetupRunnerPath } from './folder-set import { isFolderRepo } from '../../shared/repo-kind' import { buildPosixRunnerScript, buildWindowsRunnerScript } from './setup-runner-script-text' -import type { Repo } from '../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../shared/workspace-hook-owner' import type { WorktreeSetupLaunch } from '../../shared/worktree/creation/launch-types' import type { SetupAgentStartupPolicy } from '../../shared/orca-yaml-hook-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/execution-host/project-execution-runtime' @@ -55,7 +55,7 @@ async function getGitPath(cwd: string, relativePath: string): Promise { } export async function createSetupRunnerScript( - repo: Repo, + repo: WorkspaceHookOwner, worktreePath: string, script: string, projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, @@ -79,7 +79,7 @@ export async function createSetupRunnerScript( } async function createWorktreeRunnerScript(args: { - repo: Repo + repo: WorkspaceHookOwner worktreePath: string script: string runnerBaseName: 'setup-runner' @@ -110,7 +110,7 @@ async function createWorktreeRunnerScript(args: { // Report the typing shell, not the runner format: cmd /c typed through Git Bash triggers MSYS path rewriting. const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree ? (setupShell ?? { family: 'cmd' }) - : process.platform === 'win32' && runtimeTarget?.wslDistro + : process.platform === 'win32' && wslWorktree ? { family: 'posix', executable: 'wsl.exe' } : undefined const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh' diff --git a/src/preload/api/projects/project-groups-bridge.ts b/src/preload/api/projects/project-groups-bridge.ts index 006b3f17d72..fce63ab8412 100644 --- a/src/preload/api/projects/project-groups-bridge.ts +++ b/src/preload/api/projects/project-groups-bridge.ts @@ -3,6 +3,7 @@ import type { NestedRepoScanResult } from '../../../shared/project-group-types' import type { PreloadApi } from '../../api-types' export const projectGroupsApi = { + checkHooks: (args) => ipcRenderer.invoke('projectGroups:checkHooks', args), list: () => ipcRenderer.invoke('projectGroups:list'), create: (args) => ipcRenderer.invoke('projectGroups:create', args), update: (args) => ipcRenderer.invoke('projectGroups:update', args), diff --git a/src/preload/api/projects/repository-api.ts b/src/preload/api/projects/repository-api.ts index 6e0bdfca26d..376ef2812df 100644 --- a/src/preload/api/projects/repository-api.ts +++ b/src/preload/api/projects/repository-api.ts @@ -1,3 +1,4 @@ +import type { GroupHookCheckResult } from '../../../shared/project-group-hooks' import type { RepoCreateFailure } from '../../../shared/repo-create-failure-copy' import type { CloneTargetArgs, @@ -127,6 +128,7 @@ export type ProjectsApi = { } export type ProjectGroupsApi = { + checkHooks: (args: { groupId: string; background?: boolean }) => Promise list: () => Promise create: (args: { name: string diff --git a/src/preload/api/workspaces/worktree-api.ts b/src/preload/api/workspaces/worktree-api.ts index 93aeff158b6..c11bfd06685 100644 --- a/src/preload/api/workspaces/worktree-api.ts +++ b/src/preload/api/workspaces/worktree-api.ts @@ -1,3 +1,7 @@ +import type { + FolderWorkspaceCreateInput, + FolderWorkspaceCreateResult +} from '../../../shared/folder-workspaces/folder-workspace-create' import type { WorkspaceMetadataSet } from '../../../shared/worktree/metadata/workspace-metadata-set' import type { SparsePresetFailure } from '../../../shared/worktree/creation/sparse-preset-failure' import type { @@ -168,15 +172,7 @@ export type WorktreeApi = { export type FolderWorkspacesApi = { list: () => Promise getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise - create: (args: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }) => Promise + create: (args: FolderWorkspaceCreateInput) => Promise update: (args: { folderWorkspaceId: string updates: Partial< diff --git a/src/renderer/src/components/settings/page/settings-project-section-renderer.tsx b/src/renderer/src/components/settings/page/settings-project-section-renderer.tsx index 1efb29f1a4a..76792624c84 100644 --- a/src/renderer/src/components/settings/page/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/page/settings-project-section-renderer.tsx @@ -1,3 +1,5 @@ +import { ProjectGroupHooksSection } from '../repository/hooks/ProjectGroupHooksSection' +import { projectGroupHookOwner } from '../../../../../shared/workspace-hook-owner' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID @@ -19,72 +21,102 @@ export function renderProjectSettingsSections( t: Translate = translate ): React.ReactNode { const { model, navigation, terminal, view, interactions } = context - return model.settingsProjectList.map((settingsProject) => { - const repoSectionId = `repo-${settingsProject.representativeRepoId}` - // Identity edits must follow the selected Available Hosts entry. - const hostSelection = getSettingsEntryHostSelection( - settingsProject, - model.settingsProjectHostSelection, - model.settingsProjectSetupSelection - ) - const repo = getSettingsProjectHostRepo( - settingsProject, - model.repos, - hostSelection.hostId, - hostSelection.setupId - ) - if (!repo) { - return null - } - const repoHostIdentity = getRepoHostIdentity(repo) - const repoHooksState = model.repoHooksMap[repoHostIdentity] - const project = navigation.projectByRepoId.get(repo.id) ?? settingsProject.project + return ( + <> + {model.hookSettingsGroups.map((group) => { + const owner = projectGroupHookOwner(group) + if (!owner) { + return null + } + const id = `group-${encodeURIComponent(owner.id)}` + return ( + {{name}}', { + name: group.settingsLabel ?? group.name + })} + description={owner.path} + searchEntries={navigation.getSectionSearchEntries(id)} + > + {view.isSectionMounted(id) ? ( + + ) : null} + + ) + })} + {model.settingsProjectList.map((settingsProject) => { + const repoSectionId = `repo-${settingsProject.representativeRepoId}` + // Identity edits must follow the selected Available Hosts entry. + const hostSelection = getSettingsEntryHostSelection( + settingsProject, + model.settingsProjectHostSelection, + model.settingsProjectSetupSelection + ) + const repo = getSettingsProjectHostRepo( + settingsProject, + model.repos, + hostSelection.hostId, + hostSelection.setupId + ) + if (!repo) { + return null + } + const repoHostIdentity = getRepoHostIdentity(repo) + const repoHooksState = model.repoHooksMap[repoHostIdentity] + const project = navigation.projectByRepoId.get(repo.id) ?? settingsProject.project - return ( - {{value0}}', { - value0: settingsProject.checkoutLabel ?? project.displayName - })} - description={repo.path} - searchEntries={navigation.getSectionSearchEntries(repoSectionId)} - > - {view.isSectionMounted(repoSectionId) ? ( - // Re-key by host so same-ID hosts cannot inherit drafts/effects. - void model.removeProjectAllHosts(settingsProject.setups)} - project={project} - selectedProjectSetupId={hostSelection.setupId} - settingsSelectionKey={settingsProject.selectionKey} - settingsEntryRepoIds={ - settingsProject.splitProject - ? new Set(settingsProject.setups.map((setup) => setup.repoId)) - : undefined - } - removalScope={getSettingsProjectRemovalScope(settingsProject)} - removalSetups={settingsProject.setups} - isLocalWindowsProject={ - getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && - terminal.isWindowsTerminalHost - } - wslAvailable={terminal.windowsTerminalCapabilities.wslAvailable} - wslDistros={terminal.windowsTerminalCapabilities.wslDistros} - wslCapabilitiesLoading={terminal.windowsTerminalCapabilities.isLoading} - updateProject={(projectId, updates) => - model.updateProject(projectId, updates, getRepoExecutionHostId(repo)) - } - writeSourceControlAiSettings={interactions.writeSourceControlAiSettings} - /> - ) : null} - - ) - }) + return ( + {{value0}}', + { + value0: settingsProject.checkoutLabel ?? project.displayName + } + )} + description={repo.path} + searchEntries={navigation.getSectionSearchEntries(repoSectionId)} + > + {view.isSectionMounted(repoSectionId) ? ( + // Re-key by host so same-ID hosts cannot inherit drafts/effects. + void model.removeProjectAllHosts(settingsProject.setups)} + project={project} + selectedProjectSetupId={hostSelection.setupId} + settingsSelectionKey={settingsProject.selectionKey} + settingsEntryRepoIds={ + settingsProject.splitProject + ? new Set(settingsProject.setups.map((setup) => setup.repoId)) + : undefined + } + removalScope={getSettingsProjectRemovalScope(settingsProject)} + removalSetups={settingsProject.setups} + isLocalWindowsProject={ + getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && + terminal.isWindowsTerminalHost + } + wslAvailable={terminal.windowsTerminalCapabilities.wslAvailable} + wslDistros={terminal.windowsTerminalCapabilities.wslDistros} + wslCapabilitiesLoading={terminal.windowsTerminalCapabilities.isLoading} + updateProject={(projectId, updates) => + model.updateProject(projectId, updates, getRepoExecutionHostId(repo)) + } + writeSourceControlAiSettings={interactions.writeSourceControlAiSettings} + /> + ) : null} + + ) + })} + + ) } diff --git a/src/renderer/src/components/settings/page/settings-view-model.test.ts b/src/renderer/src/components/settings/page/settings-view-model.test.ts new file mode 100644 index 00000000000..a04985d93ab --- /dev/null +++ b/src/renderer/src/components/settings/page/settings-view-model.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { SlidersHorizontal } from 'lucide-react' +import { buildSettingsViewModel } from './settings-view-model' + +describe('Settings project navigation', () => { + it('shows group hook settings alongside repository settings', () => { + const view = buildSettingsViewModel( + { repos: [], settingsSearchQuery: '', activeSectionId: 'group-local%3Ateam' }, + { + visibleNavSections: [ + { + id: 'group-local%3Ateam', + title: 'Team (This Mac)', + description: '/team', + searchEntries: [], + icon: SlidersHorizontal, + group: 'repositories' + }, + { + id: 'repo-app', + title: 'App', + description: '/team/app', + searchEntries: [], + icon: SlidersHorizontal, + group: 'repositories' + } + ], + neededSectionIds: new Set(['group-local%3Ateam']) + } + ) + + expect(view.repoNavSections.map((section) => section.id)).toEqual([ + 'group-local%3Ateam', + 'repo-app' + ]) + }) +}) diff --git a/src/renderer/src/components/settings/page/settings-view-model.ts b/src/renderer/src/components/settings/page/settings-view-model.ts index 9aadac5c369..fd8f0bd8c26 100644 --- a/src/renderer/src/components/settings/page/settings-view-model.ts +++ b/src/renderer/src/components/settings/page/settings-view-model.ts @@ -134,12 +134,12 @@ export function useSettingsNavigationActions( export type SettingsNavigationActions = ReturnType export function buildSettingsViewModel( - model: SettingsStoreModel, - navigation: SettingsNavigationModel, + model: Pick, + navigation: Pick, t: Translate = translate ) { const generalNavSections = navigation.visibleNavSections.filter( - (section) => !section.id.startsWith('repo-') + (section) => section.group !== 'repositories' ) const generalNavGroupDefinitions = getSettingsNavGroupDefinitionsForSearch( navigation.visibleNavSections, @@ -153,7 +153,7 @@ export function buildSettingsViewModel( })) .filter((group) => group.sections.length > 0 || group.id === 'setup') const repoNavSections = navigation.visibleNavSections - .filter((section) => section.id.startsWith('repo-')) + .filter((section) => section.group === 'repositories') .map((section) => { const repo = model.repos.find((entry) => entry.id === section.id.replace('repo-', '')) return { diff --git a/src/renderer/src/components/settings/page/use-settings-store-model.ts b/src/renderer/src/components/settings/page/use-settings-store-model.ts index 1e095f268a1..c918282a5b9 100644 --- a/src/renderer/src/components/settings/page/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/page/use-settings-store-model.ts @@ -1,3 +1,4 @@ +import { useSupportedGroupHookSettings } from '@/hooks/use-supported-group-hook-settings' import type { GeneralSubPage } from '../general/general-sub-pages' import { useCallback, useMemo, useState } from 'react' import type { OrcaHooks } from '../../../../../shared/orca-yaml-hook-types' @@ -46,6 +47,7 @@ import { } from '@/components/sidebar/workspace-actions/deletion/removal-unsaved-files' export function useSettingsStoreModel() { + const hookSettingsGroups = useSupportedGroupHookSettings() const settings = useAppStore((s) => s.settings) const keybindings = useAppStore((s) => s.keybindings) const updateSettings = useAppStore((s) => s.updateSettings) @@ -205,6 +207,7 @@ export function useSettingsStoreModel() { ) return { + hookSettingsGroups, generalSubPage, setGeneralSubPage, settings, diff --git a/src/renderer/src/components/settings/repository/RepositoryPane.tsx b/src/renderer/src/components/settings/repository/RepositoryPane.tsx index 1a228d8aa07..290827dd4fd 100644 --- a/src/renderer/src/components/settings/repository/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/repository/RepositoryPane.tsx @@ -205,6 +205,7 @@ export function RepositoryPane({ ({ toast: { error: vi.fn() } })) + +vi.mock('@/lib/workspace/creation/setup/folder-setup-support', () => ({ + useFolderSetupSupport: () => 'supported' +})) +vi.mock('@/components/sidebar/workspace-creation/folder/folder-workspace-agent-startup', () => ({ + getFolderWorkspaceAgentLaunchFact: () => ({ kind: 'known', platform: 'darwin' }) +})) +vi.mock('@/runtime/repo-hooks/runtime-hooks-client', () => ({ checkRuntimeGroupHooks: vi.fn() })) + +it('loads owner-host settings and inserts/copies the real-repo example without overwriting archive settings', async () => { + const group = { + ...createProjectGroup({ + name: 'Services', + parentPath: '/srv/services', + createdFrom: 'folder-scan', + tabOrder: 0 + }), + id: 'services', + executionHostId: 'runtime:owner' as const + } + const saved = { + mode: 'auto' as const, + setupRunPolicy: 'run-by-default' as const, + commandSourcePolicy: 'local-only' as const, + scripts: { setup: 'echo saved on owner', archive: 'echo keep archive' }, + setupAgentStartupPolicy: 'wait-for-setup' as const + } + vi.mocked(checkRuntimeGroupHooks).mockResolvedValue({ + status: 'ok', + hasHooks: false, + hooks: null, + hookSettings: saved, + mayNeedUpdate: false, + setupRunPolicy: 'run-by-default', + setupConfig: null + }) + const updateProjectGroup = vi.fn(async () => true) + const writeClipboardText = vi.fn(async () => {}) + Object.defineProperty(window.api, 'ui', { + configurable: true, + value: { writeClipboardText } + }) + useAppStore.setState({ + settingsSearchQuery: '', + projectGroups: [group], + repos: [ + { + id: 'api', + path: '/srv/services/api', + displayName: 'API server', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:owner' + }, + { + id: 'web', + path: '/srv/services/web', + displayName: 'Website', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:owner' + }, + { + id: 'local', + path: '/srv/services/local', + displayName: 'Other host', + badgeColor: '#111', + addedAt: 1 + } + ], + updateProjectGroup + }) + render( + + + + ) + await waitFor(() => expect(screen.getByDisplayValue('echo saved on owner')).toBeTruthy()) + expect(screen.queryByDisplayValue('echo keep archive')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Use this example' })) + await waitFor(() => expect(updateProjectGroup).toHaveBeenCalled()) + const script = + '# Repo IDs are local to this machine; review them before sharing this script.\n# API server\n"${ORCA_CLI_COMMAND:-orca}" worktree create --repo \'id:api\' --name "$ORCA_WORKSPACE_NAME" --json\n# Website\n"${ORCA_CLI_COMMAND:-orca}" worktree create --repo \'id:web\' --name "$ORCA_WORKSPACE_NAME" --json' + expect(updateProjectGroup).toHaveBeenLastCalledWith( + 'services', + { hookSettings: { ...saved, scripts: { setup: script, archive: 'echo keep archive' } } }, + { hostId: 'runtime:owner' } + ) + const exampleActions = screen.getByRole('button', { name: 'Use this example' }).parentElement! + fireEvent.click(within(exampleActions).getByRole('button', { name: /^Copy$/ })) + expect(writeClipboardText).toHaveBeenCalledWith(script) + expect(checkRuntimeGroupHooks).toHaveBeenCalledWith('runtime:owner', 'services', { + background: true + }) +}) + +it('shows setup-only shared configuration through the group Advanced disclosure', async () => { + const group = createProjectGroup({ + name: 'Services', + parentPath: '/services', + createdFrom: 'folder-scan', + tabOrder: 0 + }) + const writeClipboardText = vi.fn(async () => {}) + Object.defineProperty(window.api, 'ui', { + configurable: true, + value: { writeClipboardText } + }) + useAppStore.setState({ settingsSearchQuery: '', projectGroups: [group], repos: [] }) + for (const state of ['missing', 'invalid', 'loaded'] as const) { + vi.mocked(checkRuntimeGroupHooks).mockClear() + vi.mocked(checkRuntimeGroupHooks).mockResolvedValue({ + status: 'ok', + hasHooks: state !== 'missing', + hooks: + state === 'loaded' + ? { + scripts: { setup: 'echo shared setup', archive: 'echo hidden archive' }, + issueCommand: 'echo hidden issue' + } + : null, + mayNeedUpdate: false, + setupRunPolicy: 'run-by-default', + setupConfig: null + }) + const view = render( + + + + ) + await waitFor(() => + expect(checkRuntimeGroupHooks).toHaveBeenCalledWith('local', group.id, { background: true }) + ) + const advancedSummary = within(view.container) + .getByText('Advanced', { selector: 'h5' }) + .closest('summary')! + const advanced = advancedSummary.closest('details')! + fireEvent.click(advancedSummary) + if (state !== 'invalid') { + const disclosure = await within(advanced).findByRole('button', { + name: state === 'loaded' ? /^orca.yaml detected$/ : /^No orca.yaml detected$/ + }) + fireEvent.click(disclosure) + } + await waitFor(() => { + const preview = advanced.querySelector('pre') + expect(preview?.textContent).toContain('setup:') + expect(preview?.textContent).not.toContain('archive:') + expect(preview?.textContent).not.toContain('issueCommand:') + if (state === 'loaded') { + expect(preview?.textContent).toContain('echo shared setup') + } + }) + if (state === 'missing') { + fireEvent.click(within(advanced).getByRole('button', { name: 'Copy template' })) + expect(writeClipboardText).toHaveBeenCalledWith( + 'scripts:\n setup: |\n echo "Setting up workspace"' + ) + } + view.unmount() + } +}) + +it('preserves saved scripts while authoritative group inspection is pending', async () => { + const group = createProjectGroup({ + name: 'Loading', + parentPath: '/loading', + createdFrom: 'folder-scan', + tabOrder: 0 + }) + let inspect!: (value: Awaited>) => void + vi.mocked(checkRuntimeGroupHooks).mockReturnValue( + new Promise((resolve) => { + inspect = resolve + }) + ) + const updateProjectGroup = vi.fn(async (..._args: unknown[]) => true) + useAppStore.setState({ + settingsSearchQuery: '', + projectGroups: [group], + repos: [], + updateProjectGroup + }) + const view = render( + + + + ) + const example = within(view.container).getByRole('button', { name: 'Use this example' }) + fireEvent.click(example) + view.unmount() + expect(updateProjectGroup).not.toHaveBeenCalled() + const saved = { + mode: 'auto' as const, + scripts: { setup: 'echo saved', archive: 'echo preserve' }, + setupRunPolicy: 'ask' as const, + setupAgentStartupPolicy: 'wait-for-setup' as const, + commandSourcePolicy: 'local-only' as const + } + const second = render( + + + + ) + inspect({ + status: 'ok', + hasHooks: false, + hooks: null, + hookSettings: saved, + mayNeedUpdate: false, + setupRunPolicy: 'ask', + setupConfig: null + }) + await waitFor(() => expect(within(second.container).getByDisplayValue('echo saved')).toBeTruthy()) + fireEvent.click(within(second.container).getByRole('button', { name: 'Use this example' })) + await waitFor(() => expect(updateProjectGroup).toHaveBeenCalled()) + expect(updateProjectGroup.mock.calls[0]?.[1]).toEqual({ + hookSettings: { + ...saved, + scripts: { + setup: '# Repo IDs are local to this machine; review them before sharing this script.', + archive: 'echo preserve' + } + } + }) +}) + +const confirmed = { + mode: 'auto' as const, + commandSourcePolicy: 'local-only' as const, + setupRunPolicy: 'run-by-default' as const, + scripts: { setup: 'echo confirmed', archive: '' }, + setupAgentStartupPolicy: 'start-immediately' as const +} +const inspected: GroupHookCheckResult = { + status: 'ok', + hasHooks: false, + hooks: null, + mayNeedUpdate: false, + setupRunPolicy: 'run-by-default', + setupConfig: null, + hookSettings: confirmed +} +function saveTestGroup() { + return { + ...createProjectGroup({ + name: 'Save', + parentPath: '/save', + createdFrom: 'folder-scan', + tabOrder: 0 + }), + id: 'save', + executionHostId: 'local' as const + } +} +function renderSaveGroup(group = saveTestGroup()) { + useAppStore.setState({ settingsSearchQuery: '', projectGroups: [group], repos: [] }) + return render( + + + + ) +} + +it('reconciles a real store false save, then keeps a later acknowledged script', async () => { + const group = saveTestGroup() + let finish!: (value: null) => void + const update = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + .mockImplementationOnce(async ({ updates }) => ({ ...group, ...updates })) + Object.defineProperty(window.api, 'projectGroups', { configurable: true, value: { update } }) + let reconcile!: (value: GroupHookCheckResult) => void + vi.mocked(checkRuntimeGroupHooks) + .mockReset() + .mockResolvedValueOnce(inspected) + .mockImplementationOnce( + () => + new Promise((resolve) => { + reconcile = resolve + }) + ) + renderSaveGroup(group) + const editor = await screen.findByDisplayValue('echo confirmed') + fireEvent.change(editor, { target: { value: 'echo attempted' } }) + fireEvent.blur(editor) + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(editor.closest('fieldset')?.disabled).toBe(true) + await act(async () => { + finish(null) + }) + await waitFor(() => expect(screen.getByDisplayValue('echo confirmed')).toBeTruthy()) + expect(editor.closest('fieldset')?.disabled).toBe(true) + await act(async () => { + reconcile(inspected) + }) + await waitFor(() => expect(editor.closest('fieldset')?.disabled).toBe(false)) + expect(toast.error).toHaveBeenCalledTimes(1) + fireEvent.change(editor, { target: { value: 'echo acknowledged' } }) + fireEvent.blur(editor) + await waitFor(() => + expect(useAppStore.getState().projectGroups[0].hookSettings?.scripts?.setup).toBe( + 'echo acknowledged' + ) + ) + await waitFor(() => expect(editor.closest('fieldset')?.disabled).toBe(false)) + expect(screen.getByDisplayValue('echo acknowledged')).toBeTruthy() +}) + +it('restores a rejected policy and retains confirmed values when reconciliation cannot answer', async () => { + let reject!: (reason: Error) => void + const updateProjectGroup = vi.fn( + () => + new Promise((_resolve, fail) => { + reject = fail + }) + ) + useAppStore.setState({ updateProjectGroup }) + vi.mocked(checkRuntimeGroupHooks) + .mockReset() + .mockResolvedValueOnce(inspected) + .mockRejectedValueOnce(new Error('owner disconnected')) + renderSaveGroup() + const editor = await screen.findByDisplayValue('echo confirmed') + const policy = screen.getByRole('switch') + fireEvent.click(policy) + await waitFor(() => expect(updateProjectGroup).toHaveBeenCalled()) + expect(policy.getAttribute('aria-checked')).toBe('true') + await act(async () => { + reject(new Error('save failed')) + }) + await waitFor(() => expect(policy.getAttribute('aria-checked')).toBe('false')) + expect(screen.getByDisplayValue('echo confirmed')).toBeTruthy() + expect(editor.closest('fieldset')?.disabled).toBe(true) + expect(toast.error).toHaveBeenCalledTimes(1) +}) + +it('flushes an unmounted owner draft without applying its late failure to the same group on another host', async () => { + const first = saveTestGroup() + let finish!: (saved: boolean) => void + const updateProjectGroup = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + useAppStore.setState({ updateProjectGroup }) + vi.mocked(checkRuntimeGroupHooks) + .mockReset() + .mockResolvedValueOnce(inspected) + .mockResolvedValueOnce({ + ...inspected, + hookSettings: { ...confirmed, scripts: { setup: 'echo other host', archive: '' } } + }) + const view = renderSaveGroup(first) + fireEvent.change(await screen.findByDisplayValue('echo confirmed'), { + target: { value: 'echo leaving' } + }) + const other = { ...first, executionHostId: 'runtime:other' as const } + useAppStore.setState({ projectGroups: [other] }) + view.rerender( + + + + ) + await waitFor(() => + expect(updateProjectGroup).toHaveBeenCalledWith( + 'save', + { + hookSettings: { ...confirmed, scripts: { setup: 'echo leaving', archive: '' } } + }, + { hostId: 'local' } + ) + ) + await screen.findByDisplayValue('echo other host') + await act(async () => { + finish(false) + }) + expect(screen.getByDisplayValue('echo other host')).toBeTruthy() + expect(toast.error).toHaveBeenCalledTimes(1) +}) + +it('settles an accepted blur before a same-turn policy edit without rolling back the newer settings', async () => { + let first!: (saved: boolean) => void + let second!: (saved: boolean) => void + const updateProjectGroup = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + first = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + second = resolve + }) + ) + useAppStore.setState({ updateProjectGroup }) + vi.mocked(checkRuntimeGroupHooks).mockReset().mockResolvedValue(inspected) + renderSaveGroup() + const editor = await screen.findByDisplayValue('echo confirmed') + const policy = screen.getByRole('switch') + act(() => { + fireEvent.change(editor, { target: { value: 'echo newest' } }) + fireEvent.blur(editor) + fireEvent.click(policy) + }) + await waitFor(() => expect(updateProjectGroup).toHaveBeenCalled()) + await act(async () => { + first(false) + }) + await waitFor(() => + expect(updateProjectGroup).toHaveBeenLastCalledWith( + 'save', + { + hookSettings: { + ...confirmed, + scripts: { setup: 'echo newest', archive: '' }, + setupAgentStartupPolicy: 'wait-for-setup' + } + }, + { hostId: 'local' } + ) + ) + await act(async () => { + second(true) + }) + await waitFor(() => expect(editor.closest('fieldset')?.disabled).toBe(false)) + expect(screen.getByDisplayValue('echo newest')).toBeTruthy() + expect(policy.getAttribute('aria-checked')).toBe('true') + expect(toast.error).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/settings/repository/hooks/ProjectGroupHooksSection.tsx b/src/renderer/src/components/settings/repository/hooks/ProjectGroupHooksSection.tsx new file mode 100644 index 00000000000..ed5f8d627c1 --- /dev/null +++ b/src/renderer/src/components/settings/repository/hooks/ProjectGroupHooksSection.tsx @@ -0,0 +1,144 @@ +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { GroupHookCheckResult } from '../../../../../../shared/project-group-hooks' +import { projectGroupHookOwner } from '../../../../../../shared/workspace-hook-owner' +import { getRepoExecutionHostId } from '../../../../../../shared/execution-host' +import { PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY } from '../../../../../../shared/worktree-runtime-capabilities' +import { getFolderSourceRepos } from '../../../../../../shared/folder-workspaces/folder-workspace-source-repos' +import { projectGroupSetupExample } from '../../../../../../shared/project-group-setup-example' +import { useFolderSetupSupport } from '@/lib/workspace/creation/setup/folder-setup-support' +import { checkRuntimeGroupHooks } from '@/runtime/repo-hooks/runtime-hooks-client' +import { useAppStore } from '@/store' +import { useTranslate } from '@/i18n/use-translate' +import { useShallow } from 'zustand/react/shallow' +import { getFolderWorkspaceAgentLaunchFact } from '@/components/sidebar/workspace-creation/folder/folder-workspace-agent-startup' +import { RepositoryHooksSection } from './RepositoryHooksSection' +import { GROUP_SETUP_HOOKS_TEMPLATE } from './repository-hook-settings-draft' + +export function ProjectGroupHooksSection({ + group +}: { + group: ProjectGroup +}): React.JSX.Element | null { + const t = useTranslate() + const [inspection, setInspection] = useState(null) + const confirmedInspection = useRef(null) + const pendingSave = useRef | null>(null) + const [saving, setSaving] = useState(false) + const fieldset = useRef(null) + const owner = projectGroupHookOwner( + inspection ? { ...group, hookSettings: inspection.hookSettings } : group + ) + const support = useFolderSetupSupport(owner, PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY) + const [repos, groups, platform] = useAppStore( + useShallow((state) => { + const facts = getFolderWorkspaceAgentLaunchFact(state, group) + return [ + state.repos, + state.projectGroups, + facts.kind === 'known' ? facts.platform : null + ] as const + }) + ) + const hostId = getRepoExecutionHostId(group) + useEffect(() => { + if (support !== 'supported') { + return + } + let cancelled = false + void checkRuntimeGroupHooks(hostId, group.id, { background: true }) + .then((result) => { + if (!cancelled) { + confirmedInspection.current = result + setInspection(result) + } + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [hostId, group.id, support]) + if (!owner || support !== 'supported') { + return null + } + const example = platform + ? projectGroupSetupExample(getFolderSourceRepos(repos, groups, group), platform) + : undefined + const updateHookSettings = (hookSettings: NonNullable): void => { + if (!inspection || inspection.unavailable || inspection.status === 'error') { + return + } + if (fieldset.current) { + setInspection({ ...inspection, hookSettings }) + setSaving(true) + } + // A blur/autosave may already be accepted before the pending fieldset renders. + const save: Promise = (pendingSave.current ?? Promise.resolve()).then(async () => { + const previous = confirmedInspection.current + if (!previous) { + return + } + const ownsInspection = (): boolean => + Boolean(fieldset.current) && pendingSave.current === save + let saved = false + try { + saved = await useAppStore + .getState() + .updateProjectGroup(group.id, { hookSettings }, { hostId }) + } catch { + // A rejected endpoint reply still needs the same owner reconciliation as false. + } + if (saved) { + confirmedInspection.current = { ...previous, hookSettings, status: 'ok' } + } else { + confirmedInspection.current = previous + if (ownsInspection()) { + setInspection({ ...previous }) + } + if (fieldset.current) { + try { + const current = await checkRuntimeGroupHooks(hostId, group.id, { background: true }) + confirmedInspection.current = + current.status !== 'error' && !current.unavailable + ? current + : { ...previous, status: 'error' } + } catch { + confirmedInspection.current = { ...previous, status: 'error' } + } + } + toast.error(t('sessionHistory.settings.saveError', 'Could not save. Try again.')) + } + if (ownsInspection()) { + setInspection(confirmedInspection.current) + } + }) + pendingSave.current = save + void save.finally(() => { + if (pendingSave.current === save) { + pendingSave.current = null + if (fieldset.current) { + setSaving(false) + } + } + }) + } + const inspectionReady = + inspection !== null && !inspection.unavailable && inspection.status !== 'error' + return ( +
+ void window.api.ui.writeClipboardText(GROUP_SETUP_HOOKS_TEMPLATE)} + setupExample={example} + onUpdateHookSettings={updateHookSettings} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/repository/hooks/RepositoryHookPolicySettings.tsx b/src/renderer/src/components/settings/repository/hooks/RepositoryHookPolicySettings.tsx index 0328b7866e6..893f25e70ae 100644 --- a/src/renderer/src/components/settings/repository/hooks/RepositoryHookPolicySettings.tsx +++ b/src/renderer/src/components/settings/repository/hooks/RepositoryHookPolicySettings.tsx @@ -176,6 +176,7 @@ function getCommandSourceLabel(policy: HookCommandSourcePolicy, t: Translate = t export function RepositoryHookCommandSourceSetting({ searchQuery, selectedPolicy, + setupOnly = false, yamlState, yamlHooks, copiedTemplate, @@ -186,6 +187,7 @@ export function RepositoryHookCommandSourceSetting({ }: { searchQuery: string selectedPolicy: HookCommandSourcePolicy + setupOnly?: boolean yamlState: string yamlHooks: OrcaHooks | null copiedTemplate: boolean @@ -279,6 +281,7 @@ export function RepositoryHookCommandSourceSetting({ + {setupExample ? ( +
+

+ {t( + 'settings.groupHooks.example', + 'Example: create a worktree in each repo (this machine only)' + )} +

+
+                {setupExample}
+              
+
+ + +
+
+ ) : null}
- - updateScriptDraft('archive', next)} - onCommit={commitScriptDraft} - /> - + {showArchive ? ( + + updateScriptDraft('archive', next)} + onCommit={commitScriptDraft} + /> + + ) : null} {localCommandSourceNotice ? (
) : null} - {isFolder ? null : ( + {gitRepo && !isFolder ? ( - )} + ) : null} void }): React.JSX.Element { @@ -97,7 +107,7 @@ function ExampleTemplateCard({ : t('auto.components.settings.RepositoryHooksSection.da37d6f10e', 'Copy')}
-          {EXAMPLE_TEMPLATE}
+          {template}
         
@@ -108,11 +118,15 @@ const YAML_CODE_COMPONENTS = { yaml: - {loaded ? renderYamlScriptPreview(yamlHooks) : EXAMPLE_TEMPLATE} + {loaded ? renderYamlScriptPreview(yamlHooks, setupOnly) : template} @@ -176,20 +190,25 @@ function YamlScriptsDisclosure({ } export function RepositoryHooksYamlStatus({ + setupOnly = false, yamlState, yamlHooks, copiedTemplate, onCopyTemplate }: { + setupOnly?: boolean yamlState: string yamlHooks: OrcaHooks | null copiedTemplate: boolean onCopyTemplate: () => void }): React.JSX.Element { const t = useTranslate() + const template = setupOnly ? GROUP_SETUP_HOOKS_TEMPLATE : EXAMPLE_TEMPLATE if (yamlState === 'loaded' || yamlState === 'missing') { return (
@@ -213,10 +232,15 @@ export function RepositoryHooksYamlStatus({

- {t( - 'auto.components.settings.RepositoryHooksSection.af49e2a19e', - 'The file is present, but Orca could not find valid `scripts` or `issueCommand` definitions.' - )} + {setupOnly + ? t( + 'settings.groupHooks.invalidSetup', + 'The file is present, but Orca could not find a valid setup script.' + ) + : t( + 'auto.components.settings.RepositoryHooksSection.af49e2a19e', + 'The file is present, but Orca could not find valid `scripts` or `issueCommand` definitions.' + )}

    {parseErrorFixes.map((fix) => ( @@ -227,10 +251,18 @@ export function RepositoryHooksYamlStatus({
- + ) : ( - + )} ) diff --git a/src/renderer/src/components/settings/repository/hooks/repository-git-hooks-search-entries.ts b/src/renderer/src/components/settings/repository/hooks/repository-git-hooks-search-entries.ts index 926c43df8ac..a4772d1191e 100644 --- a/src/renderer/src/components/settings/repository/hooks/repository-git-hooks-search-entries.ts +++ b/src/renderer/src/components/settings/repository/hooks/repository-git-hooks-search-entries.ts @@ -6,7 +6,7 @@ import type { Translate } from '@/i18n/use-translate' import { searchKeywordTranslator } from '../../search/settings-search-keywords' export function getRepositoryHookScriptDescription( - repo: Repo, + repo: Pick, hook: 'setup' | 'archive', t: Translate ): string { @@ -33,8 +33,9 @@ export function getRepositoryHookScriptDescription( } export function getRepositoryGitHooksSearchEntries( - repo: Repo, - t: Translate = translate + repo: Pick, + t: Translate = translate, + options: { showArchive?: boolean } = {} ): SettingsSearchEntry[] { const translateSearchKeyword = searchKeywordTranslator(t) return [ @@ -64,35 +65,45 @@ export function getRepositoryGitHooksSearchEntries( ...translateSearchKeyword('auto.components.settings.repository.search.bf460fded8', 'yaml') ] }, - { - title: t('auto.components.settings.repository.search.1617bc353d', 'Archive script'), - description: getRepositoryHookScriptDescription(repo, 'archive', t), - keywords: [ - repo.displayName, - ...translateSearchKeyword('auto.components.settings.repository.search.8655e3387b', 'hooks'), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.4c17787d7b', - 'archive' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.fbfd2386e8', - 'archive script' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.a1a4c51d58', - 'archive command' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.6b80f7d3c8', - 'local settings scripts' - ), - ...translateSearchKeyword( - 'auto.components.settings.repository.search.9cad92fe77', - 'orca.yaml hooks' - ), - ...translateSearchKeyword('auto.components.settings.repository.search.bf460fded8', 'yaml') - ] - }, + ...(options.showArchive === false + ? [] + : [ + { + title: t('auto.components.settings.repository.search.1617bc353d', 'Archive script'), + description: getRepositoryHookScriptDescription(repo, 'archive', t), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.8655e3387b', + 'hooks' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.4c17787d7b', + 'archive' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.fbfd2386e8', + 'archive script' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.a1a4c51d58', + 'archive command' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.6b80f7d3c8', + 'local settings scripts' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.9cad92fe77', + 'orca.yaml hooks' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.bf460fded8', + 'yaml' + ) + ] + } + ]), { title: t('auto.components.settings.repository.search.cc11699c3d', 'Advanced'), description: t( diff --git a/src/renderer/src/components/settings/repository/hooks/repository-hook-settings-draft.ts b/src/renderer/src/components/settings/repository/hooks/repository-hook-settings-draft.ts index 41533879de1..7a05cfabde6 100644 --- a/src/renderer/src/components/settings/repository/hooks/repository-hook-settings-draft.ts +++ b/src/renderer/src/components/settings/repository/hooks/repository-hook-settings-draft.ts @@ -122,11 +122,14 @@ export function getLocalCommandSourcePolicyNotice( } } -export function renderYamlScriptPreview(hooks: OrcaHooks | null): string { +export function renderYamlScriptPreview(hooks: OrcaHooks | null, setupOnly = false): string { const formatScript = (key: string, command?: string): string => command ? `\n ${key}: |\n${command.replace(/^/gm, ' ')}` : '' - const issueCommand = hooks?.issueCommand - ? `\nissueCommand: |\n${hooks.issueCommand.replace(/^/gm, ' ')}` - : '' - return `scripts:${formatScript('setup', hooks?.scripts.setup)}${formatScript('archive', hooks?.scripts.archive)}${issueCommand}` + const issueCommand = + !setupOnly && hooks?.issueCommand + ? `\nissueCommand: |\n${hooks.issueCommand.replace(/^/gm, ' ')}` + : '' + return `scripts:${formatScript('setup', hooks?.scripts.setup)}${setupOnly ? '' : formatScript('archive', hooks?.scripts.archive)}${issueCommand}` } + +export const GROUP_SETUP_HOOKS_TEMPLATE = 'scripts:\n setup: |\n echo "Setting up workspace"' diff --git a/src/renderer/src/components/settings/repository/hooks/use-repository-hook-settings-draft.ts b/src/renderer/src/components/settings/repository/hooks/use-repository-hook-settings-draft.ts index 9fa050dd649..b987acb3b58 100644 --- a/src/renderer/src/components/settings/repository/hooks/use-repository-hook-settings-draft.ts +++ b/src/renderer/src/components/settings/repository/hooks/use-repository-hook-settings-draft.ts @@ -13,7 +13,7 @@ export function useRepositoryHookSettingsDraft({ repoHostIdentity, onUpdateHookSettings }: { - repo: Repo + repo: Pick repoHostIdentity: string onUpdateHookSettings: (settings: RepoHookSettings) => void }): { diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts index c190b01e037..1f71d020902 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts @@ -3,13 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' -const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn() })) +const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn(), seedDraft: vi.fn() })) vi.mock('@/lib/workspace/activation/worktree-activation', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace } }) +vi.mock( + '@/lib/agent-session/launch/prompt/agent-launch-prompt-delivery', + async (importOriginal) => ({ + ...(await importOriginal>()), + seedNativeChatLaunchDraftForAgentTab: mocks.seedDraft + }) +) +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) + +import { getDefaultSettings } from '../../../../../../shared/constants' +import { FolderWorkspaceCreate } from '../../../../../../shared/rpc-contract/workspaces/folder-workspace-params' +import { FolderWorkspaceCreateArgs } from '../../../../../../main/ipc/repos/repo-ipc-arg-schemas' +import { + buildWorktreeStartupForAgent, + buildWorktreeStartupForDraft +} from '../../../../../../main/runtime/workspaces/startup/runtime-worktree-agent-startup' +import { prepareGroupFolderCreation } from '../../../../../../main/runtime/workspaces/creation/runtime-group-folder-setup' import { useAppStore } from '@/store/index' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' import { getFolderWorkspaceAgentLaunchFact } from './folder-workspace-agent-startup' @@ -21,7 +38,7 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => { }) it('quotes quick-agent startup for POSIX when the folder group is a local WSL UNC path', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const createFolderWorkspace = vi.fn(async () => ({ folderWorkspace: makeFolderWorkspace() })) const projectGroup = { ...makeProjectGroup(), parentPath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\platform' @@ -55,7 +72,7 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => { }) it('quotes quick-agent startup for the Windows OS the SSH relay reported', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const createFolderWorkspace = vi.fn(async () => ({ folderWorkspace: makeFolderWorkspace() })) const projectGroup = { ...makeProjectGroup(), connectionId: 'ssh-windows', @@ -109,7 +126,7 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => { }) it('quotes quick-agent startup for the Orca server that owns the folder group', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const createFolderWorkspace = vi.fn(async () => ({ folderWorkspace: makeFolderWorkspace() })) const projectGroup = { ...makeProjectGroup(), executionHostId: 'runtime:win-box', @@ -157,7 +174,7 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => { }) it('refuses an agent launch on an Orca server that never reported its OS', async () => { - const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const createFolderWorkspace = vi.fn(async () => ({ folderWorkspace: makeFolderWorkspace() })) useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) await expect( @@ -177,3 +194,223 @@ describe('submitFolderWorkspaceCreate on an Orca server', () => { expect(createFolderWorkspace).not.toHaveBeenCalled() }) }) + +it.each([ + { agent: 'codex', delivery: 'draft' }, + { agent: 'claude', delivery: null }, + { agent: 'autohand', delivery: 'auto-submit' } +] as const)( + 'preserves the actual paired $agent delivery through schemas and the host starter ($delivery)', + async ({ agent, delivery }) => { + mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'setup-tab' }) + mocks.seedDraft.mockClear() + const projectGroup = { ...makeProjectGroup(), executionHostId: 'runtime:paired' as const } + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + [ + 'paired', + { + checkedAt: 1, + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + hostPlatform: 'linux' + } + } + ] + ]) + }) + const pasteDraft = vi.fn() + const createTerminal = vi.fn(async (_selector: string, _options: unknown) => ({ + handle: 'agent', + tabId: 'returned-agent-tab', + ptyId: 'agent-pty', + worktreeId: 'folder:folder-workspace-1', + title: 'Agent' + })) + const sendFollowup = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + agentCmdOverrides: { [agent]: `custom-${agent} --color never` } + } + const environment = { + repo: { path: '/tmp', connectionId: null }, + settings, + getLaunchRuntime: () => ({ + platform: 'linux' as const, + shell: 'posix' as const, + launchRuntime: { + platform: 'linux' as const, + pty: { shellOverride: undefined, terminalWindowsWslDistro: null } + } + }) + } + const createFolderWorkspace = vi.fn( + async (input: Parameters[0]) => { + const parsed = FolderWorkspaceCreateArgs.parse(FolderWorkspaceCreate.parse(input)) + expect(parsed.startupCommand).toBeUndefined() + expect(parsed.startupAgent).toBe(agent) + if (delivery === 'auto-submit') { + expect(parsed.startupPrompt).toBe('Inspect regular task') + expect(parsed.startupDraft).toBeUndefined() + } else { + expect(parsed.startupDraft).toContain('https://linear.app/team/issue/STA-1') + expect(parsed.startupPrompt).toBeUndefined() + } + return prepareGroupFolderCreation({ + input: parsed, + group: { ...projectGroup, executionHostId: undefined }, + workspace: makeFolderWorkspace(), + settings, + startupBuilders: { + agent: (agent, prompt, launchPreferences, inputs) => + buildWorktreeStartupForAgent({ + ...environment, + agent, + prompt, + launchPreferences, + ...inputs, + toSessionOptions: () => undefined + }), + draft: (draft, requestedAgent) => + buildWorktreeStartupForDraft({ ...environment, draft, requestedAgent }) + }, + wait: async () => ({ exitCode: 0 }), + ports: { + canSpawn: true, + createTerminal, + pasteDraft, + sendFollowup, + provision: async () => ({ setupSpawned: false, setupTerminalHandle: null }), + activate: vi.fn() + } + }) + } + ) + await submitFolderWorkspaceCreate({ + projectGroup, + setupSupported: true, + setupApproval: { setupDecision: 'skip' }, + runtimeEnvironmentId: 'paired', + name: 'Linked task', + lastAutoName: '', + linkedWorkItem: + delivery === 'auto-submit' + ? null + : { + provider: 'linear', + type: 'issue', + number: 1, + title: 'Inspect task', + url: 'https://linear.app/team/issue/STA-1' + }, + note: delivery === 'auto-submit' ? 'Inspect regular task' : '', + autoRenameBranchFromWork: false, + quickAgent: agent, + agentCmdOverrides: { [agent]: `custom-${agent} --color never` }, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + if (delivery === 'draft') { + expect(pasteDraft).toHaveBeenCalledExactlyOnceWith('agent', { + agent: 'codex', + content: 'Linked Linear issue\nhttps://linear.app/team/issue/STA-1' + }) + expect(sendFollowup).not.toHaveBeenCalled() + } else if (delivery === 'auto-submit') { + expect(pasteDraft).not.toHaveBeenCalled() + expect(sendFollowup).toHaveBeenCalledExactlyOnceWith( + 'agent', + { agent: 'autohand', prompt: 'Inspect regular task' }, + expect.objectContaining({ tabId: 'returned-agent-tab' }) + ) + } else { + expect(pasteDraft).not.toHaveBeenCalled() + expect(sendFollowup).not.toHaveBeenCalled() + } + if (delivery !== 'auto-submit') { + expect(mocks.seedDraft).toHaveBeenCalledExactlyOnceWith({ + tabId: 'returned-agent-tab', + agent, + text: expect.stringContaining('https://linear.app/team/issue/STA-1') + }) + } else { + expect(mocks.seedDraft).not.toHaveBeenCalled() + } + } +) + +it('applies the worktree semantic prompt and draft contract to IPC and RPC', () => { + for (const schema of [FolderWorkspaceCreate, FolderWorkspaceCreateArgs]) { + expect(schema.safeParse({ projectGroupId: 'group', startupPrompt: 'task' }).success).toBe(false) + expect( + schema.safeParse({ projectGroupId: 'group', startupAgent: 'codex', startupPrompt: 'task' }) + .success + ).toBe(true) + expect( + schema.safeParse({ + projectGroupId: 'group', + startupAgent: 'codex', + startupDraft: 'linked task' + }).success + ).toBe(true) + expect( + schema.safeParse({ projectGroupId: 'group', startupCommand: 'custom --literal' }).success + ).toBe(true) + } +}) + +it('defers paired startup planning to the owning host when its OS has not been projected', async () => { + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + const createFolderWorkspace = vi.fn(async (input: unknown) => { + const parsed = FolderWorkspaceCreateArgs.parse(FolderWorkspaceCreate.parse(input)) + expect(parsed.startupAgent).toBe('claude') + expect(parsed.startupPrompt).toBe('Inspect on the host') + return { folderWorkspace: makeFolderWorkspace() } + }) + await submitFolderWorkspaceCreate({ + projectGroup: { ...makeProjectGroup(), executionHostId: 'runtime:unprojected' }, + setupSupported: true, + setupApproval: { setupDecision: 'skip' }, + runtimeEnvironmentId: 'unprojected', + name: 'Host task', + lastAutoName: '', + linkedWorkItem: null, + note: 'Inspect on the host', + quickAgent: 'claude', + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + expect(createFolderWorkspace).toHaveBeenCalledOnce() +}) + +it('creates a paired shell workspace without an agent prompt', async () => { + const createFolderWorkspace = vi.fn(async (input: unknown) => { + const parsed = FolderWorkspaceCreateArgs.parse(FolderWorkspaceCreate.parse(input)) + expect(parsed.startupAgent).toBeUndefined() + expect(parsed.startupPrompt).toBeUndefined() + return { folderWorkspace: makeFolderWorkspace() } + }) + await submitFolderWorkspaceCreate({ + projectGroup: { ...makeProjectGroup(), executionHostId: 'runtime:paired-shell' }, + setupSupported: true, + setupApproval: { setupDecision: 'skip' }, + runtimeEnvironmentId: 'paired-shell', + name: 'Shell task', + lastAutoName: '', + linkedWorkItem: null, + note: '', + quickAgent: null, + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + expect(createFolderWorkspace).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-input.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-input.ts index 005776440d8..708fb322093 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-input.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-input.ts @@ -2,23 +2,23 @@ import type { AgentLaunchChoice } from '@/lib/agent-session/launch/routing/agent import type { LinkedWorkItemSummary } from '@/lib/workspace/creation/new-workspace' import type { AgentStartupSettings } from '../../../../../../shared/agent-launch/startup/agent-startup-plan-inputs' import type { AgentLaunchInputViews } from '../../../../../../shared/agent-launch/choices/agent-launch-choices' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent' import type { LaunchSource } from '../../../../../../shared/telemetry-events' import type { TaskSourceContext } from '../../../../../../shared/work-items/task-source-context' -type FolderWorkspaceCreateInput = { - projectGroupId: string - name: string - connectionId?: string | null - linkedTask: FolderWorkspace['linkedTask'] - linkedTaskSourceContext?: TaskSourceContext | null - createdWithAgent?: TuiAgent - pendingFirstAgentMessageRename?: boolean -} +import type { + FolderWorkspaceCreateInput, + FolderWorkspaceCreateResult +} from '../../../../../../shared/folder-workspaces/folder-workspace-create' export type SubmitFolderWorkspaceCreateParams = { + setupSupported?: boolean + setupApproval?: Pick< + FolderWorkspaceCreateInput, + 'setupDecision' | 'approvedSetupHash' | 'approvedSetupHashes' + > + isCancelled?: () => boolean projectGroup: ProjectGroup name: string lastAutoName: string @@ -40,6 +40,8 @@ export type SubmitFolderWorkspaceCreateParams = { isRemote?: boolean launchSource?: LaunchSource runtimeEnvironmentId?: string | null - createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise + createFolderWorkspace: ( + input: FolderWorkspaceCreateInput + ) => Promise onOpenChange: (open: boolean) => void } diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.ts index 6a143c4e713..582478eb3a6 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.ts @@ -1,3 +1,11 @@ +import { toast } from 'sonner' +import { + buildFolderWorkspaceStartupPayload, + buildFolderWorkspaceHostStartupPayload +} from './folder-workspace-startup-payload' +import { reviewSetupTrustMismatch } from '@/store/slices/worktrees/create/created-worktree-setup-trust' +import { projectGroupHookTrustKey } from '../../../../../../shared/workspace-hook-owner' +import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree' import { ensureAgentLaunchPreferences } from '@/runtime/agent-launch-settings/use-host-agent-launch-preferences' import { resolveAgentStartupPlanInputs } from '../../../../../../shared/agent-launch/startup/agent-startup-plan-inputs' import { ensureAgentStartupInTerminal } from '@/lib/workspace/creation/new-workspace' @@ -5,13 +13,11 @@ import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-session/launch import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid' import { buildAgentStartupPlan } from '../../../../../../shared/agent-launch/startup/tui-agent-startup' import '@/diagnostics/telemetry/telemetry' -import { tuiAgentToAgentKind } from '../../../../../../shared/agent-kind' import { activateAndRevealFolderWorkspace } from '@/lib/workspace/activation/worktree-activation' -import { shouldApplyWorkspaceSourceAutoName } from '../../../../../../shared/new-workspace/workspace-source' import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import './folder-workspace-source-repos' import { - getLinkedItemDisplayName, + resolveFolderWorkspaceName, toFolderWorkspaceLinkedTask } from './folder-workspace-source-selection' import { @@ -36,6 +42,9 @@ import type { SubmitFolderWorkspaceCreateParams } from './folder-workspace-compo export async function submitFolderWorkspaceCreate({ projectGroup, + setupSupported = false, + setupApproval, + isCancelled = () => false, name, lastAutoName, linkedWorkItem, @@ -64,26 +73,13 @@ export async function submitFolderWorkspaceCreate({ const chatSessionOptions = launchInputs ? launchInputs.chatSessionOptions : requestedChatSessionOptions - const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null - const nameIsAutoManaged = shouldApplyWorkspaceSourceAutoName({ - currentName: name, + const workspaceName = resolveFolderWorkspaceName({ + projectGroup, + name, lastAutoName, - lookupTextIsQuery: !linkedWorkItem + linkedWorkItem }) - const workspaceName = - nameIsAutoManaged && linkedName - ? linkedName - : name.trim() || - linkedName || - translate( - 'auto.components.sidebar.folderWorkspaceComposer.defaultWorkspaceName', - '{{name}} workspace', - { name: projectGroup.name } - ) const launchHost = getFolderWorkspaceAgentLaunchFact(useAppStore.getState(), projectGroup) - if (quickAgent && launchHost.kind === 'withheld') { - throw new Error(launchHost.reason) - } const launchPlatform = launchHost.kind === 'known' ? launchHost.platform : null // SSH folders use the plain orca relay shim; keep the Linux-only local executable rename out of remote launches. const launchIsRemote = Boolean(projectGroup.connectionId) @@ -164,7 +160,12 @@ export async function submitFolderWorkspaceCreate({ : planAgentSessionLaunch(useAppStore.getState(), launchRequest) : null const structuredLaunch = plan?.route === 'structured-native-chat' - if (quickAgent && !structuredLaunch && !startupPlan) { + const hostStartsTerminal = + setupSupported && Boolean(runtimeEnvironmentId) && Boolean(quickAgent) && !structuredLaunch + if (quickAgent && !hostStartsTerminal && launchHost.kind === 'withheld') { + throw new Error(launchHost.reason) + } + if (quickAgent && !structuredLaunch && !hostStartsTerminal && !startupPlan) { throw new Error( translate( 'agentPicker.workspaceStartupFailed', @@ -181,7 +182,24 @@ export async function submitFolderWorkspaceCreate({ Boolean(quickAgent) && note.trim().length > 0 - const workspace = await createFolderWorkspace({ + if (startupPlan && !startupPlan.launchToken) { + startupPlan.launchToken = createBrowserUuid() + } + const result = await createFolderWorkspace({ + ...(setupSupported ? setupApproval : {}), + ...(setupSupported && structuredLaunch ? { agentLaunchMode: 'structured' as const } : {}), + ...(hostStartsTerminal + ? { + ...buildFolderWorkspaceHostStartupPayload({ + quickAgent, + note, + launchDraftPrompt, + agentArgs, + sessionOptions, + launchSource + }) + } + : {}), projectGroupId: projectGroup.id, name: workspaceName, // Retain the SSH target even when another host owns the focused runtime. @@ -191,44 +209,34 @@ export async function submitFolderWorkspaceCreate({ ...(quickAgent ? { createdWithAgent: quickAgent } : {}), ...(pendingFirstAgentMessageRename ? { pendingFirstAgentMessageRename: true } : {}) }) - if (!workspace) { + if (!result) { return false } - if (startupPlan && !startupPlan.launchToken) { - // Share one token so delayed startup delivery reaches exactly the pane it spawned. - startupPlan.launchToken = createBrowserUuid() - } + const workspace = result.folderWorkspace + reviewSetupTrustMismatch( + useAppStore.getState, + projectGroupHookTrustKey(projectGroup), + getRepoExecutionHostId(projectGroup), + { ...result, worktree: folderWorkspaceToWorktree(workspace) }, + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } + ) + const launchFailed = isCancelled() - const startup = - quickAgent && startupPlan - ? { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), - launchAgent: quickAgent, - freshAgentLaunch: { - agentArgs: launchInputs ? launchInputs.agentArgs : requestedAgentArgs, - sessionOptions, - prompt: launchDraftPrompt ?? note, - promptDelivery: launchDraftPrompt ? ('draft' as const) : ('auto-submit' as const), - legacy: () => ({ - startupPlan, - pasteDraftAfterLaunch: startupPlan.draftPrompt ?? startupPlan.followupPrompt ?? null, - submitPastedPrompt: Boolean(startupPlan.followupPrompt) - }) - }, - ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - telemetry: { - agent_kind: tuiAgentToAgentKind(quickAgent), - launch_source: launchSource, - request_kind: 'new' as const - } - } - : undefined + const startup = buildFolderWorkspaceStartupPayload({ + quickAgent, + startupPlan, + launchInputs, + requestedAgentArgs, + sessionOptions, + launchDraftPrompt, + note, + launchSource + }) + if (result.warning) { + toast.warning(result.warning) + } onOpenChange(false) try { const activationHolder: { @@ -236,14 +244,18 @@ export async function submitFolderWorkspaceCreate({ } = { value: false } const revealWorkspace = (): boolean => { activationHolder.value = activateAndRevealFolderWorkspace(workspace.id, { - agent: quickAgent, - ...(!structuredLaunch && startup ? { startup } : {}), - ...(structuredLaunch ? { providesInitialSurface: true } : {}), + agent: launchFailed ? null : quickAgent, + ...(!launchFailed && !structuredLaunch && !hostStartsTerminal && startup + ? { startup } + : {}), + ...(result.setup ? { setup: result.setup } : {}), + backendStartupTerminalSpawned: result.startupTerminal?.spawned === true, + ...(!launchFailed && structuredLaunch ? { providesInitialSurface: true } : {}), runtimeEnvironmentId }) return activationHolder.value !== false } - if (plan?.route === 'structured-native-chat') { + if (!launchFailed && plan?.route === 'structured-native-chat') { beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, @@ -255,22 +267,21 @@ export async function submitFolderWorkspaceCreate({ revealWorkspace() } const activation = activationHolder.value - if ( - !structuredLaunch && - quickAgent && - startupPlan && - launchDraftPrompt && - activation !== false && - activation.primaryTabId - ) { + const draftTabId = hostStartsTerminal + ? result.startupTerminal?.tabId + : activation !== false + ? activation.primaryTabId + : undefined + if (!launchFailed && !structuredLaunch && quickAgent && launchDraftPrompt && draftTabId) { // TUI prefill needs a separate chat-composer copy to remain visible after switching views. seedNativeChatLaunchDraftForAgentTab({ - tabId: activation.primaryTabId, + tabId: draftTabId, agent: quickAgent, text: launchDraftPrompt }) } if ( + !launchFailed && !runtimeEnvironmentId && !structuredLaunch && startupPlan && diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-source-selection.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-source-selection.ts index 46fd00eae1a..bf18dba5c9d 100644 --- a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-source-selection.ts +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-source-selection.ts @@ -1,8 +1,10 @@ +import type { SubmitFolderWorkspaceCreateParams } from './folder-workspace-composer-submit-input' import type { LinkedWorkItemSummary } from '@/lib/workspace/creation/new-workspace' import { buildWorkspaceSourceSelection, getWorkspaceSourceName, - getWorkspaceSourceProvider + getWorkspaceSourceProvider, + shouldApplyWorkspaceSourceAutoName } from '../../../../../../shared/new-workspace/workspace-source' import '../../../../../../shared/cross-platform-path' import '../../../../../../shared/execution-host' @@ -47,3 +49,29 @@ export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | export function getFolderWorkspacePrimaryActionLabel(t: Translate = translate): string { return t('auto.components.sidebar.FolderWorkspaceComposerDialog.create', 'Create workspace') } + +export function resolveFolderWorkspaceName({ + projectGroup, + name, + lastAutoName, + linkedWorkItem +}: Pick< + SubmitFolderWorkspaceCreateParams, + 'projectGroup' | 'name' | 'lastAutoName' | 'linkedWorkItem' +>): string { + const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null + const nameIsAutoManaged = shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName, + lookupTextIsQuery: !linkedWorkItem + }) + return nameIsAutoManaged && linkedName + ? linkedName + : name.trim() || + linkedName || + translate( + 'auto.components.sidebar.folderWorkspaceComposer.defaultWorkspaceName', + '{{name}} workspace', + { name: projectGroup.name } + ) +} diff --git a/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-startup-payload.ts b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-startup-payload.ts new file mode 100644 index 00000000000..68eba4e9aeb --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-startup-payload.ts @@ -0,0 +1,83 @@ +import { toAgentLaunchPreferences } from '../../../../../../shared/agent-launch/choices/agent-launch-preferences' +import type { FolderWorkspaceCreateInput } from '../../../../../../shared/folder-workspaces/folder-workspace-create' +import type { SubmitFolderWorkspaceCreateParams } from './folder-workspace-composer-submit-input' +import type { buildAgentStartupPlan } from '../../../../../../shared/agent-launch/startup/tui-agent-startup' +import { tuiAgentToAgentKind } from '../../../../../../shared/agent-kind' +import type { WorktreeStartupPayload } from '@/lib/workspace/creation/worktree-startup-payload' + +type FolderStartupPayloadInput = Pick< + SubmitFolderWorkspaceCreateParams, + 'quickAgent' | 'launchInputs' | 'sessionOptions' | 'note' +> & { + startupPlan: ReturnType | null + requestedAgentArgs: SubmitFolderWorkspaceCreateParams['agentArgs'] + launchDraftPrompt: string | null + launchSource: NonNullable +} + +export function buildFolderWorkspaceStartupPayload({ + quickAgent, + startupPlan, + launchInputs, + requestedAgentArgs, + sessionOptions, + launchDraftPrompt, + note, + launchSource +}: FolderStartupPayloadInput): WorktreeStartupPayload | undefined { + return quickAgent && startupPlan + ? { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), + launchAgent: quickAgent, + freshAgentLaunch: { + agentArgs: launchInputs ? launchInputs.agentArgs : requestedAgentArgs, + sessionOptions, + prompt: launchDraftPrompt ?? note, + promptDelivery: launchDraftPrompt ? ('draft' as const) : ('auto-submit' as const), + legacy: () => ({ + startupPlan, + pasteDraftAfterLaunch: startupPlan.draftPrompt ?? startupPlan.followupPrompt ?? null, + submitPastedPrompt: Boolean(startupPlan.followupPrompt) + }) + }, + ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + telemetry: { + agent_kind: tuiAgentToAgentKind(quickAgent), + launch_source: launchSource, + request_kind: 'new' as const + } + } + : undefined +} + +export function buildFolderWorkspaceHostStartupPayload({ + quickAgent, + agentArgs, + sessionOptions, + launchSource, + note, + launchDraftPrompt +}: Pick< + SubmitFolderWorkspaceCreateParams, + 'quickAgent' | 'agentArgs' | 'sessionOptions' | 'note' +> & { + launchSource: NonNullable + launchDraftPrompt: string | null +}): Partial { + return { + ...(launchDraftPrompt ? { startupDraft: launchDraftPrompt } : { startupPrompt: note }), + startupAgent: quickAgent ?? undefined, + startupAgentArgs: agentArgs, + startupSessionOptions: sessionOptions, + startupLaunchPreferences: quickAgent + ? toAgentLaunchPreferences(sessionOptions, quickAgent) + : undefined, + launchSource + } +} diff --git a/src/renderer/src/hooks/composer-state/composer-card-props.ts b/src/renderer/src/hooks/composer-state/composer-card-props.ts index 6a44512f7f9..f0b90bbc6b1 100644 --- a/src/renderer/src/hooks/composer-state/composer-card-props.ts +++ b/src/renderer/src/hooks/composer-state/composer-card-props.ts @@ -1,3 +1,4 @@ +import { composerSetupCardProps } from './composer-setup-card-props' import { resolveComposerAttachmentTarget } from './composer-attachment-target' import { getAttachmentLabel } from '@/lib/workspace/creation/new-workspace' import { getQuickComposerCreateDisabled } from '@/lib/workspace/creation/new-workspace-create-gates' @@ -66,7 +67,6 @@ export function buildComposerCardProps(state: ComposerModel) { handleRepoChange, handleReuseSelectedBranchChange, handleSelectLinkedItem, - handleSetupAgentStartupPolicyChange, handleSmartBranchSelect, handleSmartGitHubItemSelect, handleSmartGitLabItemSelect, @@ -90,8 +90,6 @@ export function buildComposerCardProps(state: ComposerModel) { projectHostSetupOptions, projectOptions, repoId, - requiresExplicitSetupChoice, - resolvedSetupDecision, reuseEligibleBranch, reuseSelectedBranch, selectedEphemeralVmRecipeId, @@ -116,14 +114,9 @@ export function buildComposerCardProps(state: ComposerModel) { setNote, setParentWorktreeId, setSelectedEphemeralVmRecipeId, - setSetupDecision, setSmartNameMode, setTuiAgent, - setupAgentStartupPolicy, - setupConfig, - setupDecision, shouldApplyLinkedOnlyTemplate, - shouldWaitForSetupCheck, workspaceSeedName, smartNameJiraSourceContext, smartNameMode, @@ -275,16 +268,7 @@ export function buildComposerCardProps(state: ComposerModel) { forkPushWarning: isProjectGroupTarget ? null : forkPushWarning, note, onNoteChange: setNote, - setupConfig: isProjectGroupTarget ? null : setupConfig, - requiresExplicitSetupChoice: isProjectGroupTarget ? false : requiresExplicitSetupChoice, - setupDecision: isProjectGroupTarget ? null : setupDecision, - onSetupDecisionChange: isProjectGroupTarget ? () => {} : setSetupDecision, - setupAgentStartupPolicy: isProjectGroupTarget ? 'start-immediately' : setupAgentStartupPolicy, - onSetupAgentStartupPolicyChange: isProjectGroupTarget - ? () => {} - : handleSetupAgentStartupPolicyChange, - shouldWaitForSetupCheck: isProjectGroupTarget ? false : shouldWaitForSetupCheck, - resolvedSetupDecision: isProjectGroupTarget ? null : resolvedSetupDecision, + ...composerSetupCardProps(state), createError, canUseSparseCheckout: isProjectGroupTarget ? false @@ -293,7 +277,7 @@ export function buildComposerCardProps(state: ComposerModel) { sparseSelectedPresetId: isProjectGroupTarget ? null : sparseSelectedPresetId, onSparseSelectPreset: isProjectGroupTarget ? () => {} : handleSparseSelectPreset, branchesEnabled: !isProjectGroupTarget, - setupControlsEnabled: !isProjectGroupTarget, + setupControlsEnabled: !isProjectGroupTarget || state.groupSetup.support === 'supported', sparseControlsEnabled: !isProjectGroupTarget } diff --git a/src/renderer/src/hooks/composer-state/composer-setup-card-props.ts b/src/renderer/src/hooks/composer-state/composer-setup-card-props.ts new file mode 100644 index 00000000000..d8668043701 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/composer-setup-card-props.ts @@ -0,0 +1,26 @@ +import type { ComposerModel } from './composer-model' + +export function composerSetupCardProps(state: ComposerModel) { + return { + setupConfig: state.isProjectGroupTarget ? state.groupSetup.setupConfig : state.setupConfig, + requiresExplicitSetupChoice: state.isProjectGroupTarget + ? state.groupSetup.requiresExplicitSetupChoice + : state.requiresExplicitSetupChoice, + setupDecision: state.isProjectGroupTarget ? state.groupSetup.decision : state.setupDecision, + onSetupDecisionChange: state.isProjectGroupTarget + ? state.groupSetup.setDecision + : state.setSetupDecision, + setupAgentStartupPolicy: state.isProjectGroupTarget + ? state.groupSetup.startupPolicy + : state.setupAgentStartupPolicy, + onSetupAgentStartupPolicyChange: state.isProjectGroupTarget + ? state.groupSetup.setStartupPolicy + : state.handleSetupAgentStartupPolicyChange, + shouldWaitForSetupCheck: state.isProjectGroupTarget + ? state.groupSetup.shouldWaitForSetupCheck + : state.shouldWaitForSetupCheck, + resolvedSetupDecision: state.isProjectGroupTarget + ? state.groupSetup.resolvedSetupDecision + : state.resolvedSetupDecision + } +} diff --git a/src/renderer/src/hooks/composer-state/composer-store-actions.ts b/src/renderer/src/hooks/composer-state/composer-store-actions.ts index 9cf35ade40b..170ea42e2e2 100644 --- a/src/renderer/src/hooks/composer-state/composer-store-actions.ts +++ b/src/renderer/src/hooks/composer-state/composer-store-actions.ts @@ -1,6 +1,5 @@ +import type { RepoSlice, RepoUpdate } from '../../store/repos/repo-state' import type { UISlice } from '../../store/slices/ui/ui-slice-contract' -import type { RepoUpdate } from '../../store/repos/repo-state' -import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { TaskSourceContext } from '../../../../shared/work-items/task-source-context' @@ -12,19 +11,7 @@ export type ComposerStoreActions = { updates: RepoUpdate, options?: { hostId?: ExecutionHostId } ) => Promise - createFolderWorkspace: ( - args: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }, - options: { runtimeEnvironmentId: string | null } - ) => Promise + createFolderWorkspace: RepoSlice['createFolderWorkspace'] closeModal: () => void openSettingsPage: () => void openSettingsTarget: (target: NonNullable) => void diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 0d0b627774c..0eb352272d3 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -15,6 +15,8 @@ export function useComposerSubmitOrchestration( source: ComposerSourceState ): ComposerSubmitState { const folderSubmitOrchestration = useFolderSubmitOrchestration({ + groupSetup: target.derivedComposerState.groupSetup, + revealSetupChoice: target.asyncComposerState.revealSetupChoice, clearNewWorkspaceDraft: target.composerTargetStore.clearNewWorkspaceDraft, createFolderWorkspace: target.composerTargetStore.createFolderWorkspace, decisions: target.composerTargetStore.decisions, diff --git a/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts b/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts index a38c6685bfb..99d18efd8b6 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-input-contracts.ts @@ -24,6 +24,8 @@ export type ComposerRuntimeTargetSelectionInput = Pick< export type DerivedComposerStateInput = Pick< ComposerModel, + | 'selectedProjectGroup' + | 'isProjectGroupTarget' | 'agentPrompt' | 'checkedHooksContextKey' | 'enableIssueAutomation' diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.ts b/src/renderer/src/hooks/composer-state/composer-target-state.ts index 81dd6fc8fdf..76dbf4e866b 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-state.ts @@ -137,6 +137,8 @@ export function useComposerTargetState( updateRepo: composerTargetStore.updateRepo }) const derivedComposerState = useDerivedComposerState({ + selectedProjectGroup: initialTargetState.selectedProjectGroup, + isProjectGroupTarget: runtimeTargetSelection.isProjectGroupTarget, agentPrompt: sourceContextState.agentPrompt, checkedHooksContextKey: asyncComposerState.checkedHooksContextKey, enableIssueAutomation: composerTargetStore.enableIssueAutomation, diff --git a/src/renderer/src/hooks/composer-state/derived-composer-state.ts b/src/renderer/src/hooks/composer-state/derived-composer-state.ts index 69b43669fbc..68a2c152f13 100644 --- a/src/renderer/src/hooks/composer-state/derived-composer-state.ts +++ b/src/renderer/src/hooks/composer-state/derived-composer-state.ts @@ -1,3 +1,4 @@ +import { useProjectGroupSetup } from './use-project-group-setup' import { useFolderSetupSupport } from '@/lib/workspace/creation/setup/folder-setup-support' import type { DerivedComposerStateInput } from './composer-target-input-contracts' @@ -28,6 +29,8 @@ const EMPTY_SPARSE_PRESETS: SparsePreset[] = [] export function useDerivedComposerState(input: DerivedComposerStateInput) { const { + selectedProjectGroup, + isProjectGroupTarget, agentPrompt, checkedHooksContextKey, enableIssueAutomation, @@ -54,6 +57,8 @@ export function useDerivedComposerState(input: DerivedComposerStateInput) { yamlHooks } = input + const groupSetup = useProjectGroupSetup(isProjectGroupTarget ? selectedProjectGroup : null) + const sparsePresetsForRepo = sparsePresetsByRepo[repoId] const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS @@ -238,6 +243,7 @@ export function useDerivedComposerState(input: DerivedComposerStateInput) { ]) return { + groupSetup, sparsePresetsForRepo, sparsePresets, normalizedSparseDirectories, diff --git a/src/renderer/src/hooks/composer-state/derived-model.ts b/src/renderer/src/hooks/composer-state/derived-model.ts index 4879b971363..4ab04190699 100644 --- a/src/renderer/src/hooks/composer-state/derived-model.ts +++ b/src/renderer/src/hooks/composer-state/derived-model.ts @@ -1,3 +1,4 @@ +import type { useProjectGroupSetup } from './use-project-group-setup' import type { FolderSetupSupport } from '@/lib/workspace/creation/setup/folder-setup-support' import type { GitHubWorkItem } from '../../../../shared/github/work-items/work-item-types' import type { OrcaHooks, SetupRunPolicy } from '../../../../shared/orca-yaml-hook-types' @@ -7,6 +8,7 @@ import type { GitHubLinkQuery } from '../../../../shared/new-workspace/github-li import type { SetupConfig } from '../../../../shared/orca-hook-setup-config' export type ComposerDerivedModel = { + groupSetup: ReturnType sparsePresetsForRepo: SparsePreset[] sparsePresets: SparsePreset[] normalizedSparseDirectories: string[] diff --git a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts index 6d04c1357c0..d0a0aae63e2 100644 --- a/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/folder-submit-orchestration.ts @@ -1,8 +1,19 @@ +import { projectGroupHookOwner } from '../../../../shared/workspace-hook-owner' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/worktree-runtime-capabilities' +import { resolveFolderSetupSupported } from '@/lib/workspace/creation/setup/folder-setup-support' +import { checkRuntimeGroupHooks } from '@/runtime/repo-hooks/runtime-hooks-client' +import { deriveWorkspaceSetupState } from '@/lib/workspace/creation/setup/workspace-setup-preparation' +import { getSetupConfig } from '../../../../shared/orca-hook-setup-config' +import { confirmWorkspaceSetupDecision } from '@/lib/workspace/creation/setup/workspace-setup-trust' +import { useAppStore } from '@/store' import type { QuickSubmitOptions } from './quick-creation-request' import type { ComposerModel } from './composer-model' type FolderSubmitOrchestrationInput = Pick< ComposerModel, + | 'groupSetup' + | 'revealSetupChoice' | 'clearNewWorkspaceDraft' | 'createFolderWorkspace' | 'decisions' @@ -41,6 +52,8 @@ import { toast } from 'sonner' export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInput) { const { + groupSetup, + revealSetupChoice, clearNewWorkspaceDraft, createFolderWorkspace, decisions, @@ -97,8 +110,58 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp if (isSubmissionCancelled()) { return } + const policySettlement = await settleComposerSubmit( + groupSetup.waitForStartupPolicySave(), + isSubmissionCancelled + ) + if (policySettlement.status === 'cancelled' || !policySettlement.value) { + return + } + const owner = projectGroupHookOwner(selectedProjectGroup) + const setupSupported = await resolveFolderSetupSupported( + owner, + PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY + ) + let setupApproval = {} + if (owner && setupSupported) { + const inspected = await checkRuntimeGroupHooks( + getRepoExecutionHostId(selectedProjectGroup), + selectedProjectGroup.id + ) + if (isSubmissionCancelled()) { + return + } + groupSetup.commitHooks(inspected) + const inspectedGroup = { ...selectedProjectGroup, hookSettings: inspected.hookSettings } + const setup = deriveWorkspaceSetupState( + inspectedGroup, + getSetupConfig({ ...owner, hookSettings: inspected.hookSettings }, inspected.hooks), + groupSetup.decision + ) + if (setup.requiresExplicitSetupChoice && !groupSetup.decision) { + revealSetupChoice() + return + } + const confirmed = await confirmWorkspaceSetupDecision( + useAppStore.getState, + owner.id, + setup.resolvedSetupDecision ?? 'inherit', + getRepoExecutionHostId(selectedProjectGroup), + isSubmissionCancelled + ) + if (confirmed === 'cancelled') { + return + } + setupApproval = confirmed + } + if (isSubmissionCancelled()) { + return + } const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, + setupSupported, + setupApproval, + isCancelled: isSubmissionCancelled, name: smartGitHubMetadata?.workspaceName ?? name, lastAutoName: lastAutoNameRef.current, linkedWorkItem: submitLinkedWorkItem, @@ -152,6 +215,8 @@ export function useFolderSubmitOrchestration(input: FolderSubmitOrchestrationInp } }, [ + groupSetup, + revealSetupChoice, clearNewWorkspaceDraft, createFolderWorkspace, canResolveFolderSmartGitHubSubmit, diff --git a/src/renderer/src/hooks/composer-state/use-project-group-setup.test.tsx b/src/renderer/src/hooks/composer-state/use-project-group-setup.test.tsx new file mode 100644 index 00000000000..5f3ecd90987 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/use-project-group-setup.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { createProjectGroup } from '../../../../shared/project-groups' +import { getDefaultRepoHookSettings } from '../../../../shared/constants' +import '../../components/settings/__fixtures__/settings-persistence-test-fixture' +import { useAppStore } from '@/store' +import { checkRuntimeGroupHooks } from '@/runtime/repo-hooks/runtime-hooks-client' +import { useProjectGroupSetup } from './use-project-group-setup' +import { useFolderSubmitOrchestration } from './folder-submit-orchestration' +import { submitFolderWorkspaceCreate } from '@/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit' +import { toast } from 'sonner' + +vi.mock('@/lib/workspace/creation/setup/folder-setup-support', () => ({ + useFolderSetupSupport: () => 'supported', + resolveFolderSetupSupported: async () => true +})) +vi.mock('@/runtime/repo-hooks/runtime-hooks-client', () => ({ checkRuntimeGroupHooks: vi.fn() })) +vi.mock('@/lib/workspace/creation/setup/workspace-setup-trust', () => ({ + confirmWorkspaceSetupDecision: async () => ({ setupDecision: 'skip' }) +})) +vi.mock('@/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit', () => ({ + submitFolderWorkspaceCreate: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +const group = { + ...createProjectGroup({ + name: 'Services', + parentPath: '/services', + createdFrom: 'folder-scan', + tabOrder: 0 + }), + id: 'services' +} +const saved = { + ...getDefaultRepoHookSettings(), + scripts: { setup: 'echo retained', archive: '' }, + setupAgentStartupPolicy: 'start-immediately' as const +} +const inspected = { + status: 'ok' as const, + hasHooks: false, + hooks: null, + hookSettings: saved, + mayNeedUpdate: false, + setupRunPolicy: 'run-by-default' as const, + setupConfig: null +} +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(checkRuntimeGroupHooks).mockResolvedValue(inspected) +}) + +function useSubmission(selected: typeof group) { + const groupSetup = useProjectGroupSetup(selected) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the folder submission path runs; unrelated composer fields are not read. + const submit = useFolderSubmitOrchestration({ + groupSetup, + selectedProjectGroup: selected, + folderCreateDisabled: false, + folderSourceRepos: [], + disabledTuiAgents: [], + isSubmissionCancelled: () => false, + decisions: { canResolveFolderSmartGitHubSubmit: () => false }, + setCreateError: vi.fn(), + setCreating: vi.fn(), + revealSetupChoice: vi.fn(), + lastAutoNameRef: { current: '' }, + name: 'task', + note: '', + linkedWorkItem: null, + settings: null, + folderTargetRuntimeEnvironmentId: null, + folderTargetIsRemote: false, + createFolderWorkspace: vi.fn(), + onCreated: vi.fn(), + taskSourceContext: null, + resolvePendingSmartGitHubSubmit: vi.fn(), + telemetrySource: 'sidebar', + clearNewWorkspaceDraft: vi.fn(), + persistDraft: false + } as unknown as Parameters[0]) + return { groupSetup, ...submit } +} + +it.each([true, false])( + 'settles a held policy save before group inspection/create (save=%s)', + async (success) => { + let finish!: (saved: boolean) => void + const updateProjectGroup = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + useAppStore.setState({ projectGroups: [group], updateProjectGroup }) + const hook = renderHook(() => { + return useSubmission(group) + }) + await waitFor(() => expect(hook.result.current.groupSetup.shouldWaitForSetupCheck).toBe(false)) + await waitFor(() => expect(checkRuntimeGroupHooks).toHaveBeenCalledOnce()) + await act(async () => { + hook.result.current.groupSetup.setStartupPolicy('wait-for-setup') + }) + await waitFor(() => expect(updateProjectGroup).toHaveBeenCalledOnce()) + let submitting!: Promise + act(() => { + submitting = hook.result.current.submitFolderTarget(null) + }) + await Promise.resolve() + expect(submitFolderWorkspaceCreate).not.toHaveBeenCalled() + expect(checkRuntimeGroupHooks).toHaveBeenCalledOnce() + await act(async () => { + finish(success) + await submitting + }) + if (success) { + expect(submitFolderWorkspaceCreate).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + } else { + expect(submitFolderWorkspaceCreate).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledOnce() + expect(hook.result.current.groupSetup.startupPolicy).toBe('start-immediately') + } + hook.unmount() + } +) + +it('does not roll an old owner failure over a newer owner policy selection', async () => { + const second = { ...group, id: 'second', parentPath: '/second' } + let finishFirst!: (value: boolean) => void + let finishSecond!: (value: boolean) => void + const updateProjectGroup = vi.fn( + (groupId: string) => + new Promise((resolve) => { + if (groupId === group.id) { + finishFirst = resolve + } else { + finishSecond = resolve + } + }) + ) + useAppStore.setState({ projectGroups: [group, second], updateProjectGroup }) + const hook = renderHook(({ selected }) => useProjectGroupSetup(selected), { + initialProps: { selected: group } + }) + await waitFor(() => expect(hook.result.current.shouldWaitForSetupCheck).toBe(false)) + await act(async () => { + hook.result.current.setStartupPolicy('wait-for-setup') + }) + let oldSettlement!: Promise + act(() => { + oldSettlement = hook.result.current.waitForStartupPolicySave() + }) + hook.rerender({ selected: second }) + await waitFor(() => expect(hook.result.current.shouldWaitForSetupCheck).toBe(false)) + await act(async () => { + hook.result.current.setStartupPolicy('wait-for-setup') + }) + await act(async () => { + finishFirst(false) + expect(await oldSettlement).toBe(false) + }) + expect(hook.result.current.startupPolicy).toBe('wait-for-setup') + await act(async () => { + finishSecond(true) + expect(await hook.result.current.waitForStartupPolicySave()).toBe(true) + }) + expect(hook.result.current.startupPolicy).toBe('wait-for-setup') + hook.unmount() +}) + +it.each([true, false])( + 'retains a held owner save across A→B(save)→A (save=%s)', + async (success) => { + const second = { ...group, id: 'second', parentPath: '/second' } + let finishFirst!: (value: boolean) => void + const updateProjectGroup = vi.fn((groupId: string) => + groupId === group.id + ? new Promise((resolve) => { + finishFirst = resolve + }) + : Promise.resolve(true) + ) + useAppStore.setState({ projectGroups: [group, second], updateProjectGroup }) + const hook = renderHook(({ selected }) => useSubmission(selected), { + initialProps: { selected: group } + }) + await waitFor(() => expect(hook.result.current.groupSetup.shouldWaitForSetupCheck).toBe(false)) + await act(async () => { + hook.result.current.groupSetup.setStartupPolicy('wait-for-setup') + }) + hook.rerender({ selected: second }) + await waitFor(() => expect(hook.result.current.groupSetup.shouldWaitForSetupCheck).toBe(false)) + await act(async () => { + hook.result.current.groupSetup.setStartupPolicy('wait-for-setup') + expect(await hook.result.current.groupSetup.waitForStartupPolicySave()).toBe(true) + }) + hook.rerender({ selected: group }) + await waitFor(() => expect(hook.result.current.groupSetup.shouldWaitForSetupCheck).toBe(false)) + const inspectionsBeforeSubmit = vi.mocked(checkRuntimeGroupHooks).mock.calls.length + let submitting!: Promise + act(() => { + submitting = hook.result.current.submitFolderTarget(null) + }) + await Promise.resolve() + expect(submitFolderWorkspaceCreate).not.toHaveBeenCalled() + expect(checkRuntimeGroupHooks).toHaveBeenCalledTimes(inspectionsBeforeSubmit) + await act(async () => { + finishFirst(success) + await submitting + }) + if (success) { + expect(submitFolderWorkspaceCreate).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + } else { + expect(submitFolderWorkspaceCreate).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledOnce() + } + hook.unmount() + } +) diff --git a/src/renderer/src/hooks/composer-state/use-project-group-setup.ts b/src/renderer/src/hooks/composer-state/use-project-group-setup.ts new file mode 100644 index 00000000000..9dcca883df8 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/use-project-group-setup.ts @@ -0,0 +1,161 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { GroupHookCheckResult } from '../../../../shared/project-group-hooks' +import type { SetupAgentStartupPolicy } from '../../../../shared/orca-yaml-hook-types' +import { getSetupConfig } from '../../../../shared/orca-hook-setup-config' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { projectGroupHookOwner } from '../../../../shared/workspace-hook-owner' +import { PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/worktree-runtime-capabilities' +import { useFolderSetupSupport } from '@/lib/workspace/creation/setup/folder-setup-support' +import { deriveWorkspaceSetupState } from '@/lib/workspace/creation/setup/workspace-setup-preparation' +import { checkRuntimeGroupHooks } from '@/runtime/repo-hooks/runtime-hooks-client' +import { useAppStore } from '@/store' +import { toast } from 'sonner' +import { useTranslate } from '@/i18n/use-translate' +import { buildSetupAgentStartupHookSettings } from './setup-policy-decisions' + +export function useProjectGroupSetup(group: ProjectGroup | null | undefined) { + const t = useTranslate() + const owner = group ? projectGroupHookOwner(group) : null + const support = useFolderSetupSupport(owner, PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY) + const context = owner?.id ?? null + const selectedContext = useRef(context) + useEffect(() => { + selectedContext.current = context + return () => { + selectedContext.current = null + } + }, [context]) + const policySaves = useRef(new Map>()) + const [inspection, setInspection] = useState<{ + context: string + result: GroupHookCheckResult + } | null>(null) + const [choice, setChoice] = useState<{ context: string; decision: 'run' | 'skip' | null } | null>( + null + ) + const groupId = group?.id + const hostId = group ? getRepoExecutionHostId(group) : null + useEffect(() => { + if (!context || !groupId || !hostId || support !== 'supported') { + return + } + let cancelled = false + void checkRuntimeGroupHooks(hostId, groupId, { background: true }) + .then((result) => { + if (!cancelled && !result.unavailable) { + setInspection({ context, result }) + } + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [context, groupId, hostId, support]) + const ready = inspection?.context === context + const inspectedGroup = + group && ready ? { ...group, hookSettings: inspection.result.hookSettings } : group + const inspectedOwner = inspectedGroup ? projectGroupHookOwner(inspectedGroup) : null + const config = + inspectedOwner && support === 'supported' + ? getSetupConfig(inspectedOwner, ready ? inspection.result.hooks : null) + : null + const decision = choice?.context === context ? choice.decision : null + const derived = deriveWorkspaceSetupState( + inspectedGroup ?? undefined, + config, + decision, + support !== 'unsupported', + support === 'pending' || !ready + ) + const setDecision: Dispatch> = (next) => { + if (context) { + setChoice((current) => ({ + context, + decision: + typeof next === 'function' + ? next(current?.context === context ? current.decision : null) + : next + })) + } + } + return { + ...derived, + support, + commitHooks: (result: GroupHookCheckResult) => { + if (context) { + setInspection({ context, result }) + } + }, + decision, + setDecision, + waitForStartupPolicySave: async (): Promise => { + if (!context || selectedContext.current !== context) { + return false + } + let pending = policySaves.current.get(context) + while (pending) { + if (!(await pending) || selectedContext.current !== context) { + return false + } + if (policySaves.current.get(context) === pending) { + break + } + pending = policySaves.current.get(context) + } + return selectedContext.current === context + }, + startupPolicy: inspectedGroup?.hookSettings?.setupAgentStartupPolicy ?? 'start-immediately', + setStartupPolicy: (policy: SetupAgentStartupPolicy) => { + if (group && hostId && context && ready) { + const hookSettings = buildSetupAgentStartupHookSettings( + inspectedGroup?.hookSettings, + policy + ) + setInspection((current) => + current?.context === context + ? { ...current, result: { ...current.result, hookSettings } } + : current + ) + const previous = policySaves.current.get(context) + const promise: Promise = Promise.resolve(previous) + .then(async () => { + const saved = await useAppStore + .getState() + .updateProjectGroup(group.id, { hookSettings }, { hostId }) + .catch(() => false) + if (!saved) { + const latest = await checkRuntimeGroupHooks(hostId, group.id, { + background: true + }).catch(() => null) + if ( + policySaves.current.get(context) === promise && + selectedContext.current === context + ) { + setInspection((current) => + current?.context === context && current.result.hookSettings === hookSettings + ? latest && !latest.unavailable + ? { context, result: latest } + : null + : current + ) + } + toast.error( + t( + 'auto.hooks.useComposerState.setupAgentStartupPolicySaveFailed', + 'Failed to save setup startup behavior.' + ) + ) + } + return saved + }) + .finally(() => { + if (policySaves.current.get(context) === promise) { + policySaves.current.delete(context) + } + }) + policySaves.current.set(context, promise) + } + } + } +} diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index c0c26ca3f7d..8088bd81f11 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -1,3 +1,4 @@ +import type { ProjectGroupSettingsEntry } from './use-supported-group-hook-settings' import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import type { Repo } from '../../../shared/repo-types' @@ -13,6 +14,7 @@ export type SettingsNavigationBuildOptions = { mobileEmulatorCreationEnabled: boolean isDev: boolean isLinearConnected: boolean + projectGroups?: readonly ProjectGroupSettingsEntry[] repos: readonly Repo[] projectGrouping?: ProjectGroupingModel } diff --git a/src/renderer/src/hooks/settings-navigation-remote-sections.ts b/src/renderer/src/hooks/settings-navigation-remote-sections.ts index da9dd76b7d0..7f52078fb56 100644 --- a/src/renderer/src/hooks/settings-navigation-remote-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-remote-sections.ts @@ -1,3 +1,5 @@ +import { projectGroupHookOwner } from '../../../shared/workspace-hook-owner' +import { getRepositoryGitHooksSearchEntries } from '@/components/settings/repository/hooks/repository-git-hooks-search-entries' import { getAdvancedPaneSearchEntries } from '@/components/settings/advanced/advanced-search' import { getDeveloperPermissionsPaneSearchEntries } from '@/components/settings/developer-permissions/developer-permissions-search' import { getExperimentalPaneSearchEntries } from '@/components/settings/advanced/experimental-search' @@ -34,6 +36,7 @@ export function buildRemoteSettingsSections( isWebClient, isDev, repos, + projectGroups = [], projectGrouping }: SettingsNavigationBuildOptions, runtimeEnvironmentsSearchEntry: SettingsNavSection['searchEntries'][number], @@ -153,6 +156,21 @@ export function buildRemoteSettingsSections( } ] : []), + ...projectGroups.flatMap((group) => { + const owner = projectGroupHookOwner(group) + return owner + ? [ + { + id: `group-${encodeURIComponent(owner.id)}`, + title: group.settingsLabel ?? group.name, + description: owner.path, + icon: SlidersHorizontal, + searchEntries: getRepositoryGitHooksSearchEntries(owner, t, { showArchive: false }), + group: 'repositories' + } + ] + : [] + }), // Collapse host-specific repo rows to one project entry using the same inputs as settings panes. ...buildSettingsProjectList(repos, projectGrouping).map((settingsProject) => { const { project, representativeRepoId, setups, checkoutLabel } = settingsProject diff --git a/src/renderer/src/hooks/use-supported-group-hook-settings.ts b/src/renderer/src/hooks/use-supported-group-hook-settings.ts new file mode 100644 index 00000000000..f69d99083ea --- /dev/null +++ b/src/renderer/src/hooks/use-supported-group-hook-settings.ts @@ -0,0 +1,69 @@ +import { useShallow } from 'zustand/react/shallow' +import { selectExecutionHostDisplayLabel } from '@/runtime/hosts/display/execution-host-display-label' +import { useEffect, useMemo, useState } from 'react' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { projectGroupHookOwner } from '../../../shared/workspace-hook-owner' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY } from '../../../shared/worktree-runtime-capabilities' +import { resolveFolderSetupSupported } from '@/lib/workspace/creation/setup/folder-setup-support' +import { useAppStore } from '@/store' + +export type ProjectGroupSettingsEntry = ProjectGroup & { settingsLabel?: string } + +export function useSupportedGroupHookSettings(): readonly ProjectGroupSettingsEntry[] { + const groups = useAppStore((state) => state.projectGroups) + const labels = useAppStore( + useShallow((state) => + groups.map( + (group) => + `${group.name} (${selectExecutionHostDisplayLabel(state, getRepoExecutionHostId(group))})` + ) + ) + ) + const [supportedHosts, setSupportedHosts] = useState>(new Set()) + const hosts = useMemo(() => { + const owners = new Map>>() + for (const group of groups) { + const owner = projectGroupHookOwner(group) + if (owner) { + owners.set(getRepoExecutionHostId(group), owner) + } + } + return [...owners] + }, [groups]) + useEffect(() => { + let cancelled = false + void Promise.all( + hosts.map(async ([host, owner]) => { + try { + return (await resolveFolderSetupSupported( + owner, + PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY + )) + ? host + : null + } catch { + return null + } + }) + ).then((answers) => { + if (!cancelled) { + setSupportedHosts(new Set(answers.filter((host): host is string => host !== null))) + } + }) + return () => { + cancelled = true + } + }, [hosts]) + return useMemo( + () => + groups.flatMap((group, index) => { + const host = getRepoExecutionHostId(group) + return Boolean(group.parentPath) && + (parseExecutionHostId(host)?.kind === 'local' || supportedHosts.has(host)) + ? [{ ...group, settingsLabel: labels[index] }] + : [] + }), + [groups, supportedHosts, labels] + ) +} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 29769de46cd..2aa81ca293a 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -1,3 +1,5 @@ +import { useSupportedGroupHookSettings } from './use-supported-group-hook-settings' +import type { ProjectGroupSettingsEntry } from './use-supported-group-hook-settings' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { @@ -55,6 +57,7 @@ export function buildSettingsNavigationMetadata( isDev = import.meta.env.DEV, isLinearConnected = false, repos, + projectGroups, projectGrouping }: { isMac: boolean @@ -68,6 +71,7 @@ export function buildSettingsNavigationMetadata( mobileEmulatorCreationEnabled?: boolean isDev?: boolean isLinearConnected?: boolean + projectGroups?: readonly ProjectGroupSettingsEntry[] repos: readonly Repo[] projectGrouping?: ProjectGroupingModel }, @@ -103,6 +107,7 @@ export function buildSettingsNavigationMetadata( isDev, isLinearConnected, repos, + projectGroups, projectGrouping } @@ -120,6 +125,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const t = useTranslate() // The locale subscription must also invalidate the memo or labels retain the previous language. const languageRevision = useUiLanguageRevision() + const projectGroups = useSupportedGroupHookSettings() const repos = useAppStore((state) => state.repos) const projectHostSetupProjection = useProjectHostSetupProjection() const settings = useAppStore((state) => state.settings) @@ -182,6 +188,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, repos, + projectGroups, projectGrouping: { projects: projectHostSetupProjection.projects, projectHostSetups: projectHostSetupProjection.setups @@ -202,6 +209,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, repos, + projectGroups, projectHostSetupProjection, languageRevision, t diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 93e4a23c9a1..a0ea1b20375 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1421,6 +1421,14 @@ "cleanup": { "recipeDescription": "Clean up dictated text before it lands." } + }, + "groupHooks": { + "title": "Project Settings > {{name}}", + "example": "Example: create a worktree in each repo (this machine only)", + "useExample": "Use this example", + "description": "Scripts that run in this folder when a workspace is created. Every workspace shares the folder. Local scripts are stored on this machine; `orca.yaml` scripts are shared with your team.", + "invalidSetup": "The file is present, but Orca could not find a valid setup script.", + "supportedKeys": "Define only the supported keys: `scripts` and `setup`." } }, "menu": { @@ -23346,6 +23354,7 @@ "retry": "Retry", "cancel": "Cancel", "unassigned": "Unassigned", + "copy": "Copy", "delete": "Delete" }, "terminal": { diff --git a/src/renderer/src/lib/workspace/activation/initial-surface/folder-workspace-initial-terminal.ts b/src/renderer/src/lib/workspace/activation/initial-surface/folder-workspace-initial-terminal.ts new file mode 100644 index 00000000000..8ce5f4298a6 --- /dev/null +++ b/src/renderer/src/lib/workspace/activation/initial-surface/folder-workspace-initial-terminal.ts @@ -0,0 +1,36 @@ +import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' +import type { WorktreeSetupLaunch } from '../../../../../../shared/worktree/creation/launch-types' +import type { InitialTerminalOptions } from '@/lib/workspace/activation/worktree-activation-store-contract' +import type { WorktreeStartupPayload } from '@/lib/workspace/creation/worktree-startup-payload' +import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' +import { useAppStore } from '@/store/index' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' + +export function ensureFolderWorkspaceInitialTerminal( + folderWorkspace: FolderWorkspace, + startup?: WorktreeStartupPayload, + providesInitialSurface?: boolean, + opts?: Pick< + InitialTerminalOptions, + 'seedUserDefaultSurface' | 'backendStartupTerminalSpawned' + > & { setup?: WorktreeSetupLaunch } +): string | null { + if (providesInitialSurface === true && startup === undefined && !opts?.setup) { + return null + } + const state = useAppStore.getState() + const workspaceKey = folderWorkspaceKey(folderWorkspace.id) + const primaryTabId = ensureWorktreeHasInitialTerminal( + state, + workspaceKey, + startup, + opts?.setup, + undefined, + undefined, + { + reseedEmptiedWorkspace: providesInitialSurface !== true, + ...opts + } + ) + return primaryTabId +} diff --git a/src/renderer/src/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding.ts index 502097a3a8f..1e3e879d96f 100644 --- a/src/renderer/src/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding.ts @@ -3,8 +3,6 @@ import type { WorktreeSetupLaunch } from '../../../../../../shared/worktree/creation/launch-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { hasSplitLandingGroup } from '@/store/slices/tabs/split-landing-groups' import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../../../../shared/workspace-layout/terminal-surfaces/closed-terminal-tab-tombstones' @@ -74,32 +72,6 @@ export function reseedGatedEmptyWorkspace( ) } -export function ensureFolderWorkspaceInitialTerminal( - folderWorkspace: FolderWorkspace, - startup?: WorktreeStartupPayload, - providesInitialSurface?: boolean, - opts?: Pick -): string | null { - if (providesInitialSurface === true && startup === undefined) { - return null - } - const state = useAppStore.getState() - const workspaceKey = folderWorkspaceKey(folderWorkspace.id) - const primaryTabId = ensureWorktreeHasInitialTerminal( - state, - workspaceKey, - startup, - undefined, - undefined, - undefined, - { - reseedEmptiedWorkspace: providesInitialSurface !== true, - ...opts - } - ) - return primaryTabId -} - export function ensureWorktreeHasInitialTerminal( store: WorktreeActivationStore, worktreeId: string, diff --git a/src/renderer/src/lib/workspace/activation/initial-surface/worktree-setup-issue-command-queue.ts b/src/renderer/src/lib/workspace/activation/initial-surface/worktree-setup-issue-command-queue.ts index 23dda0716e0..d06976fbaa6 100644 --- a/src/renderer/src/lib/workspace/activation/initial-surface/worktree-setup-issue-command-queue.ts +++ b/src/renderer/src/lib/workspace/activation/initial-surface/worktree-setup-issue-command-queue.ts @@ -32,6 +32,7 @@ export function queueSetupAndIssueCommands( const setupTab = store.createTab(worktreeId, undefined, undefined, { recordInteraction: false, ...(opts?.activateCreatedTabs === false ? { activate: false } : {}), + ...(setup.cwd ? { startupCwd: setup.cwd } : {}), pendingStartup: setupCommand }) // createTab auto-activates; restore primary focus while Setup runs in background. diff --git a/src/renderer/src/lib/workspace/activation/worktree-activation-outcome.ts b/src/renderer/src/lib/workspace/activation/worktree-activation-outcome.ts index 1b1ef77adf2..701339e36f6 100644 --- a/src/renderer/src/lib/workspace/activation/worktree-activation-outcome.ts +++ b/src/renderer/src/lib/workspace/activation/worktree-activation-outcome.ts @@ -1,3 +1,4 @@ +import type { WorktreeSetupLaunch } from '../../../../../shared/worktree/creation/launch-types' import { toast } from 'sonner' import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent' import type { ExecutionHostId } from '../../../../../shared/execution-host' @@ -31,6 +32,8 @@ export type FolderWorkspaceActivationOptions = Omit< WorkspaceRevealOptions, 'clearSidebarFilters' > & { + setup?: WorktreeSetupLaunch + backendStartupTerminalSpawned?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null } diff --git a/src/renderer/src/lib/workspace/activation/worktree-activation.ts b/src/renderer/src/lib/workspace/activation/worktree-activation.ts index d54767b2132..3363db559aa 100644 --- a/src/renderer/src/lib/workspace/activation/worktree-activation.ts +++ b/src/renderer/src/lib/workspace/activation/worktree-activation.ts @@ -17,10 +17,8 @@ import { } from '../ownership/folder-workspace-runtime-owner' import '../../../../../shared/folder-workspaces/folder-workspace-execution-host' import { getFolderWorkspaceHostId } from '../../../../../shared/folder-workspaces/folder-workspace-host-id' -import { - ensureFolderWorkspaceInitialTerminal, - ensureWorktreeHasInitialTerminal -} from '@/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding' +import { ensureFolderWorkspaceInitialTerminal } from '@/lib/workspace/activation/initial-surface/folder-workspace-initial-terminal' +import { ensureWorktreeHasInitialTerminal } from '@/lib/workspace/activation/initial-surface/worktree-initial-terminal-seeding' import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/runtime/hosted-terminals/restoration/web-runtime-worktree-terminal-after-wake' import { applyWorktreeNavViewEntry } from '@/lib/workspace/activation/worktree-nav-view-history-replay' import { @@ -105,6 +103,8 @@ export function activateAndRevealFolderWorkspaceWithOutcome( // Why: same ordering as the worktree path — gate first, then resume only when not deferring. const shouldGateAgentActivation = !opts?.startup && + !opts?.setup && + !opts?.backendStartupTerminalSpawned && (workspaceHasSleepingAgentSessions(state, workspaceKey) || (canInspectAgentActivationInventory() && shouldAutoCreateInitialTerminal( @@ -122,7 +122,9 @@ export function activateAndRevealFolderWorkspaceWithOutcome( const primaryTabId = shouldGateAgentActivation ? null : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface, { - seedUserDefaultSurface + seedUserDefaultSurface, + setup: opts?.setup, + backendStartupTerminalSpawned: opts?.backendStartupTerminalSpawned }) if (opts?.showWorkspaceList) { @@ -136,7 +138,7 @@ export function activateAndRevealFolderWorkspaceWithOutcome( }) } - if (opts?.providesInitialSurface !== true) { + if (opts?.providesInitialSurface !== true && !opts?.backendStartupTerminalSpawned) { ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, { runtimeEnvironmentId, startup: opts?.startup, diff --git a/src/renderer/src/lib/workspace/creation/setup/folder-setup-support.ts b/src/renderer/src/lib/workspace/creation/setup/folder-setup-support.ts index 6acd82434fe..8176bc49dd3 100644 --- a/src/renderer/src/lib/workspace/creation/setup/folder-setup-support.ts +++ b/src/renderer/src/lib/workspace/creation/setup/folder-setup-support.ts @@ -4,7 +4,8 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { isFolderRepo } from '../../../../../../shared/repo-kind' -import type { Repo } from '../../../../../../shared/repo-types' +import type { WorkspaceHookOwner } from '../../../../../../shared/workspace-hook-owner' +import type { RuntimeCapability } from '../../../../../../shared/protocol-version' import { WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY } from '../../../../../../shared/worktree-runtime-capabilities' import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibility/runtime-capability-proof' @@ -14,7 +15,7 @@ type FolderSetupHost = | { kind: 'known'; supported: boolean } | { kind: 'runtime'; environmentId: string } -function folderSetupHost(repo: Repo | null | undefined): FolderSetupHost { +function folderSetupHost(repo: WorkspaceHookOwner | null | undefined): FolderSetupHost { if (!repo || !isFolderRepo(repo)) { return { kind: 'known', supported: true } } @@ -37,19 +38,19 @@ type FolderSetupObservation = { // Active readers own answers; the runtime compatibility cache handles reuse after they leave. const folderSetupObservations = new Map() -function probeFolderSetup(environmentId: string): Promise { - const observation = folderSetupObservations.get(environmentId) +function probeFolderSetup( + environmentId: string, + capability: RuntimeCapability = WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY +): Promise { + const observation = folderSetupObservations.get(`${capability}:${environmentId}`) if (observation?.probe) { return observation.probe } - const probe = runtimeEnvironmentSupportsCapability( - environmentId, - WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY - ) + const probe = runtimeEnvironmentSupportsCapability(environmentId, capability) .then((supported) => { if ( observation && - folderSetupObservations.get(environmentId) === observation && + folderSetupObservations.get(`${capability}:${environmentId}`) === observation && observation.answer !== supported ) { observation.answer = supported @@ -69,14 +70,20 @@ function probeFolderSetup(environmentId: string): Promise { } /** Folder setup runs only on hosts that report it; older hosts would save scripts that never run. Rejects when the host can't answer. */ -export function resolveFolderSetupSupported(repo: Repo | null | undefined): Promise { +export function resolveFolderSetupSupported( + repo: WorkspaceHookOwner | null | undefined, + capability: RuntimeCapability = WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY +): Promise { const host = folderSetupHost(repo) return host.kind === 'known' ? Promise.resolve(host.supported) - : probeFolderSetup(host.environmentId) + : probeFolderSetup(host.environmentId, capability) } -export function useFolderSetupSupport(repo: Repo | null | undefined): FolderSetupSupport { +export function useFolderSetupSupport( + repo: WorkspaceHookOwner | null | undefined, + capability: RuntimeCapability = WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY +): FolderSetupSupport { const host = folderSetupHost(repo) const environmentId = host.kind === 'runtime' ? host.environmentId : null const subscribe = useCallback( @@ -84,33 +91,35 @@ export function useFolderSetupSupport(repo: Repo | null | undefined): FolderSetu if (!environmentId) { return () => {} } - let observation = folderSetupObservations.get(environmentId) + let observation = folderSetupObservations.get(`${capability}:${environmentId}`) if (!observation) { observation = { answer: undefined, listeners: new Set() } - folderSetupObservations.set(environmentId, observation) + folderSetupObservations.set(`${capability}:${environmentId}`, observation) } observation.listeners.add(listener) return () => { observation.listeners.delete(listener) if ( observation.listeners.size === 0 && - folderSetupObservations.get(environmentId) === observation + folderSetupObservations.get(`${capability}:${environmentId}`) === observation ) { - folderSetupObservations.delete(environmentId) + folderSetupObservations.delete(`${capability}:${environmentId}`) } } }, - [environmentId] + [environmentId, capability] ) const answer = useSyncExternalStore(subscribe, () => - environmentId ? folderSetupObservations.get(environmentId)?.answer : undefined + environmentId + ? folderSetupObservations.get(`${capability}:${environmentId}`)?.answer + : undefined ) useEffect(() => { // Re-asked on mount so an in-place host upgrade shows; a failure stays pending for Create to retry. if (environmentId) { - probeFolderSetup(environmentId).catch(() => {}) + probeFolderSetup(environmentId, capability).catch(() => {}) } - }, [environmentId]) + }, [environmentId, capability]) if (host.kind === 'known') { return host.supported ? 'supported' : 'unsupported' } diff --git a/src/renderer/src/lib/workspace/creation/setup/workspace-setup-trust.ts b/src/renderer/src/lib/workspace/creation/setup/workspace-setup-trust.ts index a7c1f1eddf0..f6ddb14c816 100644 --- a/src/renderer/src/lib/workspace/creation/setup/workspace-setup-trust.ts +++ b/src/renderer/src/lib/workspace/creation/setup/workspace-setup-trust.ts @@ -1,3 +1,7 @@ +import { + projectGroupHookOwner, + projectGroupHookTrustKey +} from '../../../../../../shared/workspace-hook-owner' import type { AppState } from '@/store/types' import { getRepoExecutionHostId, @@ -77,10 +81,18 @@ function explicitFolderRunUnderRepoTrust( if (decision !== undefined && decision !== 'inherit') { return decision } - const repo = state.repos.find( - (candidate) => - candidate.id === repoId && (!ownerHostId || getRepoExecutionHostId(candidate) === ownerHostId) + const group = state.projectGroups.find( + (entry) => + projectGroupHookTrustKey(entry) === repoId && + (!ownerHostId || getRepoExecutionHostId(entry) === ownerHostId) ) + const repo = + (group ? projectGroupHookOwner(group) : null) ?? + state.repos.find( + (candidate) => + candidate.id === repoId && + (!ownerHostId || getRepoExecutionHostId(candidate) === ownerHostId) + ) return repo && isFolderRepo(repo) && (repo.hookSettings?.setupRunPolicy ?? 'run-by-default') === 'run-by-default' diff --git a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts index 94f6fb1c22e..a5709e4b347 100644 --- a/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts +++ b/src/renderer/src/lib/workspace/creation/work-item/launch-work-item-direct-test-fixture.ts @@ -230,6 +230,7 @@ beforeEach(() => { ) mocks.pasteDraftWhenAgentReady.mockResolvedValue(true) mocks.store = { + projectGroups: [], repos: [ { id: 'repo-1', diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session-host-decline.test.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session-host-decline.test.ts index 5eacc018e76..28925bf4bda 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session-host-decline.test.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session-host-decline.test.ts @@ -236,7 +236,7 @@ describe.each(['git', 'folder'] as const)('foreground %s implicit Chat decline', createFolderWorkspace: async () => { const workspace = makeFolderWorkspace() useAppStore.setState({ folderWorkspaces: [workspace] }) - return workspace + return { folderWorkspace: workspace } }, onOpenChange: vi.fn() }) diff --git a/src/renderer/src/runtime/repo-hooks/ensure-hooks-confirmed.ts b/src/renderer/src/runtime/repo-hooks/ensure-hooks-confirmed.ts index e8808bee86f..140ff5af900 100644 --- a/src/renderer/src/runtime/repo-hooks/ensure-hooks-confirmed.ts +++ b/src/renderer/src/runtime/repo-hooks/ensure-hooks-confirmed.ts @@ -1,3 +1,5 @@ +import { findHookRepo, hookRepoOwnerHostId } from './hook-owner-resolution' +import { projectGroupHookTrustKey } from '../../../../shared/workspace-hook-owner' import type { AppState } from '@/store/types' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import { resolveHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy' @@ -13,15 +15,12 @@ import type { SetupHooksCreateSource } from '../../../../shared/worktree/setup-h import { getIssueCommandTrustContent, isUserOwnedIssueCommand } from './issue-command-trust-content' import { checkRuntimeHooks, + checkRuntimeGroupHooks, readRuntimeIssueCommand, type IssueCommandReadResult } from '@/runtime/repo-hooks/runtime-hooks-client' import { MODAL_DISMISSED_KEY } from '@/store/slices/ui/modal-slot-dismissal' -import { - getRepoExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' const NEVER_CANCEL_TRUST_CHECK = (): boolean => false @@ -38,36 +37,6 @@ export function __resetTrustPromptChainForTests(): void { trustPromptChain = Promise.resolve() } -function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) { - return hostId - ? state.repos.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) - : state.repos.find((repo) => repo.id === repoId) -} - -/** Inspect on the captured runtime owner, then explicit host, then repo owner; never substitute focused host, including server-owned SSH repos. */ -function hookRepoOwnerHostId( - state: AppState, - repoId: string, - hostId?: ExecutionHostId, - runtimeOwnerEnvironmentId?: string | null -): ExecutionHostId { - const ownerEnvironmentId = runtimeOwnerEnvironmentId?.trim() - if (ownerEnvironmentId) { - return toRuntimeExecutionHostId(ownerEnvironmentId) - } - const rows = hostId ? [] : state.repos.filter((entry) => entry.id === repoId) - // Why: with the id on two hosts, trusting one row's script could run the other's unseen. - if (rows.length > 1) { - throw new Error('The project exists on more than one host.') - } - const repo = rows[0] ?? null - const ownerHostId = hostId ?? (repo ? getRepoExecutionHostId(repo) : null) - if (!ownerHostId) { - throw new Error('The project host is unknown.') - } - return ownerHostId -} - /** A run carries the approved content's hash (empty content included) unless the whole repo is trusted. */ export type HookTrustApproval = { decision: 'run' | 'skip'; approvedContentHash?: string } @@ -306,11 +275,16 @@ export async function ensureHooksApproved( if (sourcePolicy === 'local-only') { return RUN_ANY } - const result = await checkRuntimeHooks( - hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), - repoId, - scriptKind === 'setup' && createSource ? { createSource } : {} + const group = state.projectGroups.find( + (entry) => projectGroupHookTrustKey(entry) === repoId ) + const result = group + ? await checkRuntimeGroupHooks(getRepoExecutionHostId(group), group.id) + : await checkRuntimeHooks( + hookRepoOwnerHostId(state, repoId, hostId, runtimeOwnerEnvironmentId), + repoId, + scriptKind === 'setup' && createSource ? { createSource } : {} + ) if (result.status === 'error') { return SKIP } diff --git a/src/renderer/src/runtime/repo-hooks/hook-owner-resolution.ts b/src/renderer/src/runtime/repo-hooks/hook-owner-resolution.ts new file mode 100644 index 00000000000..e8f0ae33a54 --- /dev/null +++ b/src/renderer/src/runtime/repo-hooks/hook-owner-resolution.ts @@ -0,0 +1,48 @@ +import type { AppState } from '@/store/types' +import { + projectGroupHookOwner, + projectGroupHookTrustKey +} from '../../../../shared/workspace-hook-owner' +import { + getRepoExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) { + const group = state.projectGroups.find( + (entry) => + projectGroupHookTrustKey(entry) === repoId && + (!hostId || getRepoExecutionHostId(entry) === hostId) + ) + if (group) { + return projectGroupHookOwner(group) ?? undefined + } + return hostId + ? state.repos.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) + : state.repos.find((repo) => repo.id === repoId) +} + +/** Inspect on the captured runtime owner, then explicit host, then repo owner; never substitute focused host, including server-owned SSH repos. */ +export function hookRepoOwnerHostId( + state: AppState, + repoId: string, + hostId?: ExecutionHostId, + runtimeOwnerEnvironmentId?: string | null +): ExecutionHostId { + const ownerEnvironmentId = runtimeOwnerEnvironmentId?.trim() + if (ownerEnvironmentId) { + return toRuntimeExecutionHostId(ownerEnvironmentId) + } + const rows = hostId ? [] : state.repos.filter((entry) => entry.id === repoId) + // Why: with the id on two hosts, trusting one row's script could run the other's unseen. + if (rows.length > 1) { + throw new Error('The project exists on more than one host.') + } + const repo = rows[0] ?? null + const ownerHostId = hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + if (!ownerHostId) { + throw new Error('The project host is unknown.') + } + return ownerHostId +} diff --git a/src/renderer/src/runtime/repo-hooks/runtime-hooks-client.ts b/src/renderer/src/runtime/repo-hooks/runtime-hooks-client.ts index 2db49fd8ae5..dd79f980707 100644 --- a/src/renderer/src/runtime/repo-hooks/runtime-hooks-client.ts +++ b/src/renderer/src/runtime/repo-hooks/runtime-hooks-client.ts @@ -1,3 +1,4 @@ +import type { GroupHookCheckResult } from '../../../../shared/project-group-hooks' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../../../shared/workspace-setup/setup-script-imports' @@ -116,3 +117,21 @@ export async function writeRuntimeIssueCommand( { timeoutMs: 15_000 } ) } + +export type { GroupHookCheckResult } from '../../../../shared/project-group-hooks' + +export async function checkRuntimeGroupHooks( + hostId: ExecutionHostId, + groupId: string, + options: { background?: boolean } = {} +): Promise { + const target = getHookInspectionTarget(hostId) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'projectGroup.checkHooks', + { groupId, ...options }, + { timeoutMs: 15_000 } + ) + : window.api.projectGroups.checkHooks({ groupId, ...options }) +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts index 69e9c1159ac..a0dfa2b8b55 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts @@ -127,6 +127,17 @@ function mergeFetchedFolderWorkspacesForHost({ ) } +export function mergeCreatedFolderWorkspace( + created: FolderWorkspace, + current: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[] +): readonly FolderWorkspace[] { + const resolveHostId = createFolderWorkspaceHostResolver(projectGroups) + return mergeByIdentity([created], current, (workspace) => + getFolderWorkspaceHostIdentity(workspace, resolveHostId) + ) +} + export function getFolderWorkspaceCatalogReplacementIdentities( catalog: FetchedFolderWorkspaceCatalog, currentFolderWorkspaces: readonly FolderWorkspace[], diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-create-catalog.test.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-create-catalog.test.ts new file mode 100644 index 00000000000..8e66fce00b4 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-create-catalog.test.ts @@ -0,0 +1,132 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createTestStore } from '../testing/app-store-test-fixture' +import { findFolderWorkspaceOwner } from '@/lib/workspace/ownership/folder-workspace-runtime-owner' +import { createProjectGroup } from '../../../../shared/project-groups' +import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types' + +// The host publishes the row before setup preparation returns the create reply. +it('keeps the created workspace activatable when its catalog arrives before setup', async () => { + const store = createTestStore() + const group = createProjectGroup({ + name: 'Services', + parentPath: '/services', + createdFrom: 'folder-scan', + tabOrder: 0 + }) + const workspace: FolderWorkspace = { + id: 'created-folder', + projectGroupId: group.id, + name: 'Feature', + folderPath: '/services', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + const twin = { ...workspace, executionHostId: 'runtime:other' as const } + const setup = { + runnerScriptPath: '/services/.orca/setup-runner/setup.sh', + envVars: {}, + waitForAgentStartup: true + } + store.setState({ projectGroups: [group], folderWorkspaces: [twin] }) + vi.stubGlobal('window', { + api: { + folderWorkspaces: { + create: async () => { + store.setState({ + folderWorkspaces: [ + { ...workspace, name: 'Latest catalog name', executionHostId: 'local' }, + twin + ] + }) + return { folderWorkspace: workspace, setup } + } + } + } + }) + const result = await store + .getState() + .createFolderWorkspace({ projectGroupId: group.id }, { runtimeEnvironmentId: null }) + expect(result?.setup).toEqual({ + runnerScriptPath: '/services/.orca/setup-runner/setup.sh', + envVars: {}, + waitForAgentStartup: true + }) + expect(store.getState().folderWorkspaces.map((row) => row.executionHostId)).toEqual([ + 'local', + 'runtime:other' + ]) + expect(findFolderWorkspaceOwner(store.getState(), 'created-folder', 'local')).toMatchObject({ + id: 'created-folder', + executionHostId: 'local' + }) + expect( + findFolderWorkspaceOwner(store.getState(), 'created-folder', 'runtime:other') + ).toMatchObject({ + id: 'created-folder', + executionHostId: 'runtime:other' + }) + expect(store.getState().folderWorkspaces.map((row) => row.name)).toEqual([ + 'Latest catalog name', + 'Feature' + ]) +}) + +afterEach(() => vi.unstubAllGlobals()) + +import { mergeCreatedFolderWorkspace } from './folder-workspace-catalog' + +it('merges a created row with fleet catalogs in one owner and row pass', () => { + const group = createProjectGroup({ + name: 'Services', + parentPath: '/services', + createdFrom: 'folder-scan', + tabOrder: 0 + }) + const workspace: FolderWorkspace = { + id: 'new', + projectGroupId: 'group-0', + name: 'Feature', + folderPath: '/services', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + for (const size of [750, 7500]) { + let groupReads = 0 + let rowReads = 0 + const groups = Array.from({ length: size }, (_, i) => ({ + ...group, + get id() { + groupReads += 1 + return `group-${i}` + } + })) + const rows = Array.from({ length: size }, (_, i) => ({ + ...workspace, + projectGroupId: `group-${i}`, + get id() { + rowReads += 1 + return `existing-${i}` + } + })) + const result = mergeCreatedFolderWorkspace(workspace, rows, groups) + expect(result).toHaveLength(size + 1) + expect(result[0]).toBe(workspace) + // Preserving N rows requires N identities; indexing G owners reads each key for lookup and insert. + expect(rowReads).toBeLessThanOrEqual(size + 1) + expect(groupReads).toBeLessThanOrEqual(2 * size) + } +}) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-creation.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-creation.ts new file mode 100644 index 00000000000..624f61a3440 --- /dev/null +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-creation.ts @@ -0,0 +1,73 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { RepoSlice } from '../repos/repo-state' +import type { FolderWorkspaceCreateResult } from '../../../../shared/folder-workspaces/folder-workspace-create' +import { assertWorkspaceAttachmentWriteCapability } from '../../runtime/workspace-links/runtime-workspace-attachment-capabilities' +import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/worktree-runtime-capabilities' +import { assertRuntimeEnvironmentCapability } from '../../runtime/rpc/compatibility/runtime-capability-proof' +import { callRuntimeRpc } from '../../runtime/rpc/calls/runtime-rpc-call' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/rpc/targets/runtime-client-target' +import { formatFolderWorkspaceCreateError } from '../../lib/workspace/paths/folder-workspace-path-status' +import { adoptFromEndpoint } from '../host-catalog/endpoint-catalog-adoption' +import { holdCreatedSidebarWorkspace } from '@/components/sidebar/sidebar-feed/sidebar-pending-layer' +import { getFolderWorkspaceHostId } from '../../../../shared/folder-workspaces/folder-workspace-host-id' +import { mergeCreatedFolderWorkspace } from './folder-workspace-catalog' +import { selectFolderWorkspaces } from '../workspaces/catalog/workspace-record-fields' + +export function createFolderWorkspaceCreateAction( + set: Parameters>[0], + get: Parameters>[1] +): RepoSlice['createFolderWorkspace'] { + return async (args, options) => { + try { + // Why: a new folder has no row yet, so creation goes to the host the caller picked. + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) + await assertWorkspaceAttachmentWriteCapability(target, args) + if ( + target.kind === 'environment' && + (args.linkedTask?.provider === 'jira' || args.linkedTaskSourceContext?.provider === 'jira') + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Jira' + ) + } + const result = + target.kind === 'local' + ? await window.api.folderWorkspaces.create(args) + : await callRuntimeRpc( + target, + 'folderWorkspace.create', + args, + { + timeoutMs: args.agentLaunchMode === 'structured' ? 120_000 : 15_000, + ...(args.agentLaunchMode === 'structured' + ? { signal: options.signal ?? new AbortController().signal } + : {}) + } + ) + const workspace = result.folderWorkspace + const { projectGroups } = get() + const ownedWorkspace = adoptFromEndpoint(target, { + kind: 'folderWorkspace', + row: workspace, + resolveOwnHostId: (row) => getFolderWorkspaceHostId(row, projectGroups) + }) + set((s) => ({ + folderWorkspaces: mergeCreatedFolderWorkspace( + ownedWorkspace, + selectFolderWorkspaces(s), + s.projectGroups + ), + folderWorkspacePathStatuses: {} + })) + holdCreatedSidebarWorkspace({ folderWorkspace: ownedWorkspace }) + return { ...result, folderWorkspace: ownedWorkspace } + } catch (err) { + console.error('Failed to create folder workspace:', err) + const { title, description } = formatFolderWorkspaceCreateError(err) + throw new Error(`${title}. ${description}`) + } + } +} diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index 25e87aff07d..894d86ea101 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -1,4 +1,6 @@ +import { createFolderWorkspaceCreateAction } from './folder-workspace-creation' import { launchDraftSource } from '@/components/native-chat/drafts/native-chat-launch-draft-source' +import * as chatDrafts from '../slices/worktrees/teardown/removed-worktree-chat-drafts' import { assertWorkspaceAttachmentWriteCapability } from '../../runtime/workspace-links/runtime-workspace-attachment-capabilities' import { getWorkspaceAttachments } from '../../../../shared/workspace-attachments/workspace-attachments' import type { StateCreator } from 'zustand' @@ -7,20 +9,14 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folde import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/worktree-runtime-capabilities' import { assertRuntimeEnvironmentCapability } from '../../runtime/rpc/compatibility/runtime-capability-proof' import { callRuntimeRpc } from '../../runtime/rpc/calls/runtime-rpc-call' -import { - runtimeTargetForOwnerEnvironment, - getRuntimeTargetHostId -} from '../../runtime/rpc/targets/runtime-client-target' +import { getRuntimeTargetHostId } from '../../runtime/rpc/targets/runtime-client-target' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { formatFolderWorkspaceCreateError } from '../../lib/workspace/paths/folder-workspace-path-status' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace } from '@/lib/workspace/ownership/folder-workspace-runtime-owner' import { FolderWorkspaceUpdateCoordinator } from './folder-workspace-update-coordinator' import type { FolderWorkspaceUpdates, RepoSlice } from '../repos/repo-state' -import { adoptFromEndpoint } from '../host-catalog/endpoint-catalog-adoption' -import { holdCreatedSidebarWorkspace } from '@/components/sidebar/sidebar-feed/sidebar-pending-layer' import { folderWorkspaceOwnerTarget, folderWorkspaceUpdateInvalidatesPathStatus, @@ -32,7 +28,6 @@ import { } from './folder-workspace-catalog' import '../../../../shared/folder-workspaces/folder-workspace-execution-host' import { getFolderWorkspaceHostId } from '../../../../shared/folder-workspaces/folder-workspace-host-id' -import * as chatDrafts from '../slices/worktrees/teardown/removed-worktree-chat-drafts' export type FolderWorkspaceUpdateField = keyof FolderWorkspaceUpdates @@ -63,51 +58,7 @@ export function createFolderWorkspaceMutationActions( get: Parameters>[1] ): Pick { return { - createFolderWorkspace: async (args, options) => { - try { - // Why: a new folder has no row yet, so creation goes to the host the caller picked. - const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) - await assertWorkspaceAttachmentWriteCapability(target, args) - if ( - target.kind === 'environment' && - (args.linkedTask?.provider === 'jira' || - args.linkedTaskSourceContext?.provider === 'jira') - ) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' - ) - } - const workspace = - target.kind === 'local' - ? await window.api.folderWorkspaces.create(args) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>( - target, - 'folderWorkspace.create', - args, - { timeoutMs: 15_000 } - ) - ).folderWorkspace - const { projectGroups } = get() - const ownedWorkspace = adoptFromEndpoint(target, { - kind: 'folderWorkspace', - row: workspace, - resolveOwnHostId: (row) => getFolderWorkspaceHostId(row, projectGroups) - }) - set((s) => ({ - folderWorkspaces: [ownedWorkspace, ...s.folderWorkspaces], - folderWorkspacePathStatuses: {} - })) - holdCreatedSidebarWorkspace({ folderWorkspace: ownedWorkspace }) - return ownedWorkspace - } catch (err) { - console.error('Failed to create folder workspace:', err) - const { title, description } = formatFolderWorkspaceCreateError(err) - throw new Error(`${title}. ${description}`) - } - }, + createFolderWorkspace: createFolderWorkspaceCreateAction(set, get), updateFolderWorkspace: async (folderWorkspaceId, updates, options) => { const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-owner-routed-mutations.test.ts index f707292c3fe..8d75b56d37b 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-owner-routed-mutations.test.ts @@ -64,6 +64,16 @@ beforeEach(() => { delete: folderWorkspacesDelete, list: folderWorkspacesList }, + projectGroups: { + checkHooks: vi.fn(async () => ({ + status: 'ok', + hasHooks: false, + hooks: null, + mayNeedUpdate: false, + setupRunPolicy: 'ask', + setupConfig: null + })) + }, runtimeEnvironments: { call: runtimeEnvironmentTransportCall } } }) diff --git a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts index b48e999df9d..e307aa6e972 100644 --- a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts +++ b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts @@ -1,4 +1,9 @@ import type { StateCreator } from 'zustand' +import { + projectGroupHookTrustHost, + projectGroupHookTrustKey +} from '../../../../shared/workspace-hook-owner' +import { catalogOwnsHost } from './project-group-owner-routing' import type { AppState } from '../types' import { runtimeTargetForOwnerEnvironment } from '../../runtime/rpc/targets/runtime-client-target' import type { FetchedProjectGroupCatalog } from './project-group-catalog' @@ -28,11 +33,23 @@ export function createProjectGroupCatalogActions( return s } const { projectGroups } = mergeFetchedProjectGroupCatalog(catalog, s.projectGroups) - if (arrayElementsUnchanged(projectGroups, s.projectGroups)) { + const validHookOwners = new Set( + projectGroups.filter((group) => group.parentPath).map(projectGroupHookTrustKey) + ) + const retiredKeys = Object.keys(s.trustedOrcaHooks).filter((key) => { + const host = projectGroupHookTrustHost(key) + return host && catalogOwnsHost(catalog.hostId, host) && !validHookOwners.has(key) + }) + if (arrayElementsUnchanged(projectGroups, s.projectGroups) && retiredKeys.length === 0) { return s } + const trustedOrcaHooks = { ...s.trustedOrcaHooks } + for (const key of retiredKeys) { + delete trustedOrcaHooks[key] + } return { projectGroups, + trustedOrcaHooks, folderWorkspacePathStatuses: {} } }) diff --git a/src/renderer/src/store/project-groups/project-group-hook-trust.test.ts b/src/renderer/src/store/project-groups/project-group-hook-trust.test.ts new file mode 100644 index 00000000000..f8893b0bf0a --- /dev/null +++ b/src/renderer/src/store/project-groups/project-group-hook-trust.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../testing/app-store-test-fixture' +afterEach(() => vi.unstubAllGlobals()) +import { createProjectGroup } from '../../../../shared/project-groups' +import { projectGroupHookTrustKey } from '../../../../shared/workspace-hook-owner' +import { hydrateTrustedOrcaHooks } from '../slices/ui/ui-slice-hydration-sanitizers' + +const list = vi.fn() +beforeEach(() => { + vi.stubGlobal('window', { api: { projectGroups: { list } } }) +}) + +describe('group hook trust owner lifetime', () => { + it('survives hydration until its own host returns an authoritative group catalog', async () => { + const local = { + ...createProjectGroup({ + name: 'Local', + parentPath: '/local', + createdFrom: 'folder-scan', + tabOrder: 0 + }), + id: 'same', + executionHostId: 'local' as const + } + const remote = { ...local, executionHostId: 'runtime:remote' as const } + const localKey = projectGroupHookTrustKey(local) + const remoteKey = projectGroupHookTrustKey(remote) + const entry = { setup: { contentHash: 'approved', approvedAt: 1 } } + const trusted = hydrateTrustedOrcaHooks( + { [localKey]: entry, [remoteKey]: entry }, + new Set(['repo']) + ) + const store = createTestStore() + store.setState({ projectGroups: [local, remote], trustedOrcaHooks: trusted }) + list.mockResolvedValue([local]) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + expect(store.getState().trustedOrcaHooks).toEqual({ [localKey]: entry, [remoteKey]: entry }) + list.mockResolvedValue([]) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: null }) + expect(store.getState().trustedOrcaHooks).toEqual({ [remoteKey]: entry }) + }) +}) diff --git a/src/renderer/src/store/project-groups/project-group-removal-state.ts b/src/renderer/src/store/project-groups/project-group-removal-state.ts index 21967f9eb61..425a501428a 100644 --- a/src/renderer/src/store/project-groups/project-group-removal-state.ts +++ b/src/renderer/src/store/project-groups/project-group-removal-state.ts @@ -1,3 +1,5 @@ +import { projectGroupHookTrustKey } from '../../../../shared/workspace-hook-owner' +import type { AppState } from '../types' import type { ProjectGroup } from '../../../../shared/project-group-types' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' @@ -8,10 +10,12 @@ import { getFolderWorkspaceHostId } from '../../../../shared/folder-workspaces/f // Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away. export function applyProjectGroupDeleteCascade( - state: Pick, + state: Pick & + Partial>, groupId: string, ownerHostId: ExecutionHostId | null -): Pick { +): Pick & + Partial> { const ownsRowHost = (rowHostId: string): boolean => ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true const ownerGroups = state.projectGroups.filter((group) => @@ -20,7 +24,17 @@ export function applyProjectGroupDeleteCascade( const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId) const isDeletedGroup = (group: ProjectGroup): boolean => deletedGroupIds.has(group.id) && ownsRowHost(getRepoExecutionHostId(group)) + const removedTrustKeys = new Set( + state.projectGroups.filter(isDeletedGroup).map(projectGroupHookTrustKey) + ) return { + ...(state.trustedOrcaHooks + ? { + trustedOrcaHooks: Object.fromEntries( + Object.entries(state.trustedOrcaHooks).filter(([key]) => !removedTrustKeys.has(key)) + ) + } + : {}), projectGroups: state.projectGroups.filter((group) => !isDeletedGroup(group)), folderWorkspaces: state.folderWorkspaces.filter( (workspace) => diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts index 880332b41dc..4e869e5bb02 100644 --- a/src/renderer/src/store/repos/repo-catalog-merge.ts +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -1,3 +1,4 @@ +import { isProjectGroupHookTrustKey } from '../../../../shared/workspace-hook-owner' import { setPendingLaunchReadoptions } from '@/components/native-chat/drafts/native-chat-launch-draft-source' import { continueReadoptedLaunchDrafts } from '@/components/native-chat/drafts/native-chat-launch-draft-continuity' import type { AppState } from '../types' @@ -174,7 +175,7 @@ export function filterTrustedOrcaHooksToValidRepos( ): AppState['trustedOrcaHooks'] { const next: AppState['trustedOrcaHooks'] = {} for (const [repoId, entry] of Object.entries(trust)) { - if (validRepoIds.has(repoId)) { + if (validRepoIds.has(repoId) || isProjectGroupHookTrustKey(repoId)) { next[repoId] = entry } } diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index b09e7489bd5..cb23dc2eaec 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -1,3 +1,7 @@ +import type { + FolderWorkspaceCreateInput, + FolderWorkspaceCreateResult +} from '../../../../shared/folder-workspaces/folder-workspace-create' import type { WslHostWindowsDriveRefusal } from '../../../../shared/wsl-host-windows-drive-refusal' import type { WorkspaceAttachmentMutation } from '../../../../shared/workspace-attachments/workspace-attachment-mutation' import type { GhAccountBinding } from '../../../../shared/github/accounts/account-binding' @@ -235,19 +239,9 @@ export type RepoSlice = { /** `hostId` is the host of the project that seeds the group. */ createProjectGroup: (name: string, hostId: ExecutionHostId) => Promise createFolderWorkspace: ( - args: { - projectGroupId: string - name?: string - folderPath?: string | null - connectionId?: string | null - linkedTask?: FolderWorkspace['linkedTask'] - linkedItems?: FolderWorkspace['linkedItems'] - linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] - createdWithAgent?: FolderWorkspace['createdWithAgent'] - pendingFirstAgentMessageRename?: boolean - }, - options: FolderWorkspacePathStatusRouteOptions - ) => Promise + args: FolderWorkspaceCreateInput, + options: FolderWorkspacePathStatusRouteOptions & { signal?: AbortSignal } + ) => Promise getFolderWorkspacePathStatusCacheKey: ( request: FolderWorkspacePathStatusRequest, options: FolderWorkspacePathStatusRouteOptions diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index d1f2f6e5a66..370d04fafb2 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -1,3 +1,4 @@ +import { isProjectGroupHookTrustKey } from '../../../../../shared/workspace-hook-owner' import { normalizeExplorerDisplayRootByWorktree } from '../../../../../shared/file-listing/file-explorer-display-root' import type { AppState } from '../../types' import type { PersistedTrustedOrcaHooks } from '../../../../../shared/orca-yaml-hook-types' @@ -74,7 +75,7 @@ export function hydrateTrustedOrcaHooks( } const next: PersistedTrustedOrcaHooks = {} for (const [repoId, entry] of Object.entries(sanitized)) { - if (validRepoIds.has(repoId)) { + if (validRepoIds.has(repoId) || isProjectGroupHookTrustKey(repoId)) { next[repoId] = entry } } diff --git a/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts b/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts index 2fc6b5c312f..783b41d45b8 100644 --- a/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts +++ b/src/renderer/src/store/slices/worktrees/create/created-worktree-setup-trust.ts @@ -7,7 +7,10 @@ import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibilit import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' -import { WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY } from '../../../../../../shared/worktree-runtime-capabilities' +import { + WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY, + PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY +} from '../../../../../../shared/worktree-runtime-capabilities' import type { CreateWorktreeResult, RunApprovedWorktreeSetupArgs, @@ -115,7 +118,9 @@ async function runApprovedWorktreeSetup( if (target.kind === 'environment') { const supported = await runtimeEnvironmentSupportsCapability( target.environmentId, - WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY + args.worktreeId.startsWith('folder:') + ? PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY + : WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY ).catch(() => false) return supported ? callRuntimeRpc(target, 'worktree.runApprovedSetup', { diff --git a/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts index 1c8a0db95b8..871136694dd 100644 --- a/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts @@ -153,6 +153,7 @@ export function createTestStore() { ] ]), repos: [], + projectGroups: [], projectHostSetups: [], deleteProjectHostSetup: vi.fn().mockResolvedValue(null), updateFolderWorkspace: vi.fn().mockResolvedValue(true), diff --git a/src/shared/folder-workspaces/folder-workspace-create.ts b/src/shared/folder-workspaces/folder-workspace-create.ts new file mode 100644 index 00000000000..19a9d061045 --- /dev/null +++ b/src/shared/folder-workspaces/folder-workspace-create.ts @@ -0,0 +1,36 @@ +import type { FolderWorkspace } from './folder-workspace-types' +import type { CreateWorktreeArgs, CreateWorktreeResult } from '../worktree/create-types' +import type { WorktreeStartupLaunch } from '../worktree/creation/launch-types' +import type { TuiAgent } from '../agent-launch/catalog/tui-agent' +import type { AgentLaunchPreferences } from '../agent-session/agent-session-host-authority' + +export type FolderWorkspaceCreateInput = { + projectGroupId: string + name?: string + folderPath?: string | null + connectionId?: string | null + linkedTask?: FolderWorkspace['linkedTask'] + linkedItems?: FolderWorkspace['linkedItems'] + linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] + createdWithAgent?: TuiAgent + pendingFirstAgentMessageRename?: boolean + agentLaunchMode?: 'structured' + startupCommand?: string + startupEnv?: Record + startupLaunchConfig?: WorktreeStartupLaunch['launchConfig'] + startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + startupAgent?: TuiAgent + startupPrompt?: string + startupDraft?: string + startupAgentArgs?: string | null + startupSessionOptions?: Record + startupLaunchPreferences?: AgentLaunchPreferences + launchSource?: string +} & Pick + +export type FolderWorkspaceCreateResult = { + folderWorkspace: FolderWorkspace +} & Pick< + CreateWorktreeResult, + 'setup' | 'setupReceipt' | 'startupTerminal' | 'warning' | 'setupTrustMismatch' +> diff --git a/src/shared/project-group-hooks.ts b/src/shared/project-group-hooks.ts new file mode 100644 index 00000000000..069b58ab831 --- /dev/null +++ b/src/shared/project-group-hooks.ts @@ -0,0 +1,12 @@ +import type { OrcaHooks, RepoHookSettings, SetupRunPolicy } from './orca-yaml-hook-types' +import type { SetupConfig } from './orca-hook-setup-config' +export type GroupHookCheckResult = { + status?: 'ok' | 'error' + hasHooks: boolean + hooks: OrcaHooks | null + mayNeedUpdate: boolean + unavailable?: 'wsl-distro-stopped' | 'wsl-distro-unverifiable' + hookSettings?: RepoHookSettings + setupRunPolicy: SetupRunPolicy + setupConfig: SetupConfig | null +} diff --git a/src/shared/project-group-setup-example.ts b/src/shared/project-group-setup-example.ts new file mode 100644 index 00000000000..91c9765b4fe --- /dev/null +++ b/src/shared/project-group-setup-example.ts @@ -0,0 +1,20 @@ +import type { Repo } from './repo-types' +import { quoteStartupArg } from './agent-launch/startup/tui-agent-startup-shell' + +export function projectGroupSetupExample( + repos: readonly Pick[], + platform: NodeJS.Platform +): string { + const windows = platform === 'win32' + const comment = windows ? 'REM' : '#' + const shell = windows ? 'cmd' : 'posix' + const command = windows ? 'orca' : '"${ORCA_CLI_COMMAND:-orca}"' + const workspaceName = windows ? '"%ORCA_WORKSPACE_NAME%"' : '"$ORCA_WORKSPACE_NAME"' + return [ + `${comment} Repo IDs are local to this machine; review them before sharing this script.`, + ...repos.flatMap((repo) => [ + `${comment} ${repo.displayName.replace(windows ? /[^\p{L}\p{N} ._/-]/gu : /[\r\n]/g, ' ')}`, + `${command} worktree create --repo ${quoteStartupArg(`id:${repo.id}`, shell)} --name ${workspaceName} --json` + ]) + ].join('\n') +} diff --git a/src/shared/project-group-types.ts b/src/shared/project-group-types.ts index 7bf9c19d8a7..6fd23db0401 100644 --- a/src/shared/project-group-types.ts +++ b/src/shared/project-group-types.ts @@ -1,9 +1,11 @@ +import type { RepoHookSettings } from './orca-yaml-hook-types' export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration' export type ProjectGroup = { id: string name: string parentPath: string | null + hookSettings?: RepoHookSettings /** SSH target ID for folder-backed groups imported from a remote root. */ connectionId?: string | null /** Renderer-owned host stamp for groups fetched from a runtime environment. */ @@ -19,7 +21,7 @@ export type ProjectGroup = { } export type ProjectGroupUpdate = Partial< - Pick + Pick > & { rootProjectOrder?: number | null } export type ProjectGroupMutation = { groupId: string; updates: ProjectGroupUpdate } diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 0b44cc6c96b..770a3d9fb7e 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -1,3 +1,4 @@ +import { getDefaultRepoHookSettings } from './constants' import { normalizeExecutionHostId } from './execution-host' import type { ProjectGroup, ProjectGroupCreatedFrom } from './project-group-types' import type { Repo } from './repo-types' @@ -55,6 +56,15 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] { groups.push({ id: raw.id, name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''), + ...(raw.hookSettings + ? { + hookSettings: { + ...getDefaultRepoHookSettings(), + ...raw.hookSettings, + scripts: { ...getDefaultRepoHookSettings().scripts, ...raw.hookSettings.scripts } + } + } + : {}), parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null, connectionId: typeof raw.connectionId === 'string' diff --git a/src/shared/rpc-contract/projects/repo-params.ts b/src/shared/rpc-contract/projects/repo-params.ts index 5d274b3b8c1..4aec702f359 100644 --- a/src/shared/rpc-contract/projects/repo-params.ts +++ b/src/shared/rpc-contract/projects/repo-params.ts @@ -1,3 +1,4 @@ +import { WorkspaceHookSettingsSchema } from '../../workspace-hook-settings-schema' import { z } from 'zod' // Opt-in: a caller holding afterSnapshotId's repos gets `unchanged`; older hosts take no params and ignore it. @@ -85,6 +86,7 @@ export const ProjectGroupCreate = z.object({ export const ProjectGroupUpdate = z.object({ groupId: requiredString('Missing group id'), updates: z.object({ + hookSettings: WorkspaceHookSettingsSchema.optional(), name: OptionalString, isCollapsed: z.boolean().optional(), tabOrder: OptionalFiniteNumber, @@ -97,6 +99,10 @@ export const ProjectGroupSelector = z.object({ groupId: requiredString('Missing group id') }) +export const ProjectGroupHooksCheck = ProjectGroupSelector.extend({ + background: z.boolean().optional() +}) + export const ProjectGroupUpdateMany = z.object({ mutations: z.array(ProjectGroupUpdate) }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 0ec49aa2ac4..e9dd57239ae 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -532,6 +532,7 @@ import { } from './projects/project-runtime-params' import { ProjectGroupCreate, + ProjectGroupHooksCheck, ProjectGroupImportNested, ProjectGroupMoveProject, ProjectGroupScanNested, @@ -1481,6 +1482,7 @@ export const RPC_PARAMS_BY_METHOD = { 'preflight.refreshAgents': PreflightAgentDetection, 'project.list': null, 'project.update': ProjectUpdate, + 'projectGroup.checkHooks': ProjectGroupHooksCheck, 'projectGroup.create': ProjectGroupCreate, 'projectGroup.delete': ProjectGroupSelector, 'projectGroup.importNested': ProjectGroupImportNested, diff --git a/src/shared/rpc-contract/workspaces/folder-workspace-params.ts b/src/shared/rpc-contract/workspaces/folder-workspace-params.ts index b576918a630..10f306870be 100644 --- a/src/shared/rpc-contract/workspaces/folder-workspace-params.ts +++ b/src/shared/rpc-contract/workspaces/folder-workspace-params.ts @@ -6,6 +6,37 @@ import { isWorkspaceLinkedItemSourceContextMatch } from '../../workspace-attachm import { isTuiAgent } from '../../agent-launch/catalog/tui-agent-config' import { OptionalFiniteNumber, OptionalString, requiredString } from '../rpc-param-primitives' import { DiffCommentSchema } from '../../diff-comment-schema' +import { sleepingAgentLaunchConfigSchema } from '../../workspace-session/workspace-session-sleeping-agents' +import { AgentArgs, LaunchPreferences } from '../terminal/agent-session-params' +import { LaunchSourceParam } from '../agent-launch/launch-source-param' + +export const FolderWorkspaceHookCreateShape = { + runHooks: z.boolean().optional(), + setupDecision: z.enum(['run', 'skip', 'inherit']).optional(), + approvedSetupHash: z.string().max(128).optional(), + approvedSetupHashes: z.array(z.string().max(128)).max(64).optional(), + agentLaunchMode: z.literal('structured').optional(), + startupCommand: OptionalString, + startupEnv: z.record(z.string(), z.string()).optional(), + startupLaunchConfig: sleepingAgentLaunchConfigSchema, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + startupAgent: z.string().refine(isTuiAgent).optional(), + startupPrompt: OptionalString, + startupDraft: OptionalString, + startupAgentArgs: AgentArgs.optional(), + startupSessionOptions: z.record(z.string(), z.string()).optional(), + startupLaunchPreferences: LaunchPreferences.optional(), + launchSource: LaunchSourceParam.optional() +} + +export function assertFolderWorkspaceStartup( + value: { startupAgent?: string; startupPrompt?: string }, + ctx: z.RefinementCtx +): void { + if (value.startupPrompt !== undefined && value.startupAgent === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'startupPrompt requires startupAgent' }) + } +} export const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() @@ -30,6 +61,7 @@ export function assertLinkedTaskSourceContextMatch( export const FolderWorkspaceCreate = z .object({ + ...FolderWorkspaceHookCreateShape, projectGroupId: requiredString('Missing project group id'), name: OptionalString, folderPath: OptionalString.nullable().optional(), @@ -42,6 +74,7 @@ export const FolderWorkspaceCreate = z pendingFirstAgentMessageRename: z.boolean().optional() }) .superRefine(assertLinkedTaskSourceContextMatch) + .superRefine(assertFolderWorkspaceStartup) // Hosts advertising the unique-id mutation capability refuse a shared folder id when this is set. const RequireUniqueId = z.boolean().optional() diff --git a/src/shared/workspace-hook-owner.ts b/src/shared/workspace-hook-owner.ts new file mode 100644 index 00000000000..72c9444884a --- /dev/null +++ b/src/shared/workspace-hook-owner.ts @@ -0,0 +1,50 @@ +import type { ProjectGroup } from './project-group-types' +import type { Repo } from './repo-types' +import { getRepoExecutionHostId, normalizeExecutionHostId } from './execution-host' + +export type WorkspaceHookOwner = Pick< + Repo, + 'id' | 'path' | 'displayName' | 'kind' | 'hookSettings' | 'connectionId' | 'executionHostId' +> + +export function projectGroupHookOwner(group: ProjectGroup): WorkspaceHookOwner | null { + return group.parentPath + ? { + id: projectGroupHookTrustKey(group), + path: group.parentPath, + displayName: group.name, + kind: 'folder', + hookSettings: group.hookSettings, + connectionId: group.connectionId, + executionHostId: getRepoExecutionHostId(group) + } + : null +} + +const GROUP_HOOK_PREFIX = 'group-hooks:' + +export function projectGroupHookTrustKey(group: ProjectGroup): string { + return `${GROUP_HOOK_PREFIX}${JSON.stringify([getRepoExecutionHostId(group), group.id])}` +} + +export function isProjectGroupHookTrustKey(key: string): boolean { + return key.startsWith(GROUP_HOOK_PREFIX) +} + +export function projectGroupHookTrustHost( + key: string +): ReturnType { + if (!isProjectGroupHookTrustKey(key)) { + return null + } + try { + const identity: unknown = JSON.parse(key.slice(GROUP_HOOK_PREFIX.length)) + return Array.isArray(identity) && + typeof identity[0] === 'string' && + typeof identity[1] === 'string' + ? normalizeExecutionHostId(identity[0]) + : null + } catch { + return null + } +} diff --git a/src/shared/workspace-hook-settings-schema.ts b/src/shared/workspace-hook-settings-schema.ts new file mode 100644 index 00000000000..f7c35674701 --- /dev/null +++ b/src/shared/workspace-hook-settings-schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const WorkspaceHookSettingsSchema = z.object({ + mode: z.enum(['auto', 'override']), + setupRunPolicy: z.enum(['ask', 'run-by-default', 'skip-by-default']).optional(), + setupAgentStartupPolicy: z.enum(['start-immediately', 'wait-for-setup']).optional(), + commandSourcePolicy: z.enum(['shared-only', 'local-only', 'run-both']).optional(), + scripts: z.object({ setup: z.string(), archive: z.string() }) +}) diff --git a/src/shared/worktree-runtime-capabilities.ts b/src/shared/worktree-runtime-capabilities.ts index e1e7bacdbab..682275dc17b 100644 --- a/src/shared/worktree-runtime-capabilities.ts +++ b/src/shared/worktree-runtime-capabilities.ts @@ -105,6 +105,9 @@ export const WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY = // Hosts that run a folder project's setup and archive scripts when its workspaces are created and removed. export const WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY = 'worktree.folder-setup.v1' as const +// Hosts that support setup settings, trust and creation for project-group folder workspaces. +export const PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY = 'projectGroup.folder-setup.v1' as const + // Hosts that honor branchIntent 'create-exact': the unsuffixed new branch, or a coded refusal. export const WORKTREE_CREATE_EXACT_NAME_RUNTIME_CAPABILITY = 'worktree.create.exact-name.v1' as const @@ -139,6 +142,7 @@ export const WORKTREE_RUNTIME_CAPABILITIES = [ WORKSPACE_SELECTOR_FOLDER_MATCH_RUNTIME_CAPABILITY, WORKTREE_RUN_APPROVED_SETUP_RUNTIME_CAPABILITY, WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY, + PROJECT_GROUP_FOLDER_SETUP_RUNTIME_CAPABILITY, WORKTREE_CREATE_EXACT_NAME_RUNTIME_CAPABILITY, ...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? [] : [WORKTREE_ROWS_CHANGED_EVENT_RUNTIME_CAPABILITY]) ] as const diff --git a/src/shared/worktree/creation/launch-types.ts b/src/shared/worktree/creation/launch-types.ts index 48eb349d631..c3bdb8a4c62 100644 --- a/src/shared/worktree/creation/launch-types.ts +++ b/src/shared/worktree/creation/launch-types.ts @@ -6,6 +6,7 @@ import type { OrcaDefaultTabTemplate } from '../../orca-yaml-hook-types' import type { TuiAgent } from '../../agent-launch/catalog/tui-agent' export type WorktreeSetupLaunch = { + cwd?: string runnerScriptPath: string envVars: Record shell?: SetupRunnerShell diff --git a/tests/e2e/folder-workspace-palette-wake.spec.ts b/tests/e2e/folder-workspace-palette-wake.spec.ts index 289ca4572fa..1b17577e902 100644 --- a/tests/e2e/folder-workspace-palette-wake.spec.ts +++ b/tests/e2e/folder-workspace-palette-wake.spec.ts @@ -47,7 +47,7 @@ for (const selection of ['workspace', 'tab'] as const) { if (!target || !other) { throw new Error('Could not create folder workspaces') } - return { target: target.id, other: other.id } + return { target: target.folderWorkspace.id, other: other.folderWorkspace.id } }, folderPath) const openPalette = async (): Promise => { // Native menu accelerators need desktop focus; drive the same palette through CDP. diff --git a/tests/e2e/paired-cli-agent-launch.unit.test.ts b/tests/e2e/paired-cli-agent-launch.unit.test.ts index 1dd3a1433df..4691bed3355 100644 --- a/tests/e2e/paired-cli-agent-launch.unit.test.ts +++ b/tests/e2e/paired-cli-agent-launch.unit.test.ts @@ -81,6 +81,11 @@ describe.each([ if (target.kind === 'create-worktree') { expect(boundary.createManagedWorktree).toHaveBeenCalledTimes(1) expect(boundary.createTerminal).not.toHaveBeenCalled() + } else if (target.kind === 'create-folder-workspace') { + expect(boundary.createTerminal).not.toHaveBeenCalled() + expect(boundary.createFolderWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ startupAgent: 'claude', startupPrompt: 'Reply with OK' }) + ) } else { expect(boundary.createTerminal).toHaveBeenCalledTimes(1) expect(boundary.createTerminal).toHaveBeenCalledWith( diff --git a/tests/e2e/paired-skill-installation.spec.ts b/tests/e2e/paired-skill-installation.spec.ts index 0268dbb84a3..7682549a02c 100644 --- a/tests/e2e/paired-skill-installation.spec.ts +++ b/tests/e2e/paired-skill-installation.spec.ts @@ -164,7 +164,7 @@ async function createHostFolderWorkspace(page: Page, folderPath: string): Promis name: 'Paired skill folder E2E', folderPath: path }) - return workspace.id + return workspace.folderWorkspace.id }, folderPath) } diff --git a/tests/e2e/pdf-find-counter.spec.ts b/tests/e2e/pdf-find-counter.spec.ts index d20358c7a87..560510959e9 100644 --- a/tests/e2e/pdf-find-counter.spec.ts +++ b/tests/e2e/pdf-find-counter.spec.ts @@ -141,7 +141,7 @@ test.describe('PDF in a folder workspace', () => { if (!folder) { throw new Error('Missing fixture folder') } - const worktreeId = `folder:${folder.id}` + const worktreeId = `folder:${folder.folderWorkspace.id}` state.setActiveWorktree(worktreeId) state.openFile({ filePath, diff --git a/tests/e2e/resource-manager-end-tabless-terminals.spec.ts b/tests/e2e/resource-manager-end-tabless-terminals.spec.ts index 06a19343937..f29599e6b7d 100644 --- a/tests/e2e/resource-manager-end-tabless-terminals.spec.ts +++ b/tests/e2e/resource-manager-end-tabless-terminals.spec.ts @@ -35,7 +35,7 @@ test('Resource Manager ends a tab-less terminal this run spawned', async ({ cols: 80, rows: 24, cwd: folderPath, - worktreeId: `folder:${folder.id}`, + worktreeId: `folder:${folder.folderWorkspace.id}`, initiallyHidden: true }) return spawned.id diff --git a/tests/e2e/resource-manager-folder-labels.spec.ts b/tests/e2e/resource-manager-folder-labels.spec.ts index 802195a6929..2288c3db46c 100644 --- a/tests/e2e/resource-manager-folder-labels.spec.ts +++ b/tests/e2e/resource-manager-folder-labels.spec.ts @@ -43,7 +43,7 @@ test('names a folder terminal recovered from the daemon after restart without an cols: 80, rows: 24, cwd: folderPath, - worktreeId: `folder:${folder.id}`, + worktreeId: `folder:${folder.folderWorkspace.id}`, initiallyHidden: true }) }, root) diff --git a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts index e297655177e..8a41a644f3f 100644 --- a/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts +++ b/tests/e2e/sidebar-folder-keyboard-navigation.spec.ts @@ -37,13 +37,15 @@ test('cycles visible folder workspaces with shortcuts and focused list arrows', if (!folder || !repo || !first || !last) { throw new Error('Expected two git worktrees and one folder workspace') } - await store.getState().updateFolderWorkspace(folder.id, { workspaceStatus: 'in-progress' }) + await store + .getState() + .updateFolderWorkspace(folder.folderWorkspace.id, { workspaceStatus: 'in-progress' }) // Through the metadata writes, so main's copy of the catalog agrees with the window's. await store.getState().updateWorktreeMeta(first.id, { workspaceStatus: 'todo' }) await store.getState().updateWorktreeMeta(last.id, { workspaceStatus: 'completed' }) store.getState().setGroupBy('workspace-status') store.setState({ collapsedGroups: new Set() }) - return [first.id, `folder:${folder.id}`, last.id] + return [first.id, `folder:${folder.folderWorkspace.id}`, last.id] }, folderPath) expect( diff --git a/tests/e2e/sidebar-sort-stamps-startup.spec.ts b/tests/e2e/sidebar-sort-stamps-startup.spec.ts index ce92faf7c32..c6ae16b3e66 100644 --- a/tests/e2e/sidebar-sort-stamps-startup.spec.ts +++ b/tests/e2e/sidebar-sort-stamps-startup.spec.ts @@ -79,7 +79,7 @@ test('a restart clears a tier nothing backs, keeps a hook row’s and an SSH hos if (!folder) { throw new Error('Expected a folder workspace') } - return folder.id + return folder.folderWorkspace.id }, folderPath) folderToRemove = folderPath const goneId = `folder:${goneFolderId}` diff --git a/tests/e2e/sidebar-workspace-rows.spec.ts b/tests/e2e/sidebar-workspace-rows.spec.ts index abcf89c4a87..6ca1e746fcc 100644 --- a/tests/e2e/sidebar-workspace-rows.spec.ts +++ b/tests/e2e/sidebar-workspace-rows.spec.ts @@ -59,7 +59,7 @@ test('main lists the window’s git and folder workspaces and follows its metada if (!folder || !worktree) { throw new Error('Expected a seeded worktree and a folder workspace') } - return { worktreeId: worktree.id, folderKey: `local|folder:${folder.id}` } + return { worktreeId: worktree.id, folderKey: `local|folder:${folder.folderWorkspace.id}` } }, folderPath) const rowKey = `local|${worktreeId}` diff --git a/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts b/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts index e197f695854..4896a6d1491 100644 --- a/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts +++ b/tests/e2e/worktree-jump-palette-folder-workspaces.spec.ts @@ -39,9 +39,11 @@ for (const theme of ['light', 'dark'] as const) { if (!folder) { throw new Error('Could not create folder workspace') } - await store.getState().updateFolderWorkspace(folder.id, { comment: 'Research synthesis' }) + await store + .getState() + .updateFolderWorkspace(folder.folderWorkspace.id, { comment: 'Research synthesis' }) store.setState({ showSleepingWorkspaces: false }) - return `folder:${folder.id}` + return `folder:${folder.folderWorkspace.id}` }, { folderPath, theme } ) diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts index 8c39e4265c6..28865af33b2 100644 --- a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -235,7 +235,7 @@ test.describe('Toggle Child Workspaces shortcut', () => { if (!folder) { throw new Error('Missing created folder workspace') } - return `folder:${folder.id}` + return `folder:${folder.folderWorkspace.id}` }) const folder = sidebarWorktreeRow(orcaPage, folderKey) await expect(folder).toBeVisible() diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 700e77ca8a4..d8765828b7d 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -283,7 +283,7 @@ test.describe('Reveal active workspace button', () => { if (!folder) { throw new Error(`Folder workspace ${index} was not created`) } - ids.push(folder.id) + ids.push(folder.folderWorkspace.id) } const targetKey = `folder:${ids[0]}` as const store.setState({ diff --git a/tests/e2e/wsl-move-fence.spec.ts b/tests/e2e/wsl-move-fence.spec.ts index 1780bfaf40f..985dd9da7bc 100644 --- a/tests/e2e/wsl-move-fence.spec.ts +++ b/tests/e2e/wsl-move-fence.spec.ts @@ -45,7 +45,7 @@ async function createFolders(page: Page, folders: { name: string; folderPath: st if (!workspace) { throw new Error(`Could not create folder workspace ${entry.name}`) } - ids.push(workspace.id) + ids.push(workspace.folderWorkspace.id) } return ids }, folders)