mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
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:
@@ -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,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",
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+55
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+452
@@ -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}
|
||||
|
||||
+47
-15
@@ -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>
|
||||
)
|
||||
|
||||
+43
-32
@@ -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(
|
||||
|
||||
+8
-5
@@ -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"'
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export function useRepositoryHookSettingsDraft({
|
||||
repoHostIdentity,
|
||||
onUpdateHookSettings
|
||||
}: {
|
||||
repo: Repo
|
||||
repo: Pick<Repo, 'hookSettings'>
|
||||
repoHostIdentity: string
|
||||
onUpdateHookSettings: (settings: RepoHookSettings) => void
|
||||
}): {
|
||||
|
||||
+242
-5
@@ -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()
|
||||
})
|
||||
|
||||
+13
-11
@@ -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
|
||||
}
|
||||
|
||||
+81
-70
@@ -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 &&
|
||||
|
||||
+29
-1
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
+83
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+36
@@ -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
|
||||
}
|
||||
-28
@@ -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,
|
||||
|
||||
+1
@@ -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'
|
||||
|
||||
+1
@@ -230,6 +230,7 @@ beforeEach(() => {
|
||||
)
|
||||
mocks.pasteDraftWhenAgentReady.mockResolvedValue(true)
|
||||
mocks.store = {
|
||||
projectGroups: [],
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
|
||||
+1
-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)
|
||||
|
||||
+10
@@ -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
Reference in New Issue
Block a user