mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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"]'
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+34
-7
@@ -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')
|
||||
})
|
||||
})
|
||||
+18
-15
@@ -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
|
||||
|
||||
+18
@@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -4,11 +4,7 @@
|
||||
"maxTouchPoints": 5,
|
||||
"typed": "gksrmfRownla",
|
||||
"expected": "한글깨쥠",
|
||||
"observedSent": [
|
||||
"한",
|
||||
"글",
|
||||
"깨쥠"
|
||||
],
|
||||
"observedSent": ["한", "글", "깨쥠"],
|
||||
"events": [
|
||||
{
|
||||
"type": "keydown",
|
||||
@@ -283,4 +279,4 @@
|
||||
"value": "한글깨쥠"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
Reference in New Issue
Block a user