diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 82f1f4ab5..f3f66700b 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -9,6 +9,7 @@ "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", + "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/src/main/automations/dispatch-tokens.ts b/src/main/automations/dispatch-tokens.ts new file mode 100644 index 000000000..6eb0ba34a --- /dev/null +++ b/src/main/automations/dispatch-tokens.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'crypto' + +const DISPATCH_TOKEN_TTL_MS = 30 * 60_000 + +type DispatchTokenRecord = { + automationId: string + runId: string + expiresAt: number + reservedBy?: string + inFlight: boolean +} + +const dispatchTokens = new Map() + +function pruneExpiredDispatchTokens(now = Date.now()): void { + for (const [token, record] of dispatchTokens) { + if (record.expiresAt <= now) { + dispatchTokens.delete(token) + } + } +} + +export function createAutomationDispatchToken(automationId: string, runId: string): string { + pruneExpiredDispatchTokens() + const token = randomUUID() + dispatchTokens.set(token, { + automationId, + runId, + expiresAt: Date.now() + DISPATCH_TOKEN_TTL_MS, + inFlight: false + }) + return token +} + +export function beginAutomationDispatchTokenUse(args: { + automationId: string + runId: string + token: string + reservationId: string +}): boolean { + pruneExpiredDispatchTokens() + const record = dispatchTokens.get(args.token) + const valid = + record?.automationId === args.automationId && + record.runId === args.runId && + record.expiresAt > Date.now() + if (!valid) { + return false + } + if (record.reservedBy !== undefined && record.reservedBy !== args.reservationId) { + return false + } + if (record.inFlight) { + return false + } + record.reservedBy = args.reservationId + record.inFlight = true + return true +} + +export function releaseAutomationDispatchTokenUse(args: { + token: string + reservationId: string +}): void { + const record = dispatchTokens.get(args.token) + if (record?.reservedBy === args.reservationId) { + record.inFlight = false + } +} + +export function finishAutomationDispatchTokenUse(args: { + token: string + reservationId: string +}): void { + const record = dispatchTokens.get(args.token) + if (record?.reservedBy === args.reservationId) { + dispatchTokens.delete(args.token) + } +} + +export function clearAutomationDispatchTokens(automationId: string, runId: string): void { + for (const [token, record] of dispatchTokens) { + if (record.automationId === automationId && record.runId === runId) { + dispatchTokens.delete(token) + } + } +} diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts new file mode 100644 index 000000000..3e247af50 --- /dev/null +++ b/src/main/automations/headless-workspace-create.test.ts @@ -0,0 +1,91 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../shared/automations-types' +import type { Repo } from '../../shared/types' +import { buildHeadlessAutomationWorktreeCreateArgs } from './headless-workspace-create' + +const repoPath = path.join('tmp', 'orca') + +const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 1, + kind: 'git', + executionHostId: 'ssh:ssh-target-1' +} + +const automation: Automation = { + id: 'automation-1', + name: 'Nightly review', + prompt: 'Review changes', + precheck: null, + agentId: 'codex', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:ssh-target-1', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: repoPath + }, + sourceContext: null, + projectId: 'legacy-repo-1', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: 'origin/main', + reuseSession: false, + timezone: 'UTC', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: 2, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1 +} + +describe('headless automation workspace create args', () => { + it('stamps automation provenance for serve-mode new-per-run workspaces', () => { + const args = buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run: { + id: 'run-1', + title: 'Nightly review run', + scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + repo, + createdAt: 123 + }) + + expect(args).toMatchObject({ + repoSelector: 'repo-1', + name: 'auto-nightly-review-run-20260102T0304', + baseBranch: 'origin/main', + setupDecision: 'inherit', + activate: false, + createdWithAgent: 'codex', + startupAgent: 'codex', + startupPrompt: 'Review changes', + telemetrySource: 'unknown', + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + } + }) + }) +}) diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts new file mode 100644 index 000000000..497ad8557 --- /dev/null +++ b/src/main/automations/headless-workspace-create.ts @@ -0,0 +1,47 @@ +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import type { Repo } from '../../shared/types' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type HeadlessAutomationRunForWorkspace = Pick +type RuntimeCreateManagedWorktreeArgs = Parameters[0] + +export function buildHeadlessAutomationWorkspaceName( + runTitle: string, + scheduledFor: number +): string { + // Why: generated workspace names must stay deterministic and short enough for + // cross-provider branch/path displays while still carrying the run timestamp. + const slug = runTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) + const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) + return `auto-${slug || 'run'}-${stamp}` +} + +export function buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run, + repo, + createdAt = Date.now() +}: { + automation: Automation + run: HeadlessAutomationRunForWorkspace + repo: Repo + createdAt?: number +}): RuntimeCreateManagedWorktreeArgs { + return { + repoSelector: repo.id, + name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), + baseBranch: automation.baseBranch ?? undefined, + setupDecision: 'inherit', + activate: false, + createdWithAgent: automation.agentId, + startupAgent: automation.agentId, + startupPrompt: automation.prompt, + telemetrySource: 'unknown', + automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt) + } +} diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index bd6e7fd53..53bb902de 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -14,6 +14,7 @@ import { runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import { collectAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' +import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens' import { didAutomationPrecheckPass, formatAutomationPrecheckFailure @@ -131,6 +132,7 @@ export class AutomationService { async markDispatchResult(result: AutomationDispatchResult): Promise { const run = this.store.updateAutomationRun(result) + clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalRunStatus(run.status)) { return run } @@ -231,7 +233,11 @@ export class AutomationService { workspaceId: automation.workspaceId, error: null }) - const payload: AutomationDispatchRequest = { automation, run: updated } + const payload: AutomationDispatchRequest = { + automation, + run: updated, + dispatchToken: createAutomationDispatchToken(automation.id, updated.id) + } webContents.send('automations:dispatchRequested', payload) return updated } diff --git a/src/main/automations/workspace-provenance.ts b/src/main/automations/workspace-provenance.ts new file mode 100644 index 000000000..44936edc8 --- /dev/null +++ b/src/main/automations/workspace-provenance.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import { getAutomationRunRepoId } from '../../shared/automation-run-identity' +import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance' +import type { + AutomationWorkspaceProvenance, + AutomationWorkspaceProvenanceRequest, + Repo +} from '../../shared/types' +import { + beginAutomationDispatchTokenUse, + finishAutomationDispatchTokenUse, + releaseAutomationDispatchTokenUse +} from './dispatch-tokens' + +export type AutomationWorkspaceProvenanceAuthority = { + showAutomation: (id: string) => Automation + listAutomationRuns: (automationId?: string) => AutomationRun[] +} + +export function invalidAutomationProvenanceRequest(): never { + throw new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path: ['automationProvenanceRequest'], + message: 'Invalid automation provenance request' + } + ]) +} + +function repoSelectorMatchesAutomation(selector: string, repoId: string): boolean { + return selector === repoId || selector === `id:${repoId}` +} + +export function resolveAutomationWorkspaceProvenance(args: { + authority: AutomationWorkspaceProvenanceAuthority + repoSelector: string + repo: Repo + request: AutomationWorkspaceProvenanceRequest | undefined +}): AutomationWorkspaceProvenance | undefined { + const { authority, repoSelector, repo, request } = args + if (!request) { + return undefined + } + + let automation: Automation + try { + automation = authority.showAutomation(request.automationId) + } catch { + invalidAutomationProvenanceRequest() + } + const run = authority + .listAutomationRuns(request.automationId) + .find((entry) => entry.id === request.automationRunId) + const expectedRepoId = run?.runContext?.repoId ?? getAutomationRunRepoId(automation) + + if ( + !run || + run.automationId !== automation.id || + run.status !== 'dispatching' || + run.workspaceId !== null || + automation.workspaceMode !== 'new_per_run' || + !repoSelectorMatchesAutomation(repoSelector, expectedRepoId) + ) { + invalidAutomationProvenanceRequest() + } + if ( + !beginAutomationDispatchTokenUse({ + automationId: request.automationId, + runId: request.automationRunId, + token: request.dispatchToken, + reservationId: request.createRequestId + }) + ) { + invalidAutomationProvenanceRequest() + } + + return buildAutomationWorkspaceProvenance(automation, run, repo) +} + +export function releaseAutomationWorkspaceProvenanceRequest( + request: AutomationWorkspaceProvenanceRequest | undefined +): void { + if (!request) { + return + } + releaseAutomationDispatchTokenUse({ + token: request.dispatchToken, + reservationId: request.createRequestId + }) +} + +export function finishAutomationWorkspaceProvenanceRequest( + request: AutomationWorkspaceProvenanceRequest | undefined +): void { + if (!request) { + return + } + finishAutomationDispatchTokenUse({ + token: request.dispatchToken, + reservationId: request.createRequestId + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index a96483bc8..54fccd05d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -112,6 +112,7 @@ import { initializeBrowserSessionsForApp } from './browser/browser-session-start import { setUnreadDockBadgeCount } from './dock/unread-badge' import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' import { getCrashBreadcrumbSnapshot, @@ -163,15 +164,6 @@ let runtime: OrcaRuntimeService | null = null let rateLimits: RateLimitService | null = null let runtimeRpc: OrcaRuntimeRpcServer | null = null -function buildHeadlessAutomationWorkspaceName(runTitle: string, scheduledFor: number): string { - const slug = runTitle - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 40) - const stamp = new Date(scheduledFor).toISOString().replace(/[-:]/g, '').slice(0, 13) - return `auto-${slug || 'run'}-${stamp}` -} let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null @@ -1360,15 +1352,11 @@ app.whenReady().then(async () => { if (automation.workspaceMode === 'new_per_run') { const created = await runtimeService.createManagedWorktree({ - repoSelector: target.repo.id, - name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor), - baseBranch: automation.baseBranch ?? undefined, - setupDecision: 'inherit', - activate: false, - createdWithAgent: automation.agentId, - startupAgent: automation.agentId, - startupPrompt: automation.prompt, - telemetrySource: 'unknown' + ...buildHeadlessAutomationWorktreeCreateArgs({ + automation, + run, + repo: target.repo + }) }) terminalHandle = created.startupTerminal?.handle ?? '' terminalSessionId = created.startupTerminal?.tabId ?? null diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index cdf28a239..0f3e6c6af 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -354,7 +354,20 @@ describe('mergeWorktree', () => { sortOrder: 5, lastActivityAt: 1000, workspaceStatus: 'in-review', - diffComments: [] + diffComments: [], + automationProvenance: { + kind: 'created-by-automation' as const, + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh' as const, + executionTargetId: 'openclaw-2', + projectId: 'github:stablyai/orca', + repoId: 'repo1', + hostId: 'ssh:openclaw-2' as const + } } const result = mergeWorktree('repo1', baseGit, meta) expect(result).toEqual({ @@ -387,7 +400,20 @@ describe('mergeWorktree', () => { sortOrder: 5, lastActivityAt: 1000, workspaceStatus: 'in-review', - diffComments: [] + diffComments: [], + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'ssh', + executionTargetId: 'openclaw-2', + projectId: 'github:stablyai/orca', + repoId: 'repo1', + hostId: 'ssh:openclaw-2' + } }) }) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index cf0d380ea..eb1f64d1a 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,23 +1,15 @@ -import { basename, resolve, relative, isAbsolute, posix, sep, win32 } from 'path' -import type { - GitWorktreeInfo, - GlobalSettings, - OrcaWorkspaceLayout, - Repo, - Worktree, - WorktreeMeta -} from '../../shared/types' +import { resolve, relative, isAbsolute, posix, sep, win32 } from 'path' +import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' import { resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getWslHome, parseWslPath } from '../wsl' -import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' type WorktreePathSettings = Pick type WorktreeBasePathRepo = Pick export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name' +export { mergeWorktree } from './worktree-metadata-merge' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -265,71 +257,6 @@ export function shouldSetDisplayName( return !(branchName === requestedName && sanitizedName === requestedName) } -/** - * Merge raw git worktree info with persisted user metadata into a full Worktree. - */ -export function mergeWorktree( - repoId: string, - git: GitWorktreeInfo, - meta: WorktreeMeta | undefined, - defaultDisplayName?: string -): Worktree { - const branchShort = git.branch.replace(/^refs\/heads\//, '') - return { - id: `${repoId}::${git.path}`, - ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId, - ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta?.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: git.path, - head: git.head, - branch: git.branch, - isBare: git.isBare, - ...(git.isSparse === true ? { isSparse: true } : {}), - isMainWorktree: git.isMainWorktree, - displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), - comment: meta?.comment || '', - linkedIssue: meta?.linkedIssue ?? null, - linkedPR: meta?.linkedPR ?? null, - linkedLinearIssue: meta?.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null, - ...getLinkedWorkItemMetadata(meta), - isArchived: meta?.isArchived ?? false, - isUnread: meta?.isUnread ?? false, - isPinned: meta?.isPinned ?? false, - sortOrder: meta?.sortOrder ?? 0, - ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta?.lastActivityAt ?? 0, - ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta?.pendingFirstAgentMessageRename !== undefined - ? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename } - : {}), - ...(meta?.firstAgentMessageRenameError !== undefined - ? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError } - : {}), - ...(git.isSparse === true - ? { - sparseDirectories: meta?.sparseDirectories, - sparseBaseRef: meta?.sparseBaseRef, - sparsePresetId: meta?.sparsePresetId - } - : {}), - ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), - ...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}), - workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in - // shared/types) and forwarded verbatim so the renderer store mirrors - // on-disk state. `undefined` here means the worktree has no comments yet. - diffComments: meta?.diffComments, - mobileDiffReview: meta?.mobileDiffReview - } -} - /** * Parse a composite worktreeId ("repoId::worktreePath") into its parts. */ diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts new file mode 100644 index 000000000..a64775db4 --- /dev/null +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -0,0 +1,71 @@ +import { basename } from 'path' +import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' +import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' + +/** + * Merge raw git worktree info with persisted user metadata into a full Worktree. + */ +export function mergeWorktree( + repoId: string, + git: GitWorktreeInfo, + meta: WorktreeMeta | undefined, + defaultDisplayName?: string +): Worktree { + const branchShort = git.branch.replace(/^refs\/heads\//, '') + return { + id: `${repoId}::${git.path}`, + ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId, + ...(meta?.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta?.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta?.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + path: git.path, + head: git.head, + branch: git.branch, + isBare: git.isBare, + ...(git.isSparse === true ? { isSparse: true } : {}), + isMainWorktree: git.isMainWorktree, + displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), + comment: meta?.comment || '', + linkedIssue: meta?.linkedIssue ?? null, + linkedPR: meta?.linkedPR ?? null, + linkedLinearIssue: meta?.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null, + ...getLinkedWorkItemMetadata(meta), + isArchived: meta?.isArchived ?? false, + isUnread: meta?.isUnread ?? false, + isPinned: meta?.isPinned ?? false, + sortOrder: meta?.sortOrder ?? 0, + ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta?.lastActivityAt ?? 0, + ...(meta?.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta?.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta?.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta?.pendingFirstAgentMessageRename !== undefined + ? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename } + : {}), + ...(meta?.firstAgentMessageRenameError !== undefined + ? { firstAgentMessageRenameError: meta.firstAgentMessageRenameError } + : {}), + ...(git.isSparse === true + ? { + sparseDirectories: meta?.sparseDirectories, + sparseBaseRef: meta?.sparseBaseRef, + sparsePresetId: meta?.sparsePresetId + } + : {}), + ...(meta?.baseRef !== undefined ? { baseRef: meta.baseRef } : {}), + ...(meta?.pushTarget !== undefined ? { pushTarget: meta.pushTarget } : {}), + workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + // Why: diff comments are persisted on WorktreeMeta and forwarded verbatim + // so the renderer store mirrors on-disk state. + diffComments: meta?.diffComments, + mobileDiffReview: meta?.mobileDiffReview + } +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 9a6f39dbe..216465caf 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -13,6 +13,7 @@ import { existsSync } from 'fs' import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { + AutomationWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, GitPushTarget, @@ -56,6 +57,10 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' + +type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance +} import { sanitizeWorktreeName, sanitizeWorktreeDisplayName, @@ -1341,7 +1346,7 @@ export function emitCreateWorktreeProgress( } export async function createRemoteWorktree( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, mainWindow: BrowserWindow @@ -1642,6 +1647,7 @@ export async function createRemoteWorktree( orcaCreatedAt: now, orcaCreationSource: 'ssh', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), @@ -1749,7 +1755,7 @@ export async function createRemoteWorktree( } export async function createLocalWorktree( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, mainWindow: BrowserWindow, @@ -2238,6 +2244,7 @@ export async function createLocalWorktree( orcaCreatedAt: now, orcaCreationSource: 'desktop', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8e177b23e..3f342ef26 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -669,6 +669,41 @@ describe('registerWorktreeHandlers', () => { expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('does not trust renderer-authored automation provenance during local create', async () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Forged', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Forged run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1' + } + }) + + const persistedMeta = store.setWorktreeMeta.mock.calls.find( + ([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard' + )?.[1] + expect(persistedMeta).toBeDefined() + expect(persistedMeta).not.toHaveProperty('automationProvenance') + }) + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index cf99b23e1..bceaa86f4 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -14,6 +14,7 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { + AutomationWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, DetectedWorktree, @@ -89,6 +90,15 @@ import { removeWorktreeLinkedPaths } from './worktree-symlinks' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' +import { + finishAutomationWorkspaceProvenanceRequest, + releaseAutomationWorkspaceProvenanceRequest, + resolveAutomationWorkspaceProvenance +} from '../automations/workspace-provenance' + +type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance +} import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { @@ -627,6 +637,9 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta lastActivityAt: meta.lastActivityAt ?? 0, ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, mobileDiffReview: meta.mobileDiffReview @@ -698,7 +711,7 @@ function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { } function createFolderWorkspace( - args: CreateWorktreeArgs, + args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store ): CreateWorktreeResult { @@ -715,6 +728,7 @@ function createFolderWorkspace( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), @@ -1022,6 +1036,17 @@ export function registerWorktreeHandlers( const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } + let result: CreateWorktreeResult try { // Why: only wrap the helpers themselves. The pre-validation throws @@ -1030,11 +1055,12 @@ export function registerWorktreeHandlers( // git/filesystem failures the funnel cares about — bucketing them // into `unknown` would pollute the failure taxonomy. result = isFolderRepo(repo) - ? createFolderWorkspace(args, repo, store) + ? createFolderWorkspace(createArgs, repo, store) : repo.connectionId - ? await createRemoteWorktree(args, repo, store, mainWindow) - : await createLocalWorktree(args, repo, store, mainWindow, runtime) + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) track('workspace_create_failed', { source, error_class: classifyWorkspaceCreateError(error), @@ -1042,6 +1068,7 @@ export function registerWorktreeHandlers( }) throw error } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) // Why: emit `workspace_created` only after the underlying create has // resolved (the helpers throw on failure, so reaching this line means diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index e800bd373..7decc4fa3 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -5084,6 +5084,7 @@ describe('Store', () => { 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents' @@ -5228,6 +5229,7 @@ describe('Store', () => { 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 512d642f3..57a5928e7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -42,6 +42,7 @@ import type { AutomationWorkspaceMode } from '../../shared/automations-types' import type { + AutomationWorkspaceProvenance, BaseRefSearchResult, CreateWorktreeResult, DetectedWorktree, @@ -1183,6 +1184,9 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt lastActivityAt: meta.lastActivityAt ?? 0, ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, mobileDiffReview: meta.mobileDiffReview @@ -10310,6 +10314,7 @@ export class OrcaRuntimeService { startupAgent?: TuiAgent startupPrompt?: string pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -10369,6 +10374,7 @@ export class OrcaRuntimeService { path: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -10888,6 +10894,7 @@ export class OrcaRuntimeService { ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent ? { pendingFirstAgentMessageRename: true } : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) @@ -11167,6 +11174,7 @@ export class OrcaRuntimeService { setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean + automationProvenance?: AutomationWorkspaceProvenance startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup startupDraftPaste?: WorktreeStartupDraftPaste @@ -11217,7 +11225,8 @@ export class OrcaRuntimeService { ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true ? { pendingFirstAgentMessageRename: true } - : {}) + : {}), + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}) }, repo, this.store as unknown as Store, diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 1d7d144df..fa4a022d0 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -133,6 +133,7 @@ describe('remote runtime request connection integration', () => { clientEventListeners.add(listener) return () => clientEventListeners.delete(listener) }, + showRepo: () => repo, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, @@ -347,6 +348,7 @@ describe('remote runtime request connection integration', () => { }, watchFileExplorer: async () => () => {}, listRepos: () => [repo], + showRepo: () => repo, listDetectedManagedWorktrees: () => ({ repoId: repo.id, authoritative: true, diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 6805908dd..bdb35cd2c 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -134,6 +134,7 @@ describe('client UI RPC methods', () => { rightSidebarTab: 'checks', rightSidebarExplorerView: 'search', showActiveOnly: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] } const runtime = { @@ -149,6 +150,7 @@ describe('client UI RPC methods', () => { rightSidebarExplorerView: 'search', showActiveOnly: true, hideSleepingWorkspaces: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] }) ) @@ -159,6 +161,7 @@ describe('client UI RPC methods', () => { rightSidebarExplorerView: 'search', showActiveOnly: true, hideSleepingWorkspaces: true, + hideAutomationGeneratedWorkspaces: true, filterRepoIds: ['repo-1'] }) expect(response).toMatchObject({ ok: true, result: { ui: updated } }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 04318f51c..d7018abb0 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -169,6 +169,7 @@ const UiUpdate = z visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), workspaceHostOrder: z.array(z.string()).optional(), hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index f7c8d9178..c0e5e4fc8 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -20,6 +20,13 @@ const OptionalTuiAgent = z .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined)) .optional() +const AutomationWorkspaceProvenanceRequest = z.object({ + automationId: z.string(), + automationRunId: z.string(), + dispatchToken: z.string(), + createRequestId: z.string() +}) + export const WorktreeListParams = z.object({ repo: OptionalString, limit: OptionalFiniteNumber @@ -129,7 +136,8 @@ export const WorktreeCreate = z createdWithAgent: z .unknown() .transform((value) => (isTuiAgent(value) ? value : undefined)) - .optional() + .optional(), + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional() }) .superRefine((params, ctx) => { if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 4b04f8953..6defbe295 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -3,6 +3,17 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { WORKTREE_METHODS } from './worktree' +import { createAutomationDispatchToken } from '../../../automations/dispatch-tokens' + +const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const, + executionHostId: 'ssh:ssh-target-1' as const +} function makeRequest(method: string, params?: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method, params } @@ -12,6 +23,7 @@ describe('worktree RPC methods', () => { it('routes create options to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -22,6 +34,7 @@ describe('worktree RPC methods', () => { name: 'feature', branchNameOverride: 'feature/something', baseBranch: 'origin/main', + compareBaseRef: undefined, setupDecision: 'skip', displayName: 'Feature title', telemetrySource: 'sidebar', @@ -49,6 +62,9 @@ describe('worktree RPC methods', () => { linkedLinearIssueOrganizationUrlKey: undefined, linkedGitLabIssue: 789, linkedGitLabMR: 321, + linkedBitbucketPR: undefined, + linkedAzureDevOpsPR: undefined, + linkedGiteaPR: undefined, comment: undefined, displayName: 'Feature title', telemetrySource: 'sidebar', @@ -60,6 +76,7 @@ describe('worktree RPC methods', () => { activate: false, setupDecision: 'skip', createdWithAgent: undefined, + automationProvenance: undefined, startup: undefined, startupDraft: undefined, lineage: { @@ -71,9 +88,324 @@ describe('worktree RPC methods', () => { }) }) + it('mints automation provenance from a valid dispatch request on worktree creation', async () => { + const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-1', + name: 'Nightly review', + projectId: 'legacy-repo-1', + runContext: { + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-1', + automationId: 'automation-1', + title: 'Nightly review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken, + createRequestId: 'create-request-1' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + const replay = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace-replay', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken, + createRequestId: 'create-request-1' + } + }) + ) + + expect(replay).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo-1', + name: 'automation-workspace', + automationProvenance: expect.objectContaining({ + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1', + projectId: 'project-1', + repoId: 'repo-1', + hostId: 'ssh:ssh-target-1' + }) + }) + ) + }) + + it('stamps automation provenance with the persisted runtime host from run context', async () => { + const dispatchToken = createAutomationDispatchToken('automation-runtime', 'run-runtime') + const runtimeLocalRepo = { + id: 'repo-runtime', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git' as const + } + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(runtimeLocalRepo), + showAutomation: vi.fn(() => ({ + id: 'automation-runtime', + name: 'Runtime review', + projectId: 'legacy-repo-runtime', + runContext: { + projectId: 'project-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:owner-runtime' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'runtime', + executionTargetId: 'owner-runtime' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-runtime', + automationId: 'automation-runtime', + title: 'Runtime review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-runtime' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-runtime', + name: 'runtime-automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-runtime', + automationRunId: 'run-runtime', + dispatchToken, + createRequestId: 'create-request-runtime' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + automationProvenance: expect.objectContaining({ + automationId: 'automation-runtime', + automationRunId: 'run-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:owner-runtime' + }) + }) + ) + }) + + it('validates and stamps automation provenance from the dispatching run snapshot', async () => { + const dispatchToken = createAutomationDispatchToken('automation-edited', 'run-edited') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-edited', + name: 'Edited review', + projectId: 'legacy-repo-edited', + runContext: { + projectId: 'project-after-edit', + repoId: 'repo-after-edit', + hostId: 'runtime:after-edit' + }, + workspaceMode: 'new_per_run', + executionTargetType: 'runtime', + executionTargetId: 'after-edit' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-edited', + automationId: 'automation-edited', + title: 'Pre-edit run', + runContext: { + projectId: 'project-before-edit', + repoId: 'repo-1', + hostId: 'runtime:before-edit' + }, + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-edited' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'edited-automation-workspace', + automationProvenanceRequest: { + automationId: 'automation-edited', + automationRunId: 'run-edited', + dispatchToken, + createRequestId: 'create-request-edited' + } + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + automationProvenance: expect.objectContaining({ + automationId: 'automation-edited', + automationRunId: 'run-edited', + projectId: 'project-before-edit', + repoId: 'repo-1', + hostId: 'runtime:before-edit' + }) + }) + ) + }) + + it('allows the same automation provenance request to retry after a failed create attempt', async () => { + const dispatchToken = createAutomationDispatchToken('automation-retry', 'run-retry') + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-retry', + name: 'Nightly retry', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + executionTargetType: 'ssh', + executionTargetId: 'ssh-target-1' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-retry', + automationId: 'automation-retry', + title: 'Nightly retry run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi + .fn() + .mockRejectedValueOnce(new Error('Branch "automation-workspace" already exists.')) + .mockResolvedValueOnce({ worktree: { id: 'wt-retry' } }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const automationProvenanceRequest = { + automationId: 'automation-retry', + automationRunId: 'run-retry', + dispatchToken, + createRequestId: 'create-request-retry' + } + + const firstResponse = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace', + automationProvenanceRequest + }) + ) + const retryResponse = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'automation-workspace-2', + automationProvenanceRequest + }) + ) + + expect(firstResponse).toMatchObject({ ok: false }) + expect(retryResponse).toMatchObject({ ok: true }) + expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(2) + expect(runtime.createManagedWorktree).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'automation-workspace-2', + automationProvenance: expect.objectContaining({ + automationId: 'automation-retry', + automationRunId: 'run-retry' + }) + }) + ) + }) + + it('rejects forged automation provenance requests on worktree creation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), + showAutomation: vi.fn(() => ({ + id: 'automation-1', + name: 'Nightly review', + projectId: 'repo-1', + workspaceMode: 'new_per_run', + executionTargetType: 'local', + executionTargetId: 'local' + })), + listAutomationRuns: vi.fn(() => [ + { + id: 'run-1', + automationId: 'automation-1', + title: 'Nightly review run', + status: 'dispatching', + workspaceId: null + } + ]), + createManagedWorktree: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.create', { + repo: 'repo-1', + name: 'manual-workspace', + automationProvenanceRequest: { + automationId: 'automation-1', + automationRunId: 'run-1', + dispatchToken: 'forged-token', + createRequestId: 'create-request-forged' + } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) + it('forwards startup command and env to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -106,6 +438,7 @@ describe('worktree RPC methods', () => { it('forwards task startup drafts to runtime worktree creation', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) @@ -156,6 +489,7 @@ describe('worktree RPC methods', () => { it('maps unknown telemetry sources to the runtime default instead of rejecting create', async () => { const runtime = { getRuntimeId: () => 'test-runtime', + showRepo: vi.fn().mockResolvedValue(repo), createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index a707b539e..9475886f3 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -1,3 +1,8 @@ +import { + finishAutomationWorkspaceProvenanceRequest, + releaseAutomationWorkspaceProvenanceRequest, + resolveAutomationWorkspaceProvenance +} from '../../../automations/workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { WorktreeCreate, @@ -58,56 +63,74 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => - runtime.createManagedWorktree({ + handler: async (params, { runtime }) => { + const repo = await runtime.showRepo(params.repo) + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, repoSelector: params.repo, - name: params.name ?? '', - baseBranch: params.baseBranch, - compareBaseRef: params.compareBaseRef, - branchNameOverride: params.branchNameOverride, - linkedIssue: params.linkedIssue, - linkedPR: params.linkedPR, - linkedLinearIssue: params.linkedLinearIssue, - linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, - linkedGitLabMR: params.linkedGitLabMR, - linkedGitLabIssue: params.linkedGitLabIssue, - linkedBitbucketPR: params.linkedBitbucketPR, - linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, - linkedGiteaPR: params.linkedGiteaPR, - comment: params.comment, - displayName: params.displayName, - telemetrySource: params.telemetrySource, - workspaceStatus: params.workspaceStatus, - manualOrder: params.manualOrder, - sparseCheckout: params.sparseCheckout, - pushTarget: params.pushTarget, - runHooks: params.runHooks === true, - activate: params.activate === true, - setupDecision: params.setupDecision, - createdWithAgent: params.createdWithAgent ?? params.startupAgent, - startup: params.startupCommand - ? { - command: params.startupCommand, - ...(params.startupEnv ? { env: params.startupEnv } : {}), - ...(params.startupCommandDelivery - ? { startupCommandDelivery: params.startupCommandDelivery } - : {}) - } - : undefined, - ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), - ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), - startupDraft: params.startupDraft, - lineage: { - parentWorkspace: params.parentWorkspace, - envParentWorkspace: params.envParentWorkspace, - parentWorktree: params.parentWorktree, - ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), - noParent: params.noParent === true, - callerTerminalHandle: params.callerTerminalHandle, - orchestrationContext: params.orchestrationContext - } + repo, + request: params.automationProvenanceRequest }) + // Why: provenance tokens are reserved before creation so retries can recover, + // but failed create attempts must release the reservation for a safe retry. + try { + const result = await runtime.createManagedWorktree({ + repoSelector: params.repo, + name: params.name ?? '', + baseBranch: params.baseBranch, + compareBaseRef: params.compareBaseRef, + branchNameOverride: params.branchNameOverride, + linkedIssue: params.linkedIssue, + linkedPR: params.linkedPR, + linkedLinearIssue: params.linkedLinearIssue, + linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, + linkedGitLabMR: params.linkedGitLabMR, + linkedGitLabIssue: params.linkedGitLabIssue, + linkedBitbucketPR: params.linkedBitbucketPR, + linkedAzureDevOpsPR: params.linkedAzureDevOpsPR, + linkedGiteaPR: params.linkedGiteaPR, + comment: params.comment, + displayName: params.displayName, + telemetrySource: params.telemetrySource, + workspaceStatus: params.workspaceStatus, + manualOrder: params.manualOrder, + sparseCheckout: params.sparseCheckout, + pushTarget: params.pushTarget, + runHooks: params.runHooks === true, + activate: params.activate === true, + setupDecision: params.setupDecision, + createdWithAgent: params.createdWithAgent ?? params.startupAgent, + automationProvenance, + startup: params.startupCommand + ? { + command: params.startupCommand, + ...(params.startupEnv ? { env: params.startupEnv } : {}), + ...(params.startupCommandDelivery + ? { startupCommandDelivery: params.startupCommandDelivery } + : {}) + } + : undefined, + ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), + ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + startupDraft: params.startupDraft, + lineage: { + parentWorkspace: params.parentWorkspace, + envParentWorkspace: params.envParentWorkspace, + parentWorktree: params.parentWorktree, + ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), + noParent: params.noParent === true, + callerTerminalHandle: params.callerTerminalHandle, + orchestrationContext: params.orchestrationContext + } + }) + finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + return result + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) + throw error + } + } }), defineMethod({ name: 'worktree.prefetchCreateBase', diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index 551edec53..acb8d2f3f 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -93,7 +93,18 @@ describe('stripOrcaProvenanceMetaUpdates', () => { comment: 'keep me', orcaCreatedAt: 123, orcaCreationSource: 'desktop', - orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false } + orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false }, + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1' + } }) ).toEqual({ comment: 'keep me' }) }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a798b219e..33fbe86fe 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -22,7 +22,8 @@ const ORCA_CREATION_SOURCES = new Set s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) @@ -1247,6 +1248,7 @@ function App(): React.JSX.Element { hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, // Why: rides the same debounced save so dashboard auto-acks (which fire @@ -1272,6 +1274,7 @@ function App(): React.JSX.Element { projectOrderBy, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index f4c2f3dbc..6dde3b437 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -17,7 +17,10 @@ import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/gith import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' -import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' +import { + isAutomationGeneratedWorkspace, + isDefaultBranchWorkspace +} from '@/components/sidebar/visible-worktrees' import { isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import StatusIndicator from '@/components/sidebar/StatusIndicator' @@ -321,6 +324,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) @@ -393,6 +397,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) { return false } + if (hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(worktree)) { + return false + } if ( !showSleepingWorkspaces && isInactiveWorkspace(worktree.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree) @@ -404,6 +411,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [ allWorktrees, browserTabsByWorktree, + hideAutomationGeneratedWorkspaces, hideDefaultBranchWorkspace, ptyIdsByTabId, showSleepingWorkspaces, diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 720f8e213..a3a4ced4c 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -116,7 +116,10 @@ import { import { createAutomationForTarget, deleteAutomationForTarget, + type AutomationHostTarget, getAutomationListTarget, + getAutomationOwnerTarget, + getAutomationTargetFromHostId, listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, @@ -153,6 +156,10 @@ type SelectedExternalRunPage = { run: ExternalAutomationRun } +function getAutomationHostTargetKey(target: AutomationHostTarget): string { + return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' +} + function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } @@ -353,6 +360,8 @@ export default function AutomationsPage(): React.JSX.Element { ) const selectedId = useAppStore((s) => s.selectedAutomationId) const setSelectedId = useAppStore((s) => s.setSelectedAutomationId) + const pendingAutomationRunNavigation = useAppStore((s) => s.pendingAutomationRunNavigation) + const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() const enabledAgents = filterEnabledTuiAgents(AGENTS, settings?.disabledTuiAgents) @@ -365,6 +374,7 @@ export default function AutomationsPage(): React.JSX.Element { const [automations, setAutomations] = useState([]) const [runs, setRuns] = useState([]) + const [automationHostTargetKey, setAutomationHostTargetKey] = useState(null) const [selectedAutomationRuns, setSelectedAutomationRuns] = useState<{ automationId: string | null runs: AutomationRun[] @@ -478,7 +488,9 @@ export default function AutomationsPage(): React.JSX.Element { (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) const selected = selectedExternal === null - ? (automations.find((automation) => automation.id === selectedId) ?? automations[0] ?? null) + ? selectedId + ? (automations.find((automation) => automation.id === selectedId) ?? null) + : (automations[0] ?? null) : null const runsWithWorkspaceNames = useMemo( () => @@ -515,9 +527,10 @@ export default function AutomationsPage(): React.JSX.Element { selected && selectedAutomationRuns.automationId === selected.id ? selectedAutomationRunsWithWorkspaceNames : runsWithWorkspaceNames - const selectedRuns = selected - ? selectedRunsSource.filter((run) => run.automationId === selected.id) - : [] + const selectedRuns = useMemo( + () => (selected ? selectedRunsSource.filter((run) => run.automationId === selected.id) : []), + [selected, selectedRunsSource] + ) const selectedAutomationRunPage = selectedAutomationRunPageId ? (selectedRuns.find((run) => run.id === selectedAutomationRunPageId) ?? null) : null @@ -534,6 +547,72 @@ export default function AutomationsPage(): React.JSX.Element { } } }, [worktreeMap]) + useEffect(() => { + if (!pendingAutomationRunNavigation || isLoading) { + return + } + const pending = pendingAutomationRunNavigation + const pendingTargetKey = getAutomationHostTargetKey( + getAutomationTargetFromHostId(pending.hostId) + ) + if (automationHostTargetKey !== pendingTargetKey) { + return + } + const pendingAutomation = automations.find( + (automation) => automation.id === pending.automationId + ) + if (!pendingAutomation) { + // Why: stale provenance should not silently select the first automation. + setSelectedId(pending.automationId) + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + toast.message( + translate( + 'auto.components.automations.AutomationsPage.pendingAutomationMissing', + 'Automation no longer available.' + ) + ) + return + } + if (selectedId !== pending.automationId) { + setSelectedId(pending.automationId) + return + } + if (!pending.runId) { + setActivePaneTab('overview') + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + return + } + if (selectedAutomationRuns.automationId !== pending.automationId) { + return + } + setActivePaneTab('runs') + const pendingRun = selectedRuns.find((run) => run.id === pending.runId) + if (pendingRun) { + setSelectedAutomationRunPageId(pending.runId) + setPendingAutomationRunNavigation(null) + return + } + setSelectedAutomationRunPageId(null) + setPendingAutomationRunNavigation(null) + toast.message( + translate( + 'auto.components.automations.AutomationsPage.pendingAutomationRunMissing', + 'Run history no longer available.' + ) + ) + }, [ + automations, + automationHostTargetKey, + isLoading, + pendingAutomationRunNavigation, + selectedAutomationRuns.automationId, + selectedId, + selectedRuns, + setPendingAutomationRunNavigation, + setSelectedId + ]) const activeTerminalTabIds = useMemo(() => { const ids = new Set() for (const tabs of Object.values(unifiedTabsByWorktree)) { @@ -790,7 +869,10 @@ export default function AutomationsPage(): React.JSX.Element { const refresh = useCallback(async () => { setIsLoading(true) - const automationHostTarget = getAutomationListTarget(settings) + const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation + const automationHostTarget = pendingNavigation + ? getAutomationTargetFromHostId(pendingNavigation.hostId) + : getAutomationListTarget(settings) try { const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ listAutomationsForTarget(automationHostTarget), @@ -801,20 +883,26 @@ export default function AutomationsPage(): React.JSX.Element { const hasCurrentSelection = nextAutomations.some( (automation) => automation.id === currentSelectedId ) - const nextSelectedId = hasCurrentSelection - ? currentSelectedId - : (nextAutomations[0]?.id ?? null) + let nextSelectedId: string | null + if (hasCurrentSelection) { + nextSelectedId = currentSelectedId + } else if (pendingNavigation) { + nextSelectedId = pendingNavigation.automationId + } else { + nextSelectedId = nextAutomations[0]?.id ?? null + } const nextSelectedRuns = nextSelectedId ? await listAutomationRunsForTarget(automationHostTarget, nextSelectedId) : [] setAutomations(nextAutomations) setRuns(nextRuns) + setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) setSelectedAutomationRuns({ automationId: nextSelectedId, runs: nextSelectedRuns }) setExternalManagers(nextExternalManagers) - if (!hasCurrentSelection) { + if (!hasCurrentSelection && !pendingNavigation) { selectAutomationId(nextAutomations[0]?.id ?? null) } } finally { @@ -822,6 +910,18 @@ export default function AutomationsPage(): React.JSX.Element { } }, [selectAutomationId, settings]) + useEffect(() => { + if (!pendingAutomationRunNavigation || isLoading) { + return + } + const pendingTargetKey = getAutomationHostTargetKey( + getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + ) + if (automationHostTargetKey !== pendingTargetKey) { + void refresh() + } + }, [automationHostTargetKey, isLoading, pendingAutomationRunNavigation, refresh]) + const hydratePersistedUIState = useCallback(async (): Promise => { useAppStore.getState().hydratePersistedUI(await window.api.ui.get()) }, []) @@ -843,17 +943,22 @@ export default function AutomationsPage(): React.JSX.Element { return } let cancelled = false - void listAutomationRunsForTarget(getAutomationListTarget(settings), automationId).then( - (nextRuns) => { - if (!cancelled) { - setSelectedAutomationRuns({ automationId, runs: nextRuns }) - } + const target = + pendingAutomationRunNavigation?.automationId === automationId && + pendingAutomationRunNavigation.hostId + ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) + : selected + ? getAutomationOwnerTarget(selected) + : getAutomationListTarget(settings) + void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { + if (!cancelled) { + setSelectedAutomationRuns({ automationId, runs: nextRuns }) } - ) + }) return () => { cancelled = true } - }, [selected?.id, runs, settings]) + }, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index cee3bf6f6..a657c88ad 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -22,9 +22,13 @@ type RuntimeAutomationUpdateInput = Omit ): AutomationHostTarget { - return getRuntimeTargetFromHostId(automation.runContext?.hostId) + return getAutomationTargetFromHostId(automation.runContext?.hostId) } export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { - return getRuntimeTargetFromHostId(input.runContext?.hostId) + return getAutomationTargetFromHostId(input.runContext?.hostId) } function toRuntimeAutomationCreateInput( diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index da811a736..7d21c357a 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server } from 'lucide-react' +import { Check, FolderPlus, GitBranch, ListFilter, Moon, Server, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { @@ -39,6 +39,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -85,9 +89,16 @@ const SidebarFilter = React.memo(function SidebarFilter({ const selectedCount = selectedRepoIdSet.size const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES - const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter + const hasAnyFilter = + hasSleepingFilter || + hideDefaultBranchWorkspace || + hideAutomationGeneratedWorkspaces || + hasRepoFilter const activeFilterCount = - (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + + (hideDefaultBranchWorkspace ? 1 : 0) + + (hideAutomationGeneratedWorkspaces ? 1 : 0) + + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) const commandValue = @@ -99,8 +110,14 @@ const SidebarFilter = React.memo(function SidebarFilter({ const clearAll = useCallback(() => { setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) setHideDefaultBranchWorkspace(false) + setHideAutomationGeneratedWorkspaces(false) setFilterRepoIds([]) - }, [setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setFilterRepoIds]) + }, [ + setShowSleepingWorkspaces, + setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, + setFilterRepoIds + ]) // Why: derive ids from the live repos list at click time so a repo added // while the popover is open is included immediately. @@ -176,6 +193,15 @@ const SidebarFilter = React.memo(function SidebarFilter({ checked={hideDefaultBranchWorkspace} onChange={setHideDefaultBranchWorkspace} /> + } + label={translate( + 'auto.components.sidebar.SidebarFilter.automationCreated', + 'Hide automation-created' + )} + checked={hideAutomationGeneratedWorkspaces} + onChange={setHideAutomationGeneratedWorkspaces} + /> {canFilterRepos && ( <> diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 8a2a492cd..cd0b5d843 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { GitBranch, Moon } from 'lucide-react' +import { GitBranch, Moon, Workflow } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -9,6 +9,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) return ( <> @@ -35,6 +39,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={hideDefaultBranchWorkspace} onChange={setHideDefaultBranchWorkspace} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated', + 'Hide automation-created' + )} + checked={hideAutomationGeneratedWorkspaces} + onChange={setHideAutomationGeneratedWorkspaces} + /> ) }) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 111c8f077..fcfbfb9ed 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -37,6 +37,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM }: SidebarWorkspaceOptionsMenuProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -76,10 +77,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null const hasAnyFilter = - hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter || hasHostVisibilityFilter + hasSleepingFilter || + hideDefaultBranchWorkspace || + hideAutomationGeneratedWorkspaces || + hasRepoFilter || + hasHostVisibilityFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + + (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 4741c5193..f3866e257 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -335,6 +335,98 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('Reviewer handoff note') }) + it('shows automation-created workspaces as a metadata icon property', async () => { + worktreeCardProperties = ['status', 'automation'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('Created by automation') + expect(markup).not.toContain('>Automation') + }) + + it('shows the automation metadata icon in compact card mode', async () => { + settings = { compactWorktreeCards: true } + worktreeCardProperties = ['status', 'automation'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).toContain('Created by automation') + expect(markup).not.toContain('>Automation') + }) + + it('hides automation-created card surfaces when the Automation property is disabled', async () => { + worktreeCardProperties = ['status'] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderWorktreeCardMarkup( + + ) + + expect(markup).not.toContain('Created by automation') + expect(markup).not.toContain('>Automation') + expect(markup).not.toContain('Nightly triage automation') + }) + it('hides live port metadata when the Ports card property is disabled', async () => { const worktree = makeWorktree() workspacePortScan = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 28578ae34..454367f3b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -205,6 +205,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) + const openAutomationsPage = useAppStore((s) => s.openAutomationsPage) + const setPendingAutomationRunNavigation = useAppStore((s) => s.setPendingAutomationRunNavigation) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace) const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) @@ -248,6 +250,53 @@ const WorktreeCard = React.memo(function WorktreeCard({ [worktree, openModal] ) + const handleOpenAutomation = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const automationId = worktree.automationProvenance?.automationId + if (!automationId) { + return + } + const hostId = worktree.automationProvenance?.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId, + runId: null, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance?.automationId, + worktree.automationProvenance?.hostId, + worktree.hostId + ] + ) + + const handleOpenAutomationRun = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const provenance = worktree.automationProvenance + if (!provenance) { + return + } + const hostId = provenance.hostId ?? worktree.hostId + setPendingAutomationRunNavigation({ + automationId: provenance.automationId, + runId: provenance.automationRunId, + ...(hostId ? { hostId } : {}) + }) + openAutomationsPage() + }, + [ + openAutomationsPage, + setPendingAutomationRunNavigation, + worktree.automationProvenance, + worktree.hostId + ] + ) + const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id]) const remoteBranchConflict = useAppStore((s) => s.remoteBranchConflictByWorktreeId[worktree.id]) @@ -442,6 +491,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showIssue = cardProps.includes('issue') const showLinearIssue = cardProps.includes('linear-issue') const showPR = cardProps.includes('pr') + const showAutomation = cardProps.includes('automation') const showComment = cardProps.includes('comment') const showPorts = cardProps.includes('ports') const shouldRefreshHostedReview = newCardStyle ? showStatus : showPR @@ -869,6 +919,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const metaIssue = showIssue ? hoverIssue : null const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null const metaReview = showPR ? hoverReview : null + const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null const metaComment = showComment ? hoverComment : null const handleOpenGitHubIssueInOrca = useCallback( (e: React.MouseEvent) => { @@ -958,7 +1009,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issue: metaIssue, linearIssue: metaLinearIssue, review: newCardStyle ? null : metaReview, - comment: metaComment + comment: metaComment, + automationProvenance: metaAutomationProvenance }) const hasPorts = showPorts && workspacePorts.length > 0 const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id) @@ -986,7 +1038,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showCombinedStatusSlot = showStatus const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) - const showTitleRowIndicators = newCardStyle && (hasDetails || hasPorts) + const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) // Why: detailed cards need a stable metadata lane only when it has content. // Grouped project views can hide the repo badge; don't reserve a blank // metadata lane unless branch or detached-head identity has content. @@ -1014,7 +1066,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issue: hoverIssue, linearIssue: hoverLinearIssue, review: hoverReview, - comment: hoverComment + comment: hoverComment, + automationProvenance: metaAutomationProvenance }) || workspacePorts.length > 0 || showBranchIdentityHover) @@ -1031,6 +1084,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={worktree.displayName} identityOrder="branch-first" @@ -1050,6 +1105,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: compact mode hides the metadata badge row, so title hover // carries the same explicit-link affordance without adding chrome. onUnlinkReview={ @@ -1078,6 +1135,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={newCardStyle ? null : metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} className="ml-0 pr-0" /> )} @@ -1090,6 +1148,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={metaLinearIssue} review={metaReview} comment={metaComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} detailsAfter={hasPorts ? : null} hoverControl={detailsHoverControl} onEditIssue={affiliateListMode ? undefined : handleEditIssue} @@ -1101,6 +1161,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ onOpenReviewInOrca={ metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: branch lookup can show a review without persisted metadata. Only // expose unlink when this workspace has an explicit linked PR/MR. onUnlinkReview={ @@ -1542,6 +1604,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue={hoverLinearIssue} review={hoverReview} comment={hoverComment} + automationProvenance={metaAutomationProvenance} + automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={showBranchIdentityHover ? visibleCardTitle : undefined} detailsAfter={ @@ -1560,6 +1624,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ onOpenReviewInOrca={ hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} // Why: branch lookup can show a review without persisted metadata. Only // expose unlink when this workspace has an explicit linked PR/MR. onUnlinkReview={ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx new file mode 100644 index 000000000..7d8ebd2de --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAutomationDetailSection.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import { CalendarClock, PlayCircle } from 'lucide-react' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AutomationWorkspaceProvenance } from '../../../../shared/types' +import { + WorktreeCardDetailSection, + WorktreeCardDetailSectionContent +} from './WorktreeCardDetailSection' +import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { translate } from '@/i18n/i18n' +import { + getAutomationTargetFromHostId, + listAutomationRunsForTarget, + listAutomationsForTarget +} from '@/components/automations/automation-host-client' + +type WorktreeCardAutomationDetailSectionProps = { + provenance: AutomationWorkspaceProvenance + worktreeHostId?: ExecutionHostId + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void +} + +type AutomationProvenanceAvailability = + | { status: 'checking' } + | { status: 'available'; runAvailable: boolean } + | { status: 'automation-missing' } + | { status: 'unavailable' } + +export function WorktreeCardAutomationDetailSection({ + provenance, + worktreeHostId, + onOpenAutomation, + onOpenAutomationRun +}: WorktreeCardAutomationDetailSectionProps): React.JSX.Element { + const [availability, setAvailability] = React.useState({ + status: 'checking' + }) + + React.useEffect(() => { + let cancelled = false + async function resolveAvailability(): Promise { + setAvailability({ status: 'checking' }) + try { + const target = getAutomationTargetFromHostId(provenance.hostId ?? worktreeHostId) + const automations = await listAutomationsForTarget(target) + const automation = automations.find((entry) => entry.id === provenance.automationId) + if (!automation) { + if (!cancelled) { + setAvailability({ status: 'automation-missing' }) + } + return + } + const runs = await listAutomationRunsForTarget(target, provenance.automationId) + if (!cancelled) { + setAvailability({ + status: 'available', + runAvailable: runs.some((run) => run.id === provenance.automationRunId) + }) + } + } catch { + if (!cancelled) { + setAvailability({ status: 'unavailable' }) + } + } + } + + void resolveAvailability() + return () => { + cancelled = true + } + }, [provenance.automationId, provenance.automationRunId, provenance.hostId, worktreeHostId]) + + const canOpenAutomation = availability.status === 'available' + const canOpenAutomationRun = availability.status === 'available' && availability.runAvailable + + return ( + + } + label={translate('auto.components.sidebar.WorktreeCardMeta.automationHeader', 'Automation')} + actions={ + <> + {onOpenAutomation && canOpenAutomation && ( + + + + )} + {onOpenAutomationRun && canOpenAutomationRun && ( + + + + )} + + } + /> + +
+ {provenance.automationNameSnapshot} +
+
+ {provenance.automationRunTitleSnapshot} +
+ {availability.status === 'checking' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.checkingAutomationAvailability', + 'Checking automation availability...' + )} +
+ ) : null} + {availability.status === 'automation-missing' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationMissing', + 'Automation no longer available.' + )} +
+ ) : null} + {availability.status === 'available' && !availability.runAvailable ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationRunMissing', + 'Run history no longer available.' + )} +
+ ) : null} + {availability.status === 'unavailable' ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.automationAvailabilityUnavailable', + 'Automation availability could not be checked.' + )} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx index e5e51b84c..87a265d31 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardDisplayMenuSection.tsx @@ -14,9 +14,9 @@ import type { AgentActivityDisplayMode, WorktreeCardProperty } from '../../../.. import { AGENT_ACTIVITY_DISPLAY_OPTIONS, CARD_LAYOUT_OPTIONS, - PROPERTY_OPTIONS, getWorktreeCardPropertyOptions } from './sidebar-workspace-option-items' +import { PROPERTY_OPTIONS } from './worktree-card-display-property-options' import { translate } from '@/i18n/i18n' type WorktreeCardDisplayMenuSectionProps = { diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index a63a26197..1c5fdf9a1 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' -import { CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -24,6 +24,7 @@ import type { } from './worktree-card-meta-types' import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' +import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' export type { WorktreeCardIssueDisplay, @@ -41,19 +42,20 @@ export function hasWorktreeCardDetails({ issue, linearIssue, review, - comment + comment, + automationProvenance }: WorktreeCardMetaBadgesProps): boolean { - return Boolean(issue || linearIssue || review || hasComment(comment)) + return Boolean(issue || linearIssue || review || hasComment(comment) || automationProvenance) } export const WorktreeCardMetaBadges = React.forwardRef< HTMLDivElement, WorktreeCardMetaBadgesRootProps >(function WorktreeCardMetaBadges( - { issue, linearIssue, review, comment, className, ...props }, + { issue, linearIssue, review, comment, automationProvenance, className, ...props }, ref ): React.JSX.Element | null { - if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment })) { + if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance })) { return null } @@ -79,6 +81,16 @@ export const WorktreeCardMetaBadges = React.forwardRef< )} + {automationProvenance && ( + + + + )} {issue && ( + {automationProvenance && ( + + )} + {hasComment(comment) && ( s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -4726,6 +4727,7 @@ const WorktreeList = React.memo(function WorktreeList({ ptyIdsByTabId, browserTabsByWorktree, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -4748,6 +4750,7 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, showSleepingWorkspaces, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -5582,13 +5585,23 @@ const WorktreeList = React.memo(function WorktreeList({ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, visibleWorkspaceHostIds }), - [showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, visibleWorkspaceHostIds] + [ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + visibleWorkspaceHostIds + ] ) const hasFilters = sidebarHasActiveFilters(filterState) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -5603,6 +5616,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDefaultBranchWorkspace) { setHideDefaultBranchWorkspace(false) } + if (actions.resetHideAutomationGeneratedWorkspaces) { + setHideAutomationGeneratedWorkspaces(false) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -5610,6 +5626,7 @@ const WorktreeList = React.memo(function WorktreeList({ setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts index 058cb64b2..e43f8b9d7 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.test.ts @@ -11,10 +11,13 @@ describe('worktree card property options', () => { expect(WORKTREE_CARD_PROPERTY_OPTIONS).toEqual(options) expect(options.map((option) => option.id)).toContain('tasks') + expect(options.map((option) => option.id)).toContain('automation') expect(options.find((option) => option.id === 'tasks')?.properties).toEqual( TASK_WORKTREE_CARD_PROPERTIES ) + expect(options.find((option) => option.id === 'automation')?.properties).toEqual(['automation']) expect(options.map((option) => option.label)).toContain('Tasks') + expect(options.map((option) => option.label)).toContain('Automation') expect(options.map((option) => option.label)).not.toContain('GitHub issues') expect(options.map((option) => option.label)).not.toContain('Linear issues') }) @@ -28,7 +31,9 @@ describe('worktree card property options', () => { expect(options.find((option) => option.id === 'linear-issue')?.properties).toEqual([ 'linear-issue' ]) + expect(options.find((option) => option.id === 'automation')?.properties).toEqual(['automation']) expect(options.map((option) => option.label)).toContain('GitHub issues') expect(options.map((option) => option.label)).toContain('Linear issues') + expect(options.map((option) => option.label)).toContain('Automation') }) }) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 3ceeee444..1772ea4c1 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -44,57 +44,6 @@ export const CARD_LAYOUT_OPTIONS = [ } ] as const -export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ - { - id: 'issue', - get label() { - return translate( - 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', - 'GitHub ticket' - ) - } - }, - { - id: 'linear-issue', - get label() { - return translate( - 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', - 'Linear issue' - ) - } - }, - { - id: 'pr', - get label() { - return translate( - 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', - 'PR/MR link' - ) - } - }, - { - id: 'comment', - get label() { - return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') - } - }, - { - id: 'ports', - get label() { - return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') - } - }, - { - id: 'inline-agents', - get label() { - return translate( - 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', - 'Agent activity' - ) - } - } -] - export const AGENT_ACTIVITY_DISPLAY_OPTIONS: { id: AgentActivityDisplayMode label: string @@ -137,6 +86,16 @@ const BASE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [ return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.8d62c68b35', 'Notes') } }, + { + id: 'automation', + properties: ['automation'], + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.automation', + 'Automation' + ) + } + }, { id: 'ports', properties: ['ports'], diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 5ce06c51a..03c3dac37 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -16,6 +16,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const settings = useAppStore((s) => s.settings) @@ -38,6 +39,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ ptyIdsByTabId, browserTabsByWorktree, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -52,6 +54,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ browserTabsByWorktree, filterRepoIds, hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c0a2bb9d9..81a78bec1 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -81,6 +81,7 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions ptyIdsByTabId: {}, browserTabsByWorktree: {}, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, repoMap, workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -96,6 +97,7 @@ function filterState(overrides: Partial = {}): FilterState { showSleepingWorkspaces: true, filterRepoIds: [], hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, ...overrides } } @@ -130,6 +132,36 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([]) }) + it('hides automation-created workspaces when the automation filter is enabled', () => { + const manual = makeWorktree('manual') + const automationCreated = { + ...makeWorktree('automation-created'), + automationProvenance: { + kind: 'created-by-automation' as const, + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local' as const, + executionTargetId: 'local', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local' as const + } + } + + const result = computeVisibleWorktreeIds( + { repo1: [manual, automationCreated] }, + [manual.id, automationCreated.id], + visibleOptions({ + hideAutomationGeneratedWorkspaces: true + }) + ) + + expect(result).toEqual([manual.id]) + }) + it('does not treat slept wake-hint tabs as live surfaces', () => { const wt = makeWorktree('wt-slept') @@ -517,6 +549,12 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true) }) + it('returns true when only automation-created workspaces are hidden', () => { + expect(sidebarHasActiveFilters(filterState({ hideAutomationGeneratedWorkspaces: true }))).toBe( + true + ) + }) + it('returns true when sleeping workspaces are hidden', () => { expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true) }) @@ -536,6 +574,7 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, resetVisibleWorkspaceHostIds: false }) }) @@ -548,6 +587,19 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: false, resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: true, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: false + }) + }) + + it('flags only hideAutomationGeneratedWorkspaces for reset when it is the sole filter', () => { + expect( + computeClearFilterActions(filterState({ hideAutomationGeneratedWorkspaces: true })) + ).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: true, resetVisibleWorkspaceHostIds: false }) }) @@ -572,6 +624,7 @@ describe('computeClearFilterActions', () => { showSleepingWorkspaces: false, filterRepoIds: ['repo1', 'repo2'], hideDefaultBranchWorkspace: true, + hideAutomationGeneratedWorkspaces: true, visibleWorkspaceHostIds: ['local'] }) ) @@ -579,6 +632,7 @@ describe('computeClearFilterActions', () => { resetShowSleepingWorkspaces: true, resetFilterRepoIds: true, resetHideDefaultBranchWorkspace: true, + resetHideAutomationGeneratedWorkspaces: true, resetVisibleWorkspaceHostIds: true }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 41cbe9753..785fc2a39 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -25,11 +25,16 @@ export function isDefaultBranchWorkspace(worktree: Worktree): boolean { return worktree.isMainWorktree && worktree.branch.trim() !== '' } +export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean { + return worktree.automationProvenance?.kind === 'created-by-automation' +} + /** Inputs describing sidebar filter settings that the Clear Filters path owns. */ export type SidebarFilterState = { showSleepingWorkspaces: boolean filterRepoIds: readonly string[] hideDefaultBranchWorkspace: boolean + hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null } @@ -47,6 +52,7 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES || state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || + state.hideAutomationGeneratedWorkspaces || state.visibleWorkspaceHostIds != null ) } @@ -57,6 +63,7 @@ export type ClearFilterActions = { resetShowSleepingWorkspaces: boolean resetFilterRepoIds: boolean resetHideDefaultBranchWorkspace: boolean + resetHideAutomationGeneratedWorkspaces: boolean resetVisibleWorkspaceHostIds: boolean } @@ -75,6 +82,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES, resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null } } @@ -103,6 +111,7 @@ export function computeVisibleWorktreeIds( // required prevents a future caller from silently dropping the filter by // forgetting to pass it. hideDefaultBranchWorkspace: boolean + hideAutomationGeneratedWorkspaces: boolean repoMap: Map workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -123,6 +132,10 @@ export function computeVisibleWorktreeIds( all = all.filter((w) => !isDefaultBranchWorkspace(w)) } + if (opts.hideAutomationGeneratedWorkspaces) { + all = all.filter((w) => !isAutomationGeneratedWorkspace(w)) + } + const visibleHostIds = opts.visibleWorkspaceHostIds ?? (opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope]) @@ -289,6 +302,7 @@ export function getVisibleWorktreeIds(): string[] { ptyIdsByTabId: state.ptyIdsByTabId, browserTabsByWorktree: state.browserTabsByWorktree, hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, repoMap, workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, diff --git a/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts new file mode 100644 index 000000000..45ed0bab6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-display-property-options.ts @@ -0,0 +1,62 @@ +import type { WorktreeCardProperty } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ + { + id: 'issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', + 'GitHub ticket' + ) + } + }, + { + id: 'linear-issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', + 'Linear issue' + ) + } + }, + { + id: 'pr', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', + 'PR/MR link' + ) + } + }, + { + id: 'automation', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.automation', + 'Automation' + ) + } + }, + { + id: 'comment', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') + } + }, + { + id: 'ports', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') + } + }, + { + id: 'inline-agents', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', + 'Agent activity' + ) + } + } +] diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index 129354766..f0393848d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -1,4 +1,5 @@ -import type { IssueInfo } from '../../../../shared/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AutomationWorkspaceProvenance, IssueInfo } from '../../../../shared/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' @@ -25,6 +26,7 @@ export type WorktreeCardMetaBadgesProps = { linearIssue: WorktreeCardLinearIssueDisplay | null review: WorktreeCardPrDisplay | null comment: string | null + automationProvenance?: AutomationWorkspaceProvenance | null } export type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps & @@ -35,6 +37,7 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { branchName?: string workspaceTitle?: string identityOrder?: 'workspace-first' | 'branch-first' + automationHostId?: ExecutionHostId detailsAfter?: React.ReactNode openDelay?: number closeDelay?: number @@ -44,5 +47,7 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { onOpenLinearIssueInOrca?: (event: React.MouseEvent) => void onOpenReviewInOrca?: (event: React.MouseEvent) => void onUnlinkReview?: () => void + onOpenAutomation?: (event: React.MouseEvent) => void + onOpenAutomationRun?: (event: React.MouseEvent) => void hoverControl?: WorktreeCardDetailsHoverControl } diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 2fc2eb3e7..b44c9b525 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -22,6 +22,7 @@ import { selectAutomationRunOutputSnapshot } from '@/components/automations/automation-run-output-snapshot' import { translate } from '@/i18n/i18n' +import { createBrowserUuid } from '@/lib/browser-uuid' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -46,424 +47,453 @@ function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): s export function useAutomationDispatchEvents(): void { useEffect(() => { - const unsubscribe = window.api.automations.onDispatchRequested(async ({ automation, run }) => { - const markDispatchResult = async (result: AutomationDispatchResult): Promise => { - await window.api.automations.markDispatchResult(result) - window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) - } - const state = useAppStore.getState() - const focusBeforeDispatch = { - activeView: state.activeView, - activeWorktreeId: state.activeWorktreeId, - activeTabId: state.activeTabId, - activeTabType: state.activeTabType - } - const runRepoId = getAutomationRunRepoId(automation) - const repo = state.repos.find((entry) => entry.id === runRepoId) - const automationWorktree = automation.workspaceId - ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) - : null - let dispatchWorkspaceId = automation.workspaceId - let dispatchWorkspaceDisplayName = - automationWorktree?.displayName ?? run.workspaceDisplayName ?? null - let precheckResult: AutomationPrecheckResult | null = null - - if (!repo) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: run.workspaceId, - workspaceDisplayName: run.workspaceDisplayName ?? null, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.386db94f3e', - 'The target project is no longer available.' - ) - }) - return - } - - if (repo.connectionId) { - const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ - targetId: repo.connectionId - }) - if (needsPrompt) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_needs_interactive_auth', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.16a21d6413', - 'SSH reconnect requires interactive credentials.' - ) - }) - return + const unsubscribe = window.api.automations.onDispatchRequested( + async ({ automation, run, dispatchToken }) => { + const markDispatchResult = async (result: AutomationDispatchResult): Promise => { + await window.api.automations.markDispatchResult(result) + window.dispatchEvent(new Event(AUTOMATIONS_CHANGED_EVENT)) } - const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) - if (sshState?.status !== 'connected') { - try { - const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) - if (connected?.status !== 'connected') { - throw new Error('SSH target is unavailable.') - } - } catch (error) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: error instanceof Error ? error.message : String(error) - }) - return - } + const state = useAppStore.getState() + const focusBeforeDispatch = { + activeView: state.activeView, + activeWorktreeId: state.activeWorktreeId, + activeTabId: state.activeTabId, + activeTabType: state.activeTabType } - } + const runRepoId = getAutomationRunRepoId(automation) + const repo = state.repos.find((entry) => entry.id === runRepoId) + const automationWorktree = automation.workspaceId + ? state.allWorktrees().find((entry) => entry.id === automation.workspaceId) + : null + let dispatchWorkspaceId = automation.workspaceId + let dispatchWorkspaceDisplayName = + automationWorktree?.displayName ?? run.workspaceDisplayName ?? null + let precheckResult: AutomationPrecheckResult | null = null - if ( - automation.workspaceMode === 'existing' && - automationWorktree && - automation.runContext?.repoId && - automationWorktree.repoId !== automation.runContext.repoId - ) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', - 'The target workspace is on a different host than this automation run target.' - ) - }) - return - } - - if (automation.workspaceMode === 'existing' && !automationWorktree) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' - ) - }) - return - } - - if (run.trigger === 'scheduled' && automation.precheck) { - precheckResult = await window.api.automations.runPrecheck({ - automationId: automation.id, - runId: run.id - }) - if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { - await markDispatchResult({ - runId: run.id, - status: 'skipped_precheck', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: formatAutomationPrecheckFailure(precheckResult) - }) - return - } - } - - try { - const worktree = - automation.workspaceMode === 'new_per_run' - ? ( - await useAppStore - .getState() - .createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - 'inherit', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - automation.agentId - ) - ).worktree - : automation.workspaceId - ? automationWorktree - : null - - if (!worktree) { + if (!repo) { await markDispatchResult({ runId: run.id, status: 'skipped_unavailable', - workspaceId: automation.workspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, + workspaceId: run.workspaceId, + workspaceDisplayName: run.workspaceDisplayName ?? null, error: translate( - 'auto.hooks.useAutomationDispatchEvents.59718b120b', - 'The target workspace is no longer available.' + 'auto.hooks.useAutomationDispatchEvents.386db94f3e', + 'The target project is no longer available.' ) }) return } - dispatchWorkspaceId = worktree.id - dispatchWorkspaceDisplayName = worktree.displayName - const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() - let latestAssistantMessage: string | null = null - const getOutputSnapshot = () => - selectAutomationRunOutputSnapshot(latestAssistantMessage, outputSnapshotBuffer.snapshot()) - let dispatchMarked = false - let pendingExitCode: number | null = null - let pendingDone = false - let completionMarked = false - let unsubscribeAgentStatus = (): void => {} - let unsubscribeSessionObserver = (): void => {} - let releaseReuseDispatchTab = (): void => {} - const cleanupRunObservers = (): void => { - unsubscribeAgentStatus() - unsubscribeSessionObserver() - releaseReuseDispatchTab() - unsubscribeAgentStatus = (): void => {} - unsubscribeSessionObserver = (): void => {} - releaseReuseDispatchTab = (): void => {} - } - const markCompletionResult = async (): Promise => { - if (completionMarked) { - return - } - completionMarked = true - cleanupRunObservers() - await markDispatchResult({ - runId: run.id, - status: 'completed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: null - }) - } - const markExitResult = (code: number): Promise => { - cleanupRunObservers() - return markDispatchResult({ - runId: run.id, - status: code === 0 ? 'completed' : 'dispatch_failed', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - outputSnapshot: getOutputSnapshot(), - precheckResult, - error: code === 0 ? null : `Automation process exited with code ${code}.` - }) - } - const handleAgentDone = (): void => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingDone = true - return - } - void markCompletionResult() - } - const observeAgentStatus = ( - tabId: string, - startedAfter: number, - options?: { requireWorkingAfterStart?: boolean } - ): void => { - let sawWorkingAfterStart = false - const checkCurrentStatus = (): void => { - const { agentStatusByPaneKey } = useAppStore.getState() - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) - if (parsed?.tabId !== tabId || entry.updatedAt < startedAfter) { - continue - } - if (entry.state === 'working') { - sawWorkingAfterStart = true - } - if ( - entry.state === 'done' && - (!options?.requireWorkingAfterStart || sawWorkingAfterStart) - ) { - latestAssistantMessage = - entry.lastAssistantMessage?.trim() || latestAssistantMessage - handleAgentDone() + try { + if (repo.connectionId) { + const needsPrompt = await window.api.ssh.needsPassphrasePrompt({ + targetId: repo.connectionId + }) + if (needsPrompt) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_needs_interactive_auth', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.16a21d6413', + 'SSH reconnect requires interactive credentials.' + ) + }) + return + } + const sshState = await window.api.ssh.getState({ targetId: repo.connectionId }) + if (sshState?.status !== 'connected') { + try { + const connected = await window.api.ssh.connect({ targetId: repo.connectionId }) + if (connected?.status !== 'connected') { + throw new Error('SSH target is unavailable.') + } + } catch (error) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: error instanceof Error ? error.message : String(error) + }) return } } } - // Why: Codex/Claude completion normally arrives through the global - // hook IPC listener, not the hidden PTY OSC fallback. - unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) - checkCurrentStatus() - } - const dispatchStartedAt = Date.now() - if (automation.reuseSession) { - const reusableSession = findReusableAutomationSession({ - automationId: automation.id, - agentId: automation.agentId, - worktreeId: worktree.id, - currentRunId: run.id, - runs: await window.api.automations.listRuns({ automationId: automation.id }), - state: useAppStore.getState() - }) - if (reusableSession) { - const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) - if (releaseTab) { - releaseReuseDispatchTab = releaseTab - try { - const submitted = await submitPromptToAgentTab({ - tabId: reusableSession.tabId, - content: automation.prompt - }) - if (!submitted) { - cleanupRunObservers() - } else { - let reuseSawWorking = false - const handleReusableAgentStatus = (payload: { state: string }): void => { - if (payload.state === 'working') { - reuseSawWorking = true - return - } - if (payload.state === 'done' && reuseSawWorking) { - handleAgentDone() - } - } - const reuseCompletionStartedAt = Date.now() - unsubscribeSessionObserver = await observeExistingAutomationSession({ - ptyId: reusableSession.ptyId, - paneKey: reusableSession.paneKey, - runId: run.id, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = - payload.lastAssistantMessage?.trim() || latestAssistantMessage - handleReusableAgentStatus(payload) - }, - onExit: (code) => { - if (completionMarked) { - return - } - if (!dispatchMarked) { - pendingExitCode = code - return - } - void markExitResult(code) - } - }) - observeAgentStatus(reusableSession.tabId, reuseCompletionStartedAt, { - requireWorkingAfterStart: true - }) - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: reusableSession.tabId, - precheckResult, - error: null - }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) - } - return - } - } catch (error) { - cleanupRunObservers() - throw error - } - } + + if ( + automation.workspaceMode === 'existing' && + automationWorktree && + automation.runContext?.repoId && + automationWorktree.repoId !== automation.runContext.repoId + ) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.3ad7d77f57', + 'The target workspace is on a different host than this automation run target.' + ) + }) + return } - } - const result = await launchAgentBackgroundSession({ - agent: automation.agentId, - worktreeId: worktree.id, - prompt: automation.prompt, - launchSource: 'unknown', - title: run.title, - onData: (chunk) => { - outputSnapshotBuffer.append(chunk) - }, - onAgentStatus: (payload) => { - latestAssistantMessage = payload.lastAssistantMessage?.trim() || latestAssistantMessage - if (payload.state !== 'done') { + + if (automation.workspaceMode === 'existing' && !automationWorktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return + } + + if (run.trigger === 'scheduled' && automation.precheck) { + precheckResult = await window.api.automations.runPrecheck({ + automationId: automation.id, + runId: run.id + }) + if (precheckResult && !didAutomationPrecheckPass(precheckResult)) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_precheck', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + precheckResult, + error: formatAutomationPrecheckFailure(precheckResult) + }) return } - handleAgentDone() - }, - onExit: (_ptyId, code) => { + } + + const automationWorkspaceCreateRequestId = createBrowserUuid() + const worktree = + automation.workspaceMode === 'new_per_run' + ? ( + await useAppStore + .getState() + .createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + 'inherit', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + automation.agentId, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId + } + } + ) + ).worktree + : automation.workspaceId + ? automationWorktree + : null + + if (!worktree) { + await markDispatchResult({ + runId: run.id, + status: 'skipped_unavailable', + workspaceId: automation.workspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + error: translate( + 'auto.hooks.useAutomationDispatchEvents.59718b120b', + 'The target workspace is no longer available.' + ) + }) + return + } + dispatchWorkspaceId = worktree.id + dispatchWorkspaceDisplayName = worktree.displayName + + const outputSnapshotBuffer = createAutomationRunOutputSnapshotBuffer() + let latestAssistantMessage: string | null = null + const getOutputSnapshot = () => + selectAutomationRunOutputSnapshot( + latestAssistantMessage, + outputSnapshotBuffer.snapshot() + ) + let dispatchMarked = false + let pendingExitCode: number | null = null + let pendingDone = false + let completionMarked = false + let unsubscribeAgentStatus = (): void => {} + let unsubscribeSessionObserver = (): void => {} + let releaseReuseDispatchTab = (): void => {} + const cleanupRunObservers = (): void => { + unsubscribeAgentStatus() + unsubscribeSessionObserver() + releaseReuseDispatchTab() + unsubscribeAgentStatus = (): void => {} + unsubscribeSessionObserver = (): void => {} + releaseReuseDispatchTab = (): void => {} + } + const markCompletionResult = async (): Promise => { + if (completionMarked) { + return + } + completionMarked = true + cleanupRunObservers() + await markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: null + }) + } + const markExitResult = (code: number): Promise => { + cleanupRunObservers() + return markDispatchResult({ + runId: run.id, + status: code === 0 ? 'completed' : 'dispatch_failed', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + outputSnapshot: getOutputSnapshot(), + precheckResult, + error: code === 0 ? null : `Automation process exited with code ${code}.` + }) + } + const handleAgentDone = (): void => { if (completionMarked) { return } if (!dispatchMarked) { - pendingExitCode = code + pendingDone = true return } - void markExitResult(code) + void markCompletionResult() } - }) - if (!result) { - throw new Error('Unable to build an agent launch plan.') - } - const launchedTabId = result.tabId - // Why: host-backed automation terminals may lack a local tab id; skip - // pane-key status observation while background session output still - // tracks completion. - if (launchedTabId) { - observeAgentStatus(launchedTabId, dispatchStartedAt) - } - try { - await markDispatchResult({ - runId: run.id, - status: 'dispatched', - workspaceId: worktree.id, - workspaceDisplayName: worktree.displayName, - terminalSessionId: launchedTabId, - precheckResult, - error: null + const observeAgentStatus = ( + tabId: string, + startedAfter: number, + options?: { requireWorkingAfterStart?: boolean } + ): void => { + let sawWorkingAfterStart = false + const checkCurrentStatus = (): void => { + const { agentStatusByPaneKey } = useAppStore.getState() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + const parsed = parsePaneKey(paneKey) + if (parsed?.tabId !== tabId || entry.updatedAt < startedAfter) { + continue + } + if (entry.state === 'working') { + sawWorkingAfterStart = true + } + if ( + entry.state === 'done' && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + latestAssistantMessage = + entry.lastAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + return + } + } + } + // Why: Codex/Claude completion normally arrives through the global + // hook IPC listener, not the hidden PTY OSC fallback. + unsubscribeAgentStatus = useAppStore.subscribe(checkCurrentStatus) + checkCurrentStatus() + } + const dispatchStartedAt = Date.now() + if (automation.reuseSession) { + const reusableSession = findReusableAutomationSession({ + automationId: automation.id, + agentId: automation.agentId, + worktreeId: worktree.id, + currentRunId: run.id, + runs: await window.api.automations.listRuns({ automationId: automation.id }), + state: useAppStore.getState() + }) + if (reusableSession) { + const releaseTab = acquireReuseDispatchTab(reusableSession.tabId) + if (releaseTab) { + releaseReuseDispatchTab = releaseTab + try { + const submitted = await submitPromptToAgentTab({ + tabId: reusableSession.tabId, + content: automation.prompt + }) + if (!submitted) { + cleanupRunObservers() + } else { + let reuseSawWorking = false + const handleReusableAgentStatus = (payload: { state: string }): void => { + if (payload.state === 'working') { + reuseSawWorking = true + return + } + if (payload.state === 'done' && reuseSawWorking) { + handleAgentDone() + } + } + const reuseCompletionStartedAt = Date.now() + unsubscribeSessionObserver = await observeExistingAutomationSession({ + ptyId: reusableSession.ptyId, + paneKey: reusableSession.paneKey, + runId: run.id, + onData: (chunk) => { + outputSnapshotBuffer.append(chunk) + }, + onAgentStatus: (payload) => { + latestAssistantMessage = + payload.lastAssistantMessage?.trim() || latestAssistantMessage + handleReusableAgentStatus(payload) + }, + onExit: (code) => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingExitCode = code + return + } + void markExitResult(code) + } + }) + observeAgentStatus(reusableSession.tabId, reuseCompletionStartedAt, { + requireWorkingAfterStart: true + }) + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: reusableSession.tabId, + precheckResult, + error: null + }) + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + return + } + } catch (error) { + cleanupRunObservers() + throw error + } + } + } + } + const result = await launchAgentBackgroundSession({ + agent: automation.agentId, + worktreeId: worktree.id, + prompt: automation.prompt, + launchSource: 'unknown', + title: run.title, + onData: (chunk) => { + outputSnapshotBuffer.append(chunk) + }, + onAgentStatus: (payload) => { + latestAssistantMessage = + payload.lastAssistantMessage?.trim() || latestAssistantMessage + if (payload.state !== 'done') { + return + } + handleAgentDone() + }, + onExit: (_ptyId, code) => { + if (completionMarked) { + return + } + if (!dispatchMarked) { + pendingExitCode = code + return + } + void markExitResult(code) + } }) - dispatchMarked = true - if (pendingDone) { - await markCompletionResult() - } else if (pendingExitCode !== null) { - await markExitResult(pendingExitCode) + if (!result) { + throw new Error('Unable to build an agent launch plan.') + } + const launchedTabId = result.tabId + // Why: host-backed automation terminals may lack a local tab id; skip + // pane-key status observation while background session output still + // tracks completion. + if (launchedTabId) { + observeAgentStatus(launchedTabId, dispatchStartedAt) + } + try { + await markDispatchResult({ + runId: run.id, + status: 'dispatched', + workspaceId: worktree.id, + workspaceDisplayName: worktree.displayName, + terminalSessionId: launchedTabId, + precheckResult, + error: null + }) + dispatchMarked = true + if (pendingDone) { + await markCompletionResult() + } else if (pendingExitCode !== null) { + await markExitResult(pendingExitCode) + } + } catch (error) { + cleanupRunObservers() + throw error + } + const currentState = useAppStore.getState() + // Why: Run Now and scheduled dispatches should create workspaces/tabs in + // the background; only an explicit row click should navigate there. + if ( + focusBeforeDispatch.activeWorktreeId !== worktree.id && + currentState.activeWorktreeId === worktree.id + ) { + currentState.setActiveView(focusBeforeDispatch.activeView) + currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) + if (focusBeforeDispatch.activeTabId) { + currentState.setActiveTab(focusBeforeDispatch.activeTabId) + } + currentState.setActiveTabType(focusBeforeDispatch.activeTabType) } } catch (error) { - cleanupRunObservers() - throw error + await markDispatchResult({ + runId: run.id, + status: 'dispatch_failed', + workspaceId: dispatchWorkspaceId, + workspaceDisplayName: dispatchWorkspaceDisplayName, + precheckResult, + error: error instanceof Error ? error.message : String(error) + }) } - const currentState = useAppStore.getState() - // Why: Run Now and scheduled dispatches should create workspaces/tabs in - // the background; only an explicit row click should navigate there. - if ( - focusBeforeDispatch.activeWorktreeId !== worktree.id && - currentState.activeWorktreeId === worktree.id - ) { - currentState.setActiveView(focusBeforeDispatch.activeView) - currentState.setActiveWorktree(focusBeforeDispatch.activeWorktreeId) - if (focusBeforeDispatch.activeTabId) { - currentState.setActiveTab(focusBeforeDispatch.activeTabId) - } - currentState.setActiveTabType(focusBeforeDispatch.activeTabType) - } - } catch (error) { - await markDispatchResult({ - runId: run.id, - status: 'dispatch_failed', - workspaceId: dispatchWorkspaceId, - workspaceDisplayName: dispatchWorkspaceDisplayName, - precheckResult, - error: error instanceof Error ? error.message : String(error) - }) } - }) + ) void window.api.automations.rendererReady() return unsubscribe }, []) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6f74439a9..bb284d0b0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3544,7 +3544,8 @@ "f506a1262a": "Filter workspaces", "75405270ed": "Edit filters ({{value0}} active)", "489d1c8c9f": "Search projects...", - "ee240a39eb": "Edit filters" + "ee240a39eb": "Edit filters", + "automationCreated": "Hide automation-created" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -3606,7 +3607,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Hide default branch", "ed1611b65b": "Hide sleeping", - "82594419ba": "Filters" + "82594419ba": "Filters", + "automationCreated": "Hide automation-created" }, "sidebarHostOptions": { "3e102f111c": "All hosts", @@ -3765,7 +3767,15 @@ "3ea2702e62": "Linked {{value0}} #{{value1}}", "b105fd3057": "Linked Linear {{value0}}", "3f2649eeb8": "Linked issue #{{value0}}", - "fe075cb851": "Workspace notes" + "fe075cb851": "Workspace notes", + "automationHeader": "Automation", + "openAutomation": "Open automation", + "openAutomationRun": "Open run", + "automationCreated": "Created by automation", + "checkingAutomationAvailability": "Checking automation availability...", + "automationMissing": "Automation no longer available.", + "automationRunMissing": "Run history no longer available.", + "automationAvailabilityUnavailable": "Automation availability could not be checked." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "State: Open", @@ -11175,7 +11185,9 @@ "d441032f7e": "pause", "5918020edc": "run", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "Automation no longer available.", + "pendingAutomationRunMissing": "Run history no longer available." }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4bdbdb64b..78ce8ab3e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3538,7 +3538,8 @@ "f506a1262a": "Filtrar espacios de trabajo", "75405270ed": "Editar filtros ({{value0}} activo)", "489d1c8c9f": "Buscar proyectos...", - "ee240a39eb": "Editar filtros" + "ee240a39eb": "Editar filtros", + "automationCreated": "Ocultar creados por automatizaciones" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -3600,7 +3601,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Ocultar rama predeterminada", "ed1611b65b": "Ocultar durmiendo", - "82594419ba": "Filtros" + "82594419ba": "Filtros", + "automationCreated": "Ocultar creados por automatizaciones" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -3751,7 +3753,15 @@ "3ea2702e62": "Vinculado {{value0}} #{{value1}}", "b105fd3057": "Linear vinculado {{value0}}", "3f2649eeb8": "Problema vinculado #{{value0}}", - "fe075cb851": "Notas del espacio de trabajo" + "fe075cb851": "Notas del espacio de trabajo", + "automationHeader": "Automatización", + "openAutomation": "Abrir automatización", + "openAutomationRun": "Abrir ejecución", + "automationCreated": "Creado por automatización", + "checkingAutomationAvailability": "Comprobando disponibilidad de la automatización...", + "automationMissing": "La automatización ya no está disponible.", + "automationRunMissing": "El historial de ejecuciones ya no está disponible.", + "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", @@ -11175,7 +11185,9 @@ "d441032f7e": "pausa", "5918020edc": "correr", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "La automatización ya no está disponible.", + "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." }, "CreateFromPicker": { "f061f49e3f": "Buscar sucursales de repo...", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2f119fa47..5768c12d2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3519,7 +3519,8 @@ "f506a1262a": "ワークスペースのフィルタリング", "75405270ed": "フィルターの編集 ({{value0}} がアクティブ)", "489d1c8c9f": "プロジェクトを検索...", - "ee240a39eb": "フィルターの編集" + "ee240a39eb": "フィルターの編集", + "automationCreated": "自動化で作成されたワークスペースを非表示" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "デフォルトのブランチを非表示にする", "ed1611b65b": "スリープ中を非表示", - "82594419ba": "フィルター" + "82594419ba": "フィルター", + "automationCreated": "自動化で作成されたワークスペースを非表示" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -3732,7 +3734,15 @@ "3ea2702e62": "リンク済み {{value0}} #{{value1}}", "b105fd3057": "リンク Linear {{value0}}", "3f2649eeb8": "リンクされた Issue #{{value0}}", - "fe075cb851": "ワークスペースのメモ" + "fe075cb851": "ワークスペースのメモ", + "automationHeader": "自動化", + "openAutomation": "自動化を開く", + "openAutomationRun": "実行を開く", + "automationCreated": "自動化によって作成", + "checkingAutomationAvailability": "自動化の利用可否を確認中...", + "automationMissing": "自動化は利用できなくなりました。", + "automationRunMissing": "実行履歴は利用できなくなりました。", + "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", @@ -11175,7 +11185,9 @@ "d441032f7e": "一時停止", "5918020edc": "走る", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "自動化は利用できなくなりました。", + "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。" }, "CreateFromPicker": { "f061f49e3f": "repo ブランチを検索...", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 17d2f8c83..457c889ef 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3519,7 +3519,8 @@ "f506a1262a": "워크스페이스 필터링", "75405270ed": "필터 편집({{value0}} 활성)", "489d1c8c9f": "프로젝트 검색...", - "ee240a39eb": "필터 편집" + "ee240a39eb": "필터 편집", + "automationCreated": "자동화로 생성된 워크스페이스 숨기기" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "기본 브랜치 숨기기", "ed1611b65b": "슬립 중인 항목 숨기기", - "82594419ba": "필터" + "82594419ba": "필터", + "automationCreated": "자동화로 생성된 워크스페이스 숨기기" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -3732,7 +3734,15 @@ "3ea2702e62": "연결됨 {{value0}} #{{value1}}", "b105fd3057": "연결된 Linear {{value0}}", "3f2649eeb8": "연결된 이슈 #{{value0}}", - "fe075cb851": "워크스페이스 메모" + "fe075cb851": "워크스페이스 메모", + "automationHeader": "자동화", + "openAutomation": "자동화 열기", + "openAutomationRun": "실행 열기", + "automationCreated": "자동화가 생성함", + "checkingAutomationAvailability": "자동화 사용 가능 여부 확인 중...", + "automationMissing": "자동화를 더 이상 사용할 수 없습니다.", + "automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", + "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다." }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "상태: 열림", @@ -11175,7 +11185,9 @@ "d441032f7e": "정지시키다", "5918020edc": "달리다", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "자동화를 더 이상 사용할 수 없습니다.", + "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다." }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 165c3e01f..33be7d884 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3519,7 +3519,8 @@ "f506a1262a": "搜索工作区", "75405270ed": "编辑搜索器({{value0}} 活动)", "489d1c8c9f": "搜索项目...", - "ee240a39eb": "编辑搜索器" + "ee240a39eb": "编辑搜索器", + "automationCreated": "隐藏自动化创建的工作区" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -3581,7 +3582,8 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "隐藏默认分支", "ed1611b65b": "隐藏休眠项", - "82594419ba": "搜索器" + "82594419ba": "搜索器", + "automationCreated": "隐藏自动化创建的工作区" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 活动布局", @@ -3732,7 +3734,15 @@ "3ea2702e62": "已链接 {{value0}} #{{value1}}", "b105fd3057": "链接 Linear {{value0}}", "3f2649eeb8": "链接议题#{{value0}}", - "fe075cb851": "工作区笔记" + "fe075cb851": "工作区笔记", + "automationHeader": "自动化", + "openAutomation": "打开自动化", + "openAutomationRun": "打开运行", + "automationCreated": "由自动化创建", + "checkingAutomationAvailability": "正在检查自动化可用性...", + "automationMissing": "自动化已不可用。", + "automationRunMissing": "运行历史已不可用。", + "automationAvailabilityUnavailable": "无法检查自动化可用性。" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状态:开放", @@ -11175,7 +11185,9 @@ "d441032f7e": "暂停", "5918020edc": "跑步", "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source" + "53f06f0ad5": "Retry source", + "pendingAutomationMissing": "自动化已不可用。", + "pendingAutomationRunMissing": "运行历史已不可用。" }, "CreateFromPicker": { "f061f49e3f": "搜索 repo 分支...", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 54f64270a..d219a468c 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -47,6 +47,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], uiZoomLevel: 0, diff --git a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts new file mode 100644 index 000000000..a6968e414 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../shared/types' +import { useAppStore } from '@/store' +import { activateAndRevealWorktree } from './worktree-activation' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +function makeAutomationWorktree(): Worktree { + return { + id: 'repo-1::/workspace/automation-run', + repoId: 'repo-1', + path: '/workspace/automation-run', + head: 'abc123', + branch: 'refs/heads/automation-run', + isBare: false, + isMainWorktree: false, + displayName: 'automation-run', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly review run', + createdAt: 123, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo-1', + repoId: 'repo-1', + hostId: 'local' + } + } +} + +describe('activateAndRevealWorktree automation filters', () => { + it('clears the automation-generated filter before revealing an automation-created worktree', () => { + const worktree = makeAutomationWorktree() + const revealWorktreeInSidebar = vi.fn() + + useAppStore.setState({ + repos: [ + { + id: worktree.repoId, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + activeWorktreeId: worktree.id, + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabsByWorktree: { [worktree.id]: [] }, + ptyIdsByTabId: {}, + everActivatedWorktreeIds: new Set([worktree.id]), + hideAutomationGeneratedWorkspaces: true, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar + }) + + activateAndRevealWorktree(worktree.id) + + expect(useAppStore.getState().hideAutomationGeneratedWorkspaces).toBe(false) + expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2957c43d5..fe0dd8255 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -347,6 +347,12 @@ export function activateAndRevealWorktree( if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) { state.setFilterRepoIds([]) } + if ( + state.hideAutomationGeneratedWorkspaces && + wt.automationProvenance?.kind === 'created-by-automation' + ) { + state.setHideAutomationGeneratedWorkspaces(false) + } // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ac8c67d37..e3954536d 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -667,6 +667,14 @@ export type UISlice = { closeActivityPage: () => void selectedAutomationId: string | null setSelectedAutomationId: (id: string | null) => void + pendingAutomationRunNavigation: { + automationId: string + runId: string | null + hostId?: ExecutionHostId + } | null + setPendingAutomationRunNavigation: ( + navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null + ) => void openAutomationsPage: () => void closeAutomationsPage: () => void openSpacePage: () => void @@ -786,6 +794,8 @@ export type UISlice = { setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void hideDefaultBranchWorkspace: boolean setHideDefaultBranchWorkspace: (v: boolean) => void + hideAutomationGeneratedWorkspaces: boolean + setHideAutomationGeneratedWorkspaces: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -1310,6 +1320,9 @@ export const createUISlice: StateCreator = (set, get) })), selectedAutomationId: null, setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: (navigation) => + set({ pendingAutomationRunNavigation: navigation }), openAutomationsPage: () => { get().recordViewVisit('automations') set((state) => ({ @@ -1887,6 +1900,8 @@ export const createUISlice: StateCreator = (set, get) hideDefaultBranchWorkspace: false, setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), + hideAutomationGeneratedWorkspaces: false, + setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2213,6 +2228,7 @@ export const createUISlice: StateCreator = (set, get) visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, + hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), collapsedGroups: new Set(ui.collapsedGroups ?? []), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index b0f2171b4..b1feaf7e0 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -1,5 +1,6 @@ import type { CreateWorktreeResult, + CreateWorktreeArgs, CreateSparseCheckoutRequest, DetectedWorktree, DetectedWorktreeListResult, @@ -147,7 +148,10 @@ export type WorktreeSlice = { linkedBitbucketPR?: number | null, linkedAzureDevOpsPR?: number | null, linkedGiteaPR?: number | null, - compareBaseRef?: string + compareBaseRef?: string, + // Why: reserved for automation-dispatch flows so host-side provenance can + // be minted securely; regular create callers should omit this. + options?: { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] } ) => Promise /** Register an in-flight background creation and make it the active surface. */ beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c4faadc87..652ce713d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1584,8 +1584,10 @@ export const createWorktreeSlice: StateCreator linkedBitbucketPR, linkedAzureDevOpsPR, linkedGiteaPR, - compareBaseRef + compareBaseRef, + options ) => { + const automationProvenanceRequest = options?.automationProvenanceRequest const retryableConflictPatterns = [ /already exists locally/i, /already exists on a remote/i, @@ -1646,7 +1648,8 @@ export const createWorktreeSlice: StateCreator ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(startup ? { startup } : {}), - ...(creationId ? { creationId } : {}) + ...(creationId ? { creationId } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) const result = @@ -1687,6 +1690,7 @@ export const createWorktreeSlice: StateCreator ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { startupCommand: startup.command, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2046a58cf..bdf334a94 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1132,7 +1132,8 @@ function createWorktreesApi(): NonNullable['worktrees']> { pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, parentWorkspace: args.parentWorkspace, workspaceStatus: args.workspaceStatus, - manualOrder: args.manualOrder + manualOrder: args.manualOrder, + automationProvenanceRequest: args.automationProvenanceRequest }) }, // Why: the runtime create path emits no two-phase progress, so the web diff --git a/src/shared/automation-workspace-provenance.ts b/src/shared/automation-workspace-provenance.ts new file mode 100644 index 000000000..7fd42119e --- /dev/null +++ b/src/shared/automation-workspace-provenance.ts @@ -0,0 +1,31 @@ +import type { Automation, AutomationRun } from './automations-types' +import { getRepoExecutionHostId } from './execution-host' +import type { AutomationWorkspaceProvenance, Repo } from './types' + +type AutomationProvenanceRun = Pick + +export function buildAutomationWorkspaceProvenance( + automation: Automation, + run: AutomationProvenanceRun, + repo: Repo, + createdAt = Date.now() +): AutomationWorkspaceProvenance { + return { + kind: 'created-by-automation', + automationId: automation.id, + automationNameSnapshot: automation.name, + automationRunId: run.id, + automationRunTitleSnapshot: run.title, + createdAt, + executionTargetType: automation.executionTargetType, + executionTargetId: automation.executionTargetId, + projectId: + run.runContext?.projectId ?? automation.runContext?.projectId ?? automation.projectId, + ...(run.runContext?.repoId + ? { repoId: run.runContext.repoId } + : automation.runContext?.repoId + ? { repoId: automation.runContext.repoId } + : {}), + hostId: run.runContext?.hostId ?? automation.runContext?.hostId ?? getRepoExecutionHostId(repo) + } +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 776085ab9..0eebbccc0 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -183,6 +183,7 @@ export type AutomationUpdateInput = Partial< export type AutomationDispatchRequest = { automation: Automation run: AutomationRun + dispatchToken: string } export type AutomationDispatchResult = { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 510652d0b..7ffefd7ea 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -444,6 +444,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceHostOrder: [], showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index 81f977637..82db4123b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { ExecutionHostId } from './execution-host' import type { SshRemotePtyLease, SshTarget } from './ssh-types' -import type { Automation, AutomationRun } from './automations-types' +import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' import type { GitHubProjectSettings } from './github-project-types' import type { @@ -496,8 +496,30 @@ export type Worktree = { workspaceStatus?: WorkspaceStatus diffComments?: DiffComment[] mobileDiffReview?: MobileDiffReviewState + automationProvenance?: AutomationWorkspaceProvenance } & GitWorktreeInfo +export type AutomationWorkspaceProvenance = { + kind: 'created-by-automation' + automationId: string + automationNameSnapshot: string + automationRunId: string + automationRunTitleSnapshot: string + createdAt: number + executionTargetType: AutomationExecutionTargetType + executionTargetId: string + projectId: string + repoId?: string + hostId?: ExecutionHostId +} + +export type AutomationWorkspaceProvenanceRequest = { + automationId: string + automationRunId: string + dispatchToken: string + createRequestId: string +} + export type GitPushTarget = { remoteName: string branchName: string @@ -584,6 +606,8 @@ export type WorktreeMeta = { * them. Self-prunes when the worktree is deleted. */ priorWorktreeIds?: string[] mobileDiffReview?: MobileDiffReviewState + /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ + automationProvenance?: AutomationWorkspaceProvenance } export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' @@ -1958,6 +1982,8 @@ export type CreateWorktreeArgs = { * creation in the renderer, so concurrent background creates each drive * their own status surface. Omitted by synchronous callers. */ creationId?: string + /** Authorizes the host to mint system-owned automation provenance. */ + automationProvenanceRequest?: AutomationWorkspaceProvenanceRequest } export type CreateWorktreeResult = { @@ -2912,6 +2938,7 @@ export type WorktreeCardProperty = | 'issue' | 'linear-issue' | 'pr' + | 'automation' | 'comment' | 'ports' // Why: inline list of agent activity rendered directly inside each @@ -3009,6 +3036,8 @@ export type PersistedUIState = { * the predicate in visible-worktrees.ts excludes worktrees with an empty * branch. */ hideDefaultBranchWorkspace: boolean + /** Hide workspaces created by automation new-per-run dispatches. */ + hideAutomationGeneratedWorkspaces?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/src/shared/worktree-card-properties.test.ts b/src/shared/worktree-card-properties.test.ts index b471988f3..20791b47d 100644 --- a/src/shared/worktree-card-properties.test.ts +++ b/src/shared/worktree-card-properties.test.ts @@ -14,10 +14,11 @@ describe('worktree card properties', () => { expect(props).toContain('inline-agents') expect(props).not.toContain('branch') expect(props).toContain('pr') + expect(props).toContain('automation') expect(props).toEqual(DEFAULT_WORKTREE_CARD_PROPERTIES) }) - it('defines Compact without extra rows or branch metadata', () => { + it('defines Compact with status and automation but without extra rows or branch metadata', () => { const props = getWorktreeCardModeProperties('Compact') expect(props).not.toContain('inline-agents') @@ -27,6 +28,7 @@ describe('worktree card properties', () => { expect(props).not.toContain('ports') expect(props).not.toContain('branch') expect(props).not.toContain('pr') + expect(props).toContain('automation') }) it('keeps status enabled in both presets', () => { @@ -41,13 +43,9 @@ describe('worktree card properties', () => { }) it('normalizes fixed and legacy properties while preserving selected properties', () => { - expect(normalizeWorktreeCardProperties(['ci', 'branch', 'pr', 'unread'])).toEqual([ - 'status', - 'unread', - 'ci', - 'branch', - 'pr' - ]) + expect(normalizeWorktreeCardProperties(['ci', 'branch', 'pr', 'automation', 'unread'])).toEqual( + ['status', 'unread', 'ci', 'branch', 'pr', 'automation'] + ) }) it('returns combined mode update payloads', () => { diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree-card-properties.ts index beee43ed8..cddbfe73b 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree-card-properties.ts @@ -13,6 +13,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ ...FIXED_WORKTREE_CARD_PROPERTIES, ...TASK_WORKTREE_CARD_PROPERTIES, 'pr', + 'automation', 'comment', 'ports', // Why: agent activity is the primary reason users opt into the feature, so @@ -21,7 +22,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'inline-agents' ] -export const COMPACT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status'] +export const COMPACT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = ['status', 'automation'] const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'status', @@ -31,6 +32,7 @@ const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'issue', 'linear-issue', 'pr', + 'automation', 'comment', 'ports', 'inline-agents'