From dcd8de3fc43b7dec22964835d4de451fe1c1d257 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:58:47 -0700 Subject: [PATCH] Support moving automations across execution authorities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users to move automations between different authorities (desktop ↔ runtime environments) during editing. A save to a different authority creates the automation on the destination and deletes the original with its run history. Includes clear messaging about the move operation, proper handling of workspace id resets, and graceful error handling when deletion fails. Supports destination-aware project and worktree fetching. --- src/main/persistence-automations.test.ts | 22 + .../automation-definition-operations.ts | 9 + src/main/runtime/orca-runtime.ts | 1 + .../runtime/rpc/methods/automation-schemas.ts | 1 + .../AutomationDestinationField.test.tsx | 18 +- .../AutomationDestinationField.tsx | 9 +- ...utomationsPage.create-destination.test.tsx | 136 +++++- .../automations/AutomationsPage.tsx | 410 +++++++++++++++--- .../automation-capability-probe.ts | 14 +- .../automations/automation-host-client.ts | 9 + .../automation-scoped-list-client.test.ts | 6 +- .../automation-scoped-list-client.ts | 4 + .../automations-page-test-harness.tsx | 10 +- .../use-automation-create-destination.ts | 2 + src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- src/shared/automations-types.ts | 4 + src/shared/protocol-version.ts | 8 +- 21 files changed, 597 insertions(+), 96 deletions(-) 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 & {})