Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Neil
2026-08-27 20:19:12 -07:00
48 changed files with 1677 additions and 272 deletions
+20
View File
@@ -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"]'
+6 -51
View File
@@ -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.
+59
View File
@@ -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 }}
@@ -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'
)
+5 -1
View File
@@ -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', () => {
+24 -13
View File
@@ -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')
@@ -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
)
})
})
+53 -19
View File
@@ -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<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'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<string, ClaudeModelPricing> = {
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<string, ClaudeModelPricing> = {
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<string, string> = {
@@ -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
)
@@ -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 {
@@ -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)
})
})
@@ -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<string, ClaudeUsageBreakdownRow>()
// Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type.
const cacheWrite1hTokensByKey = new Map<string, number>()
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
)
}
}
+62 -7
View File
@@ -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([
+74 -21
View File
@@ -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
}
]
}
+3 -2
View File
@@ -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.
@@ -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
}
}
+5
View File
@@ -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 & {
+11 -2
View File
@@ -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
})
}
}
+22
View File
@@ -86,6 +86,28 @@ describe('Store', () => {
expect(persisted.automations[0].baseBranch).toBeNull()
})
it('returns the existing automation for a repeated creation key', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const input = {
creationKey: 'move-retry-1',
name: 'Retry-safe move',
prompt: 'Run checks',
agentId: 'claude' as const,
projectId: 'r1',
workspaceMode: 'new_per_run' as const,
timezone: 'UTC',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
}
const first = store.createAutomation(input)
const retry = store.createAutomation({ ...input, name: 'Changed by a retry' })
expect(retry.id).toBe(first.id)
expect(store.listAutomations()).toHaveLength(1)
})
it('persists session reuse only for existing-workspace automations', async () => {
const store = await createStore()
store.addRepo(makeRepo())
@@ -50,6 +50,14 @@ export function createAutomation(
input: AutomationCreateInput,
options?: { destination?: AutomationDestination }
): Automation {
if (input.creationKey) {
const existing = (operations.state.automations ?? []).find(
(automation) => automation.creationKey === input.creationKey
)
if (existing) {
return existing
}
}
const repo = operations.state.repos.find((entry) => entry.id === input.projectId)
const now = Date.now()
const workspaceId = input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null
@@ -72,6 +80,7 @@ export function createAutomation(
const contexts = getAutomationContextsForRepo(repo, operations.state.projectHostSetups ?? [])
const automation: Automation = {
id: randomUUID(),
...(input.creationKey ? { creationKey: input.creationKey } : {}),
name: input.name.trim() || 'Untitled automation',
prompt: input.prompt,
precheck: normalizeAutomationPrecheck(input.precheck),
+1
View File
@@ -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,
@@ -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,
@@ -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(
<TooltipProvider>
<AutomationCreateDestinationField control={control(projects, entries)} />
<AutomationDestinationField control={control(projects, entries, moveWarning)} />
</TooltipProvider>
)
})
@@ -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')
})
})
@@ -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 (
<Field label={label} labelClassName={labelClassName}>
@@ -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)}
</p>
) : control.moveWarning ? (
// Replaces the storedOn line: both name the same host, and the move is
// the consequential half.
<p className="text-xs text-destructive" data-testid="automation-host-move">
{control.moveWarning}
</p>
) : (
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
)}
@@ -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(', '))
}
</p>
) : null}
</Field>
@@ -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<Automation>,
hostEntry: AutomationHostCatalogEntry | null
): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<TooltipProvider>
<AutomationDetail
automation={makeAutomation(overrides)}
runs={[]}
projectName="orca"
workspaceName="main"
projectDefaultBaseRef="main"
hostEntry={hostEntry}
hostLabelById={new Map([['local', 'Local Mac']])}
runNowAvailability={null}
now={0}
onRunNow={vi.fn()}
onEdit={vi.fn()}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
</TooltipProvider>
)
})
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')
})
})
@@ -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<string, string>
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'
}
/>
<DetailMetric
label={translate('auto.components.automations.AutomationDetail.host', 'Host')}
value={hostDisplay.label}
title={hostDisplay.title}
/>
<DetailMetric
label={
automation.workspaceMode === 'new_per_run'
@@ -71,8 +71,10 @@ type AutomationEditorDialogProps = {
worktrees: Worktree[]
settings: GlobalSettings | null
draft: AutomationDraft
/** Present only while creating an Orca automation; editing keeps the record's captured owner. */
/** Present only while creating an Orca automation. */
createDestination?: AutomationCreateDestinationControl
/** Present only while editing an Orca automation; selecting another host moves the record. */
editDestination?: AutomationCreateDestinationControl
/** Why a save was refused. Belongs here rather than on the page: this dialog covers it. */
notice?: AutomationActionNotice | null
onNoticeRecover?: (action: AutomationHostRecoveryAction) => 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)}
/>
<AutomationEditorSettingsSidebar
isCreateMode={isCreateMode}
createDestination={createDestination}
destination={destination}
isHermesTarget={isHermesTarget}
isHermesCreate={isHermesCreate}
repos={repos}
@@ -19,13 +19,13 @@ import { AutomationSchedulePicker } from './AutomationSchedulePicker'
import { AutomationSessionField } from './AutomationSessionField'
import { AutomationSetupDecisionField } from './AutomationSetupDecisionField'
import { AutomationWorkspaceField } from './AutomationWorkspaceField'
import { AutomationCreateDestinationField } from './AutomationCreateDestinationField'
import { AutomationDestinationField } from './AutomationDestinationField'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { AutomationDraft } from './AutomationEditorDialog'
type AutomationEditorSettingsSidebarProps = {
isCreateMode?: boolean
createDestination?: AutomationCreateDestinationControl
/** The host picker: where a create lands, or the host an edit can move the record to. */
destination?: AutomationCreateDestinationControl
isHermesTarget: boolean
isHermesCreate: boolean
repos: readonly Repo[]
@@ -48,8 +48,7 @@ type AutomationEditorSettingsSidebarProps = {
}
export function AutomationEditorSettingsSidebar({
isCreateMode,
createDestination,
destination,
isHermesTarget,
isHermesCreate,
repos,
@@ -113,10 +112,10 @@ export function AutomationEditorSettingsSidebar({
</div>
</div>
</div>
{createDestination && isCreateMode && !isHermesTarget ? (
{destination && !isHermesTarget ? (
<div className="mb-4">
<AutomationCreateDestinationField
control={createDestination}
<AutomationDestinationField
control={destination}
labelClassName={AUTOMATION_EDITOR_SECTION_LABEL_CLASS}
/>
</div>
@@ -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<string, string>
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}
@@ -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<string, unknown>),
projectId: RUNTIME_REPO_ID,
workspaceMode: 'existing',
workspaceId: RUNTIME_WORKSPACE_ID
}))
})
await save()
expect(api.automations.update).not.toHaveBeenCalled()
expect(runtimeCreateCalls()).toHaveLength(1)
expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({
repo: `id:${RUNTIME_REPO_ID}`,
dtstart: automation.dtstart
})
expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' }))
})
it('moves when the destination project has the same host-local id', async () => {
const automation = makeAutomation({ id: 'a-1' })
api.automations.list.mockResolvedValue([])
scopedList([automation])
runtimeHost([], [])
addCollidingRuntimeProject()
runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: REPO_ID }))
await renderPage()
await settleHostQueries()
await act(async () => {
void mocks.listPanel?.openEditDialog(listedRow(automation.id))
})
await act(async () => {
mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
})
await save()
expect(runtimeCreateCalls()).toHaveLength(1)
expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ repo: `id:${REPO_ID}` })
expect(api.automations.delete).toHaveBeenCalled()
})
it('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<string, unknown>),
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<string, unknown>),
projectId: RUNTIME_REPO_ID,
workspaceMode: 'existing',
workspaceId: RUNTIME_WORKSPACE_ID
}))
})
await save()
expect(runtimeCreateCalls()).toHaveLength(1)
expect(api.automations.delete).toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('could not be deleted'))
expect(mocks.toastSuccess).not.toHaveBeenCalled()
})
it('moves the record to the host the user picks, clearing the stranded project', async () => {
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<string, unknown>),
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
@@ -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()
@@ -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<AutomationActionNotice | null>(null)
const [editorNoticeHost, setEditorNoticeHost] = useState<AutomationHostCatalogEntry | null>(null)
const [externalActionKey, setExternalActionKey] = useState<string | null>(null)
const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState<ReadonlySet<string>>(
() => new Set()
@@ -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<string | null>(null)
// Reuse a move's create key after an ambiguous transport failure so retrying
// cannot schedule a second copy on the destination authority.
const moveCreationKeysRef = useRef(new Map<string, string>())
const [relativeNow, setRelativeNow] = useState(Date.now())
const [activePaneTab, setActivePaneTab] = useState<AutomationPaneTab>('overview')
const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState<string | null>(
@@ -503,32 +519,70 @@ export default function AutomationsPage(): React.JSX.Element {
(
candidate: Pick<AutomationDraft, 'projectId' | 'workspaceMode'>
): AutomationDraft['setupDecision'] => {
const settingsForRepo = getSettingsForRepoRuntimeOwner(
{ repos, settings },
candidate.projectId
)
const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
// During an edit the selected destination, not the ambient repo-id lookup,
// owns setup policy. Repo ids can collide across runtime authorities.
const selectedEditEntry =
editingAutomationId !== null && editingHostStableKey
? hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey)
: null
const selectedEditAuthority = selectedEditEntry?.stableRef.authority
const setupHostId =
selectedEditAuthority?.kind === 'runtime'
? toRuntimeExecutionHostId(selectedEditAuthority.environmentId)
: undefined
const setupRepos =
selectedEditAuthority?.kind === 'runtime'
? getAutomationCreateRepos(repos, {
kind: 'environment',
environmentId: selectedEditAuthority.environmentId
})
: repos
const setupProjectHostSetups = setupHostId
? projectHostSetups.filter(
(setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId
)
: projectHostSetups
const settingsForRepo = setupHostId
? {
...settings,
activeRuntimeEnvironmentId:
selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null
}
: getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId)
const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}`
return getVisibleAutomationSetupDecision({
createTarget,
workspaceMode: candidate.workspaceMode,
repoId: candidate.projectId,
repos,
projectHostSetups,
repos: setupRepos,
projectHostSetups: setupProjectHostSetups,
yamlHooks: automationYamlHooksByRepoKey[hookKey]
})
},
[automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings]
[
automationYamlHooksByRepoKey,
createTarget,
editingAutomationId,
editingHostStableKey,
hostCatalog.entries,
projectHostSetups,
repos,
settings
]
)
const getAutomationHooksCacheKey = useCallback(
(repoId: string): string => {
(repoId: string, hostId?: ExecutionHostId): string => {
if (hostId) {
return `${hostId}:${repoId}`
}
const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId)
return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}`
},
[repos, settings]
)
const loadAutomationYamlHooksForRepo = useCallback(
async (repoId: string): Promise<OrcaHooks | null> => {
const key = getAutomationHooksCacheKey(repoId)
async (repoId: string, hostId?: ExecutionHostId): Promise<OrcaHooks | null> => {
const key = getAutomationHooksCacheKey(repoId, hostId)
if (Object.hasOwn(automationYamlHooksByRepoKey, key)) {
return automationYamlHooksByRepoKey[key] ?? null
}
@@ -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<void> => {
@@ -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<Automation>; 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)
}}
@@ -5,6 +5,7 @@ import {
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../../shared/protocol-version'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
@@ -129,7 +130,18 @@ export async function assertOwnerFencingSupported(
)
}
export async function assertAutomationCreateIdempotencySupported(
authority: AutomationAuthorityRef
): Promise<void> {
await assertAuthorityCapability(
authority,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
'Moving automations to this host requires a newer Orca server. Update the HUB and try again.'
)
}
export {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
}
@@ -5,6 +5,7 @@ import type {
AutomationRun,
AutomationUpdateInput
} from '../../../../shared/automations-types'
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -49,6 +50,14 @@ export function getAutomationTargetFromHostId(
: { kind: 'local' }
}
export function getAutomationAuthorityTarget(
authority: AutomationAuthorityRef
): AutomationHostTarget {
return authority.kind === 'runtime'
? { kind: 'environment', environmentId: authority.environmentId }
: { kind: 'local' }
}
export function getAutomationListTarget(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): AutomationHostTarget {
@@ -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>): 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' })
})
})
@@ -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<Automation, 'executionTargetType' | 'executionTargetId'>
entry?: AutomationHostCatalogEntry | null
hostLabelById?: ReadonlyMap<string, string>
}): 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 }
}
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict'
import {
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
const callRuntimeRpc = vi.fn()
@@ -27,7 +28,8 @@ const SSH_OWNER = {
const ALL_CAPABILITIES = {
capabilities: [
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
]
}
@@ -38,6 +38,7 @@ import {
import {
assertAuthorityCapability,
assertOwnerFencingSupported,
assertAutomationCreateIdempotencySupported,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE,
AutomationHostScopeUnsupportedError,
@@ -286,6 +287,9 @@ export async function createAutomationForDestination(
input: AutomationCreateInput,
destination: AutomationDestination
): Promise<Automation> {
if (input.creationKey) {
await assertAutomationCreateIdempotencySupported(authority)
}
await assertOwnerFencingSupported(authority)
const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', {
...toRuntimeAutomationCreateInput(input),
@@ -17,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: {
@@ -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.'
)
}
@@ -4,11 +4,7 @@
"maxTouchPoints": 5,
"typed": "gksrmfRownla",
"expected": "한글깨쥠",
"observedSent": [
"한",
"글",
"깨쥠"
],
"observedSent": ["한", "글", "깨쥠"],
"events": [
{
"type": "keydown",
@@ -283,4 +279,4 @@
"value": "한글깨쥠"
}
]
}
}
+8 -3
View File
@@ -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": {
+17
View File
@@ -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": {
+17
View File
@@ -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": {
+17
View File
@@ -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": {
+20 -3
View File
@@ -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": "无匹配项。"
}
},
@@ -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<RuntimeRpcResponse<unknown>> {
@@ -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' })
+4
View File
@@ -90,6 +90,8 @@ export type AutomationPrecheckResult = {
export type Automation = {
id: string
/** Optional client request key used to make cross-authority creates retry-safe. */
creationKey?: string
name: string
prompt: string
precheck: AutomationPrecheck | null
@@ -169,6 +171,8 @@ export type AutomationRun = {
}
export type AutomationCreateInput = {
/** Optional idempotency key; repeated creates return the original record. */
creationKey?: string
name: string
prompt: string
precheck?: AutomationPrecheck | null
+6 -2
View File
@@ -137,6 +137,8 @@ export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE =
export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const
export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE =
'Editing automations on this host requires a newer Orca server. Update the HUB and try again.'
export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'automation.create-idempotency.v1' as const
// Generic native clients include the CLI and must not claim Electron-only page
// placement support.
@@ -145,7 +147,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
// Electron clients can decode client-hosted page placement; becoming a page
@@ -213,7 +216,8 @@ export const RUNTIME_CAPABILITIES = [
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_DELETE_CAPABILITY,
AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})