Show and Filter Automation-Created Workspaces (#5697)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-18 13:05:57 -07:00 committed by GitHub
parent 281edba14a
commit 674639205c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 2363 additions and 666 deletions

View File

@ -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": {

View File

@ -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<string, DispatchTokenRecord>()
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)
}
}
}

View File

@ -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'
}
})
})
})

View File

@ -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<AutomationRun, 'id' | 'title' | 'scheduledFor'>
type RuntimeCreateManagedWorktreeArgs = Parameters<OrcaRuntimeService['createManagedWorktree']>[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)
}
}

View File

@ -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<AutomationRun> {
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
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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'
}
})
})

View File

@ -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<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
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.
*/

View File

@ -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
}
}

View File

@ -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 } : {}),

View File

@ -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.

View File

@ -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

View File

@ -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'

View File

@ -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,

View File

@ -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,

View File

@ -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 } })

View File

@ -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(),

View File

@ -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) {

View File

@ -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 })

View File

@ -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',

View File

@ -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' })
})

View File

@ -22,7 +22,8 @@ const ORCA_CREATION_SOURCES = new Set<NonNullable<WorktreeMeta['orcaCreationSour
const ORCA_OWNED_PROVENANCE_META_KEYS = [
'orcaCreatedAt',
'orcaCreationSource',
'orcaCreationWorkspaceLayout'
'orcaCreationWorkspaceLayout',
'automationProvenance'
] as const
type UnregisteredOrcaCleanupMeta = Pick<
WorktreeMeta,

View File

@ -573,6 +573,7 @@ function App(): React.JSX.Element {
const projectOrderBy = useAppStore((s) => 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

View File

@ -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,

View File

@ -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<Automation[]>([])
const [runs, setRuns] = useState<AutomationRun[]>([])
const [automationHostTargetKey, setAutomationHostTargetKey] = useState<string | null>(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<string>()
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<void> => {
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 => {

View File

@ -22,9 +22,13 @@ type RuntimeAutomationUpdateInput = Omit<AutomationUpdateInput, 'projectId' | 'w
workspace?: string
}
type AutomationHostTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
export type AutomationHostTarget =
| { kind: 'local' }
| { kind: 'environment'; environmentId: string }
function getRuntimeTargetFromHostId(hostId: string | null | undefined): AutomationHostTarget {
export function getAutomationTargetFromHostId(
hostId: string | null | undefined
): AutomationHostTarget {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'runtime'
? { kind: 'environment', environmentId: parsed.environmentId }
@ -41,11 +45,11 @@ export function getAutomationListTarget(
export function getAutomationOwnerTarget(
automation: Pick<Automation, 'runContext'>
): 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(

View File

@ -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}
/>
<FilterToggleRow
icon={<Workflow className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarFilter.automationCreated',
'Hide automation-created'
)}
checked={hideAutomationGeneratedWorkspaces}
onChange={setHideAutomationGeneratedWorkspaces}
/>
{canFilterRepos && (
<>

View File

@ -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}
/>
<FilterToggleRow
icon={<Workflow className="size-3.5" />}
label={translate(
'auto.components.sidebar.SidebarWorkspaceFilterSection.automationCreated',
'Hide automation-created'
)}
checked={hideAutomationGeneratedWorkspaces}
onChange={setHideAutomationGeneratedWorkspaces}
/>
</>
)
})

View File

@ -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'}`

View File

@ -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(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
})
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(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
})
it('hides automation-created card surfaces when the Automation property is disabled', async () => {
worktreeCardProperties = ['status']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Created by automation')
expect(markup).not.toContain('>Automation</span>')
expect(markup).not.toContain('Nightly triage automation')
})
it('hides live port metadata when the Ports card property is disabled', async () => {
const worktree = makeWorktree()
workspacePortScan = {

View File

@ -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 ? <WorktreeCardPortsDetails ports={workspacePorts} /> : 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={

View File

@ -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<AutomationProvenanceAvailability>({
status: 'checking'
})
React.useEffect(() => {
let cancelled = false
async function resolveAvailability(): Promise<void> {
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 (
<WorktreeCardDetailSection>
<DetailHeader
icon={<CalendarClock className="size-3 text-muted-foreground" />}
label={translate('auto.components.sidebar.WorktreeCardMeta.automationHeader', 'Automation')}
actions={
<>
{onOpenAutomation && canOpenAutomation && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.openAutomation',
'Open automation'
)}
onClick={onOpenAutomation}
>
<CalendarClock className="size-3" />
</MetadataActionIcon>
)}
{onOpenAutomationRun && canOpenAutomationRun && (
<MetadataActionIcon
label={translate(
'auto.components.sidebar.WorktreeCardMeta.openAutomationRun',
'Open run'
)}
onClick={onOpenAutomationRun}
>
<PlayCircle className="size-3" />
</MetadataActionIcon>
)}
</>
}
/>
<WorktreeCardDetailSectionContent className="space-y-1.5">
<div className="text-[13px] font-semibold leading-snug text-foreground break-words">
{provenance.automationNameSnapshot}
</div>
<div className="text-[11.5px] leading-snug text-muted-foreground break-words">
{provenance.automationRunTitleSnapshot}
</div>
{availability.status === 'checking' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.checkingAutomationAvailability',
'Checking automation availability...'
)}
</div>
) : null}
{availability.status === 'automation-missing' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationMissing',
'Automation no longer available.'
)}
</div>
) : null}
{availability.status === 'available' && !availability.runAvailable ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationRunMissing',
'Run history no longer available.'
)}
</div>
) : null}
{availability.status === 'unavailable' ? (
<div className="text-[11px] leading-snug text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeCardMeta.automationAvailabilityUnavailable',
'Automation availability could not be checked.'
)}
</div>
) : null}
</WorktreeCardDetailSectionContent>
</WorktreeCardDetailSection>
)
}

View File

@ -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 = {

View File

@ -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<
<StickyNote className="text-muted-foreground" />
</MetaIconBadge>
)}
{automationProvenance && (
<MetaIconBadge
label={translate(
'auto.components.sidebar.WorktreeCardMeta.automationCreated',
'Created by automation'
)}
>
<CalendarClock className="text-muted-foreground" />
</MetaIconBadge>
)}
{issue && (
<MetaIconBadge
label={translate(
@ -121,10 +133,12 @@ export function WorktreeCardDetailsHover({
linearIssue,
review,
comment,
automationProvenance,
children,
branchName,
workspaceTitle,
identityOrder = 'workspace-first',
automationHostId,
detailsAfter,
openDelay = 250,
closeDelay = 120,
@ -134,6 +148,8 @@ export function WorktreeCardDetailsHover({
onOpenLinearIssueInOrca,
onOpenReviewInOrca,
onUnlinkReview,
onOpenAutomation,
onOpenAutomationRun,
hoverControl
}: WorktreeCardDetailsHoverProps): React.JSX.Element {
const internalHoverControl = useWorktreeCardDetailsHoverControl()
@ -156,7 +172,7 @@ export function WorktreeCardDetailsHover({
if (
!showIdentityHeader &&
!hasWorktreeCardDetails({ issue, linearIssue, review, comment }) &&
!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance }) &&
!detailsAfter
) {
return children
@ -343,6 +359,17 @@ export function WorktreeCardDetailsHover({
closeHover={closeHover}
/>
{automationProvenance && (
<WorktreeCardAutomationDetailSection
provenance={automationProvenance}
worktreeHostId={automationHostId}
onOpenAutomation={onOpenAutomation ? dismissAndRun(onOpenAutomation) : undefined}
onOpenAutomationRun={
onOpenAutomationRun ? dismissAndRun(onOpenAutomationRun) : undefined
}
/>
)}
{hasComment(comment) && (
<WorktreeCardDetailSection>
<DetailHeader

View File

@ -4380,6 +4380,7 @@ const WorktreeList = React.memo(function WorktreeList({
const projectOrderBy = useAppStore((s) => 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
])

View File

@ -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')
})
})

View File

@ -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'],

View File

@ -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,

View File

@ -81,6 +81,7 @@ function visibleOptions(overrides: Partial<VisibleOptions> = {}): VisibleOptions
ptyIdsByTabId: {},
browserTabsByWorktree: {},
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
repoMap,
workspaceHostScope: 'all',
defaultHostId: LOCAL_EXECUTION_HOST_ID,
@ -96,6 +97,7 @@ function filterState(overrides: Partial<FilterState> = {}): 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
})
})

View File

@ -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<string, Repo>
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,

View File

@ -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'
)
}
}
]

View File

@ -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
}

View File

@ -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<string>()
@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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
}, [])

View File

@ -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...",

View File

@ -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...",

View File

@ -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 ブランチを検索...",

View File

@ -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 브랜치 검색...",

View File

@ -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 分支...",

View File

@ -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,

View File

@ -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)
})
})

View File

@ -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) {

View File

@ -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<string, boolean>
setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void
toggleShowDotfilesForWorktree: (worktreeId: string) => void
@ -1310,6 +1320,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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 ?? []),

View File

@ -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<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void

View File

@ -1584,8 +1584,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
...(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<AppState, [], [], WorktreeSlice>
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup
? {
startupCommand: startup.command,

View File

@ -1132,7 +1132,8 @@ function createWorktreesApi(): NonNullable<Partial<PreloadApi>['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

View File

@ -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<AutomationRun, 'id' | 'title' | 'runContext'>
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)
}
}

View File

@ -183,6 +183,7 @@ export type AutomationUpdateInput = Partial<
export type AutomationDispatchRequest = {
automation: Automation
run: AutomationRun
dispatchToken: string
}
export type AutomationDispatchResult = {

View File

@ -444,6 +444,7 @@ export function getDefaultUIState(): PersistedUIState {
workspaceHostOrder: [],
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
showDotfilesByWorktree: {},
filterRepoIds: [],
collapsedGroups: [],

View File

@ -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<string, boolean>
filterRepoIds: string[]

View File

@ -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', () => {

View File

@ -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'