From 52ade074a9a92e1d2ce78bba02d03ba73c559c29 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:10:17 -0700 Subject: [PATCH 1/4] Display host on automation details and dialog (#16823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show automation host in details and support moving between hosts - Rename AutomationCreateDestinationField to AutomationDestinationField to reflect dual use in create and edit modes - Add host display to automation detail view, showing storage authority - Allow editing automations to move them to different hosts within same authority; project list filters to available projects on chosen host - Update copy from create-only terminology to mode-agnostic wording * 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. * Remove cross-authority move support for automations An automation's authority (the Orca instance that stores and schedules it) cannot change; edits now only offer hosts within the same authority and move logic is removed entirely. This simplifies the destination picker and removes move-specific UI messaging. * Fix undefined selectedRowKey in automation host recovery Replace references to the undefined selectedRowKey variable with selectedRow?.key to properly access the row's key when recovering automation runs across hosts. * 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. * Reuse creationKey across move retries when schedule changes When retrying a failed automation move, dtstart is minted fresh each attempt, changing the payload. Previously, operationKey included the full payload, so retries would mint new creationKeys and risk duplicate automations on the destination if the initial create failed in transport. Now key only by the move (source + destination) to ensure stable creationKey across retries. Also fix workspace auto-selection to use authority-scoped worktrees instead of the merged cache, preventing unwanted restoration of source-host workspaces after switching authorities. * Rename `note` to `moveWarning` for automation host moves Clarifies that the field specifically warns when an automation would move to another host, replacing the plain storage line. --- 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 + ...sx => AutomationDestinationField.test.tsx} | 41 +- ...eld.tsx => AutomationDestinationField.tsx} | 33 +- .../AutomationDetail.host.test.tsx | 100 ++++ .../automations/AutomationDetail.tsx | 15 + .../automations/AutomationEditorDialog.tsx | 9 +- .../AutomationEditorSettingsSidebar.tsx | 15 +- .../automations/AutomationsDetailPane.tsx | 5 + ...utomationsPage.create-destination.test.tsx | 268 +++++++++ ...tionsPage.cross-authority-actions.test.tsx | 18 + .../automations/AutomationsPage.tsx | 513 +++++++++++++++--- .../automation-capability-probe.ts | 14 +- .../automations/automation-host-client.ts | 9 + .../automation-host-detail-display.test.ts | 69 +++ .../automation-host-detail-display.ts | 36 ++ .../automation-scoped-list-client.test.ts | 6 +- .../automation-scoped-list-client.ts | 4 + .../automations-page-test-harness.tsx | 13 +- .../use-automation-create-destination.ts | 4 +- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 17 + src/renderer/src/i18n/locales/ja.json | 17 + src/renderer/src/i18n/locales/ko.json | 17 + src/renderer/src/i18n/locales/zh.json | 17 + src/shared/automations-types.ts | 4 + src/shared/protocol-version.ts | 8 +- 29 files changed, 1162 insertions(+), 134 deletions(-) rename src/renderer/src/components/automations/{AutomationCreateDestinationField.test.tsx => AutomationDestinationField.test.tsx} (68%) rename src/renderer/src/components/automations/{AutomationCreateDestinationField.tsx => AutomationDestinationField.tsx} (81%) create mode 100644 src/renderer/src/components/automations/AutomationDetail.host.test.tsx create mode 100644 src/renderer/src/components/automations/automation-host-detail-display.test.ts create mode 100644 src/renderer/src/components/automations/automation-host-detail-display.ts 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 4e94b6d6872..8f30d27c3ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4374,6 +4374,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/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx similarity index 68% rename from src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx rename to src/renderer/src/components/automations/AutomationDestinationField.test.tsx index e32e6500d5f..29d71acf0b8 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx +++ b/src/renderer/src/components/automations/AutomationDestinationField.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import { AutomationCreateDestinationField } from './AutomationCreateDestinationField' +import { AutomationDestinationField } from './AutomationDestinationField' import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' @@ -61,10 +61,12 @@ const LEGACY_ENTRY: AutomationHostCatalogEntry = { function control( projects: Repo[], - entries: AutomationHostCatalogEntry[] = [ENTRY] + entries: AutomationHostCatalogEntry[] = [ENTRY], + moveWarning?: string ): AutomationCreateDestinationControl { return { entries, + moveWarning, resolution: { status: 'ready', authority: OWNER.authority, @@ -76,11 +78,15 @@ function control( } } -function render(projects: Repo[], entries?: AutomationHostCatalogEntry[]): void { +function render( + projects: Repo[], + entries?: AutomationHostCatalogEntry[], + moveWarning?: string +): void { act(() => { root.render( - + ) }) @@ -90,7 +96,7 @@ function emptyNote(): HTMLElement | null { return container.querySelector('[data-testid="automation-create-no-projects"]') } -describe('AutomationCreateDestinationField', () => { +describe('AutomationDestinationField', () => { it('names the host that has no projects rather than leaving Create dead', () => { render([]) @@ -107,8 +113,10 @@ describe('AutomationCreateDestinationField', () => { it('names the hosts a server update would let create, instead of hiding them', () => { render([{ id: 'repo-1' } as Repo], [ENTRY, LEGACY_ENTRY]) - const note = container.querySelector('[data-testid="automation-create-update-required"]') - expect(note?.textContent).toContain('legacy-box') + const updateRequired = container.querySelector( + '[data-testid="automation-create-update-required"]' + ) + expect(updateRequired?.textContent).toContain('legacy-box') }) it('says nothing about updates when every offered host is eligible', () => { @@ -116,4 +124,23 @@ describe('AutomationCreateDestinationField', () => { 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]) + + // The same field serves both modes; create-only wording would misread on an + // existing record, whose save moves it rather than creating it. + expect(container.querySelector('[aria-label="Host"]')).not.toBeNull() + expect(container.textContent).not.toContain('created') + }) }) diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationDestinationField.tsx similarity index 81% rename from src/renderer/src/components/automations/AutomationCreateDestinationField.tsx rename to src/renderer/src/components/automations/AutomationDestinationField.tsx index 43a4f2cc08c..e3ef789b670 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationDestinationField.tsx @@ -19,16 +19,11 @@ import { import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' -/** - * Where the new automation will be stored, 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. - */ +// 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 AutomationCreateDestinationField({ +export function AutomationDestinationField({ control, labelClassName }: { @@ -42,7 +37,7 @@ export function AutomationCreateDestinationField({ control.entries.filter(automationCreateHostOffered) ) const updateRequiredAuthorities = automationCreateUpdateRequiredAuthorityLabels(control.entries) - const label = translate('auto.components.automations.createDestination.label', 'Create on') + const label = translate('auto.components.automations.createDestination.label', 'Host') return ( @@ -84,6 +79,12 @@ export function AutomationCreateDestinationField({ 'No projects are set up on {host}. Add one there, or choose another host.' ).replace('{host}', selected.label)}

+ ) : control.moveWarning ? ( + // Replaces the storedOn line: both name the same host, and the move is + // the consequential half. +

+ {control.moveWarning} +

) : (

{selected @@ -93,7 +94,7 @@ export function AutomationCreateDestinationField({ ).replace('{authority}', selected.authorityLabel) : translate( 'auto.components.automations.createDestination.unselected', - 'Choose the host this automation will be created on.' + 'Choose the host that stores and schedules this automation.' )}

)} @@ -103,11 +104,13 @@ export function AutomationCreateDestinationField({ className="text-xs text-muted-foreground" data-testid="automation-create-update-required" > - {translate( - 'auto.components.automations.createDestination.updateRequired', - 'Update the Orca server on {hosts} to create automations there.' + { // Replacer fn: a literal replacement would expand `$` patterns in host labels. - ).replace('{hosts}', () => updateRequiredAuthorities.join(', '))} + translate( + 'auto.components.automations.createDestination.updateRequired', + 'Update the Orca server on {hosts} to store automations there.' + ).replace('{hosts}', () => updateRequiredAuthorities.join(', ')) + }

) : null}
diff --git a/src/renderer/src/components/automations/AutomationDetail.host.test.tsx b/src/renderer/src/components/automations/AutomationDetail.host.test.tsx new file mode 100644 index 00000000000..14fe6ef799d --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDetail.host.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom + +/** + * The detail view never said which machine an automation lived on, so two rows + * from different hosts read identically. These pin the host to the row's own + * catalog entry rather than to the record's host-local execution target. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { Automation } from '../../../../shared/automations-types' +import { AutomationDetail } from './AutomationDetail' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { makeAutomation } from './automations-page-fixtures' + +const roots: Root[] = [] + +const RUNTIME_ENTRY: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:r1:self', + label: 'GPU box', + authorityLabel: 'GPU box', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +async function render( + overrides: Partial, + hostEntry: AutomationHostCatalogEntry | null +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + + + ) + }) + return container +} + +function hostMetric(container: HTMLDivElement): string | null { + const label = [...container.querySelectorAll('div')].find( + (node) => node.textContent === 'Host' && node.className.includes('uppercase') + ) + return label?.nextElementSibling?.textContent ?? null +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true +}) + +afterEach(async () => { + await act(async () => { + roots.splice(0).forEach((root) => root.unmount()) + }) + document.body.innerHTML = '' +}) + +describe('AutomationDetail host', () => { + it('names the host the row was listed from', async () => { + // executionTargetType 'local' means local to the runtime, so the record + // alone would have this read as this Mac. + const container = await render({ executionTargetType: 'local' }, RUNTIME_ENTRY) + + expect(hostMetric(container)).toBe('GPU box') + }) + + it('falls back to the record’s own target when no host answered for the row', async () => { + const container = await render({ executionTargetType: 'local' }, null) + + expect(hostMetric(container)).toBe('Local Mac') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index a15c56e5abf..11a7545a1d3 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -14,6 +14,8 @@ import { summarizeAutomationRunUsage } from './automation-usage-model' import type { AutomationTargetAvailability } from './automation-target-availability' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { getAutomationHostDetailDisplay } from './automation-host-detail-display' import { getAutomationSourceDisplay } from './automation-source-display' import { translate } from '@/i18n/i18n' import { AutomationPromptDisclosure } from './AutomationPromptDisclosure' @@ -24,6 +26,8 @@ type AutomationDetailProps = { projectName: string workspaceName: string projectDefaultBaseRef: string | null + /** The catalog entry the selected row was listed from; absent for legacy unscoped rows. */ + hostEntry?: AutomationHostCatalogEntry | null hostLabelById?: ReadonlyMap runNowAvailability: AutomationTargetAvailability | null now: number @@ -101,6 +105,7 @@ export function AutomationDetail({ projectName, workspaceName, projectDefaultBaseRef, + hostEntry, hostLabelById, runNowAvailability, now, @@ -133,6 +138,11 @@ export function AutomationDetail({ ? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default') : workspaceName const sourceDisplay = getAutomationSourceDisplay(automation.sourceContext, hostLabelById) + const hostDisplay = getAutomationHostDetailDisplay({ + automation, + entry: hostEntry, + hostLabelById + }) const runNowDisabled = runNowAvailability?.canRunNow === false return ( @@ -238,6 +248,11 @@ export function AutomationDetail({ : 'Paused' } /> + void @@ -104,6 +106,7 @@ export function AutomationEditorDialog({ settings, draft, createDestination, + editDestination, notice, onNoticeRecover, onNoticeDismiss, @@ -122,6 +125,7 @@ export function AutomationEditorDialog({ const isHermesTarget = createTarget === 'hermes' const isCreateMode = !isEditing && !isEditingExternal const isHermesCreate = isCreateMode && isHermesTarget + const destination = isCreateMode ? createDestination : editDestination const visibleAgents = React.useMemo(() => { const enabledIds = new Set( filterEnabledTuiAgents( @@ -186,8 +190,7 @@ export function AutomationEditorDialog({ onDismiss={() => onOpenChange(false)} /> - {createDestination && isCreateMode && !isHermesTarget ? ( + {destination && !isHermesTarget ? (
-
diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index f596c21c36b..7eb2ff13c1d 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -36,6 +36,7 @@ import { import { getAutomationRunContent } from './automation-run-content' import type { AutomationActionNotice } from './automation-row-action-dispatch' import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationTargetAvailability } from './automation-target-availability' import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' @@ -56,6 +57,8 @@ type AutomationsDetailPaneProps = { selectedRepoDisplayName: string selectedRepoDefaultBaseRef: string | null selectedWorkspaceName: string + /** Catalog entry the selected row was listed from; absent for legacy unscoped rows. */ + selectedHostEntry: AutomationHostCatalogEntry | null hostLabelById: ReadonlyMap selectedRunNowAvailability: AutomationTargetAvailability | null selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null @@ -107,6 +110,7 @@ export function AutomationsDetailPane({ selectedRepoDisplayName, selectedRepoDefaultBaseRef, selectedWorkspaceName, + selectedHostEntry, hostLabelById, selectedRunNowAvailability, selectedAutomationRunPageWorkspaceDisplay, @@ -221,6 +225,7 @@ export function AutomationsDetailPane({ projectName={selectedRepoDisplayName} projectDefaultBaseRef={selectedRepoDefaultBaseRef} workspaceName={selectedWorkspaceName} + hostEntry={selectedHostEntry} hostLabelById={hostLabelById} runNowAvailability={selectedRunNowAvailability} now={relativeNow} 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 da04a8cd58f..a0778029ef9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -32,6 +32,7 @@ import { } from './automations-page-test-harness' import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' import type { Repo } from '../../../../shared/repo-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' installAutomationsPageHarness() @@ -71,6 +72,52 @@ function addSshProject(): void { } as Repo mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.repoMap.set(SSH_REPO_ID, repo) + // Without a ready setup on that host the project has no run context, and a + // save against it is refused before any destination is stated. + mocks.state.projectHostSetups = [ + ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + { + id: 'setup-ssh', + projectId: 'project-ssh', + hostId: `ssh:${SSH_TARGET_ID}`, + repoId: SSH_REPO_ID, + path: '/repos/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] +} + +/** 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. */ @@ -395,6 +442,227 @@ describe('AutomationsPage edit dialog projects', () => { }) }) +describe('AutomationsPage edit destination', () => { + 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]) + addSshHost() + addSshProject() + runtimeHost([], []) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + const resolution = mocks.editorDialog?.editDestination?.resolution + expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(DESKTOP_SELF_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?.moveWarning).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?.moveWarning).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('reuses the move creation key on retry even when the schedule changed', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + runtimeHost([], []) + addRuntimeProject() + // The first create dies in transport after the server may have committed; + // only an identical creationKey on the retry keeps that copy singular. + let failCreate = true + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + if (failCreate) { + failCreate = false + throw new Error('socket hang up') + } + return { automation: makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }) } + } + return await previous?.(target, method, params, options) + } + ) + + 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, + // A changed schedule mints dtstart per attempt — the volatile input that + // must not re-key the move. + preset: 'custom', + customSchedule: 'FREQ=DAILY;BYHOUR=10;BYMINUTE=0' + })) + }) + + const realNow = Date.now.bind(Date) + let clockSkew = 0 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockSkew) + try { + await save() + clockSkew = 5000 + await save() + } finally { + nowSpy.mockRestore() + } + + const creationKeys = runtimeCreateCalls().map( + (call) => (call[2] as { creationKey?: string }).creationKey + ) + expect(creationKeys).toHaveLength(2) + expect(creationKeys[0]).toBeTruthy() + expect(creationKeys[1]).toBe(creationKeys[0]) + 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() + + 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 () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(SSH_HOST_KEY) + }) + + // Keeping the local project selected only defers the same refusal to submit. + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID]) + expect(mocks.editorDialog?.draft?.projectId).toBe('') + + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: SSH_REPO_ID, + workspaceMode: 'new_per_run', + workspaceId: '' + })) + }) + await save() + + expect(api.automations.update).toHaveBeenCalledWith( + expect.objectContaining({ + destination: { selector: { kind: 'ssh', targetId: SSH_TARGET_ID, targetGeneration: 1 } } + }) + ) + }) +}) + describe('AutomationsPage create admission', () => { it('keeps the create button available while every offered host is ineligible', async () => { // No host has answered, so nothing resolves ready — but the dialog is where 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 7eef4ac6f57..1ff12cf347a 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, @@ -139,13 +142,18 @@ import { groupReposByAutomationAuthority } from './automation-authority-identity' import { + automationCreateEligibleProjects, automationCreateHostOffered, automationCreateHostStableKey, + automationCreateProjectMismatch, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, type AutomationCreateDestination } from './automation-create-destination' -import { useAutomationCreateDestination } from './use-automation-create-destination' +import { + useAutomationCreateDestination, + type AutomationCreateDestinationControl +} from './use-automation-create-destination' import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference' import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft' import { createAutomationAtDestination } from './automation-owner-action-runner' @@ -193,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) @@ -257,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() @@ -277,6 +287,12 @@ export default function AutomationsPage(): React.JSX.Element { projectId: string destination: AutomationCreateDestination } | null>(null) + // 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( @@ -503,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 } @@ -537,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' @@ -604,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] @@ -658,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 @@ -923,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) @@ -1015,14 +1070,127 @@ export default function AutomationsPage(): React.JSX.Element { ) })() const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null + const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget) + // 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 + const editHostResolution = resolveAutomationCreateDestination( + editingHostStableKey + ? editHostEntries.find((entry) => entry.stableKey === editingHostStableKey) + : null + ) + const editHostProjects = + editHostResolution.status === 'ready' + ? 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 => { + setEditingHostStableKey(stableKey) + const resolved = resolveAutomationCreateDestination( + editHostEntries.find((entry) => entry.stableKey === stableKey) + ) + const projectId = draftRef.current.projectId + if ( + projectId && + 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) + setDraft((current) => ({ ...current, projectId: '', workspaceId: '', baseBranch: '' })) + } + const editDestinationControl: AutomationCreateDestinationControl = { + entries: editHostEntries, + resolution: editHostResolution, + onSelect: handleEditHostChange, + projects: editHostProjects, + // Replacer fn: a literal replacement would expand `$` patterns in host labels. + moveWarning: 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 - ? getAutomationCreateRepos(repos, automationDialogTarget) + ? 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, @@ -1038,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 @@ -1158,7 +1326,9 @@ export default function AutomationsPage(): React.JSX.Element { }, [refresh]) useEffect(() => { - if (!draft.projectId) { + // Creates only: an edit whose project was cleared is waiting for one on the + // chosen host, and this default would reinstate a project that host lacks. + if (!draft.projectId && editingAutomationId === null) { const target = getDefaultTarget() if (!target.projectId) { return @@ -1169,18 +1339,19 @@ export default function AutomationsPage(): React.JSX.Element { workspaceId: target.workspaceId })) } - }, [draft.projectId, getDefaultTarget]) + }, [draft.projectId, editingAutomationId, getDefaultTarget]) useEffect(() => { if (!draft.projectId) { return } - const available = worktreesByRepo[draft.projectId] ?? [] - const defaultWorktree = getDefaultWorktree(available) + // Authority-scoped: the merged cache would restore a source-host workspace + // after a host change clears it, which save validation then rejects. + const defaultWorktree = getDefaultWorktree(dialogWorktrees) if (!draft.workspaceId && defaultWorktree) { setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id })) } - }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + }, [dialogWorktrees, draft.projectId, draft.workspaceId]) useEffect(() => { if ( @@ -1191,10 +1362,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 @@ -1280,6 +1452,7 @@ export default function AutomationsPage(): React.JSX.Element { setEditingAutomationId(null) setEditingExternalTarget(null) setEditingDestination(null) + setEditingHostStableKey(null) setCreateTarget('orca') const baseDraft: AutomationDraft = { name: '', @@ -1344,10 +1517,12 @@ 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 ) + setEditingHostStableKey(initialDestination?.entry.stableKey ?? null) const nextDraft = buildAutomationEditDraft(latest) setDraft(nextDraft) setDraftAtOpen(nextDraft) @@ -1383,6 +1558,7 @@ export default function AutomationsPage(): React.JSX.Element { setEditingAutomationId(null) setEditingRowKey(null) setEditingDestination(null) + setEditingHostStableKey(null) setEditingExternalTarget({ manager, job, scope }) setCreateTarget('hermes') setDraft(nextDraft) @@ -1394,9 +1570,27 @@ 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) + } } setDraft((current) => ({ ...current, @@ -1405,7 +1599,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) { @@ -1420,7 +1614,14 @@ export default function AutomationsPage(): React.JSX.Element { ) }) }, - [destinationForProject, editingAutomationId, fetchWorktrees, worktreesByRepo] + [ + destinationForProject, + editingAutomationId, + editingHostStableKey, + fetchWorktrees, + hostCatalog.entries, + worktreesByRepo + ] ) const handleDraftChange = useCallback( @@ -1433,11 +1634,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 => { @@ -1496,7 +1700,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( @@ -1614,18 +1818,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 }) @@ -1633,7 +1848,8 @@ export default function AutomationsPage(): React.JSX.Element { const trustDecision = await ensureHooksConfirmed( useAppStore.getState(), draft.projectId, - 'setup' + 'setup', + setupHostId ) if (trustDecision === 'skip') { setupDecision = 'skip' @@ -1669,9 +1885,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) { @@ -1713,6 +1934,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, @@ -1734,44 +1960,57 @@ 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: 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 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 @@ -1788,7 +2027,9 @@ export default function AutomationsPage(): React.JSX.Element { }) setDraft((current) => ({ ...current, name: '', prompt: '' })) await refresh() - if (editingAutomationId && editingRowKey) { + // A move retires the edited row key; the else branch selects the new copy + // by id alone and lets the refreshed list resolve its row. + if (editingAutomationId && editingRowKey && !moveTarget) { setSelectedAutomationRunPageId(null) setSelectedRowKey(editingRowKey) setSelectedId(automation.id) @@ -1799,13 +2040,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) { @@ -1824,6 +2077,79 @@ 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 + } + } + // Keyed by the move, not its payload: dtstart is minted per attempt, so a + // payload-keyed retry would mint a fresh creationKey and let an ambiguous + // create failure schedule a second copy on the destination. + const operationKey = `${source.id}:${target.entry.stableKey}` + 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 @@ -2279,27 +2605,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} @@ -2350,10 +2690,11 @@ export default function AutomationsPage(): React.JSX.Element { selectedAutomationRunPage={selectedAutomationRunPage} selectedRuns={selectedRuns} selectedRunsNotice={selectedRunsNotice} + selectedHostEntry={rowRecoveryHost(selectedRow?.key ?? null)} recoverSelectedRuns={(action) => { // Reconnect/Update server act on the selected row's own host; the // re-ask is what brings this automation's history back either way. - hostCatalog.recover(action, rowRecoveryHost(selectedRowKey)) + hostCatalog.recover(action, rowRecoveryHost(selectedRow?.key ?? null)) setSelectedAutomationRuns((current) => ({ ...current, notice: null })) setRunHistoryReloadToken((token) => token + 1) }} 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-host-detail-display.test.ts b/src/renderer/src/components/automations/automation-host-detail-display.test.ts new file mode 100644 index 00000000000..a2b2817194b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-detail-display.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { getAutomationHostDetailDisplay } from './automation-host-detail-display' + +const RUNTIME_SELF: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:r1:self', + label: 'GPU box', + authorityLabel: 'GPU box', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +const DESKTOP_SSH: AutomationHostCatalogEntry = { + ...RUNTIME_SELF, + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } + }, + stableKey: 'host:desktop:ssh:t1', + label: 'openclaw', + authorityLabel: 'Local Mac', + kind: 'ssh' +} + +function automation(overrides: Partial): Automation { + return { executionTargetType: 'local', executionTargetId: 'local', ...overrides } as Automation +} + +describe('getAutomationHostDetailDisplay', () => { + it('names the runtime that stores the record, not the “local” its target reads', () => { + // The record's own target is local *to that server*; rendering it verbatim + // would claim a remote automation runs on this Mac. + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'local' }), + entry: RUNTIME_SELF + }) + + expect(display).toEqual({ label: 'GPU box', title: 'GPU box' }) + }) + + it('qualifies a host with the authority that stores it when the two differ', () => { + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }), + entry: DESKTOP_SSH + }) + + expect(display).toEqual({ label: 'openclaw', title: 'Local Mac · openclaw' }) + }) + + it('falls back to the record’s own target for a row no host answered for', () => { + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }), + entry: null, + hostLabelById: new Map([['ssh:t1', 'openclaw']]) + }) + + expect(display).toEqual({ label: 'openclaw', title: 'openclaw' }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-detail-display.ts b/src/renderer/src/components/automations/automation-host-detail-display.ts new file mode 100644 index 00000000000..2d8bdc3ee65 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-detail-display.ts @@ -0,0 +1,36 @@ +// 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' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +export type AutomationHostDetailDisplay = { + label: string + /** Authority-qualified when the two differ; a self host is its own authority. */ + title: string +} + +export function getAutomationHostDetailDisplay(input: { + automation: Pick + entry?: AutomationHostCatalogEntry | null + hostLabelById?: ReadonlyMap +}): AutomationHostDetailDisplay { + const { automation, entry, hostLabelById } = input + if (entry) { + return { + label: entry.label, + title: + entry.authorityLabel === entry.label + ? entry.label + : `${entry.authorityLabel} · ${entry.label}` + } + } + const hostId = + automation.executionTargetType === 'ssh' + ? toSshExecutionHostId(automation.executionTargetId) + : 'local' + const label = hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) + return { label, title: label } +} 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 815338927c6..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,10 +17,12 @@ 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' 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 +78,7 @@ export type ListPanelProps = { export type DetailPaneProps = { selected: Automation | null + selectedHostEntry: AutomationHostCatalogEntry | null selectedRuns: AutomationRun[] selectedRunsNotice: { message: string } | null runNow: (automation: Automation) => void @@ -95,6 +98,7 @@ export type EditorDialogProps = { open: boolean isEditing: boolean createDestination?: AutomationCreateDestinationControl + editDestination?: AutomationCreateDestinationControl notice?: { message: string; recovery: string | null } | null onNoticeRecover?: (action: string) => void repos?: { id: string }[] @@ -300,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 e6d83e9491a..a47faa37c71 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[] + /** Set when saving would move the record to another host; replaces the plain line. */ + moveWarning?: string | null } export type AutomationCreateDestinationCheck = @@ -73,7 +75,7 @@ function choiceMessage(reason: AutomationCreateDestinationChoiceReason): string } return translate( 'auto.components.automations.createDestination.unselected', - 'Choose the host this automation will be created on.' + 'Choose the host that stores and schedules this automation.' ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 06989bb3f9e..115cfb5ae1f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15220,6 +15220,7 @@ "showMore": "Show more" }, "AutomationDetail": { + "host": "Host", "007c8ad874": "Prompt", "a1d52c2189": "Usage coverage", "449fc83bf7": "Tokens", @@ -15388,6 +15389,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.", @@ -15693,16 +15697,17 @@ "occurrences": "{{times}} times" }, "createDestination": { - "label": "Create on", + "label": "Host", "placeholder": "Select a host", "storedOn": "Stored and scheduled by {authority}.", - "unselected": "Choose the host this automation will be created on.", + "unselected": "Choose the host that stores and schedules this automation.", "orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.", "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "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 create 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 69e7c383533..8b2717c3385 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13727,6 +13727,7 @@ "showMore": "Mostrar más" }, "AutomationDetail": { + "host": "Host", "007c8ad874": "Inmediato", "a1d52c2189": "Cobertura de uso", "449fc83bf7": "Fichas", @@ -13890,6 +13891,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.", @@ -14086,6 +14090,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 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 43129d595af..a105e956622 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13718,6 +13718,7 @@ "showMore": "さらに表示" }, "AutomationDetail": { + "host": "ホスト", "007c8ad874": "プロンプト", "a1d52c2189": "使用範囲", "449fc83bf7": "トークン", @@ -13881,6 +13882,9 @@ "b11170a008": "オートメーションを保存できませんでした。", "2a20596d6b": "オートメーションが保存されました。", "244727e655": "自動化が更新されました。", + "moved": "自動化を {host} に移動しました。", + "moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。", + "moveOriginalUnverified": "{host} に作成しましたが、元の自動化の削除を確認できませんでした。再試行する前に元のホストを確認してください。", "77b81bc4ac": "Hermes オートメーションが作成されました。", "08efc3ae12": "Hermes オートメーションが更新されました。", "e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。", @@ -14077,6 +14081,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 e88670171ac..e09d641641a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13750,6 +13750,7 @@ "showMore": "더 보기" }, "AutomationDetail": { + "host": "호스트", "007c8ad874": "프롬프트", "a1d52c2189": "사용 범위", "449fc83bf7": "토큰", @@ -13913,6 +13914,9 @@ "b11170a008": "자동화를 저장하지 못했습니다.", "2a20596d6b": "자동화가 저장되었습니다.", "244727e655": "자동화가 업데이트되었습니다.", + "moved": "자동화를 {host}(으)로 이동했습니다.", + "moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.", + "moveOriginalUnverified": "{host}에 생성했지만 원본 삭제를 확인할 수 없습니다. 다시 시도하기 전에 이전 호스트를 확인하세요.", "77b81bc4ac": "Hermes 자동화가 생성되었습니다.", "08efc3ae12": "Hermes 자동화가 업데이트되었습니다.", "e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.", @@ -14109,6 +14113,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 00f5e077a07..4d4a7010809 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13774,6 +13774,7 @@ "showMore": "显示更多" }, "AutomationDetail": { + "host": "主机", "007c8ad874": "提示词", "a1d52c2189": "使用范围", "449fc83bf7": "token", @@ -13937,6 +13938,9 @@ "b11170a008": "无法保存自动化。", "2a20596d6b": "自动化已保存。", "244727e655": "自动化已更新。", + "moved": "自动化已移动到 {host}。", + "moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。", + "moveOriginalUnverified": "已在 {host} 上创建,但无法确认是否删除了原有自动化。重试前请检查原主机。", "77b81bc4ac": "Hermes 自动化创建。", "08efc3ae12": "Hermes 自动化已更新。", "e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。", @@ -14133,6 +14137,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/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 & {}) From 3d796b82cba90ffec6018410931bfd40e2287318 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:13:14 -0700 Subject: [PATCH 2/4] fix(claude-usage): bill 1-hour cache writes at 2x base input (#16878) --- .../claude-usage/claude-model-pricing.test.ts | 77 +++++++++++++++ src/main/claude-usage/claude-model-pricing.ts | 72 ++++++++++---- .../claude-usage-automation-attribution.ts | 7 +- .../claude-usage-report-aggregation.test.ts | 71 ++++++++++++++ .../claude-usage-report-aggregation.ts | 12 ++- src/main/claude-usage/scanner.test.ts | 69 ++++++++++++-- src/main/claude-usage/store.test.ts | 95 +++++++++++++++---- src/main/claude-usage/store.ts | 5 +- .../claude-usage/transcript-record-parser.ts | 17 +++- src/main/claude-usage/types.ts | 5 + src/main/claude-usage/usage-aggregation.ts | 13 ++- 11 files changed, 386 insertions(+), 57 deletions(-) create mode 100644 src/main/claude-usage/claude-model-pricing.test.ts create mode 100644 src/main/claude-usage/claude-usage-report-aggregation.test.ts diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts new file mode 100644 index 00000000000..8c5f317d875 --- /dev/null +++ b/src/main/claude-usage/claude-model-pricing.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { estimateCostUsd } from './claude-model-pricing' + +describe('estimateCostUsd cache-write TTL rates', () => { + it('bills 5-minute cache writes at 1.25x base input', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25) + }) + + it('keeps the legacy five-argument call billing every write at the 5-minute rate', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000)).toBeCloseTo(6.25) + }) + + it('bills 1-hour cache writes at 2x base input', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10) + }) + + it('splits a mixed write bucket without double-billing the 1-hour share', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75) + }) + + it('clamps a 1-hour count that exceeds the reported write total', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01) + }) + + it('applies the long-context tier to 1-hour writes', () => { + expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6) + }) + + it('shares one long-context allowance across both TTL buckets', () => { + // 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier + // getting half of the 200k allowance. + expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925) + }) + + it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => { + const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map( + (write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)! + ) + for (let index = 1; index < costs.length; index++) { + expect(costs[index]).toBeGreaterThan(costs[index - 1]) + } + }) + + it('still returns null for unknown models', () => { + expect(estimateCostUsd('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull() + }) +}) + +// Published Claude Sonnet 5 rates (platform.claude.com/docs/en/about-claude/pricing): +// $2 base input, $10 output, $0.20 cache hit, $2.50 5m write, $4 1h write per MTok. +// The $2/$10 launch price, announced as introductory through 2026-08-31, is now the +// standard price — the scheduled 2026-09-01 rise to $3/$15 was cancelled — so the rate +// is date-independent. +describe('estimateCostUsd Claude Sonnet 5 rates', () => { + it('bills base input at $2/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2) + }) + + it('bills output at $10/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10) + }) + + it('bills cache hits at $0.20/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2) + }) + + it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5) + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) + }) + + it('keeps the full 1M window at flat rates with no long-context tier', () => { + expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo( + estimateCostUsd('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2 + ) + }) +}) diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts index 6d28b3deace..eace643eb3b 100644 --- a/src/main/claude-usage/claude-model-pricing.ts +++ b/src/main/claude-usage/claude-model-pricing.ts @@ -2,12 +2,16 @@ type ClaudeModelPricing = { input: number output: number cacheRead: number + /** 5-minute TTL cache write (1.25x base input). */ cacheWrite: number + /** 1-hour TTL cache write (2x base input). */ + cacheWrite1h: number thresholdTokens?: number inputAboveThreshold?: number outputAboveThreshold?: number cacheReadAboveThreshold?: number cacheWriteAboveThreshold?: number + cacheWrite1hAboveThreshold?: number } const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 @@ -16,26 +20,29 @@ const SONNET_LONG_CONTEXT_PRICING = { inputAboveThreshold: 6, outputAboveThreshold: 22.5, cacheReadAboveThreshold: 0.6, - cacheWriteAboveThreshold: 7.5 + cacheWriteAboveThreshold: 7.5, + cacheWrite1hAboveThreshold: 12 } satisfies Partial const MODEL_PRICING: Record = { - 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5, cacheWrite1h: 20 }, + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, // Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here. - // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension. - 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + // Why: $2/$10 needs no date dimension — it launched as introductory pricing through + // 2026-08-31 but is now the standard price; the 2026-09-01 rise to $3/$15 was cancelled. + 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 }, + 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 }, + 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 }, 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, 'claude-sonnet-4-5': { @@ -43,6 +50,7 @@ const MODEL_PRICING: Record = { output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, 'claude-sonnet-4': { @@ -50,13 +58,20 @@ const MODEL_PRICING: Record = { output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, - 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, - 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } + 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, + 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, cacheWrite1h: 1.6 }, + 'claude-haiku-3': { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + cacheWrite1h: 0.5 + } } const MODEL_ALIASES: Record = { @@ -179,18 +194,31 @@ function calculateTieredCost( return belowTokens * basePrice + aboveTokens * abovePrice } +/** + * @param cacheWriteTokens every cache write, both TTLs + * @param cacheWrite1hTokens the 1-hour-TTL subset of `cacheWriteTokens` + */ export function estimateCostUsd( model: string | null, inputTokens: number, outputTokens: number, cacheReadTokens: number, - cacheWriteTokens: number + cacheWriteTokens: number, + cacheWrite1hTokens = 0 ): number | null { const normalized = normalizeModelForPricing(model) if (!normalized) { return null } const pricing = MODEL_PRICING[normalized] + const write1hTokens = Math.min(Math.max(cacheWrite1hTokens, 0), cacheWriteTokens) + // Why: both TTL buckets share one long-context allowance. Giving each its own + // would make a split bucket cheaper than the same tokens billed entirely at 5m. + const write1hShare = cacheWriteTokens > 0 ? write1hTokens / cacheWriteTokens : 0 + const write5mThreshold = + pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * (1 - write1hShare) + const write1hThreshold = + pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * write1hShare return ( (calculateTieredCost( inputTokens, @@ -211,10 +239,16 @@ export function estimateCostUsd( pricing.thresholdTokens ) + calculateTieredCost( - cacheWriteTokens, + cacheWriteTokens - write1hTokens, pricing.cacheWrite, pricing.cacheWriteAboveThreshold, - pricing.thresholdTokens + write5mThreshold + ) + + calculateTieredCost( + write1hTokens, + pricing.cacheWrite1h, + pricing.cacheWrite1hAboveThreshold, + write1hThreshold )) / 1_000_000 ) diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts index adda1ad394e..0b4a8a9844f 100644 --- a/src/main/claude-usage/claude-usage-automation-attribution.ts +++ b/src/main/claude-usage/claude-usage-automation-attribution.ts @@ -106,6 +106,7 @@ export async function resolveAutomationRunUsage( acc.outputTokens += entry.outputTokens acc.cacheReadTokens += entry.cacheReadTokens acc.cacheWriteTokens += entry.cacheWriteTokens + acc.cacheWrite1hTokens += entry.cacheWrite1hTokens return acc }, { @@ -113,7 +114,8 @@ export async function resolveAutomationRunUsage( inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ) const estimatedCostUsd = estimateCostUsd( @@ -121,7 +123,8 @@ export async function resolveAutomationRunUsage( totals.inputTokens, totals.outputTokens, totals.cacheReadTokens, - totals.cacheWriteTokens + totals.cacheWriteTokens, + totals.cacheWrite1hTokens ) return { diff --git a/src/main/claude-usage/claude-usage-report-aggregation.test.ts b/src/main/claude-usage/claude-usage-report-aggregation.test.ts new file mode 100644 index 00000000000..7d09c3074f2 --- /dev/null +++ b/src/main/claude-usage/claude-usage-report-aggregation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeUsageDailyAggregate, ClaudeUsagePersistedState } from './types' +import { buildSummary } from './claude-usage-report-aggregation' + +function dailyRow(day: string, model: string): ClaudeUsageDailyAggregate { + return { + day, + model, + projectKey: 'project', + projectLabel: 'project', + repoId: null, + worktreeId: 'worktree-1', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 + } +} + +function stateWithDaily(dailyAggregates: ClaudeUsageDailyAggregate[]): ClaudeUsagePersistedState { + return { + schemaVersion: 6, + worktreeFingerprint: null, + processedFiles: [], + sessions: [], + dailyAggregates, + scanState: { + enabled: true, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +describe('buildSummary Claude Sonnet 5 pricing over time', () => { + // Why: the "All" range re-prices historical days on every render, so a day inside the + // announced 2026-08-31 introductory window has to price the same as a later day — + // the scheduled $3/$15 increase was cancelled and never took effect. + it('prices an introductory-window day and a post-window day at the same $2/$10 rate', () => { + const inWindow = buildSummary( + stateWithDaily([dailyRow('2026-08-15', 'claude-sonnet-5')]), + 'all', + 'all' + ) + const afterWindow = buildSummary( + stateWithDaily([dailyRow('2026-09-15', 'claude-sonnet-5')]), + 'all', + 'all' + ) + + expect(inWindow.estimatedCostUsd).toBeCloseTo(12) + expect(afterWindow.estimatedCostUsd).toBeCloseTo(12) + }) + + it('sums both days in the All range without re-pricing history upward', () => { + const summary = buildSummary( + stateWithDaily([ + dailyRow('2026-08-15', 'claude-sonnet-5'), + dailyRow('2026-09-15', 'claude-sonnet-5') + ]), + 'all', + 'all' + ) + + expect(summary.estimatedCostUsd).toBeCloseTo(24) + }) +}) diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts index f9bdd863c36..9231af85cfa 100644 --- a/src/main/claude-usage/claude-usage-report-aggregation.ts +++ b/src/main/claude-usage/claude-usage-report-aggregation.ts @@ -47,7 +47,8 @@ export function buildSummary( row.inputTokens, row.outputTokens, row.cacheReadTokens, - row.cacheWriteTokens + row.cacheWriteTokens, + row.cacheWrite1hTokens ) if (cost !== null) { hasAnyBillableCost = true @@ -111,6 +112,8 @@ export function buildBreakdown( kind: ClaudeUsageBreakdownKind ): ClaudeUsageBreakdownRow[] { const rows = new Map() + // Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type. + const cacheWrite1hTokensByKey = new Map() const filteredDaily = getFilteredDaily(state, scope, range) const filteredSessions = getFilteredSessions(state, scope, range) @@ -133,6 +136,10 @@ export function buildBreakdown( existing.outputTokens += daily.outputTokens existing.cacheReadTokens += daily.cacheReadTokens existing.cacheWriteTokens += daily.cacheWriteTokens + cacheWrite1hTokensByKey.set( + key, + (cacheWrite1hTokensByKey.get(key) ?? 0) + daily.cacheWrite1hTokens + ) rows.set(key, existing) } @@ -168,7 +175,8 @@ export function buildBreakdown( row.inputTokens, row.outputTokens, row.cacheReadTokens, - row.cacheWriteTokens + row.cacheWriteTokens, + cacheWrite1hTokensByKey.get(row.key) ?? 0 ) } } diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index dc4d6085a5a..cf5b4cc99a3 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -36,10 +36,59 @@ describe('parseClaudeUsageRecord', () => { inputTokens: 100, outputTokens: 25, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }) }) + it('splits the cache-write total by TTL when the breakdown is present', () => { + const parsed = parseClaudeUsageRecord( + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 25, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 1000, + cache_creation: { + ephemeral_5m_input_tokens: 600, + ephemeral_1h_input_tokens: 400 + } + } + } + }) + ) + + expect(parsed?.cacheWriteTokens).toBe(1000) + expect(parsed?.cacheWrite1hTokens).toBe(400) + }) + + it('clamps a 1-hour breakdown that exceeds the reported cache-write total', () => { + const parsed = parseClaudeUsageRecord( + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 25, + cache_creation_input_tokens: 100, + cache_creation: { ephemeral_1h_input_tokens: 400 } + } + } + }) + ) + + expect(parsed?.cacheWriteTokens).toBe(100) + expect(parsed?.cacheWrite1hTokens).toBe(100) + }) + it('merges duplicate streamed assistant usage by message and request id', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-')) const filePath = join(root, 'session.jsonl') @@ -92,7 +141,8 @@ describe('parseClaudeUsageRecord', () => { inputTokens: 120, outputTokens: 30, cacheReadTokens: 12, - cacheWriteTokens: 7 + cacheWriteTokens: 7, + cacheWrite1hTokens: 0 } ]) } finally { @@ -114,7 +164,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }, { sessionId: 'session-1', @@ -125,7 +176,8 @@ describe('Claude usage aggregation', () => { inputTokens: 50, outputTokens: 10, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ], new Map([ @@ -154,7 +206,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }, { locationKey: 'cwd:/outside/repo-b', @@ -165,7 +218,8 @@ describe('Claude usage aggregation', () => { inputTokens: 50, outputTokens: 10, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ]) }) @@ -182,7 +236,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ], new Map([ diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 3609399ac33..650976a2455 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -101,6 +101,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 20, totalCacheReadTokens: 10, totalCacheWriteTokens: 5, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: 'cwd:/outside/repo', @@ -111,7 +112,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] } @@ -129,7 +131,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -159,6 +162,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 20, totalCacheReadTokens: 10, totalCacheWriteTokens: 5, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: 'worktree:repo-1::/workspace/repo-a', @@ -169,7 +173,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] } @@ -187,7 +192,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -213,7 +219,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -239,7 +246,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -253,6 +261,37 @@ describe('ClaudeUsageStore', () => { ).toBeCloseTo(36.75) }) + it('prices the 1-hour cache-write share above the 5-minute rate', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-opus-4-7-20260416', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 400_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + // 5 + 25 + 0.5 + (0.6 * 6.25 + 0.4 * 10); the flat 5m rate would give 36.75. + expect(summary.estimatedCostUsd).toBeCloseTo(38.25) + expect( + breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd + ).toBeCloseTo(38.25) + }) + it('prices Claude Opus 4.8 with current Anthropic rates', async () => { const store = createStoreWithState({ dailyAggregates: [ @@ -268,7 +307,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -282,7 +322,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -314,7 +355,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -328,7 +370,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -342,7 +385,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -357,7 +401,7 @@ describe('ClaudeUsageStore', () => { ).toBeCloseTo(73.5) expect( breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd - ).toBeCloseTo(22.05) + ).toBeCloseTo(14.7) }) it('prices Sonnet 5 long-context usage at flat rates', async () => { @@ -375,7 +419,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 } ] }) @@ -383,7 +428,7 @@ describe('ClaudeUsageStore', () => { const summary = await store.getSummary('orca', '30d') // Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not. - expect(summary.estimatedCostUsd).toBeCloseTo(6.615) + expect(summary.estimatedCostUsd).toBeCloseTo(4.41) }) it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => { @@ -400,7 +445,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 })) }) @@ -433,7 +479,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -447,7 +494,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -472,7 +520,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -486,7 +535,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -511,7 +561,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 } ] }) @@ -546,6 +597,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 500, totalCacheReadTokens: 200, totalCacheWriteTokens: 100, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: `worktree:${worktreeId}`, @@ -556,7 +608,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, - cacheWriteTokens: 100 + cacheWriteTokens: 100, + cacheWrite1hTokens: 0 } ] } diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 466b30571cb..921c09c8732 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -22,8 +22,9 @@ import { resolveAutomationRunUsage } from './claude-usage-automation-attribution // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count -// after fork reclaim (#8006). -const SCHEMA_VERSION = 5 +// after fork reclaim (#8006). v6 adds the 1-hour cache-write split, which older +// caches never recorded, so their cost estimates stay stuck at the 5m rate (#15993). +const SCHEMA_VERSION = 6 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. diff --git a/src/main/claude-usage/transcript-record-parser.ts b/src/main/claude-usage/transcript-record-parser.ts index 50623fa295f..59b0e75be39 100644 --- a/src/main/claude-usage/transcript-record-parser.ts +++ b/src/main/claude-usage/transcript-record-parser.ts @@ -23,6 +23,11 @@ type ClaudeUsageSourceRecord = { output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number + /** TTL split of `cache_creation_input_tokens`; 1h writes bill at 2x base input. */ + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } } } } @@ -43,7 +48,8 @@ export function stripClaudeSourceMetadata( inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens } } @@ -64,6 +70,7 @@ function dedupeClaudeUsageTurns( existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens) existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens) existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens) + existing.cacheWrite1hTokens = Math.max(existing.cacheWrite1hTokens, turn.cacheWrite1hTokens) continue } } @@ -101,6 +108,11 @@ function parseClaudeUsageSourceRecord( const outputTokens = usage?.output_tokens ?? 0 const cacheReadTokens = usage?.cache_read_input_tokens ?? 0 const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0 + // Why: clamp so the implied 5m remainder can never go negative on a partial row. + const cacheWrite1hTokens = Math.min( + usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0, + cacheWriteTokens + ) if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) { return null @@ -119,7 +131,8 @@ function parseClaudeUsageSourceRecord( inputTokens, outputTokens, cacheReadTokens, - cacheWriteTokens + cacheWriteTokens, + cacheWrite1hTokens } } diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index ec497e5e098..0a4d061c332 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -15,6 +15,7 @@ export type ClaudeUsageLocationBreakdown = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + cacheWrite1hTokens: number } export type ClaudeUsageSession = { @@ -31,6 +32,7 @@ export type ClaudeUsageSession = { totalOutputTokens: number totalCacheReadTokens: number totalCacheWriteTokens: number + totalCacheWrite1hTokens: number locationBreakdown: ClaudeUsageLocationBreakdown[] } @@ -47,6 +49,7 @@ export type ClaudeUsageDailyAggregate = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + cacheWrite1hTokens: number } export type ClaudeUsagePersistedState = { @@ -86,6 +89,8 @@ export type ClaudeUsageParsedTurn = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + /** 1-hour-TTL subset of `cacheWriteTokens`; billed at 2x base input. */ + cacheWrite1hTokens: number } export type ClaudeUsageAttributedTurn = ClaudeUsageParsedTurn & { diff --git a/src/main/claude-usage/usage-aggregation.ts b/src/main/claude-usage/usage-aggregation.ts index 5ead6af0177..7626845827a 100644 --- a/src/main/claude-usage/usage-aggregation.ts +++ b/src/main/claude-usage/usage-aggregation.ts @@ -30,6 +30,7 @@ export function mergeClaudeSessions( existing.totalOutputTokens += session.totalOutputTokens existing.totalCacheReadTokens += session.totalCacheReadTokens existing.totalCacheWriteTokens += session.totalCacheWriteTokens + existing.totalCacheWrite1hTokens += session.totalCacheWrite1hTokens for (const location of session.locationBreakdown) { const existingLocation = @@ -41,6 +42,7 @@ export function mergeClaudeSessions( existingLocation.outputTokens += location.outputTokens existingLocation.cacheReadTokens += location.cacheReadTokens existingLocation.cacheWriteTokens += location.cacheWriteTokens + existingLocation.cacheWrite1hTokens += location.cacheWrite1hTokens } else { existing.locationBreakdown.push({ ...location }) } @@ -65,6 +67,7 @@ export function mergeClaudeDailyAggregates( existing.outputTokens += aggregate.outputTokens existing.cacheReadTokens += aggregate.cacheReadTokens existing.cacheWriteTokens += aggregate.cacheWriteTokens + existing.cacheWrite1hTokens += aggregate.cacheWrite1hTokens } } @@ -113,6 +116,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + totalCacheWrite1hTokens: 0, locationBreakdown: [] }) } @@ -132,6 +136,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { session.totalOutputTokens += turn.outputTokens session.totalCacheReadTokens += turn.cacheReadTokens session.totalCacheWriteTokens += turn.cacheWriteTokens + session.totalCacheWrite1hTokens += turn.cacheWrite1hTokens const location = session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null @@ -141,6 +146,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { location.outputTokens += turn.outputTokens location.cacheReadTokens += turn.cacheReadTokens location.cacheWriteTokens += turn.cacheWriteTokens + location.cacheWrite1hTokens += turn.cacheWrite1hTokens } else { session.locationBreakdown.push({ locationKey: turn.projectKey, @@ -151,7 +157,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens }) } @@ -166,6 +173,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { existingDaily.outputTokens += turn.outputTokens existingDaily.cacheReadTokens += turn.cacheReadTokens existingDaily.cacheWriteTokens += turn.cacheWriteTokens + existingDaily.cacheWrite1hTokens += turn.cacheWrite1hTokens } else { dailyByKey.set(dailyKey, { day: turn.day, @@ -179,7 +187,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens }) } } From 6f8c5888b3acc4011c09498cda27cf3906edd91a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:17:25 -0700 Subject: [PATCH 3/4] Run Node 26 compatibility daily instead of per PR (#16946) * Run Node 26 compatibility daily * Update relocated unit workflow contracts --- .github/workflows/node-next-compat.yml | 20 +++++++ .github/workflows/pr.yml | 57 ++---------------- .github/workflows/unit-tests.yml | 59 +++++++++++++++++++ ...package-electron-runtime-contract.test.mjs | 9 +-- config/scripts/pr-code-change-scope.test.mjs | 6 +- .../scripts/pr-workflow-parallelism.test.mjs | 37 ++++++++---- .../web/web-preload-api-runtime-calls.test.ts | 8 ++- 7 files changed, 124 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/node-next-compat.yml create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml new file mode 100644 index 00000000000..eb9de70dab7 --- /dev/null +++ b/.github/workflows/node-next-compat.yml @@ -0,0 +1,20 @@ +name: Node next compatibility + +on: + schedule: + # Full future-runtime coverage is useful, but not worth doubling every PR matrix. + - cron: '0 10 * * *' + workflow_dispatch: + +concurrency: + group: node-next-compat + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["26"]' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9f3b14f5903..338bcda7dbd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -444,16 +444,12 @@ jobs: src/shared/posix-command-path-lookup.test.ts # Cache-key input changes would otherwise make every shard compile the same - # native addon concurrently. Prime each Node ABI once before the matrix fans out. + # native addon concurrently. Prime the supported Node ABI before the matrix fans out. test_native_cache: - name: prepare test native cache node ${{ matrix.node }} + name: prepare test native cache node 24 needs: [code_paths] if: needs.code_paths.outputs.native_cache_changed == 'true' runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: ['24', '26'] steps: - name: Checkout @@ -464,58 +460,17 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: ${{ matrix.node }} + node-version: '24' test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} needs: [code_paths, test_native_cache] if: >- always() && needs.code_paths.outputs.test == 'true' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: ['24', '26'] - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - node-version: ${{ matrix.node }} - - - name: Install Electron package binary for tests - run: node config/scripts/install-electron-package-binary.mjs - - - name: Test shard - run: | - pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["24"]' # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000000..9906b569324 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,59 @@ +name: Unit tests + +on: + workflow_call: + inputs: + node_versions: + description: JSON array of Node.js major versions to test. + required: true + type: string + +permissions: + contents: read + +jobs: + test: + name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_versions) }} + shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard_total: [8] + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: ${{ matrix.node }} + + - name: Install Electron package binary for tests + run: node config/scripts/install-electron-package-binary.mjs + + - name: Test shard + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ + --exclude=src/main/daemon/shell-ready.test.ts \ + --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ + --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ + --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/main/shell-startup-feature-channel.test.ts \ + --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ + --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ + --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ + --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ + --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ + --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ + --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=tests/e2e/cross-version-wire/** \ + --shard=${{ matrix.shard }}/${{ matrix.shard_total }} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index ef9745e476c..9c440280098 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => { expect(copyStep.run).toContain('git add "$CASK_PATH"') }) - it('installs the Electron package binary in PR checks without changing native module ABI', () => { - const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') - const parsedWorkflow = parse(prWorkflow) - const installStep = parsedWorkflow.jobs.test.steps.find( + it('installs the Electron package binary in the shared unit-test workflow', () => { + const unitTestWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8') + ) + const installStep = unitTestWorkflow.jobs.test.steps.find( (step) => step.name === 'Install Electron package binary for tests' ) diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 223929fc213..ea83796e6cb 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.test_native_cache.if).toBe( "needs.code_paths.outputs.native_cache_changed == 'true'" ) - expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) + expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined() + const primerInstall = prWorkflow.jobs.test_native_cache.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(primerInstall.with['node-version']).toBe('24') }) it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 88bf4165d63..29f532b9436 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -3,6 +3,8 @@ import { parse } from 'yaml' import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) +const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8')) const dependencyAction = parse( readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') ) @@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => { expect(workflow.permissions).toEqual({ contents: 'read' }) }) - it('shards the general test suite across Node 24 and Node 26', () => { - expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26']) - expect(workflow.jobs.test.strategy.matrix.shard).toEqual( - Array.from({ length: 8 }, (_, index) => index + 1) + it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => { + const sharedTest = unitTestWorkflow.jobs.test + const testStep = sharedTest.steps.find((step) => step.name === 'Test shard') + const installStep = sharedTest.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' ) - expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8]) - const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') - const installStep = workflow.jobs.test.steps.find( + const primerInstall = workflow.jobs.test_native_cache.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24']) + expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26']) + expect(nodeNextWorkflow.on.schedule).toHaveLength(1) + expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() + expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') + expect(sharedTest.strategy.matrix.shard).toEqual( + Array.from({ length: 8 }, (_, index) => index + 1) + ) + expect(sharedTest.strategy.matrix.shard_total).toEqual([8]) expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}') for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } - const primerInstall = workflow.jobs.test_native_cache.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) expect(primerInstall.with['native-runtime']).toBe('node') - expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}') + expect(primerInstall.with['node-version']).toBe('24') expect(workflow.jobs.test.needs).toContain('test_native_cache') }) @@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => { workflow.jobs[jobName].steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('static_analysis').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') - expect(installFor('test').with['native-runtime']).toBe('node') + expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') expect(installFor('package_windows').with['native-runtime']).toBe('node') expect(installFor('package_windows').with['persist-native-cache']).toBe('false') diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts index 6960ba5b83f..fcf24437595 100644 --- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -65,7 +65,7 @@ describe('web preload runtime calls', () => { ).toMatchObject({ runtimeId: 'runtime-failure' }) }) - it('unwraps domain failures to their message after persisting failure metadata', async () => { + it('unwraps domain failures with their code after persisting failure metadata', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(method: string): Promise> { @@ -96,11 +96,13 @@ describe('web preload runtime calls', () => { rejection = error } - expect(rejection).toEqual(new Error('Repository catalog is unavailable')) + expect(rejection).toEqual( + Object.assign(new Error('Repository catalog is unavailable'), { code: 'repo_unavailable' }) + ) if (!(rejection instanceof Error)) { throw new Error('Expected a domain Error rejection') } - expect(Reflect.get(rejection, 'code')).toBeUndefined() + expect(Reflect.get(rejection, 'code')).toBe('repo_unavailable') expect( JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') ).toMatchObject({ runtimeId: 'runtime-domain-failure' }) From 6f3c5cc03995ceeb7181ba421dfa58a50ea77f71 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:18:04 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(i18n):=20use=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=A0=91=20consistently=20for=20parent=20worktree=20strings=20?= =?UTF-8?q?in=20zh=20locale=20(#16954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages * fix(i18n): use 工作树 consistently for parent worktree strings in zh locale Addresses PR #15420 review feedback: parent worktree strings mixed 工作区 (workspace) and 工作树 (worktree) terminology. --- ...ados-hardware-2set-hangul-batchim-migration-trace.json | 8 ++------ src/renderer/src/i18n/locales/zh.json | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json index 832e08ed997..c208ba06ff1 100644 --- a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json +++ b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json @@ -4,11 +4,7 @@ "maxTouchPoints": 5, "typed": "gksrmfRownla", "expected": "한글깨쥠", - "observedSent": [ - "한", - "글", - "깨쥠" - ], + "observedSent": ["한", "글", "깨쥠"], "events": [ { "type": "keydown", @@ -283,4 +279,4 @@ "value": "한글깨쥠" } ] -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d4a7010809..3e81e69b8b3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -269,7 +269,7 @@ "runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。", "createdWithoutParentNesting": "已创建,但未嵌套在“{{value0}}”下", "createdWithoutParentNestingUnnamed": "已创建,但未嵌套在所选父工作树下", - "createdWithoutParentNestingDetail": "父工作区已不可用。您可以从工作区菜单中重新设置。" + "createdWithoutParentNestingDetail": "父工作树已不可用。您可以从工作区菜单中重新设置。" }, "repos": { "2975400634": "请更新 Orca 服务器,以便在此运行时打开非 Git 文件夹。", @@ -14504,8 +14504,8 @@ "ComposerParentWorktreePicker": { "label": "父工作树", "noParent": "无父工作树", - "description": "在侧边栏中将此工作区嵌套到另一个工作区下。不会更改基础分支。", - "searchPlaceholder": "搜索工作区...", + "description": "在侧边栏中将此工作区嵌套到另一个工作树下。不会更改基础分支。", + "searchPlaceholder": "搜索工作树...", "noMatches": "无匹配项。" } },