Support moving automations across execution authorities

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.
This commit is contained in:
Jinjing
2026-08-27 16:59:23 -07:00
parent a39b0d4426
commit dcd8de3fc4
21 changed files with 597 additions and 96 deletions
+22
View File
@@ -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())
@@ -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),
+1
View File
@@ -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,
@@ -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,
@@ -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(
<TooltipProvider>
<AutomationDestinationField control={control(projects, entries)} />
<AutomationDestinationField control={control(projects, entries, note)} />
</TooltipProvider>
)
})
@@ -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])
@@ -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)}
</p>
) : control.note ? (
// Replaces the storedOn line: both name the same host, and the move is
// the consequential half.
<p className="text-xs text-destructive" data-testid="automation-host-move">
{control.note}
</p>
) : (
<p className="text-xs text-muted-foreground">
{selected
@@ -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<string, unknown>),
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<string, unknown>),
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 () => {
@@ -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<AutomationActionNotice | null>(null)
const [editorNoticeHost, setEditorNoticeHost] = useState<AutomationHostCatalogEntry | null>(null)
const [externalActionKey, setExternalActionKey] = useState<string | null>(null)
const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState<ReadonlySet<string>>(
() => 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<string | null>(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<string, string>())
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState<AutomationPaneTab>('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState<string | null>(
@@ -511,32 +519,70 @@ export default function AutomationsPage(): React.JSX.Element {
(
candidate: Pick<AutomationDraft, 'projectId' | 'workspaceMode'>
): 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<OrcaHooks | null> => {
const key = getAutomationHooksCacheKey(repoId)
async (repoId: string, hostId?: ExecutionHostId): Promise<OrcaHooks | null> => {
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<void> => {
@@ -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<Automation>; 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}
@@ -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<void> {
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
}
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): AutomationHostTarget {
@@ -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
]
}
@@ -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<Automation> {
if (input.creationKey) {
await assertAutomationCreateIdempotencySupported(authority)
}
await assertOwnerFencingSupported(authority)
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
...toRuntimeAutomationCreateInput(input),
@@ -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: {
@@ -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 =
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+4
View File
@@ -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
+6 -2
View File
@@ -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 & {})