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 & {})