From 4e122fb3dd518957b9b1db65947aa34577d31bdc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:46:45 -0700 Subject: [PATCH] Display automation host and support cross-authority moves Users can now move automations to different storage authorities. The destination picker shows all available hosts, and selecting a new one displays a warning about the move. The save creates the automation on the destination and deletes it from the source; if deletion fails, both copies remain and the user is notified. --- .../AutomationDestinationField.test.tsx | 18 +- .../AutomationDestinationField.tsx | 21 +- ...utomationsPage.create-destination.test.tsx | 85 +++++++- ...tionsPage.cross-authority-actions.test.tsx | 18 ++ .../automations/AutomationsPage.tsx | 193 +++++++++++++----- .../automations/automation-host-client.ts | 9 + .../automation-host-detail-display.ts | 12 +- .../automations-page-test-harness.tsx | 2 + .../use-automation-create-destination.ts | 2 + src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 16 ++ src/renderer/src/i18n/locales/ja.json | 16 ++ src/renderer/src/i18n/locales/ko.json | 93 ++++++++- src/renderer/src/i18n/locales/zh.json | 101 ++++++++- 14 files changed, 501 insertions(+), 90 deletions(-) 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 f0cc83bd3cc..0f222f1dd5f 100644 --- a/src/renderer/src/components/automations/AutomationDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationDestinationField.tsx @@ -19,18 +19,9 @@ import { import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' -/** - * Which host stores and schedules the automation, named on the form before submit. - * - * This is the storage authority, not the workspace's execution host — the two - * routinely differ, and only this one decides which machine keeps and schedules - * the record. A concrete host filter only preselects: the list filter narrows a - * view, so it must not decide where a new record is allowed to live. - * - * Editing offers the same picker over the record's own authority, because a save - * that lands on another host is a move, not a create. The copy stays tense-free - * so both readings hold. - */ +// The host that stores and schedules the automation, not the workspace's +// 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, @@ -88,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..d10a6441c98 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -414,7 +414,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 +430,84 @@ 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) + }) + + // The move is stated before it happens: the record is deleted from here. + 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() + + // `update` reaches only the authority already holding the record, so a move + // is the pair — and leaving the original behind would run it twice. + expect(api.automations.update).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(1) + expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ repo: `id:${RUNTIME_REPO_ID}` }) + expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' })) + }) + + 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() + + // The new copy is live and the old one still runs; "moved" would hide that. + 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.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx index 5c7225a9bd9..618c092b45a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -53,6 +53,24 @@ function runtimeAutomationCalls(): string[] { } describe('AutomationsPage row actions under a colliding automation id', () => { + it('names the selected row’s own host, not the desktop that listed it', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + const remote = mocks.listPanel?.filteredRows.find( + (candidate) => candidate.automation.name === 'Remote nightly' + ) + await act(async () => { + mocks.listPanel?.selectAutomationRow(remote?.key ?? '') + }) + + // The runtime copy's own execution target reads `local` — local to that + // server — so only the row's catalog entry can name the host on the detail view. + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + expect(mocks.detailPane?.selectedHostEntry?.authorityLabel).toBe('GPU box') + }) + it('runs the selected desktop row on the desktop, not on the runtime holding the same id', async () => { await collidingHosts() const { container } = await renderPage() diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index ff985887225..9fae9cdf407 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -75,6 +75,7 @@ import { import { deleteAutomationForTarget, type AutomationHostTarget, + getAutomationAuthorityTarget, getAutomationHostTargetFromKey, getAutomationHostTargetKey, getAutomationListTarget, @@ -1024,25 +1025,36 @@ 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) : null ) + // Projects follow the picked host's authority, not the record's: a move offers + // what the target stores, and nothing on the old authority is reachable there. 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 // 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 => { @@ -1065,7 +1077,14 @@ 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. Its run history stays on the old host.' + ).replace('{host}', () => editMoveTargetEntry.authorityLabel) + : null } const dialogRepos = isOrcaForm ? editingAutomationId !== null @@ -1732,6 +1751,7 @@ export default function AutomationsPage(): React.JSX.Element { currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation } let editDestination: AutomationDestination | undefined + let moveTarget: AutomationCreateDestination | null = null const destinationChanged = currentAutomation && (currentAutomation.projectId !== draft.projectId || @@ -1776,6 +1796,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, @@ -1797,42 +1822,53 @@ export default function AutomationsPage(): React.JSX.Element { updates.rrule = rrule updates.dtstart = now } + const createInput: AutomationCreateInput = { + name: draft.name, + prompt: draft.prompt, + precheck, + agentId: draft.agentId, + runContext, + projectId: draft.projectId, + workspaceMode: draft.workspaceMode, + workspaceId: draft.workspaceId, + baseBranch: draft.baseBranch.trim() || null, + setupDecision, + reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, + timezone, + rrule, + dtstart: updates.dtstart ?? currentAutomation?.dtstart ?? now, + 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({ - name: draft.name, - prompt: draft.prompt, - precheck, - agentId: draft.agentId, - runContext, - projectId: draft.projectId, - workspaceMode: draft.workspaceMode, - workspaceId: draft.workspaceId, - baseBranch: draft.baseBranch.trim() || null, - setupDecision, - reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, - timezone, - rrule, - dtstart: now, - missedRunGraceMinutes + const move = moveTarget + ? await moveAutomationToDestination(editSource, moveTarget, { + ...createInput, + // A move carries the record across; it does not resume a paused one. + 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) return @@ -1851,7 +1887,9 @@ export default function AutomationsPage(): React.JSX.Element { }) setDraft((current) => ({ ...current, name: '', prompt: '' })) await refresh() - if (editingAutomationId && editingRowKey) { + // A move leaves the edited row behind: the record it selects is a new one + // on another host, so its key is found rather than restored. + if (editingAutomationId && editingRowKey && !moveTarget) { setSelectedAutomationRunPageId(null) setSelectedRowKey(editingRowKey) setSelectedId(automation.id) @@ -1862,13 +1900,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) { @@ -1887,6 +1937,55 @@ export default function AutomationsPage(): React.JSX.Element { } } + /** + * A record cannot change the authority that stores it, so a save aimed at + * another one creates the automation there and deletes the original. A failed + * delete leaves two live copies, which is said rather than reported as a move. + */ + 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 created = toDispatchResult( + await createAutomationAtDestination(target.authority, input, 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 }, + // The dialog's own target, which names the edited row's host even when no + // owner was captured to fence the delete with. + () => deleteAutomationForTarget(source, automationDialogTarget) + ) + if (!removed.ok) { + // Both copies are live; said as such rather than reported as a move. + toast.error( + translate( + 'auto.components.automations.AutomationsPage.moveOriginalKept', + 'Created on {host}, but the original could not be deleted. Remove it on the old host.' + ).replace('{host}', () => target.entry.authorityLabel) + ) + } + 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 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-host-detail-display.ts b/src/renderer/src/components/automations/automation-host-detail-display.ts index af845417d48..2d8bdc3ee65 100644 --- a/src/renderer/src/components/automations/automation-host-detail-display.ts +++ b/src/renderer/src/components/automations/automation-host-detail-display.ts @@ -1,12 +1,6 @@ -/** - * The host a stored automation belongs to, named on the detail view. - * - * The catalog entry the row was listed from is the truthful source: it names the - * storing authority too, which the record's own execution target cannot — a - * runtime-stored automation's target reads `local`, meaning local to that - * server, not to this Mac. The record's target is only the fallback for a legacy - * row no host answered for. - */ +// The row's catalog entry names the storing authority; the record's own target +// cannot — a runtime-stored automation reads `local`, meaning local to that +// server. That target is only the fallback for a legacy row no host answered for. import type { Automation } from '../../../../shared/automations-types' import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host' 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 72acb190112..47796464c4a 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -21,6 +21,7 @@ import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { AppState } from '@/store' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' @@ -76,6 +77,7 @@ export type ListPanelProps = { export type DetailPaneProps = { selected: Automation | null + selectedHostEntry: AutomationHostCatalogEntry | null selectedRuns: AutomationRun[] selectedRunsNotice: { message: string } | null runNow: (automation: Automation) => void 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..86ed852cc78 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15376,6 +15376,8 @@ "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.", "77b81bc4ac": "Hermes automation created.", "08efc3ae12": "Hermes automation updated.", "e431bb85d4": "Choose a workspace on the same host as this Hermes automation.", @@ -15690,7 +15692,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. Its run history stays on the old host." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8b014a386da..a00e1bdd24e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13724,6 +13724,7 @@ "showMore": "Mostrar más" }, "AutomationDetail": { + "host": "Host", "007c8ad874": "Inmediato", "a1d52c2189": "Cobertura de uso", "449fc83bf7": "Fichas", @@ -13887,6 +13888,8 @@ "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.", "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.", @@ -14083,6 +14086,19 @@ "filters": "Filtros", "clear": "Limpiar filtros", "removeFilter": "Eliminar filtro de {{value0}}" + }, + "createDestination": { + "label": "Host", + "placeholder": "Seleccionar un host", + "storedOn": "Almacenada y programada por {authority}.", + "unselected": "Elija el host que almacena y programa esta automatización.", + "orphan": "Las automatizaciones sin host no pueden alojar otras nuevas. Elija un host en el que crear esta automatización.", + "unavailable": "Ese host todavía no puede alojar una nueva automatización. Elija otro host para crear esta.", + "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í.", + "move": "Al guardar se crea esta automatización en {host} y se elimina la original. Su historial de ejecuciones permanece en el host anterior." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0e5e5fd0e9d..1c0f8a9a748 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13715,6 +13715,7 @@ "showMore": "さらに表示" }, "AutomationDetail": { + "host": "ホスト", "007c8ad874": "プロンプト", "a1d52c2189": "使用範囲", "449fc83bf7": "トークン", @@ -13878,6 +13879,8 @@ "b11170a008": "オートメーションを保存できませんでした。", "2a20596d6b": "オートメーションが保存されました。", "244727e655": "自動化が更新されました。", + "moved": "自動化を {host} に移動しました。", + "moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。", "77b81bc4ac": "Hermes オートメーションが作成されました。", "08efc3ae12": "Hermes オートメーションが更新されました。", "e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。", @@ -14074,6 +14077,19 @@ "filters": "フィルター", "clear": "フィルターをクリア", "removeFilter": "{{value0}} フィルターを解除" + }, + "createDestination": { + "label": "ホスト", + "placeholder": "ホストを選択", + "storedOn": "{authority} が保存してスケジュールします。", + "unselected": "この自動化を保存してスケジュールするホストを選択します。", + "orphan": "ホストのない自動化は新しい自動化を保持できません。この自動化を作成するホストを選択してください。", + "unavailable": "そのホストはまだ新しい自動化を保持できません。これを作成する別のホストを選択してください。", + "stale": "このフォームを開いている間に {host} が変更されました。保存する前にもう一度選択してください。", + "projectMismatch": "そのプロジェクトは {host} にありません。そのホスト上のプロジェクト、または別のホストを選択してください。", + "noProjects": "{host} にはプロジェクトが設定されていません。そこに追加するか、別のホストを選択してください。", + "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 a36f0c50c7f..175735ba5e5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3879,17 +3879,82 @@ }, "skills": { "SkillsPage": { - "cb142070b4": "새로 고침", "a68dee6a32": "스킬 검색", "f43ad6edf3": "스킬", "ea72d6185b": "스킬을 검색하지 못했습니다", "dc4c3328ee": "파일 표시", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 표시하지 못했습니다", "4acd6d68ec": "스킬을 찾을 수 없습니다", "6a62a0168c": "일치하는 항목 없음", "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", "cd7893fbc1": "스킬 검색 중", "35b9a724a0": "사용 가능", "c13b82793c": "설치 관리", "aee7b99cc6": "링크에서 설치", "filterProvider": "에이전트별 필터", "filterSource": "소스별 필터", "allSources": "전체", "clearFilters": "필터 지우기", "closeSkills": "스킬 닫기", "closeTooltip": "닫기 · Esc", "moreActions": "추가 작업", "sharedLinks": "공유 링크", "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", "retry": "다시 시도", "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", "viewSwitch": "표시", "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…" + "cb142070b4": "새로 고침", + "a68dee6a32": "스킬 검색", + "f43ad6edf3": "스킬", + "ea72d6185b": "스킬을 검색하지 못했습니다", + "dc4c3328ee": "파일 표시", + "9963dff6d3": "설명을 찾을 수 없습니다.", + "995fde8337": "스킬 파일을 표시하지 못했습니다", + "4acd6d68ec": "스킬을 찾을 수 없습니다", + "6a62a0168c": "일치하는 항목 없음", + "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", + "cd7893fbc1": "스킬 검색 중", + "35b9a724a0": "사용 가능", + "c13b82793c": "설치 관리", + "aee7b99cc6": "링크에서 설치", + "filterProvider": "에이전트별 필터", + "filterSource": "소스별 필터", + "allSources": "전체", + "clearFilters": "필터 지우기", + "closeSkills": "스킬 닫기", + "closeTooltip": "닫기 · Esc", + "moreActions": "추가 작업", + "sharedLinks": "공유 링크", + "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", + "retry": "다시 시도", + "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", + "viewSwitch": "표시", + "searchLinks": "링크 검색", + "deleteSkills": "스킬 삭제…" }, "SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" }, - "SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", "copyPath": "경로 복사", "detailPath": "경로", "skillActions": "{{value0}} 작업", "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" }, + "SkillRow": { + "updatedUnknown": "날짜 없음", + "pathCopied": "경로 복사됨", + "copyPath": "경로 복사", + "detailPath": "경로", + "skillActions": "{{value0}} 작업", + "viewDetails": "세부 정보 보기", + "deleteSkill": "삭제…" + }, "SkillsList": { "listLabel": "스킬" }, - "sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" }, + "sourceStatus": { + "missing": "폴더를 찾을 수 없음", + "remoteRepo": "원격 리포지토리 — 검색 안 됨", + "unavailable": "검색 안 됨" + }, "sources": { "heading": "스킬 폴더" }, - "sourceKind": { "home": "홈", "workspace": "워크스페이스", "bundled": "번들", "plugin": "플러그인" }, - "count": { "skillOne": "스킬 {{count}}개", "skillOther": "스킬 {{count}}개", "sourceOne": "소스 {{count}}개", "sourceOther": "소스 {{count}}개", "fileOne": "파일 {{count}}개", "fileOther": "파일 {{count}}개", "resultOne": "결과 {{count}}개", "resultOther": "결과 {{count}}개", "selected": "{{count}}개 선택됨", "shareOne": "스킬 {{count}}개 공유", "shareOther": "스킬 {{count}}개 공유", "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" }, + "sourceKind": { + "home": "홈", + "workspace": "워크스페이스", + "bundled": "번들", + "plugin": "플러그인" + }, + "count": { + "skillOne": "스킬 {{count}}개", + "skillOther": "스킬 {{count}}개", + "sourceOne": "소스 {{count}}개", + "sourceOther": "소스 {{count}}개", + "fileOne": "파일 {{count}}개", + "fileOther": "파일 {{count}}개", + "resultOne": "결과 {{count}}개", + "resultOther": "결과 {{count}}개", + "selected": "{{count}}개 선택됨", + "shareOne": "스킬 {{count}}개 공유", + "shareOther": "스킬 {{count}}개 공유", + "linkOne": "링크 {{count}}개", + "linkOther": "링크 {{count}}개" + }, "filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" }, - "SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", "title": "공유할 스킬 선택", "selectAll": "가능한 {{count}}개 모두 선택", "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" }, + "SkillsSelectionHeader": { + "exit": "선택 나가기", + "exitTooltip": "선택 나가기 · Esc", + "title": "공유할 스킬 선택", + "selectAll": "가능한 {{count}}개 모두 선택", + "clear": "지우기", + "deleteTitle": "삭제할 스킬 선택" + }, "SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" }, "SkillFreshnessNudge": { "titleOne": "설치된 Orca 스킬이 오래되었습니다", @@ -13682,6 +13747,7 @@ "showMore": "더 보기" }, "AutomationDetail": { + "host": "호스트", "007c8ad874": "프롬프트", "a1d52c2189": "사용 범위", "449fc83bf7": "토큰", @@ -13845,6 +13911,8 @@ "b11170a008": "자동화를 저장하지 못했습니다.", "2a20596d6b": "자동화가 저장되었습니다.", "244727e655": "자동화가 업데이트되었습니다.", + "moved": "자동화를 {host}(으)로 이동했습니다.", + "moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.", "77b81bc4ac": "Hermes 자동화가 생성되었습니다.", "08efc3ae12": "Hermes 자동화가 업데이트되었습니다.", "e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.", @@ -14041,6 +14109,19 @@ "filters": "필터", "clear": "필터 지우기", "removeFilter": "{{value0}} 필터 제거" + }, + "createDestination": { + "label": "호스트", + "placeholder": "호스트 선택", + "storedOn": "{authority}에서 저장하고 예약합니다.", + "unselected": "이 자동화를 저장하고 예약할 호스트를 선택하세요.", + "orphan": "호스트가 없는 자동화는 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 호스트를 선택하세요.", + "unavailable": "해당 호스트는 아직 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 다른 호스트를 선택하세요.", + "stale": "이 양식이 열려 있는 동안 {host}이(가) 변경되었습니다. 저장하기 전에 다시 선택하세요.", + "projectMismatch": "해당 프로젝트는 {host}에 없습니다. 그 호스트의 프로젝트를 선택하거나 다른 호스트를 선택하세요.", + "noProjects": "{host}에 설정된 프로젝트가 없습니다. 거기에 추가하거나 다른 호스트를 선택하세요.", + "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 5b55818c67d..2ad8a4a2b99 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3889,17 +3889,90 @@ }, "skills": { "SkillsPage": { - "cb142070b4": "刷新", "a68dee6a32": "搜索技能", "f43ad6edf3": "技能", "ea72d6185b": "无法扫描技能", "dc4c3328ee": "显示文件", "9963dff6d3": "未找到说明。", "995fde8337": "无法显示技能文件", "4acd6d68ec": "未找到技能", "6a62a0168c": "无匹配项", "08a321a984": "没有技能匹配当前搜索和筛选条件。", "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用", "c13b82793c": "管理安装", "aee7b99cc6": "从链接安装", "filterProvider": "按 Agent 筛选", "filterSource": "按来源筛选", "allSources": "全部", "clearFilters": "清除筛选", "closeSkills": "关闭技能", "closeTooltip": "关闭 · Esc", "moreActions": "更多操作", "sharedLinks": "共享链接", "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", "retry": "重试", "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", "viewSwitch": "显示", "searchLinks": "搜索链接", "deleteSkills": "删除技能…" + "cb142070b4": "刷新", + "a68dee6a32": "搜索技能", + "f43ad6edf3": "技能", + "ea72d6185b": "无法扫描技能", + "dc4c3328ee": "显示文件", + "9963dff6d3": "未找到说明。", + "995fde8337": "无法显示技能文件", + "4acd6d68ec": "未找到技能", + "6a62a0168c": "无匹配项", + "08a321a984": "没有技能匹配当前搜索和筛选条件。", + "cd7893fbc1": "正在扫描技能", + "35b9a724a0": "可用", + "c13b82793c": "管理安装", + "aee7b99cc6": "从链接安装", + "filterProvider": "按 Agent 筛选", + "filterSource": "按来源筛选", + "allSources": "全部", + "clearFilters": "清除筛选", + "closeSkills": "关闭技能", + "closeTooltip": "关闭 · Esc", + "moreActions": "更多操作", + "sharedLinks": "共享链接", + "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", + "retry": "重试", + "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", + "viewSwitch": "显示", + "searchLinks": "搜索链接", + "deleteSkills": "删除技能…" }, "SkillShareSelectionControls": { "01c5a15e02": "共享技能" }, - "SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", "copyPath": "复制路径", "detailPath": "路径", "skillActions": "{{value0}} 的操作", "viewDetails": "查看详情", "deleteSkill": "删除…" }, + "SkillRow": { + "updatedUnknown": "无日期", + "pathCopied": "路径已复制", + "copyPath": "复制路径", + "detailPath": "路径", + "skillActions": "{{value0}} 的操作", + "viewDetails": "查看详情", + "deleteSkill": "删除…" + }, "SkillsList": { "listLabel": "技能" }, - "sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" }, + "sourceStatus": { + "missing": "未找到文件夹", + "remoteRepo": "远程仓库 — 未扫描", + "unavailable": "未扫描" + }, "sources": { "heading": "技能文件夹" }, - "sourceKind": { "home": "主目录", "workspace": "工作区", "bundled": "内置", "plugin": "插件" }, - "count": { "skillOne": "{{count}} 个技能", "skillOther": "{{count}} 个技能", "sourceOne": "{{count}} 个来源", "sourceOther": "{{count}} 个来源", "fileOne": "{{count}} 个文件", "fileOther": "{{count}} 个文件", "resultOne": "{{count}} 个结果", "resultOther": "{{count}} 个结果", "selected": "已选择 {{count}} 个", "shareOne": "共享 {{count}} 个技能", "shareOther": "共享 {{count}} 个技能", "linkOne": "{{count}} 个链接", "linkOther": "{{count}} 个链接", "deleteOne": "删除 {{count}} 个技能", "deleteOther": "删除 {{count}} 个技能", "deletedOne": "已删除 {{count}} 个技能", "deletedOther": "已删除 {{count}} 个技能", "deleteFolderOne": "{{count}} 个文件夹", "deleteFolderOther": "{{count}} 个文件夹", "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" }, + "sourceKind": { + "home": "主目录", + "workspace": "工作区", + "bundled": "内置", + "plugin": "插件" + }, + "count": { + "skillOne": "{{count}} 个技能", + "skillOther": "{{count}} 个技能", + "sourceOne": "{{count}} 个来源", + "sourceOther": "{{count}} 个来源", + "fileOne": "{{count}} 个文件", + "fileOther": "{{count}} 个文件", + "resultOne": "{{count}} 个结果", + "resultOther": "{{count}} 个结果", + "selected": "已选择 {{count}} 个", + "shareOne": "共享 {{count}} 个技能", + "shareOther": "共享 {{count}} 个技能", + "linkOne": "{{count}} 个链接", + "linkOther": "{{count}} 个链接", + "deleteOne": "删除 {{count}} 个技能", + "deleteOther": "删除 {{count}} 个技能", + "deletedOne": "已删除 {{count}} 个技能", + "deletedOther": "已删除 {{count}} 个技能", + "deleteFolderOne": "{{count}} 个文件夹", + "deleteFolderOther": "{{count}} 个文件夹", + "deleteLinkOne": "{{count}} 个链接", + "deleteLinkOther": "{{count}} 个链接" + }, "filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" }, - "SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", "title": "选择要共享的技能", "selectAll": "选择全部 {{count}} 个符合条件的项目", "clear": "清除", "deleteTitle": "选择要删除的技能" }, + "SkillsSelectionHeader": { + "exit": "退出选择", + "exitTooltip": "退出选择 · Esc", + "title": "选择要共享的技能", + "selectAll": "选择全部 {{count}} 个符合条件的项目", + "clear": "清除", + "deleteTitle": "选择要删除的技能" + }, "SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" }, "SkillFreshnessNudge": { "titleOne": "已安装的 Orca 技能已过期", @@ -13698,6 +13771,7 @@ "showMore": "显示更多" }, "AutomationDetail": { + "host": "主机", "007c8ad874": "提示词", "a1d52c2189": "使用范围", "449fc83bf7": "token", @@ -13861,6 +13935,8 @@ "b11170a008": "无法保存自动化。", "2a20596d6b": "自动化已保存。", "244727e655": "自动化已更新。", + "moved": "自动化已移动到 {host}。", + "moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。", "77b81bc4ac": "Hermes 自动化创建。", "08efc3ae12": "Hermes 自动化已更新。", "e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。", @@ -14057,6 +14133,19 @@ "filters": "筛选器", "clear": "清除筛选器", "removeFilter": "移除 {{value0}} 筛选器" + }, + "createDestination": { + "label": "主机", + "placeholder": "选择主机", + "storedOn": "由 {authority} 存储和调度。", + "unselected": "选择存储和调度此自动化的主机。", + "orphan": "没有主机的自动化无法容纳新的自动化。请选择要在其上创建此自动化的主机。", + "unavailable": "该主机尚无法容纳新的自动化。请选择另一台主机来创建它。", + "stale": "此表单打开期间 {host} 已更改。保存前请重新选择。", + "projectMismatch": "该项目不在 {host} 上。请选择该主机上的项目,或选择另一台主机。", + "noProjects": "{host} 上未设置任何项目。请在该主机上添加一个,或选择另一台主机。", + "updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。", + "move": "保存后会在 {host} 上创建此自动化并删除原有自动化。运行历史仍保留在原主机上。" } }, "agent": {