diff --git a/src/main/persistence-automations.test.ts b/src/main/persistence-automations.test.ts
index 605b7c35296..9db16b7e681 100644
--- a/src/main/persistence-automations.test.ts
+++ b/src/main/persistence-automations.test.ts
@@ -86,6 +86,28 @@ describe('Store', () => {
expect(persisted.automations[0].baseBranch).toBeNull()
})
+ it('returns the existing automation for a repeated creation key', async () => {
+ const store = await createStore()
+ store.addRepo(makeRepo())
+ const input = {
+ creationKey: 'move-retry-1',
+ name: 'Retry-safe move',
+ prompt: 'Run checks',
+ agentId: 'claude' as const,
+ projectId: 'r1',
+ workspaceMode: 'new_per_run' as const,
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: new Date('2026-05-13T00:00:00Z').getTime()
+ }
+
+ const first = store.createAutomation(input)
+ const retry = store.createAutomation({ ...input, name: 'Changed by a retry' })
+
+ expect(retry.id).toBe(first.id)
+ expect(store.listAutomations()).toHaveLength(1)
+ })
+
it('persists session reuse only for existing-workspace automations', async () => {
const store = await createStore()
store.addRepo(makeRepo())
diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts
index 3107ae37a5a..93bb7de2cea 100644
--- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts
+++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts
@@ -50,6 +50,14 @@ export function createAutomation(
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
): Automation {
+ if (input.creationKey) {
+ const existing = (operations.state.automations ?? []).find(
+ (automation) => automation.creationKey === input.creationKey
+ )
+ if (existing) {
+ return existing
+ }
+ }
const repo = operations.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const workspaceId = input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null
@@ -72,6 +80,7 @@ export function createAutomation(
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
const automation: Automation = {
id: randomUUID(),
+ ...(input.creationKey ? { creationKey: input.creationKey } : {}),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
precheck: normalizeAutomationPrecheck(input.precheck),
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index e5cf999e4e4..79df84cddc0 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -4366,6 +4366,7 @@ export class OrcaRuntimeService {
throw new Error('Session reuse requires an existing workspace target.')
}
const createInput: AutomationCreateInput = {
+ creationKey: input.creationKey,
name: input.name,
prompt: input.prompt,
precheck: input.precheck,
diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts
index 4d4f43a2350..5e5dbe4e4ad 100644
--- a/src/main/runtime/rpc/methods/automation-schemas.ts
+++ b/src/main/runtime/rpc/methods/automation-schemas.ts
@@ -141,6 +141,7 @@ export const AutomationRuns = z.object({
})
export const AutomationCreate = z.object({
+ creationKey: OptionalString,
name: requiredString('Missing automation name'),
prompt: requiredString('Missing automation prompt'),
precheck: AutomationPrecheck,
diff --git a/src/renderer/src/components/automations/AutomationDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx
index cc46c81873f..48eff9cc900 100644
--- a/src/renderer/src/components/automations/AutomationDestinationField.test.tsx
+++ b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx
@@ -61,10 +61,12 @@ const LEGACY_ENTRY: AutomationHostCatalogEntry = {
function control(
projects: Repo[],
- entries: AutomationHostCatalogEntry[] = [ENTRY]
+ entries: AutomationHostCatalogEntry[] = [ENTRY],
+ note?: string
): AutomationCreateDestinationControl {
return {
entries,
+ note,
resolution: {
status: 'ready',
authority: OWNER.authority,
@@ -76,11 +78,11 @@ function control(
}
}
-function render(projects: Repo[], entries?: AutomationHostCatalogEntry[]): void {
+function render(projects: Repo[], entries?: AutomationHostCatalogEntry[], note?: string): void {
act(() => {
root.render(
-
+
)
})
@@ -117,6 +119,16 @@ describe('AutomationDestinationField', () => {
expect(container.querySelector('[data-testid="automation-create-update-required"]')).toBeNull()
})
+ it('states a move in place of the host it would otherwise just name', () => {
+ render([{ id: 'repo-1' } as Repo], undefined, 'Saving creates this automation on GPU box.')
+
+ // Both lines name the same host; the one that mentions the delete wins.
+ expect(container.querySelector('[data-testid="automation-host-move"]')?.textContent).toContain(
+ 'GPU box'
+ )
+ expect(container.textContent).not.toContain('Stored and scheduled by')
+ })
+
it('names the host without a tense, so editing reads as a move', () => {
render([{ id: 'repo-1' } as Repo])
diff --git a/src/renderer/src/components/automations/AutomationDestinationField.tsx b/src/renderer/src/components/automations/AutomationDestinationField.tsx
index f7956e55c0d..0f222f1dd5f 100644
--- a/src/renderer/src/components/automations/AutomationDestinationField.tsx
+++ b/src/renderer/src/components/automations/AutomationDestinationField.tsx
@@ -20,7 +20,8 @@ import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
// The host that stores and schedules the automation, not the workspace's
-// execution host — the two routinely differ.
+// execution host — the two routinely differ. Serves create and edit alike; on an
+// existing record a save that lands elsewhere is a move.
export function AutomationDestinationField({
control,
@@ -78,6 +79,12 @@ export function AutomationDestinationField({
'No projects are set up on {host}. Add one there, or choose another host.'
).replace('{host}', selected.label)}
+ ) : control.note ? (
+ // Replaces the storedOn line: both name the same host, and the move is
+ // the consequential half.
+
+ {control.note}
+
) : (
{selected
diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
index fb9b359ec1d..93ff1adc93b 100644
--- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx
@@ -91,6 +91,35 @@ function addSshProject(): void {
]
}
+/** A runtime project with the same host-local id as the desktop project. */
+function addCollidingRuntimeProject(): void {
+ const repo = {
+ id: REPO_ID,
+ displayName: 'orca on m4-air',
+ path: '/repos/orca',
+ badgeColor: '#111111',
+ addedAt: 1,
+ worktreeBaseRef: 'main',
+ executionHostId: `runtime:${RUNTIME_ID}`
+ } as Repo
+ mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
+ mocks.state.projectHostSetups = [
+ ...(mocks.state.projectHostSetups as ProjectHostSetup[]),
+ {
+ id: 'setup-runtime-collision',
+ projectId: 'project-runtime-collision',
+ hostId: `runtime:${RUNTIME_ID}`,
+ repoId: REPO_ID,
+ path: '/repos/orca',
+ displayName: 'orca on m4-air',
+ setupState: 'ready',
+ setupMethod: 'legacy-repo',
+ createdAt: 1,
+ updatedAt: 1
+ }
+ ]
+}
+
/** The runtime answers a create; without this the RPC double returns an empty result. */
function runtimeCreateReturns(automation: Automation): void {
const previous = mocks.callRuntimeRpc.getMockImplementation()
@@ -414,7 +443,7 @@ describe('AutomationsPage edit dialog projects', () => {
})
describe('AutomationsPage edit destination', () => {
- it('names the edited row’s own host, and only its authority’s hosts', async () => {
+ it('names the edited row’s own host, and offers every host it could move to', async () => {
const automation = makeAutomation({ id: 'a-1' })
api.automations.list.mockResolvedValue([])
scopedList([automation])
@@ -430,11 +459,106 @@ describe('AutomationsPage edit destination', () => {
const resolution = mocks.editorDialog?.editDestination?.resolution
expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(DESKTOP_SELF_KEY)
- // A record cannot move between authorities, so the runtime is not offered.
- expect(mocks.editorDialog?.editDestination?.entries.map((entry) => entry.stableKey)).toEqual([
- DESKTOP_SELF_KEY,
- SSH_HOST_KEY
- ])
+ // Another authority is a legal destination — reached by a create there and a
+ // delete here — so restricting the list to this one would hide a real move.
+ expect(mocks.editorDialog?.editDestination?.entries.map((entry) => entry.stableKey)).toContain(
+ RUNTIME_SELF_KEY
+ )
+ expect(mocks.editorDialog?.editDestination?.note).toBeFalsy()
+ })
+
+ it('moves the record across authorities by creating there and deleting here', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+
+ expect(mocks.editorDialog?.editDestination?.note).toBeTruthy()
+
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: RUNTIME_REPO_ID,
+ workspaceMode: 'existing',
+ workspaceId: RUNTIME_WORKSPACE_ID
+ }))
+ })
+ await save()
+
+ expect(api.automations.update).not.toHaveBeenCalled()
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({
+ repo: `id:${RUNTIME_REPO_ID}`,
+ dtstart: automation.dtstart
+ })
+ expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' }))
+ })
+
+ it('moves when the destination project has the same host-local id', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addCollidingRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: REPO_ID }))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+ await save()
+
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ repo: `id:${REPO_ID}` })
+ expect(api.automations.delete).toHaveBeenCalled()
+ })
+
+ it('reports a kept original as two live copies, never as a completed move', async () => {
+ const automation = makeAutomation({ id: 'a-1' })
+ api.automations.list.mockResolvedValue([])
+ scopedList([automation])
+ runtimeHost([], [])
+ addRuntimeProject()
+ runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }))
+ api.automations.delete.mockRejectedValue(new Error('gone away'))
+
+ await renderPage()
+ await settleHostQueries()
+ await act(async () => {
+ void mocks.listPanel?.openEditDialog(listedRow(automation.id))
+ })
+ await act(async () => {
+ mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
+ })
+ await act(async () => {
+ mocks.editorDialog?.onDraftChange((current) => ({
+ ...(current as Record),
+ projectId: RUNTIME_REPO_ID,
+ workspaceMode: 'existing',
+ workspaceId: RUNTIME_WORKSPACE_ID
+ }))
+ })
+ await save()
+
+ expect(runtimeCreateCalls()).toHaveLength(1)
+ expect(api.automations.delete).toHaveBeenCalled()
+ expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('could not be deleted'))
+ expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('moves the record to the host the user picks, clearing the stranded project', async () => {
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index 9c4aa0225a6..2f375024371 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -26,7 +26,9 @@ import {
getLocalExecutionHostLabel,
getRepoExecutionHostId,
getWorktreeExecutionHostId,
- parseExecutionHostId
+ parseExecutionHostId,
+ toRuntimeExecutionHostId,
+ type ExecutionHostId
} from '../../../../shared/execution-host'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
@@ -75,6 +77,7 @@ import {
import {
deleteAutomationForTarget,
type AutomationHostTarget,
+ getAutomationAuthorityTarget,
getAutomationHostTargetFromKey,
getAutomationHostTargetKey,
getAutomationListTarget,
@@ -198,6 +201,7 @@ export default function AutomationsPage(): React.JSX.Element {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
+ const fetchRuntimeEnvironmentRepos = useAppStore((s) => s.fetchRuntimeEnvironmentRepos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const updateSettings = useAppStore((s) => s.updateSettings)
@@ -262,6 +266,7 @@ export default function AutomationsPage(): React.JSX.Element {
// Separate from the page notice: while the editor is open it covers the page,
// so a refusal posted there is a save that visibly did nothing at all.
const [editorNotice, setEditorNotice] = useState(null)
+ const [editorNoticeHost, setEditorNoticeHost] = useState(null)
const [externalActionKey, setExternalActionKey] = useState(null)
const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState>(
() => new Set()
@@ -285,6 +290,9 @@ export default function AutomationsPage(): React.JSX.Element {
// The host the edit form names. It filters the project list; the destination a
// save commits still comes from the chosen project, which is what moves the record.
const [editingHostStableKey, setEditingHostStableKey] = useState(null)
+ // Reuse a move's create key after an ambiguous transport failure so retrying
+ // cannot schedule a second copy on the destination authority.
+ const moveCreationKeysRef = useRef(new Map())
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState(
@@ -511,32 +519,70 @@ export default function AutomationsPage(): React.JSX.Element {
(
candidate: Pick
): AutomationDraft['setupDecision'] => {
- const settingsForRepo = getSettingsForRepoRuntimeOwner(
- { repos, settings },
- candidate.projectId
- )
- const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
+ // During an edit the selected destination, not the ambient repo-id lookup,
+ // owns setup policy. Repo ids can collide across runtime authorities.
+ const selectedEditEntry =
+ editingAutomationId !== null && editingHostStableKey
+ ? hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey)
+ : null
+ const selectedEditAuthority = selectedEditEntry?.stableRef.authority
+ const setupHostId =
+ selectedEditAuthority?.kind === 'runtime'
+ ? toRuntimeExecutionHostId(selectedEditAuthority.environmentId)
+ : undefined
+ const setupRepos =
+ selectedEditAuthority?.kind === 'runtime'
+ ? getAutomationCreateRepos(repos, {
+ kind: 'environment',
+ environmentId: selectedEditAuthority.environmentId
+ })
+ : repos
+ const setupProjectHostSetups = setupHostId
+ ? projectHostSetups.filter(
+ (setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId
+ )
+ : projectHostSetups
+ const settingsForRepo = setupHostId
+ ? {
+ ...settings,
+ activeRuntimeEnvironmentId:
+ selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null
+ }
+ : getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId)
+ const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
return getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: candidate.workspaceMode,
repoId: candidate.projectId,
- repos,
- projectHostSetups,
+ repos: setupRepos,
+ projectHostSetups: setupProjectHostSetups,
yamlHooks: automationYamlHooksByRepoKey[hookKey]
})
},
- [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings]
+ [
+ automationYamlHooksByRepoKey,
+ createTarget,
+ editingAutomationId,
+ editingHostStableKey,
+ hostCatalog.entries,
+ projectHostSetups,
+ repos,
+ settings
+ ]
)
const getAutomationHooksCacheKey = useCallback(
- (repoId: string): string => {
+ (repoId: string, hostId?: ExecutionHostId): string => {
+ if (hostId) {
+ return `${hostId}:${repoId}`
+ }
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}`
},
[repos, settings]
)
const loadAutomationYamlHooksForRepo = useCallback(
- async (repoId: string): Promise => {
- const key = getAutomationHooksCacheKey(repoId)
+ async (repoId: string, hostId?: ExecutionHostId): Promise => {
+ const key = getAutomationHooksCacheKey(repoId, hostId)
if (Object.hasOwn(automationYamlHooksByRepoKey, key)) {
return automationYamlHooksByRepoKey[key] ?? null
}
@@ -545,7 +591,7 @@ export default function AutomationsPage(): React.JSX.Element {
return (await existingPromise).hooks
}
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
- const promise = checkRuntimeHooks(settingsForRepo, repoId)
+ const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId)
.then((result) => ({
hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null),
ok: result.status !== 'error'
@@ -612,10 +658,6 @@ export default function AutomationsPage(): React.JSX.Element {
})
)
}, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap])
- const worktrees = useMemo(
- () => worktreesByRepo[draft.projectId] ?? [],
- [draft.projectId, worktreesByRepo]
- )
const automationHostTarget = useMemo(
() => getAutomationHostTargetFromKey(automationHostTargetKey),
[automationHostTargetKey]
@@ -666,6 +708,9 @@ export default function AutomationsPage(): React.JSX.Element {
createDestination.control.resolution.authority.kind === 'runtime'
? createDestination.control.resolution.authority.environmentId
: null
+ const createDestinationHostId = createDestinationRuntimeEnvironmentId
+ ? toRuntimeExecutionHostId(createDestinationRuntimeEnvironmentId)
+ : undefined
useEffect(() => {
if (createDestinationRuntimeEnvironmentId) {
void useAppStore
@@ -931,7 +976,9 @@ export default function AutomationsPage(): React.JSX.Element {
const canSaveDraft =
editingAutomationId === null ||
!draftAtOpen ||
- JSON.stringify(draft) !== JSON.stringify(draftAtOpen)
+ JSON.stringify(draft) !== JSON.stringify(draftAtOpen) ||
+ (editingAutomationId !== null &&
+ editingHostStableKey !== rowRecoveryHost(editingRowKey)?.stableKey)
const getAutomationRepoHostLabel = useCallback(
(repo: Repo): string => {
const hostId = getRepoExecutionHostId(repo)
@@ -1024,16 +1071,13 @@ export default function AutomationsPage(): React.JSX.Element {
})()
const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null
const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget)
- // An update moves the record's execution target, never the authority that
- // stores it, so the edit picker offers only that authority's own hosts.
+ // The authority storing the edited record; a save landing on another one is a move.
const dialogAuthorityKey = automationAuthorityCatalogKey(
automationDialogTarget.kind === 'environment'
? { kind: 'runtime', environmentId: automationDialogTarget.environmentId }
: { kind: 'desktop' }
)
- const editHostEntries = hostCatalog.entries.filter(
- (entry) => automationAuthorityCatalogKey(entry.stableRef.authority) === dialogAuthorityKey
- )
+ const editHostEntries = hostCatalog.entries
const editHostResolution = resolveAutomationCreateDestination(
editingHostStableKey
? editHostEntries.find((entry) => entry.stableKey === editingHostStableKey)
@@ -1041,8 +1085,29 @@ export default function AutomationsPage(): React.JSX.Element {
)
const editHostProjects =
editHostResolution.status === 'ready'
- ? automationCreateEligibleProjects(repoTables, editHostResolution, dialogAuthorityRepos)
+ ? automationCreateEligibleProjects(
+ repoTables,
+ editHostResolution,
+ getAutomationCreateRepos(
+ repos,
+ getAutomationAuthorityTarget(editHostResolution.authority)
+ )
+ )
: dialogAuthorityRepos
+ const editMoveTargetEntry =
+ editHostResolution.status === 'ready' &&
+ automationAuthorityCatalogKey(editHostResolution.authority) !== dialogAuthorityKey
+ ? editHostResolution.entry
+ : null
+ const editDestinationRuntimeEnvironmentId =
+ editHostResolution.status === 'ready' && editHostResolution.authority.kind === 'runtime'
+ ? editHostResolution.authority.environmentId
+ : null
+ useEffect(() => {
+ if (editDestinationRuntimeEnvironmentId) {
+ void fetchRuntimeEnvironmentRepos(editDestinationRuntimeEnvironmentId)
+ }
+ }, [editDestinationRuntimeEnvironmentId, fetchRuntimeEnvironmentRepos])
// Picking a host strands a project that lives on another one; clearing it here
// states the move instead of deferring the same refusal to submit.
const handleEditHostChange = (stableKey: string): void => {
@@ -1056,6 +1121,17 @@ export default function AutomationsPage(): React.JSX.Element {
resolved.status === 'ready' &&
!automationCreateProjectMismatch(repoTables, resolved, projectId)
) {
+ setEditingDestination({ projectId, destination: resolved })
+ // Workspace ids are host-local. Never carry the source host's workspace
+ // into a move, even when the destination happens to reuse the project id.
+ if (editingHostStableKey !== stableKey) {
+ setDraft((current) => ({ ...current, workspaceId: '', baseBranch: '' }))
+ if (resolved.authority.kind === 'runtime') {
+ void fetchWorktrees(projectId, {
+ executionHostId: toRuntimeExecutionHostId(resolved.authority.environmentId)
+ })
+ }
+ }
return
}
setEditingDestination(null)
@@ -1065,16 +1141,56 @@ export default function AutomationsPage(): React.JSX.Element {
entries: editHostEntries,
resolution: editHostResolution,
onSelect: handleEditHostChange,
- projects: editHostProjects
+ projects: editHostProjects,
+ // Replacer fn: a literal replacement would expand `$` patterns in host labels.
+ note: editMoveTargetEntry
+ ? translate(
+ 'auto.components.automations.createDestination.move',
+ 'Saving creates this automation on {host} and deletes the original and its run history.'
+ ).replace('{host}', () => editMoveTargetEntry.authorityLabel)
+ : null
}
const dialogRepos = isOrcaForm
? editingAutomationId !== null
? editHostProjects
: editorProjects
: getAutomationCreateRepos(repos, { kind: 'local' })
+ // Worktree caches are merged by project id across hosts. The dialog's repo
+ // list is already authority-scoped, so use that repo instance to keep
+ // host-local workspace ids from crossing an authority boundary.
+ const dialogWorktrees = useMemo(() => {
+ const candidates = worktreesByRepo[draft.projectId] ?? []
+ const project = dialogRepos.find((repo) => repo.id === draft.projectId)
+ if (!project) {
+ return candidates
+ }
+ const hostId = getRepoExecutionHostId(project)
+ const parsedHost = parseExecutionHostId(hostId)
+ return candidates.filter((worktree) => {
+ if (getWorktreeExecutionHostId(worktree, project) === hostId) {
+ return true
+ }
+ // A paired runtime can own an SSH execution host; nested worktrees keep
+ // hostId=ssh:* while runtimeOwnerEnvironmentId names the routing owner.
+ return (
+ parsedHost?.kind === 'runtime' &&
+ worktree.runtimeOwnerEnvironmentId === parsedHost.environmentId
+ )
+ })
+ }, [dialogRepos, draft.projectId, worktreesByRepo])
const destinationForProject = useCallback(
- (projectId: string): AutomationCreateDestination | null => {
+ (projectId: string, hostStableKey?: string | null): AutomationCreateDestination | null => {
+ const selectedEntry = hostStableKey
+ ? hostCatalog.entries.find((candidate) => candidate.stableKey === hostStableKey)
+ : null
+ const selected = resolveAutomationCreateDestination(selectedEntry)
+ if (hostStableKey) {
+ return selected.status === 'ready' &&
+ !automationCreateProjectMismatch(repoTables, selected, projectId)
+ ? selected
+ : null
+ }
const runContext = buildAutomationRunContextForRepo({
repoId: projectId,
repos,
@@ -1090,7 +1206,7 @@ export default function AutomationsPage(): React.JSX.Element {
const resolved = resolveAutomationCreateDestination(entry)
return resolved.status === 'ready' ? resolved : null
},
- [hostCatalog.entries, projectHostSetups, repos]
+ [hostCatalog.entries, projectHostSetups, repoTables, repos]
)
const reloadExternalManagers = scopedExternal.reload
@@ -1245,10 +1361,11 @@ export default function AutomationsPage(): React.JSX.Element {
) {
return
}
- void loadAutomationYamlHooksForRepo(draft.projectId)
+ void loadAutomationYamlHooksForRepo(draft.projectId, createDestinationHostId)
}, [
createOpen,
createTarget,
+ createDestinationHostId,
draft.projectId,
draft.workspaceMode,
loadAutomationYamlHooksForRepo
@@ -1399,7 +1516,8 @@ export default function AutomationsPage(): React.JSX.Element {
}
setEditingAutomationId(latest.id)
setEditingRowKey(row.key)
- const initialDestination = destinationForProject(latest.projectId)
+ const initialHostStableKey = rowRecoveryHost(row.key)?.stableKey ?? null
+ const initialDestination = destinationForProject(latest.projectId, initialHostStableKey)
setEditingDestination(
initialDestination ? { projectId: latest.projectId, destination: initialDestination } : null
)
@@ -1451,8 +1569,23 @@ export default function AutomationsPage(): React.JSX.Element {
(projectId: string): void => {
const currentWorktrees = worktreesByRepo[projectId] ?? []
const currentDefaultWorktree = getDefaultWorktree(currentWorktrees)
+ const selectedEditDestination =
+ editingAutomationId !== null && editingHostStableKey
+ ? resolveAutomationCreateDestination(
+ hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey)
+ )
+ : null
+ const worktreeFetchOptions =
+ selectedEditDestination?.status === 'ready' &&
+ selectedEditDestination.authority.kind === 'runtime'
+ ? {
+ executionHostId: toRuntimeExecutionHostId(
+ selectedEditDestination.authority.environmentId
+ )
+ }
+ : undefined
if (editingAutomationId !== null) {
- const destination = destinationForProject(projectId)
+ const destination = destinationForProject(projectId, editingHostStableKey)
setEditingDestination(destination ? { projectId, destination } : null)
if (destination) {
setEditingHostStableKey(destination.entry.stableKey)
@@ -1465,7 +1598,7 @@ export default function AutomationsPage(): React.JSX.Element {
baseBranch: ''
}))
- void fetchWorktrees(projectId).then(() => {
+ void fetchWorktrees(projectId, worktreeFetchOptions).then(() => {
const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? []
const latestWorktree = getDefaultWorktree(latestWorktrees)
if (!latestWorktree) {
@@ -1480,7 +1613,14 @@ export default function AutomationsPage(): React.JSX.Element {
)
})
},
- [destinationForProject, editingAutomationId, fetchWorktrees, worktreesByRepo]
+ [
+ destinationForProject,
+ editingAutomationId,
+ editingHostStableKey,
+ fetchWorktrees,
+ hostCatalog.entries,
+ worktreesByRepo
+ ]
)
const handleDraftChange = useCallback(
@@ -1493,14 +1633,14 @@ export default function AutomationsPage(): React.JSX.Element {
editingAutomationId !== null &&
(next.projectId !== current.projectId || next.workspaceId !== current.workspaceId)
) {
- const destination = destinationForProject(next.projectId)
+ const destination = destinationForProject(next.projectId, editingHostStableKey)
setEditingDestination(destination ? { projectId: next.projectId, destination } : null)
if (destination) {
setEditingHostStableKey(destination.entry.stableKey)
}
}
},
- [destinationForProject, editingAutomationId]
+ [destinationForProject, editingAutomationId, editingHostStableKey]
)
const saveAutomation = async (): Promise => {
@@ -1559,7 +1699,7 @@ export default function AutomationsPage(): React.JSX.Element {
try {
const selectedWorkspaceExists =
draft.workspaceMode !== 'existing' ||
- worktrees.some((worktree) => worktree.id === draft.workspaceId)
+ dialogWorktrees.some((worktree) => worktree.id === draft.workspaceId)
if (!selectedWorkspaceExists) {
toast.error(
translate(
@@ -1677,18 +1817,29 @@ export default function AutomationsPage(): React.JSX.Element {
const precheck = buildDraftPrecheck(draft)
const runContext = buildAutomationRunContextForRepo({
repoId: draft.projectId,
- repos,
+ repos: editingAutomationId !== null ? dialogRepos : repos,
projectHostSetups
})
+ const setupDestination =
+ editingAutomationId !== null ? editHostResolution : createDestination.control.resolution
+ const setupHostId: ExecutionHostId | undefined =
+ setupDestination.status === 'ready' && setupDestination.authority.kind === 'runtime'
+ ? toRuntimeExecutionHostId(setupDestination.authority.environmentId)
+ : undefined
+ const setupProjectHostSetups = setupHostId
+ ? projectHostSetups.filter(
+ (setup) => setup.repoId !== draft.projectId || setup.hostId === setupHostId
+ )
+ : projectHostSetups
let setupDecision = resolveAutomationSetupDecisionForSave({
createTarget,
workspaceMode: draft.workspaceMode,
repoId: draft.projectId,
- repos,
- projectHostSetups,
+ repos: editingAutomationId !== null ? dialogRepos : repos,
+ projectHostSetups: setupProjectHostSetups,
yamlHooks:
createTarget === 'orca' && draft.workspaceMode === 'new_per_run'
- ? await loadAutomationYamlHooksForRepo(draft.projectId)
+ ? await loadAutomationYamlHooksForRepo(draft.projectId, setupHostId)
: null,
draftSetupDecision: draft.setupDecision
})
@@ -1696,7 +1847,8 @@ export default function AutomationsPage(): React.JSX.Element {
const trustDecision = await ensureHooksConfirmed(
useAppStore.getState(),
draft.projectId,
- 'setup'
+ 'setup',
+ setupHostId
)
if (trustDecision === 'skip') {
setupDecision = 'skip'
@@ -1732,9 +1884,14 @@ export default function AutomationsPage(): React.JSX.Element {
currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation
}
let editDestination: AutomationDestination | undefined
+ let moveTarget: AutomationCreateDestination | null = null
+ const destinationHostChanged =
+ editingDestination !== null &&
+ editingDestination.destination.entry.stableKey !== rowRecoveryHost(editingRowKey)?.stableKey
const destinationChanged =
currentAutomation &&
- (currentAutomation.projectId !== draft.projectId ||
+ (destinationHostChanged ||
+ currentAutomation.projectId !== draft.projectId ||
currentAutomation.workspaceId !== (draft.workspaceId || null) ||
currentAutomation.workspaceMode !== draft.workspaceMode)
if (editingAutomationId && currentAutomation && destinationChanged) {
@@ -1776,6 +1933,11 @@ export default function AutomationsPage(): React.JSX.Element {
return
}
editDestination = revalidated.destination
+ // `update` reaches only the authority already holding the record, so a
+ // save landing on another one is a create there plus a delete here.
+ if (automationAuthorityCatalogKey(revalidated.authority) !== dialogAuthorityKey) {
+ moveTarget = revalidated
+ }
}
const updates: AutomationUpdateInput = {
name: draft.name,
@@ -1815,27 +1977,39 @@ export default function AutomationsPage(): React.JSX.Element {
missedRunGraceMinutes
}
const editSource = currentAutomation
- const saved = editingAutomationId
- ? await dispatchAutomationUpdate(
- automationDispatchContext,
- { rowKey: editingRowKey ?? '', automationId: editingAutomationId },
- updates,
- () => {
- // Nothing names a host: no captured owner and no record to read one
- // from, so the save is refused rather than sent unfenced.
- if (!editSource) {
- throw new Error(
- automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
- )
- }
- return updateAutomationForTarget(editSource, updates, automationHostTarget)
- },
- 'save',
- editDestination
- )
- : await createDraftAutomation(createInput)
+ const move = moveTarget
+ ? await moveAutomationToDestination(editSource, moveTarget, {
+ ...createInput,
+ // A move keeps the existing schedule anchor and paused state.
+ dtstart: updates.dtstart ?? editSource?.dtstart ?? createInput.dtstart,
+ enabled: editSource?.enabled ?? true,
+ sourceContext: editSource?.sourceContext ?? null
+ })
+ : null
+ const saved = move
+ ? move.saved
+ : editingAutomationId
+ ? await dispatchAutomationUpdate(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: editingAutomationId },
+ updates,
+ () => {
+ // Nothing names a host: no captured owner and no record to read one
+ // from, so the save is refused rather than sent unfenced.
+ if (!editSource) {
+ throw new Error(
+ automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged)
+ )
+ }
+ return updateAutomationForTarget(editSource, updates, automationHostTarget)
+ },
+ 'save',
+ editDestination
+ )
+ : await createDraftAutomation(createInput)
if (!saved.ok) {
setEditorNotice(saved.notice)
+ setEditorNoticeHost(moveTarget?.entry ?? null)
return
}
const automation = saved.value
@@ -1852,7 +2026,8 @@ export default function AutomationsPage(): React.JSX.Element {
})
setDraft((current) => ({ ...current, name: '', prompt: '' }))
await refresh()
- if (editingAutomationId && editingRowKey) {
+ // A move creates a new row on another authority; select it by its new id.
+ if (editingAutomationId && editingRowKey && !moveTarget) {
setSelectedAutomationRunPageId(null)
setSelectedRowKey(editingRowKey)
setSelectedId(automation.id)
@@ -1863,13 +2038,25 @@ export default function AutomationsPage(): React.JSX.Element {
if (!editingAutomationId) {
useAppStore.getState().recordFeatureInteraction('automation-created')
}
+ // A kept original already raised its own toast; "moved" would contradict it.
+ if (move && !move.originalRemoved) {
+ return
+ }
toast.success(
- editingAutomationId
+ moveTarget
? translate(
- 'auto.components.automations.AutomationsPage.244727e655',
- 'Automation updated.'
- )
- : translate('auto.components.automations.AutomationsPage.2a20596d6b', 'Automation saved.')
+ 'auto.components.automations.AutomationsPage.moved',
+ 'Automation moved to {host}.'
+ ).replace('{host}', () => moveTarget.entry.authorityLabel)
+ : editingAutomationId
+ ? translate(
+ 'auto.components.automations.AutomationsPage.244727e655',
+ 'Automation updated.'
+ )
+ : translate(
+ 'auto.components.automations.AutomationsPage.2a20596d6b',
+ 'Automation saved.'
+ )
)
} catch (error) {
if (isHermesSave) {
@@ -1888,6 +2075,76 @@ export default function AutomationsPage(): React.JSX.Element {
}
}
+ /** Creates on the new authority before removing the old copy. */
+ const moveAutomationToDestination = async (
+ source: Automation | null,
+ target: AutomationCreateDestination,
+ input: AutomationCreateInput
+ ): Promise<{ saved: AutomationDispatchResult; originalRemoved: boolean }> => {
+ if (!source) {
+ return {
+ saved: {
+ ok: false,
+ notice: {
+ message: automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged),
+ recovery: 'retry',
+ severity: 'owner'
+ }
+ },
+ originalRemoved: false
+ }
+ }
+ const operationKey = `${source.id}:${target.entry.stableKey}:${JSON.stringify(input)}`
+ const creationKey = moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID()
+ moveCreationKeysRef.current.set(operationKey, creationKey)
+ const created = toDispatchResult(
+ await createAutomationAtDestination(
+ target.authority,
+ { ...input, creationKey },
+ target.destination
+ )
+ )
+ if (!created.ok) {
+ return { saved: created, originalRemoved: false }
+ }
+ invalidateWrittenHost(target.entry.stableRef, 'definition')
+ const removed = await dispatchAutomationDelete(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: source.id },
+ () => deleteAutomationForTarget(source, automationDialogTarget)
+ )
+ if (!removed.ok) {
+ // A transport error is not proof that deletion failed. Re-read the source
+ // under its captured owner before telling the user a duplicate remains.
+ const reread = await dispatchAutomationReread(
+ automationDispatchContext,
+ { rowKey: editingRowKey ?? '', automationId: source.id },
+ async () =>
+ (await listAutomationsForTarget(automationDialogTarget)).find(
+ (automation) => automation.id === source.id
+ ) ?? null
+ )
+ if (reread.ok && reread.value === null) {
+ moveCreationKeysRef.current.delete(operationKey)
+ return { saved: created, originalRemoved: true }
+ }
+ const messageKey =
+ reread.ok && reread.value
+ ? 'auto.components.automations.AutomationsPage.moveOriginalKept'
+ : 'auto.components.automations.AutomationsPage.moveOriginalUnverified'
+ const message =
+ reread.ok && reread.value
+ ? 'Created on {host}, but the original could not be deleted. Remove it on the old host.'
+ : 'Created on {host}, but the original deletion could not be verified. Check the old host before retrying.'
+ toast.error(
+ translate(messageKey, message).replace('{host}', () => target.entry.authorityLabel)
+ )
+ } else {
+ moveCreationKeysRef.current.delete(operationKey)
+ }
+ return { saved: created, originalRemoved: removed.ok }
+ }
+
/** Creation states its destination and re-checks it at submit; it never infers one. */
const createDraftAutomation = async (
input: AutomationCreateInput
@@ -2343,28 +2600,41 @@ export default function AutomationsPage(): React.JSX.Element {
automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
getAutomationHooksCacheKey={getAutomationHooksCacheKey}
repoMap={repoMap}
- worktrees={worktrees}
+ worktrees={dialogWorktrees}
settings={settings}
draft={draft}
createDestination={createDestination.control}
editDestination={isOrcaForm ? editDestinationControl : undefined}
notice={editorNotice}
onNoticeRecover={(action) => {
+ const recoveryHost = editorNoticeHost ?? editorRecoveryHost
setEditorNotice(null)
- hostCatalog.recover(action, editorRecoveryHost)
+ setEditorNoticeHost(null)
+ hostCatalog.recover(action, recoveryHost)
if (action === 'retry') {
void refresh()
}
}}
- onNoticeDismiss={() => setEditorNotice(null)}
+ onNoticeDismiss={() => {
+ setEditorNotice(null)
+ setEditorNoticeHost(null)
+ }}
onProjectChange={handleProjectChange}
getRepoHostLabel={getAutomationRepoHostLabel}
- allowAddProject={!isOrcaForm || automationDialogTarget.kind === 'local'}
+ allowAddProject={
+ !isOrcaForm ||
+ (editingAutomationId !== null
+ ? editHostResolution.status === 'ready'
+ ? getAutomationAuthorityTarget(editHostResolution.authority).kind === 'local'
+ : automationDialogTarget.kind === 'local'
+ : automationDialogTarget.kind === 'local')
+ }
onCreateTargetChange={handleCreateTargetChange}
onOpenChange={(open) => {
setCreateOpen(open)
if (!open) {
setEditorNotice(null)
+ setEditorNoticeHost(null)
}
}}
onDraftChange={handleDraftChange}
diff --git a/src/renderer/src/components/automations/automation-capability-probe.ts b/src/renderer/src/components/automations/automation-capability-probe.ts
index 38cf3c69326..258fb4eb292 100644
--- a/src/renderer/src/components/automations/automation-capability-probe.ts
+++ b/src/renderer/src/components/automations/automation-capability-probe.ts
@@ -5,6 +5,7 @@ import {
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
@@ -129,7 +130,18 @@ export async function assertOwnerFencingSupported(
)
}
+export async function assertAutomationCreateIdempotencySupported(
+ authority: AutomationAuthorityRef
+): Promise {
+ await assertAuthorityCapability(
+ authority,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
+ 'Moving automations to this host requires a newer Orca server. Update the HUB and try again.'
+ )
+}
+
export {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE
+ AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
}
diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts
index 2b7c3b7c102..e7ef5a44770 100644
--- a/src/renderer/src/components/automations/automation-host-client.ts
+++ b/src/renderer/src/components/automations/automation-host-client.ts
@@ -5,6 +5,7 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
+import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -49,6 +50,14 @@ export function getAutomationTargetFromHostId(
: { kind: 'local' }
}
+export function getAutomationAuthorityTarget(
+ authority: AutomationAuthorityRef
+): AutomationHostTarget {
+ return authority.kind === 'runtime'
+ ? { kind: 'environment', environmentId: authority.environmentId }
+ : { kind: 'local' }
+}
+
export function getAutomationListTarget(
settings: Pick | null | undefined
): AutomationHostTarget {
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
index e1bf8873e97..e07399319fb 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict'
import {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
const callRuntimeRpc = vi.fn()
@@ -27,7 +28,8 @@ const SSH_OWNER = {
const ALL_CAPABILITIES = {
capabilities: [
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
]
}
diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts
index ec44ac74ece..825329a1812 100644
--- a/src/renderer/src/components/automations/automation-scoped-list-client.ts
+++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts
@@ -38,6 +38,7 @@ import {
import {
assertAuthorityCapability,
assertOwnerFencingSupported,
+ assertAutomationCreateIdempotencySupported,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AutomationHostScopeUnsupportedError,
@@ -286,6 +287,9 @@ export async function createAutomationForDestination(
input: AutomationCreateInput,
destination: AutomationDestination
): Promise {
+ if (input.creationKey) {
+ await assertAutomationCreateIdempotencySupported(authority)
+ }
await assertOwnerFencingSupported(authority)
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
...toRuntimeAutomationCreateInput(input),
diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx
index 47796464c4a..ae01653093c 100644
--- a/src/renderer/src/components/automations/automations-page-test-harness.tsx
+++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx
@@ -17,8 +17,9 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, type Mock, vi } from 'vitest'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import {
- AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY as LIST_HOST_SCOPE,
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY as OWNER_FENCING,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY as CREATE_IDEMPOTENCY
} from '../../../../shared/protocol-version'
import type { AppState } from '@/store'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
@@ -303,10 +304,7 @@ export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: {
export const RUNTIME_ID = 'gpu'
/** A runtime that advertises both automation capabilities, so its rows carry owners. */
-const RUNTIME_CAPABILITIES = [
- AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
-]
+const RUNTIME_CAPABILITIES = [LIST_HOST_SCOPE, OWNER_FENCING, CREATE_IDEMPOTENCY]
export const RUNTIME_SELF_FILTER = {
kind: 'host' as const,
host: {
diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts
index 72175fb1f1e..9ae52aa6729 100644
--- a/src/renderer/src/components/automations/use-automation-create-destination.ts
+++ b/src/renderer/src/components/automations/use-automation-create-destination.ts
@@ -35,6 +35,8 @@ export type AutomationCreateDestinationControl = {
onSelect: (stableKey: string) => void
/** Projects the resolved destination can hold; the whole list until one resolves. */
projects: readonly Repo[]
+ /** Stated consequence of saving here, when it is not the plain one. */
+ note?: string | null
}
export type AutomationCreateDestinationCheck =
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index e8be1452f02..cf9083cf8db 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -15376,6 +15376,9 @@
"b11170a008": "Failed to save automation.",
"2a20596d6b": "Automation saved.",
"244727e655": "Automation updated.",
+ "moved": "Automation moved to {host}.",
+ "moveOriginalKept": "Created on {host}, but the original could not be deleted. Remove it on the old host.",
+ "moveOriginalUnverified": "Created on {host}, but the original deletion could not be verified. Check the old host before retrying.",
"77b81bc4ac": "Hermes automation created.",
"08efc3ae12": "Hermes automation updated.",
"e431bb85d4": "Choose a workspace on the same host as this Hermes automation.",
@@ -15690,7 +15693,8 @@
"stale": "{host} changed while this form was open. Choose it again before saving.",
"projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.",
"noProjects": "No projects are set up on {host}. Add one there, or choose another host.",
- "updateRequired": "Update the Orca server on {hosts} to store automations there."
+ "updateRequired": "Update the Orca server on {hosts} to store automations there.",
+ "move": "Saving creates this automation on {host} and deletes the original and its run history."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d3198f1700f..d2d9c533693 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13888,6 +13888,9 @@
"b11170a008": "No se pudo guardar la automatización.",
"2a20596d6b": "Automatización guardada.",
"244727e655": "Automatización actualizada.",
+ "moved": "Automatización movida a {host}.",
+ "moveOriginalKept": "Se creó en {host}, pero no se pudo eliminar la original. Elimínela en el host anterior.",
+ "moveOriginalUnverified": "Se creó en {host}, pero no se pudo verificar la eliminación de la original. Compruebe el host anterior antes de volver a intentarlo.",
"77b81bc4ac": "Se crea la automatización de Hermes.",
"08efc3ae12": "Automatización Hermes actualizada.",
"e431bb85d4": "Elija un espacio de trabajo en el mismo host que esta automatización de Hermes.",
@@ -14095,7 +14098,8 @@
"stale": "{host} cambió mientras este formulario estaba abierto. Elíjalo de nuevo antes de guardar.",
"projectMismatch": "Ese proyecto no está en {host}. Elija un proyecto en ese host, u otro host.",
"noProjects": "No hay proyectos configurados en {host}. Agregue uno allí o elija otro host.",
- "updateRequired": "Actualice el servidor de Orca en {hosts} para almacenar automatizaciones allí."
+ "updateRequired": "Actualice el servidor de Orca en {hosts} para almacenar automatizaciones allí.",
+ "move": "Al guardar se crea esta automatización en {host} y se eliminan la original y su historial de ejecuciones."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index f000f02561b..7a1f723f309 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13879,6 +13879,9 @@
"b11170a008": "オートメーションを保存できませんでした。",
"2a20596d6b": "オートメーションが保存されました。",
"244727e655": "自動化が更新されました。",
+ "moved": "自動化を {host} に移動しました。",
+ "moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。",
+ "moveOriginalUnverified": "{host} に作成しましたが、元の自動化の削除を確認できませんでした。再試行する前に元のホストを確認してください。",
"77b81bc4ac": "Hermes オートメーションが作成されました。",
"08efc3ae12": "Hermes オートメーションが更新されました。",
"e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。",
@@ -14086,7 +14089,8 @@
"stale": "このフォームを開いている間に {host} が変更されました。保存する前にもう一度選択してください。",
"projectMismatch": "そのプロジェクトは {host} にありません。そのホスト上のプロジェクト、または別のホストを選択してください。",
"noProjects": "{host} にはプロジェクトが設定されていません。そこに追加するか、別のホストを選択してください。",
- "updateRequired": "{hosts} の Orca サーバーを更新すると、そこに自動化を保存できます。"
+ "updateRequired": "{hosts} の Orca サーバーを更新すると、そこに自動化を保存できます。",
+ "move": "保存すると、この自動化は {host} に作成され、元の自動化と実行履歴は削除されます。"
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 421c6770464..a23b1034c15 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13911,6 +13911,9 @@
"b11170a008": "자동화를 저장하지 못했습니다.",
"2a20596d6b": "자동화가 저장되었습니다.",
"244727e655": "자동화가 업데이트되었습니다.",
+ "moved": "자동화를 {host}(으)로 이동했습니다.",
+ "moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.",
+ "moveOriginalUnverified": "{host}에 생성했지만 원본 삭제를 확인할 수 없습니다. 다시 시도하기 전에 이전 호스트를 확인하세요.",
"77b81bc4ac": "Hermes 자동화가 생성되었습니다.",
"08efc3ae12": "Hermes 자동화가 업데이트되었습니다.",
"e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.",
@@ -14118,7 +14121,8 @@
"stale": "이 양식이 열려 있는 동안 {host}이(가) 변경되었습니다. 저장하기 전에 다시 선택하세요.",
"projectMismatch": "해당 프로젝트는 {host}에 없습니다. 그 호스트의 프로젝트를 선택하거나 다른 호스트를 선택하세요.",
"noProjects": "{host}에 설정된 프로젝트가 없습니다. 거기에 추가하거나 다른 호스트를 선택하세요.",
- "updateRequired": "{hosts}의 Orca 서버를 업데이트하면 거기에 자동화를 저장할 수 있습니다."
+ "updateRequired": "{hosts}의 Orca 서버를 업데이트하면 거기에 자동화를 저장할 수 있습니다.",
+ "move": "저장하면 이 자동화가 {host}에 생성되고 원본과 실행 기록은 삭제됩니다."
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index ceb26968d58..47f626e2bac 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13935,6 +13935,9 @@
"b11170a008": "无法保存自动化。",
"2a20596d6b": "自动化已保存。",
"244727e655": "自动化已更新。",
+ "moved": "自动化已移动到 {host}。",
+ "moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。",
+ "moveOriginalUnverified": "已在 {host} 上创建,但无法确认是否删除了原有自动化。重试前请检查原主机。",
"77b81bc4ac": "Hermes 自动化创建。",
"08efc3ae12": "Hermes 自动化已更新。",
"e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。",
@@ -14142,7 +14145,8 @@
"stale": "此表单打开期间 {host} 已更改。保存前请重新选择。",
"projectMismatch": "该项目不在 {host} 上。请选择该主机上的项目,或选择另一台主机。",
"noProjects": "{host} 上未设置任何项目。请在该主机上添加一个,或选择另一台主机。",
- "updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。"
+ "updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。",
+ "move": "保存后会在 {host} 上创建此自动化,并删除原有自动化及其运行历史。"
}
},
"agent": {
diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts
index e3c661f976e..24d9345242d 100644
--- a/src/shared/automations-types.ts
+++ b/src/shared/automations-types.ts
@@ -90,6 +90,8 @@ export type AutomationPrecheckResult = {
export type Automation = {
id: string
+ /** Optional client request key used to make cross-authority creates retry-safe. */
+ creationKey?: string
name: string
prompt: string
precheck: AutomationPrecheck | null
@@ -169,6 +171,8 @@ export type AutomationRun = {
}
export type AutomationCreateInput = {
+ /** Optional idempotency key; repeated creates return the original record. */
+ creationKey?: string
name: string
prompt: string
precheck?: AutomationPrecheck | null
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 8f10558d7e5..9a262cafd7e 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -137,6 +137,8 @@ export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE =
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const
export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE =
'Editing automations on this host requires a newer Orca server. Update the HUB and try again.'
+export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
+ 'automation.create-idempotency.v1' as const
// Generic native clients include the CLI and must not claim Electron-only page
// placement support.
@@ -145,7 +147,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
// Electron clients can decode client-hosted page placement; becoming a page
@@ -213,7 +216,8 @@ export const RUNTIME_CAPABILITIES = [
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_DELETE_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
- AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
+ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})