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
This commit is contained in:
Brennan Benson
2026-11-13 15:37:54 -08:00
committed by GitHub
parent 8268704516
commit 03c828eece
132 changed files with 3671 additions and 676 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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"
],
+1 -1
View File
@@ -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
@@ -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<ReturnType<typeof resolveWorkspace>> | 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 } : {}),
@@ -123,10 +123,7 @@ export type AgentLaunchWorkspaceFactory = {
}>
/** Leave a usable workspace when chat creation has an indeterminate outcome. */
createRecoveryShell?: (worktreeId: string) => Promise<void>
/** Folders have no startup terminal; the executor launches their agent after creation. */
createFolderWorkspace?(args: {
create: Readonly<Record<string, unknown>>
}): 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. */
@@ -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<string, (...args: unknown[]) => 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(() => {
+2 -1
View File
@@ -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)
@@ -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(
+13 -34
View File
@@ -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<OrcaRuntimeService, 'deleteFolderWorkspace'>
runtime: Pick<OrcaRuntimeService, 'deleteFolderWorkspace' | 'createFolderWorkspace'>
): 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<FolderWorkspace> => {
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',
+15 -1
View File
@@ -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<OrcaRuntimeService, 'checkProjectGroupHooks'>
): 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 => {
@@ -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),
@@ -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 }))
})
@@ -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) => {
@@ -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()
}
@@ -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<ResolvedWorktree>
export type CandidateResult = Promise<WorkspaceSelectorCandidate>
export type FinishGroupFolderCreation = NonNullable<
RuntimeProjectGroupDependencies['finishFolderCreation']
>
export type AcquireFileWatcherRemoval = RuntimeProjectGroupDependencies['acquireFileWatcherRemoval']
@@ -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(),
@@ -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 })
}
}
)
@@ -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<Repo>
notifyReposChanged: () => void
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise<void>
acquireFileWatcherRemoval: (
path: string,
connectionId?: string
) => Promise<{ finish(removed: boolean): Promise<void> }>
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise<void>
emitWorktreeLifecycle: (event: RuntimeWorktreeLifecycleEvent) => void
finishFolderCreation?: (
input: RuntimeGroupFolderCreateInput,
group: ProjectGroup,
workspace: FolderWorkspace
) => Promise<FolderWorkspaceCreateResult>
}
export type FolderWorkspaceUpdates = Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'linkedItems'
| 'linkedTaskSourceContext'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
| 'diffComments'
>
> &
WorkspaceAttachmentMutation
@@ -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<Repo>
notifyReposChanged: () => void
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise<void>
acquireFileWatcherRemoval: (
path: string,
connectionId?: string
) => Promise<{ finish(removed: boolean): Promise<void> }>
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void | Promise<void>
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<FolderWorkspace> {
async createFolderWorkspace(
input: RuntimeGroupFolderCreateInput
): Promise<FolderWorkspaceCreateResult> {
// 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(
@@ -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),
@@ -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<Repo>
type RuntimeRepositoryHooksCommandsDeps<TRepo extends WorkspaceHookOwner> = {
resolveRepo: (selector: string) => Promise<TRepo>
readCreateSourceHooks: (
repo: Repo,
repo: TRepo,
source: SetupHooksCreateSource
) => Promise<{ hooks: OrcaHooks | null } | undefined>
}
export class RuntimeRepositoryHooksCommands {
constructor(private readonly deps: RuntimeRepositoryHooksCommandsDeps) {}
export class RuntimeRepositoryHooksCommands<TRepo extends WorkspaceHookOwner = Repo> {
constructor(private readonly deps: RuntimeRepositoryHooksCommandsDeps<TRepo>) {}
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()
@@ -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(
@@ -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
}
}
}
@@ -159,7 +159,7 @@ function withoutHostActivation(params: WorktreeCreateParams): WorktreeCreatePara
}
}
async function waitForStructuredSetup(
export async function waitForStructuredSetup(
runtime: Pick<OrcaRuntimeService, 'waitForSetupTerminalCompletion'>,
receipt: Awaited<ReturnType<OrcaRuntimeService['createManagedWorktree']>>['setupReceipt']
): Promise<void> {
@@ -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<string, unknown>) => ({ id: 'fw-new' })),
createFolderWorkspace: vi.fn(
async (input: Record<string, unknown>): Promise<FolderWorkspaceCreateResult> => {
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<string, unknown>) => {
reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt)
if (args.startupAgent && options.startupTerminalPaneKey) {
@@ -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',
@@ -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',
@@ -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<Pick<Worktree, 'id' | 'repoId' | 'path' | 'displayName'>>
requireStore: () => Pick<RuntimeStore, 'getRepo' | 'getSettings'>
requireStore: () => Pick<RuntimeStore, 'getRepo' | 'getSettings' | 'getProjectGroups'>
provisionManagedWorktreeTerminals: (args: WorktreeTerminalProvisioningArgs) => Promise<unknown>
}
@@ -39,13 +43,21 @@ export async function runApprovedCreatedWorktreeSetup(
approvedSetupHash: string,
options: { wslUserAct?: true } = {}
): Promise<RunApprovedWorktreeSetupResult> {
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(
@@ -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
}
}
}
@@ -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<FolderWorkspaceCreateInput> = {}) {
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<FolderWorkspaceCreateInput>[] = [
{ 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<unknown>
}
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()
})
})
@@ -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<RuntimeStore['getSettings']>
ports: RuntimeLocalWorktreeTerminalPorts
startupBuilders: Parameters<typeof resolveManagedWorktreeCreateStartup>[2]
wait: (handle: string, signal: AbortSignal) => Promise<{ exitCode: number | null }>
}): Promise<FolderWorkspaceCreateResult> {
const { input, workspace, group } = args
const result: FolderWorkspaceCreateResult = { folderWorkspace: workspace }
const owner = projectGroupHookOwner(group)
const skip = resolveRuntimeSetupDecision(input) === 'skip'
let prepared: Awaited<ReturnType<typeof prepareRuntimeLocalWorktreeSetup>> | 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
}
@@ -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<RuntimeStore['getSettings']>
runtimeTarget: { wslDistro?: string } | undefined
@@ -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
}
}
}
@@ -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<RuntimeManagedWorktreeCreateArgs, 'repoSelector' | 'name'>,
disabledTuiAgents: Parameters<typeof isTuiAgentEnabled>[1],
build: StartupBuilders
) {
@@ -63,8 +63,8 @@ export type RuntimeLocalWorktreeTerminalStartupResult = {
}
export async function startRuntimeLocalWorktreeTerminals(args: {
request: RuntimeManagedWorktreeCreateArgs
repo: Repo
request: Omit<RuntimeManagedWorktreeCreateArgs, 'repoSelector' | 'name'>
repo: Pick<Repo, 'id'>
worktree: Worktree
setup?: CreateWorktreeResult['setup']
defaultTabs?: CreateWorktreeResult['defaultTabs']
@@ -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' }
},
@@ -39,7 +39,7 @@ type AttributedWorktreeStartupLaunch = WorktreeStartupLaunch & {
}
type StartupEnvironment = {
repo: Repo
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>
settings: ReturnType<RuntimeStore['getSettings']>
/** Resolve the execution host's quoting/spawn fact, or the repair error that refuses startup. */
getLaunchRuntime: (agent: TuiAgent) => ReturnType<typeof resolveStartupAgentLaunchRuntime>
@@ -257,7 +257,7 @@ export async function buildWorktreeStartupForAgent(
}
export async function resolveWorktreeCreateAgentStartup(
args: RuntimeManagedWorktreeCreateArgs,
args: Omit<RuntimeManagedWorktreeCreateArgs, 'repoSelector' | 'name'>,
build: (
agent: TuiAgent,
prompt: string | undefined,
@@ -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<string, string>
@@ -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,
@@ -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 {
@@ -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<SetupDecision, 'run' | 'skip'> {
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 ?? []
@@ -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
+7 -4
View File
@@ -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 } = {}
@@ -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, 'path' | 'kind'>,
repo: Pick<WorkspaceHookOwner, 'path' | 'kind'>,
worktreePath: string,
identity?: HookWorkspaceIdentity
): Record<string, string> {
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<string, string> {
@@ -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<string> {
}
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'
@@ -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),
@@ -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<GroupHookCheckResult>
list: () => Promise<ProjectGroup[]>
create: (args: {
name: string
+5 -9
View File
@@ -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<FolderWorkspace[]>
getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise<FolderWorkspacePathStatus>
create: (args: {
projectGroupId: string
name?: string
folderPath?: string | null
connectionId?: string | null
linkedTask?: FolderWorkspace['linkedTask']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}) => Promise<FolderWorkspace>
create: (args: FolderWorkspaceCreateInput) => Promise<FolderWorkspaceCreateResult>
update: (args: {
folderWorkspaceId: string
updates: Partial<
@@ -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 (
<SettingsSection
key={id}
id={id}
title={t('settings.groupHooks.title', 'Project Settings > {{name}}', {
name: group.settingsLabel ?? group.name
})}
description={owner.path}
searchEntries={navigation.getSectionSearchEntries(id)}
>
{view.isSectionMounted(id) ? (
<ProjectGroupHooksSection key={owner.id} group={group} />
) : null}
</SettingsSection>
)
})}
{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 (
<SettingsSection
key={repoSectionId}
id={repoSectionId}
title={t('auto.components.settings.Settings.3bf149e873', 'Project Settings > {{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.
<RepositoryPane
key={repoHostIdentity}
repo={repo}
yamlHooks={repoHooksState?.hooks ?? null}
hasHooksFile={repoHooksState?.hasHooks ?? false}
hooksInspectionReady={Boolean(repoHooksState)}
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
updateRepo={model.updateRepo}
removeProject={() => 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}
</SettingsSection>
)
})
return (
<SettingsSection
key={repoSectionId}
id={repoSectionId}
title={t(
'auto.components.settings.Settings.3bf149e873',
'Project Settings > {{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.
<RepositoryPane
key={repoHostIdentity}
repo={repo}
yamlHooks={repoHooksState?.hooks ?? null}
hasHooksFile={repoHooksState?.hasHooks ?? false}
hooksInspectionReady={Boolean(repoHooksState)}
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
updateRepo={model.updateRepo}
removeProject={() => 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}
</SettingsSection>
)
})}
</>
)
}
@@ -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'
])
})
})
@@ -134,12 +134,12 @@ export function useSettingsNavigationActions(
export type SettingsNavigationActions = ReturnType<typeof useSettingsNavigationActions>
export function buildSettingsViewModel(
model: SettingsStoreModel,
navigation: SettingsNavigationModel,
model: Pick<SettingsStoreModel, 'repos' | 'settingsSearchQuery' | 'activeSectionId'>,
navigation: Pick<SettingsNavigationModel, 'visibleNavSections' | 'neededSectionIds'>,
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 {
@@ -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,
@@ -205,6 +205,7 @@ export function RepositoryPane({
<RepositoryHooksSection
key="hooks"
repo={repo}
gitRepo={repo}
yamlHooks={yamlHooks}
hasHooksFile={hasHooksFile}
hooksInspectionReady={hooksInspectionReady}
@@ -0,0 +1,452 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import '../../__fixtures__/settings-persistence-test-fixture'
import { useAppStore } from '@/store'
import { createProjectGroup } from '../../../../../../shared/project-groups'
import { ProjectGroupHooksSection } from './ProjectGroupHooksSection'
import { TooltipProvider } from '@/components/ui/tooltip'
import { checkRuntimeGroupHooks } from '@/runtime/repo-hooks/runtime-hooks-client'
import { toast } from 'sonner'
import type { GroupHookCheckResult } from '../../../../../../shared/project-group-hooks'
vi.mock('sonner', () => ({ 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(
<TooltipProvider>
<ProjectGroupHooksSection group={group} />
</TooltipProvider>
)
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(
<TooltipProvider>
<ProjectGroupHooksSection group={group} />
</TooltipProvider>
)
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<ReturnType<typeof checkRuntimeGroupHooks>>) => 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(
<TooltipProvider>
<ProjectGroupHooksSection group={group} />
</TooltipProvider>
)
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(
<TooltipProvider>
<ProjectGroupHooksSection group={group} />
</TooltipProvider>
)
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(
<TooltipProvider>
<ProjectGroupHooksSection key={group.executionHostId} group={group} />
</TooltipProvider>
)
}
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<null>((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<boolean>((_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<boolean>((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(
<TooltipProvider>
<ProjectGroupHooksSection key={other.executionHostId} group={other} />
</TooltipProvider>
)
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<boolean>((resolve) => {
first = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<boolean>((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)
})
@@ -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<GroupHookCheckResult | null>(null)
const confirmedInspection = useRef<GroupHookCheckResult | null>(null)
const pendingSave = useRef<Promise<void> | null>(null)
const [saving, setSaving] = useState(false)
const fieldset = useRef<HTMLFieldSetElement>(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<ProjectGroup['hookSettings']>): 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<void> = (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 (
<fieldset ref={fieldset} disabled={!inspectionReady || saving} className="min-w-0">
<RepositoryHooksSection
repo={owner}
showArchive={false}
yamlHooks={inspection?.hooks ?? null}
hasHooksFile={inspection?.hasHooks ?? false}
hooksInspectionReady={inspectionReady}
mayNeedUpdate={inspection?.mayNeedUpdate ?? false}
copiedTemplate={false}
onCopyTemplate={() => void window.api.ui.writeClipboardText(GROUP_SETUP_HOOKS_TEMPLATE)}
setupExample={example}
onUpdateHookSettings={updateHookSettings}
/>
</fieldset>
)
}
@@ -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({
</div>
</div>
<RepositoryHooksYamlStatus
setupOnly={setupOnly}
yamlState={yamlState}
yamlHooks={yamlHooks}
copiedTemplate={copiedTemplate}
@@ -7,6 +7,8 @@ import type {
SetupAgentStartupPolicy,
SetupRunPolicy
} from '../../../../../../shared/orca-yaml-hook-types'
import type { WorkspaceHookOwner } from '../../../../../../shared/workspace-hook-owner'
import { Button } from '@/components/ui/button'
import type { Repo } from '../../../../../../shared/repo-types'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import { isFolderRepo } from '../../../../../../shared/repo-kind'
@@ -35,7 +37,10 @@ import { useRepositoryIssueCommand } from './use-repository-issue-command'
import { getRepositoryHookScriptDescription } from './repository-git-hooks-search-entries'
type RepositoryHooksSectionProps = {
repo: Repo
repo: WorkspaceHookOwner
gitRepo?: Repo
showArchive?: boolean
setupExample?: string
yamlHooks: OrcaHooks | null
hasHooksFile: boolean
hooksInspectionReady: boolean
@@ -48,6 +53,9 @@ type RepositoryHooksSectionProps = {
export function RepositoryHooksSection({
repo,
gitRepo,
setupExample,
showArchive = true,
yamlHooks,
hasHooksFile,
hooksInspectionReady,
@@ -85,10 +93,11 @@ export function RepositoryHooksSection({
const sharedSetupScript = yamlHooks?.scripts.setup
const sharedArchiveScript = yamlHooks?.scripts.archive
const hasSharedSetupScript = Boolean(sharedSetupScript?.trim())
const hasSharedArchiveScript = Boolean(sharedArchiveScript?.trim())
const hasSharedArchiveScript = showArchive && Boolean(sharedArchiveScript?.trim())
const hasSharedScript = hasSharedSetupScript || hasSharedArchiveScript
const hasLocalScript = Boolean(
hookSettingsDraft.scripts.setup?.trim() || hookSettingsDraft.scripts.archive?.trim()
hookSettingsDraft.scripts.setup?.trim() ||
(showArchive && hookSettingsDraft.scripts.archive?.trim())
)
const selectedCommandSourcePolicy: HookCommandSourcePolicy = resolveHookCommandSourcePolicy(
hookSettingsDraft.commandSourcePolicy,
@@ -99,7 +108,7 @@ export function RepositoryHooksSection({
hooksInspectionReady,
currentPolicy: selectedCommandSourcePolicy,
setupScript: hookSettingsDraft.scripts.setup,
archiveScript: hookSettingsDraft.scripts.archive,
archiveScript: showArchive ? hookSettingsDraft.scripts.archive : undefined,
hasSharedScript
},
t
@@ -115,15 +124,20 @@ export function RepositoryHooksSection({
: t('auto.components.settings.RepositoryHooksSection.01bdba296f', 'Worktree hooks')
}
description={
isFolder
!showArchive
? t(
'components.settings.RepositoryHooksSection.folderDescription',
'Scripts that run in this folder when a workspace is created or removed. Every workspace shares the folder. Local scripts are stored on this machine; `orca.yaml` scripts are shared with your team.'
)
: t(
'auto.components.settings.RepositoryHooksSection.8567127a40',
'Scripts that run when worktrees are created or archived. Local scripts are stored on this machine; `orca.yaml` scripts are shared with your team.'
'settings.groupHooks.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.'
)
: isFolder
? t(
'components.settings.RepositoryHooksSection.folderDescription',
'Scripts that run in this folder when a workspace is created or removed. Every workspace shares the folder. Local scripts are stored on this machine; `orca.yaml` scripts are shared with your team.'
)
: t(
'auto.components.settings.RepositoryHooksSection.8567127a40',
'Scripts that run when worktrees are created or archived. Local scripts are stored on this machine; `orca.yaml` scripts are shared with your team.'
)
}
>
<SearchableSetting
@@ -152,6 +166,38 @@ export function RepositoryHooksSection({
onCommit={commitScriptDraft}
sectionId={getRepositoryLocalCommandsSectionId(repo.id)}
/>
{setupExample ? (
<div className="mt-3 space-y-2">
<p className="text-settings-detail text-muted-foreground">
{t(
'settings.groupHooks.example',
'Example: create a worktree in each repo (this machine only)'
)}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">
<code>{setupExample}</code>
</pre>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
updateScriptDraft('setup', setupExample)
commitScriptDraft()
}}
>
{t('settings.groupHooks.useExample', 'Use this example')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void window.api.ui.writeClipboardText(setupExample)}
>
{t('common.copy', 'Copy')}
</Button>
</div>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={t(
@@ -194,32 +240,37 @@ export function RepositoryHooksSection({
}
/>
</SearchableSetting>
<SearchableSetting
title={t('auto.components.settings.RepositoryHooksSection.bfdb1266ce', 'Archive script')}
description={getRepositoryHookScriptDescription(repo, 'archive', t)}
className="py-4"
forceVisible={forceVisible}
keywords={[
'archive',
'script',
'command',
'local',
'local settings scripts',
'orca.yaml',
'orca.yaml hooks',
'hook'
]}
>
<RepositoryHookScriptSetting
key={`${repo.id}:archive`}
field={localHookFields[1]}
value={hookSettingsDraft.scripts.archive ?? ''}
hasShared={hasSharedArchiveScript}
sharedScript={sharedArchiveScript}
onChange={(next) => updateScriptDraft('archive', next)}
onCommit={commitScriptDraft}
/>
</SearchableSetting>
{showArchive ? (
<SearchableSetting
title={t(
'auto.components.settings.RepositoryHooksSection.bfdb1266ce',
'Archive script'
)}
description={getRepositoryHookScriptDescription(repo, 'archive', t)}
className="py-4"
forceVisible={forceVisible}
keywords={[
'archive',
'script',
'command',
'local',
'local settings scripts',
'orca.yaml',
'orca.yaml hooks',
'hook'
]}
>
<RepositoryHookScriptSetting
key={`${repo.id}:archive`}
field={localHookFields[1]}
value={hookSettingsDraft.scripts.archive ?? ''}
hasShared={hasSharedArchiveScript}
sharedScript={sharedArchiveScript}
onChange={(next) => updateScriptDraft('archive', next)}
onCommit={commitScriptDraft}
/>
</SearchableSetting>
) : null}
{localCommandSourceNotice ? (
<div data-settings-row="" className="py-4">
<LocalCommandSourceNotice
@@ -230,13 +281,13 @@ export function RepositoryHooksSection({
/>
</div>
) : null}
{isFolder ? null : (
{gitRepo && !isFolder ? (
<RepositoryIssueCommandRow
repo={repo}
repo={gitRepo}
repoHostIdentity={repoHostIdentity}
forceVisible={forceVisible}
/>
)}
) : null}
<SearchableSetting
title={t('auto.components.settings.RepositoryHooksSection.c9bc1bfd8f', 'Advanced')}
description={t(
@@ -256,6 +307,7 @@ export function RepositoryHooksSection({
]}
>
<RepositoryHookCommandSourceSetting
setupOnly={!showArchive}
searchQuery={settingsSearchQuery}
selectedPolicy={selectedCommandSourcePolicy}
yamlState={yamlState}
@@ -5,7 +5,10 @@ import { Button } from '../../../ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../ui/collapsible'
import { i18n, translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { renderYamlScriptPreview } from './repository-hook-settings-draft'
import {
renderYamlScriptPreview,
GROUP_SETUP_HOOKS_TEMPLATE
} from './repository-hook-settings-draft'
const EXAMPLE_TEMPLATE = `scripts:
setup: |
@@ -50,16 +53,21 @@ function getYamlStateCopy(
}
}
function getParseErrorFixes(t: Translate = translate): string[] {
function getParseErrorFixes(t: Translate = translate, setupOnly = false): string[] {
return [
t(
'auto.components.settings.RepositoryHooksSection.07ba35bc68',
'Check the indentation under `scripts:`. Hook keys should use two spaces, and command lines should use four.'
),
t(
'auto.components.settings.RepositoryHooksSection.787ca433ef',
'Define only the supported keys: `scripts`, `setup`, `archive`, and `issueCommand`.'
),
setupOnly
? t(
'settings.groupHooks.supportedKeys',
'Define only the supported keys: `scripts` and `setup`.'
)
: t(
'auto.components.settings.RepositoryHooksSection.787ca433ef',
'Define only the supported keys: `scripts`, `setup`, `archive`, and `issueCommand`.'
),
t(
'auto.components.settings.RepositoryHooksSection.ecc73d9125',
'Compare your file against the working template below and copy that shape if needed.'
@@ -68,9 +76,11 @@ function getParseErrorFixes(t: Translate = translate): string[] {
}
function ExampleTemplateCard({
template,
copiedTemplate,
onCopyTemplate
}: {
template: string
copiedTemplate: boolean
onCopyTemplate: () => void
}): React.JSX.Element {
@@ -97,7 +107,7 @@ function ExampleTemplateCard({
: t('auto.components.settings.RepositoryHooksSection.da37d6f10e', 'Copy')}
</Button>
<pre className="overflow-x-auto whitespace-pre-wrap break-words p-3 pr-16 font-mono text-[11px] leading-5 text-muted-foreground">
{EXAMPLE_TEMPLATE}
{template}
</pre>
</div>
</div>
@@ -108,11 +118,15 @@ const YAML_CODE_COMPONENTS = { yaml: <code className="rounded bg-muted px-1 py-0
// Healthy states are a plain disclosure row; only problems get a tinted card.
function YamlScriptsDisclosure({
setupOnly,
template,
yamlState,
yamlHooks,
copiedTemplate,
onCopyTemplate
}: {
setupOnly: boolean
template: string
yamlState: 'loaded' | 'missing'
yamlHooks: OrcaHooks | null
copiedTemplate: boolean
@@ -168,7 +182,7 @@ function YamlScriptsDisclosure({
</div>
<CollapsibleContent>
<pre className="mt-2 ml-5.5 overflow-x-auto whitespace-pre-wrap break-words rounded-lg border border-border/50 bg-muted/30 p-3 font-mono text-[11.5px] leading-5 text-foreground">
{loaded ? renderYamlScriptPreview(yamlHooks) : EXAMPLE_TEMPLATE}
{loaded ? renderYamlScriptPreview(yamlHooks, setupOnly) : template}
</pre>
</CollapsibleContent>
</Collapsible>
@@ -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 (
<YamlScriptsDisclosure
setupOnly={setupOnly}
template={template}
yamlState={yamlState}
yamlHooks={yamlHooks}
copiedTemplate={copiedTemplate}
@@ -198,7 +217,7 @@ export function RepositoryHooksYamlStatus({
)
}
const copy = getYamlStateCopy(yamlState, t)
const parseErrorFixes = getParseErrorFixes(t)
const parseErrorFixes = getParseErrorFixes(t, setupOnly)
return (
<div className={`space-y-3 rounded-xl border p-3 ${YAML_WARNING_CARD_STYLES.card}`}>
<div className="space-y-1">
@@ -213,10 +232,15 @@ export function RepositoryHooksYamlStatus({
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="space-y-2 text-xs text-muted-foreground">
<p>
{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.'
)}
</p>
<ol className="space-y-1.5 pl-4 text-[11.5px]">
{parseErrorFixes.map((fix) => (
@@ -227,10 +251,18 @@ export function RepositoryHooksYamlStatus({
</ol>
</div>
</div>
<ExampleTemplateCard copiedTemplate={copiedTemplate} onCopyTemplate={onCopyTemplate} />
<ExampleTemplateCard
template={template}
copiedTemplate={copiedTemplate}
onCopyTemplate={onCopyTemplate}
/>
</div>
) : (
<ExampleTemplateCard copiedTemplate={copiedTemplate} onCopyTemplate={onCopyTemplate} />
<ExampleTemplateCard
template={template}
copiedTemplate={copiedTemplate}
onCopyTemplate={onCopyTemplate}
/>
)}
</div>
)
@@ -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<Repo, 'kind' | 'displayName'>,
hook: 'setup' | 'archive',
t: Translate
): string {
@@ -33,8 +33,9 @@ export function getRepositoryHookScriptDescription(
}
export function getRepositoryGitHooksSearchEntries(
repo: Repo,
t: Translate = translate
repo: Pick<Repo, 'kind' | 'displayName'>,
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(
@@ -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"'
@@ -13,7 +13,7 @@ export function useRepositoryHookSettingsDraft({
repoHostIdentity,
onUpdateHookSettings
}: {
repo: Repo
repo: Pick<Repo, 'hookSettings'>
repoHostIdentity: string
onUpdateHookSettings: (settings: RepoHookSettings) => void
}): {
@@ -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<Record<string, unknown>>()
return { ...actual, activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace }
})
vi.mock(
'@/lib/agent-session/launch/prompt/agent-launch-prompt-delivery',
async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<typeof FolderWorkspaceCreate.parse>[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()
})
@@ -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<FolderWorkspace | null>
createFolderWorkspace: (
input: FolderWorkspaceCreateInput
) => Promise<FolderWorkspaceCreateResult | null>
onOpenChange: (open: boolean) => void
}
@@ -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 &&
@@ -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 }
)
}
@@ -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<typeof buildAgentStartupPlan> | null
requestedAgentArgs: SubmitFolderWorkspaceCreateParams['agentArgs']
launchDraftPrompt: string | null
launchSource: NonNullable<SubmitFolderWorkspaceCreateParams['launchSource']>
}
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<SubmitFolderWorkspaceCreateParams['launchSource']>
launchDraftPrompt: string | null
}): Partial<FolderWorkspaceCreateInput> {
return {
...(launchDraftPrompt ? { startupDraft: launchDraftPrompt } : { startupPrompt: note }),
startupAgent: quickAgent ?? undefined,
startupAgentArgs: agentArgs,
startupSessionOptions: sessionOptions,
startupLaunchPreferences: quickAgent
? toAgentLaunchPreferences(sessionOptions, quickAgent)
: undefined,
launchSource
}
}
@@ -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
}
@@ -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
}
}
@@ -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<boolean>
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<FolderWorkspace | null>
createFolderWorkspace: RepoSlice['createFolderWorkspace']
closeModal: () => void
openSettingsPage: () => void
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
@@ -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,
@@ -24,6 +24,8 @@ export type ComposerRuntimeTargetSelectionInput = Pick<
export type DerivedComposerStateInput = Pick<
ComposerModel,
| 'selectedProjectGroup'
| 'isProjectGroupTarget'
| 'agentPrompt'
| 'checkedHooksContextKey'
| 'enableIssueAutomation'
@@ -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,
@@ -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,
@@ -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<typeof useProjectGroupSetup>
sparsePresetsForRepo: SparsePreset[]
sparsePresets: SparsePreset[]
normalizedSparseDirectories: string[]
@@ -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,
@@ -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<typeof useFolderSubmitOrchestration>[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<boolean>((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<void>
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<boolean>((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<boolean>
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<boolean>((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<void>
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()
}
)
@@ -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<string, Promise<boolean>>())
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<SetStateAction<'run' | 'skip' | null>> = (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<boolean> => {
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<boolean> = 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)
}
}
}
}
@@ -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
}
@@ -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
@@ -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<ReadonlySet<string>>(new Set())
const hosts = useMemo(() => {
const owners = new Map<string, NonNullable<ReturnType<typeof projectGroupHookOwner>>>()
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]
)
}
@@ -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
+9
View File
@@ -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": {
@@ -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
}
@@ -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<InitialTerminalOptions, 'seedUserDefaultSurface'>
): 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,
@@ -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.
@@ -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
}
@@ -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,
@@ -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<string, FolderSetupObservation>()
function probeFolderSetup(environmentId: string): Promise<boolean> {
const observation = folderSetupObservations.get(environmentId)
function probeFolderSetup(
environmentId: string,
capability: RuntimeCapability = WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY
): Promise<boolean> {
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<boolean> {
}
/** 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<boolean> {
export function resolveFolderSetupSupported(
repo: WorkspaceHookOwner | null | undefined,
capability: RuntimeCapability = WORKTREE_FOLDER_SETUP_RUNTIME_CAPABILITY
): Promise<boolean> {
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'
}
@@ -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<T extends SetupDecision | undefined>(
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'
@@ -230,6 +230,7 @@ beforeEach(() => {
)
mocks.pasteDraftWhenAgentReady.mockResolvedValue(true)
mocks.store = {
projectGroups: [],
repos: [
{
id: 'repo-1',
@@ -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()
})
@@ -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
}
@@ -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
}
@@ -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<GroupHookCheckResult> {
const target = getHookInspectionTarget(hostId)
return target.kind === 'environment'
? callRuntimeRpc<GroupHookCheckResult>(
target,
'projectGroup.checkHooks',
{ groupId, ...options },
{ timeoutMs: 15_000 }
)
: window.api.projectGroups.checkHooks({ groupId, ...options })
}
@@ -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[],
@@ -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)
}
})
@@ -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<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[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<FolderWorkspaceCreateResult>(
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}`)
}
}
}
@@ -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<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'createFolderWorkspace' | 'updateFolderWorkspace' | 'deleteFolderWorkspace'> {
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)
@@ -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 }
}
})
@@ -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: {}
}
})

Some files were not shown because too many files have changed in this diff Show More