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/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 }) } } 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/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/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..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 文件夹。", @@ -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": { @@ -14487,8 +14504,8 @@ "ComposerParentWorktreePicker": { "label": "父工作树", "noParent": "无父工作树", - "description": "在侧边栏中将此工作区嵌套到另一个工作区下。不会更改基础分支。", - "searchPlaceholder": "搜索工作区...", + "description": "在侧边栏中将此工作区嵌套到另一个工作树下。不会更改基础分支。", + "searchPlaceholder": "搜索工作树...", "noMatches": "无匹配项。" } }, 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' }) 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 & {})