Merge origin/main into orchestration-mailbox-delivery

This commit is contained in:
Jinwoo-H
2026-08-14 21:23:36 -04:00
154 changed files with 9356 additions and 3485 deletions
+24
View File
@@ -50,6 +50,27 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
src/shared/ephemeral-vm-runtime-store.ts \
src/shared/ephemeral-vm-runtime-feature-store.ts \
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
src/shared/ephemeral-vm-runtimes.ts \
src/shared/ephemeral-vm-recipes.ts \
src/shared/orca-yaml-hook-types.ts \
src/main/ephemeral-vm-runtime-service.ts \
src/main/ephemeral-vm-runtime-provisioning-persistence.ts \
src/main/ephemeral-vm-failed-start-cleanup.ts; then
echo "VM runtime persistence is unchanged."
exit 0
fi
node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
@@ -479,6 +500,9 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
-1
View File
@@ -178,7 +178,6 @@ inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/App.tsx
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
+218 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-13",
"updatedAt": "2026-08-14",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,223 @@
}
},
"gates": [
{
"id": "ephemeral-vm-runtime.rollback-readable-sidecar",
"title": "VM lifecycle stays rollback-readable and cleanup-retryable",
"maturity": "experimental",
"protection": "partial",
"owner": "ephemeral-vm-runtime-store",
"layer": "desktop-main-persistence",
"surfaces": [
"VM runtime sidecar",
"recipe lifecycle",
"cleanup retry",
"provisioned-root direct SSH"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["orca-server", "direct-ssh"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["orca-server", "direct-ssh"],
"coverageNotes": "A pinned exact-source harness runs the pre-#14352 reader and lifecycle service against baseline, affected-main, candidate, and fix-reverted stores. Platform-neutral tests cover store compatibility, user-cancellable cleanup, branch/ref adoption, and resume ownership integrity. The direct-SSH provisioned-root Electron journey remains the live transport and destroy proof; this desktop sidecar is not mobile-facing and changes no RPC or remote wire payload.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4274",
"https://github.com/stablyai/orca/pull/14352",
"https://github.com/stablyai/orca/issues/13044"
],
"invariant": "A sidecar written by the new build cannot make the previous production build lose otherwise compatible VM runtimes. Downgrade lifecycle writes remain authoritative after re-upgrade, provisioned-root metadata remains recoverable, and corrupt or future feature metadata is preserved without poisoning the rollback-readable v1 store. Destroy attempts have no automatic wall-clock cutoff; a user stop terminates the process tree, retains cleanup ownership, and permits a fresh retry. Provider failure retains the SSH/project context needed for safe cleanup. Unrecoverable recipe context releases only the hidden SSH registration while preserving lifecycle/provider metadata. Provisioned-root create receives the source ref's pinned commit, adoption verifies the requested branch/ref, and resume preserves the connection mode plus stable SSH ownership identity while allowing endpoint rotation.",
"oracle": "Write one ordinary schema-v1 runtime and one provisioned-root runtime. Require the exact pre-#14352 reader to return both, run the old destroy lifecycle against the projected provisioned record, then require the candidate to retain the cleaned state while restoring schema-v2 and checkout-mode metadata. The same old reader must reject affected-main and fix-reverted bytes. Malformed, oversized, unknown-record, and future-version feature sidecars must leave rollback-compatible records accessible and must not be overwritten during compatible lifecycle mutations. Require destroy to have no implicit deadline, stop a hung destroy through the public desktop action, require cleanup_failed plus a fresh successful retry, prove provider or project-host rollback failure retains every ownership record required to retry, and prove missing recipe context cannot leave an unremovable hidden SSH registration. Supply the source ref's captured commit to create, reject a provisioned checkout on the wrong branch/ref, and reject a resume that changes connection mode or SSH ownership.",
"commands": [
"node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/ephemeral-vm-cleanup-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs",
"src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
"src/shared/ephemeral-vm-runtime-store.test.ts",
"src/shared/ephemeral-vm-recipe-process.test.ts",
"src/main/ephemeral-vm-runtime-service.test.ts",
"src/main/ephemeral-vm-recipe-runner.test.ts",
"src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts",
"src/main/ephemeral-vm-resume-integrity.test.ts",
"src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
"src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
"src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
"src/main/ipc/ephemeral-vm.test.ts",
"src/main/provisioned-root-ssh-adoption.test.ts",
"src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
"src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
"src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
"src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts",
"src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
"src/renderer/src/lib/provisioned-root-create-options.test.ts",
"src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
"tests/e2e/ephemeral-vm-provisioned-root.spec.ts"
],
"assertionRefs": [
{
"file": "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"assertions": [
"the exact rollback reader accepts both projected records",
"the rollback lifecycle service destroys the provisioned resource using schema-v1 provider data",
"re-upgrade restores provisioned-root metadata without reverting lifecycle state"
]
},
{
"file": "src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
"assertions": [
"ordinary runtime bytes remain unchanged and create no feature sidecar",
"malformed, future, oversized, and unknown feature data is preserved without poisoning v1 records",
"current-main poisoned bytes migrate to a rollback-readable projection",
"unchanged compatibility metadata is not rewritten during lifecycle updates"
]
},
{
"file": "src/main/ephemeral-vm-runtime-service.test.ts",
"assertions": [
"an unreadable companion rejects checkout-mode recipes before their create command runs",
"post-create persistence failure destroys the provider resource when possible",
"failed destroy persists a durable rollback-readable cleanup recovery record"
]
},
{
"file": "src/shared/ephemeral-vm-recipe-process.test.ts",
"assertions": [
"recipe commands have no implicit wall-clock deadline",
"a stopped process that ignores graceful termination is force-killed and settled"
]
},
{
"file": "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
"assertions": [
"a running cleanup exposes a persistent Stop cleanup action and becomes retryable after stopping"
]
},
{
"file": "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
"assertions": [
"unrecoverable recipe context releases the hidden SSH registration but preserves failed lifecycle state"
]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
"assertions": [
"provider failure retains runtime-owned SSH targets and confirmed success releases them",
"unknown runtime inventory retains an explicitly requested target"
]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
"assertions": ["failed or unconfirmed project-host rollback prevents provider destroy"]
},
{
"file": "src/main/provisioned-root-ssh-adoption.test.ts",
"assertions": [
"adoption rejects a checkout on an unrequested branch or ref",
"tag and commit refs remain valid when the requested local branch is attached"
]
},
{
"file": "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
"assertions": [
"selected and default source ref commits are captured before provider create",
"the create recipe receives both the symbolic ref and its pinned commit",
"multi-remote bases pair the selected remote URL with a fetchable branch ref",
"an unresolved source ref rejects before provider create"
]
},
{
"file": "src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
"assertions": ["cancellation during source-ref resolution aborts before provider create"]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
"assertions": [
"the captured source commit reaches the main-owned provisioned-root adoption request"
]
},
{
"file": "src/main/ephemeral-vm-resume-integrity.test.ts",
"assertions": [
"resume allows host and port rotation",
"resume rejects connection-mode and stable SSH ownership drift"
]
},
{
"file": "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
"assertions": [
"the rendered runtime row stops a live cleanup process without an automatic deadline and a retry succeeds"
]
},
{
"file": "tests/e2e/ephemeral-vm-provisioned-root.spec.ts",
"assertions": [
"default-base create adopts the recipe checkout at the captured source commit",
"workspace removal invokes recipe destroy and removes the provider container"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"result": "passed",
"durationSeconds": 11,
"summary": "The pinned baseline and affected-main oracles passed, candidate downgrade ran the exact old destroy service, candidate re-upgrade retained lifecycle state, and both affected-main and fix-reverted bytes failed the old reader as expected."
},
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"result": "passed",
"durationSeconds": 3,
"summary": "One hundred twenty-two focused store, recipe-process, lifecycle, cleanup-handler, rendered stop-action, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed."
},
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 22,
"summary": "The final-tree full-build run completed the Docker Linux SSH journey: default-base create at the captured source commit, provisioned-root adoption, terminal, workspace removal, destroy, and provider-container cleanup."
}
],
"runtimeBudget": {
"p95Seconds": 180,
"scope": "pinned cross-version source oracle, focused lifecycle contracts, and one Docker SSH Electron journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic contract runs are stable. Two local Electron attempts reached successful adoption and terminal use, then hit the existing final context-menu detach; the immediate build-reuse rerun completed removal and destroy. CI soak history has not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The same pinned oracle passes the pre-#14352 ordinary store, fails the affected-main mixed store with file-is-invalid, passes the candidate mixed store plus old-service destroy and re-upgrade, and fails again when the candidate compatibility files are reverted to affected main."
},
"performanceBudget": {
"required": true,
"evidence": "Ordinary writes remain byte-identical and create no feature sidecar. Reads add one bounded feature-file existence check; sorted feature comparisons avoid unchanged rewrites, and durable companion writes occur only when compatibility metadata changes. Cleanup adds one constant-size in-flight controller per active destroy and no automatic deadline; the visible settings section polls once per second only while cleanup is active. Provisioning adds one source-host Git ref-resolution command only when a ref was requested and runs it before provider create; adoption adds no remote subprocess beyond its existing checkout probes, and resume adds only in-memory comparisons."
},
"promotionCriteria": [
"Keep the pinned baseline/latest/candidate/revert oracle green in CI.",
"Keep the Docker provisioned-root create/adopt/terminal/remove/destroy journey green.",
"Keep user-stopped destroy, fresh retry, and retained-context failure tests green.",
"Collect soak history without unexplained lifecycle or sidecar flakes."
],
"knownGaps": [
"No packaged-build downgrade installer journey; the exact released source reader and service are executed in-process.",
"Live Docker SSH evidence runs locally on macOS and in the changed-file PR E2E lane on Ubuntu; Windows desktop behavior is covered by the platform-neutral filesystem codec and package job."
],
"demotionRule": "Demote if a new write fails the pinned rollback reader, a downgrade lifecycle mutation is lost after re-upgrade, malformed/future metadata overwrites recoverable bytes, ordinary v1 bytes change, user stop cannot settle a destroy process tree, cleanup loses retry ownership, an unrecoverable context leaves a permanent hidden SSH registration, or the focused gate flakes without an identified harness defect."
},
{
"id": "terminal-session.shell-ready-exec-prompt-fallback",
"title": "Startup exec falls back to the identified shell's line editor",
@@ -0,0 +1,184 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterAll, expect, test } from 'vitest'
const targetRoot = process.env.STA_4274_TARGET_ROOT
const operation = process.env.STA_4274_OPERATION
const ownedDirs: string[] = []
function makeOwnedDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
ownedDirs.push(dir)
return dir
}
afterAll(() => {
for (const dir of ownedDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
const ordinary = {
id: 'ordinary-runtime',
recipeId: 'ordinary-recipe',
recipe: {
id: 'ordinary-recipe',
name: 'Ordinary VM',
create: './ordinary-create.sh',
destroy: './ordinary-destroy.sh'
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 1_000,
updatedAt: 1_000,
recipeResult: {
schemaVersion: 1 as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/ordinary',
target: {
label: 'Ordinary VM',
host: 'ordinary.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'ordinary-resource' }
}
}
const provisionedRoot = {
id: 'provisioned-root-runtime',
recipeId: 'provisioned-root-recipe',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: './provisioned-root-destroy.sh',
checkoutMode: 'provisioned-root' as const
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 2_000,
updatedAt: 2_000,
recipeResult: {
schemaVersion: 2 as const,
checkoutMode: 'provisioned-root' as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/provisioned',
target: {
label: 'Provisioned Root VM',
host: 'provisioned.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'provisioned-resource' }
}
}
test.skipIf(!targetRoot || !operation)(`STA-4274 ${operation ?? 'disabled'}`, async () => {
if (!targetRoot || !operation) {
throw new Error('STA_4274_TARGET_ROOT and STA_4274_OPERATION are required')
}
const userDataPath = process.env.STA_4274_USER_DATA_PATH ?? makeOwnedDir('sta-4274-')
const moduleUrl = pathToFileURL(
resolve(targetRoot, 'src/shared/ephemeral-vm-runtime-store.ts')
).href
const store = await import(/* @vite-ignore */ moduleUrl)
if (operation === 'write-legacy') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
return
}
if (operation === 'write-mixed') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
store.upsertEphemeralVmRuntime(userDataPath, provisionedRoot)
return
}
if (operation === 'read') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes.map((record: { id: string }) => record.id)).toEqual([
'provisioned-root-runtime',
'ordinary-runtime'
])
return
}
if (operation === 'read-rollback-projection') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes).toHaveLength(2)
expect(runtimes[0].recipe).not.toHaveProperty('checkoutMode')
expect(runtimes[0].recipeResult).toMatchObject({ schemaVersion: 1 })
return
}
if (operation === 'mutate-lifecycle') {
store.updateEphemeralVmRuntimeStatus(userDataPath, 'ordinary-runtime', {
status: 'suspended',
updatedAt: 3_000
})
const repoPath = makeOwnedDir('sta-4274-cleanup-')
const cleanupPath = join(repoPath, 'cleanup.js')
const proofPath = join(repoPath, 'cleanup-proof')
writeFileSync(
cleanupPath,
[
"let input = ''",
"process.stdin.on('data', (chunk) => { input += chunk })",
"process.stdin.on('end', () => {",
' const payload = JSON.parse(input)',
' if (payload.recipeResult.schemaVersion !== 1) process.exit(12)',
" if (payload.recipeResult.userData.resourceId !== 'provisioned-resource') process.exit(13)",
` require('fs').writeFileSync(${JSON.stringify(proofPath)}, 'destroyed')`,
'})'
].join('\n')
)
const serviceUrl = pathToFileURL(
resolve(targetRoot, 'src/main/ephemeral-vm-runtime-service.ts')
).href
const service = await import(/* @vite-ignore */ serviceUrl)
const result = await service.cleanupEphemeralVmRuntime({
userDataPath,
repoPath,
runtimeId: 'provisioned-root-runtime',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: `${JSON.stringify(process.execPath)} ${JSON.stringify(cleanupPath)}`
},
now: 3_000
})
expect(result).toMatchObject({
ok: true,
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
})
expect(existsSync(proofPath)).toBe(true)
return
}
if (operation === 'read-after-downgrade') {
expect(store.listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
id: 'provisioned-root-runtime',
status: 'cleaned',
cleanupStatus: 'succeeded',
recipe: expect.objectContaining({ checkoutMode: 'provisioned-root' }),
recipeResult: expect.objectContaining({
schemaVersion: 2,
checkoutMode: 'provisioned-root'
})
}),
expect.objectContaining({ id: 'ordinary-runtime', status: 'suspended' })
])
return
}
if (operation === 'read-legacy') {
expect(
store.listEphemeralVmRuntimes(userDataPath).map((record: { id: string }) => record.id)
).toEqual(['ordinary-runtime'])
return
}
throw new Error(`Unknown operation: ${operation}`)
})
@@ -14,6 +14,9 @@ const sshDockerRunner = readFileSync(
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
(step) => step.name === 'Check VM runtime rollback compatibility'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
@@ -121,4 +124,11 @@ describe('PR E2E gate contract', () => {
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
})
})
@@ -0,0 +1,118 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
const BASELINE_COMMIT = 'bf0c77d5bc800e19117084c27fd1441eda9134ad'
const AFFECTED_MAIN_COMMIT = '25abb9368d98ad84a174f530e02f4228d2269062'
const root = process.cwd()
const driver = process.argv[2] ?? 'config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts'
const config = 'config/vitest.config.ts'
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-sta-4274-repro-'))
try {
const baselineRoot = extractSource(BASELINE_COMMIT, 'baseline')
const affectedRoot = extractSource(AFFECTED_MAIN_COMMIT, 'affected-main')
const revertedRoot = extractSource('HEAD', 'candidate-reverted')
restoreAffectedStoreFiles(revertedRoot)
const baselineData = makeDataDir('baseline-data')
runOracle('baseline write', baselineRoot, 'write-legacy', baselineData)
runOracle('baseline read', baselineRoot, 'read-legacy', baselineData)
const affectedData = makeDataDir('affected-data')
runOracle('affected write', affectedRoot, 'write-mixed', affectedData)
runOracle('affected current read', affectedRoot, 'read', affectedData)
runOracle('affected rollback read', baselineRoot, 'read', affectedData, {
expectFailure: 'file is invalid'
})
const candidateData = makeDataDir('candidate-data')
runOracle('candidate write', root, 'write-mixed', candidateData)
runOracle(
'candidate rollback projection',
baselineRoot,
'read-rollback-projection',
candidateData
)
runOracle('rollback lifecycle mutation', baselineRoot, 'mutate-lifecycle', candidateData)
runOracle('candidate re-upgrade', root, 'read-after-downgrade', candidateData)
const revertedData = makeDataDir('reverted-data')
runOracle('reverted write', revertedRoot, 'write-mixed', revertedData)
runOracle('reverted rollback read', baselineRoot, 'read', revertedData, {
expectFailure: 'file is invalid'
})
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
function extractSource(commit, name) {
const destination = join(tempRoot, name)
const archive = join(tempRoot, `${name}.tar`)
mkdirSync(destination)
run('git', ['archive', '--format=tar', `--output=${archive}`, commit, 'src/main', 'src/shared'])
run('tar', ['-xf', archive, '-C', destination])
return destination
}
function restoreAffectedStoreFiles(destination) {
for (const relativePath of [
'src/shared/ephemeral-vm-runtime-store.ts',
'src/shared/ephemeral-vm-runtimes.ts'
]) {
const result = run('git', ['show', `${AFFECTED_MAIN_COMMIT}:${relativePath}`], {
encoding: 'utf8'
})
writeFileSync(join(destination, relativePath), result.stdout)
}
}
function makeDataDir(name) {
const destination = join(tempRoot, name)
mkdirSync(destination)
return destination
}
function runOracle(label, targetRoot, operation, userDataPath, options = {}) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'vitest', 'run', driver, '--config', config], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
STA_4274_TARGET_ROOT: resolve(targetRoot),
STA_4274_OPERATION: operation,
STA_4274_USER_DATA_PATH: userDataPath
}
})
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
if (options.expectFailure) {
if (result.status === 0 || !output.includes(options.expectFailure)) {
throw new Error(
`${label} did not fail with ${JSON.stringify(options.expectFailure)}\n${output}`
)
}
process.stdout.write(`EXPECTED_FAIL ${label}\n`)
return
}
if (result.status !== 0) {
throw new Error(`${label} failed\n${output}`)
}
process.stdout.write(`PASS ${label}\n`)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: root,
encoding: options.encoding,
maxBuffer: 16 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} failed\n${String(result.stdout ?? '')}${String(result.stderr ?? '')}`
)
}
return result
}
+15 -3
View File
@@ -470,8 +470,19 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
For an explicitly requested one-VM-per-workspace checkout, the create script must read
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and
`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create
`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race
with an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when
the desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the
same SSH result with:
```bash
[ -n "${ORCA_REPO_REF_HEAD:-}" ] || { echo "missing pinned source commit" >&2; exit 1; }
git fetch origin "$ORCA_REPO_REF"
git cat-file -e "${ORCA_REPO_REF_HEAD}^{commit}"
git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
```
```json
{
@@ -645,7 +656,8 @@ worked script in §7g). `pairingCode` is **not** used in SSH mode.
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
the requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only
to fetch that commit) at the returned `projectRoot`, and emit schema version 2 with
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
Lifecycle hooks (all run locally):
File diff suppressed because one or more lines are too long
+13
View File
@@ -57,4 +57,17 @@ describe('CLI error recovery', () => {
expect(output).toContain('Fix the command flags or RPC params')
})
it('does not replace mutation recovery with generic runtime startup advice', () => {
const error = new RuntimeClientError(
'runtime_unavailable',
'Re-issue the same command with --retry-request mutation_1.',
{ orchestrationRequestId: 'mutation_1' }
)
const output = formatCliError(error)
expect(output).toContain('--retry-request mutation_1')
expect(output).not.toContain('orca open')
})
})
+11
View File
@@ -78,6 +78,9 @@ export function printResult<TResult>(
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
if (hasOrchestrationRequestId(error.data)) {
return message
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
// Why: error-specific recovery must win over the generic computer fallback.
@@ -105,6 +108,14 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
return message
}
function hasOrchestrationRequestId(data: unknown): boolean {
return (
data !== null &&
typeof data === 'object' &&
typeof (data as { orchestrationRequestId?: unknown }).orchestrationRequestId === 'string'
)
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
@@ -195,4 +195,47 @@ describe('orchestration gate commands carry caller identity', () => {
expect(stderr).toContain('Pass --from <terminal-handle>')
expect(callMock).not.toHaveBeenCalledWith('orchestration.gateCreate', expect.anything())
})
it('reports idempotent recovery when a mutation connection drops', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
callMock
.mockResolvedValueOnce(okFixture('req_show', { terminal: { handle: 'term_coord' } }))
.mockRejectedValueOnce(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
{
orchestrationRequestId: 'mutation_1',
failedStage: 'dispatch_input',
residualResources: [
{ kind: 'worktree', id: 'repo::child' },
{ kind: 'terminal', id: 'term_worker' }
]
}
)
)
await main(
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?', '--json'],
'/tmp/repo'
)
expect(process.exitCode).toBe(1)
const output = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
error: { message: string; data: Record<string, unknown> }
}
expect(output.error.message).toContain('--retry-request mutation_1')
expect(output.error.message).toContain('may already have taken effect')
expect(output.error.message).toContain('Failed stage: dispatch_input')
expect(output.error.message).toMatch(/Residual resources:.*repo::child.*term_worker/)
expect(output.error.message).not.toMatch(/restart Orca/i)
expect(output.error.data).toMatchObject({
orchestrationRequestId: 'mutation_1',
failedStage: 'dispatch_input',
residualResources: expect.arrayContaining([
expect.objectContaining({ kind: 'worktree', id: 'repo::child' }),
expect.objectContaining({ kind: 'terminal', id: 'term_worker' })
])
})
})
})
+6 -6
View File
@@ -32,6 +32,7 @@ import {
type LegacyCompatibilityResult,
type OrchestrationMessageSummary as MessageSummary
} from '../../shared/orchestration-check-output'
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
@@ -397,14 +398,13 @@ function callMutation<TResult>(
options?: { timeoutMs?: number; orchestrationCapability?: string }
) {
const requestId = getOptionalStringFlag(flags, 'retry-request')
if (!requestId) {
return options
const result = requestId
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
: options
? client.call<TResult>(method, params, options)
: client.call<TResult>(method, params)
}
return client.call<TResult>(method, params, {
...options,
orchestrationRequestId: requestId
return result.catch((error) => {
throw orchestrationMutationRecoveryError(error)
})
}
@@ -0,0 +1,44 @@
import { RuntimeClientError } from './runtime-client'
export function orchestrationMutationRecoveryError(error: unknown): unknown {
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
return error
}
const data = objectRecord(error.data)
const requestId = data?.orchestrationRequestId
if (typeof requestId !== 'string' || requestId.length === 0) {
return error
}
const message = [
stripUnsafeRetryAdvice(error.message, requestId),
'The orchestration mutation may already have taken effect; do not assume it failed.',
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
Array.isArray(data?.residualResources)
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
: undefined
].filter((line): line is string => line !== undefined)
return new RuntimeClientError(error.code, message.join('\n'), error.data)
}
function isUnknownMutationOutcomeCode(code: string): boolean {
return [
'runtime_unavailable',
'remote_runtime_unavailable',
'runtime_timeout',
'invalid_runtime_response'
].includes(code)
}
function objectRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
return message
.replace(' Restart Orca and try again.', '')
.replace(' Retry the command.', '')
.replace(` Orchestration mutation request ID: ${requestId}.`, '')
}
+31 -1
View File
@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { createServer, type Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version'
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { launchOrcaApp } from './runtime/launch'
vi.mock('./runtime/launch', () => ({
@@ -415,6 +415,36 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
})
})
it('preserves a dropped read-only orchestration failure exactly', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
let request: Record<string, unknown> | undefined
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
request = JSON.parse(String(data).trim()) as Record<string, unknown>
socket.end()
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeMetadata(userDataPath, endpoint)
const client = new RuntimeClient(userDataPath, 100)
const failure = await client
.call('orchestration.workerShow', { dispatch: 'ctx_1' })
.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(RuntimeClientError)
expect((failure as RuntimeClientError).message).toBe(
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
)
expect((failure as RuntimeClientError).data).toBeUndefined()
expect(request).toMatchObject({ method: 'orchestration.workerShow' })
expect(request).not.toHaveProperty('orchestrationRequestId')
})
it('allows a per-call timeout override for long runtime requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
const endpoint = join(userDataPath, 'runtime.sock')
+3 -1
View File
@@ -132,7 +132,9 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => {
// pre-fix behavior would hang the full duration and trip vitest's own limit.
const start = Date.now()
await expect(sendRequest(metadata, 'status.get', undefined, 60000)).rejects.toMatchObject({
code: 'runtime_unavailable'
code: 'runtime_unavailable',
message:
'The Orca runtime closed the connection before responding. Restart Orca and try again.'
})
expect(Date.now() - start).toBeLessThan(5000)
})
+14 -12
View File
@@ -6,7 +6,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'run-create'],
summary: 'Create and bind a lightweight orchestration Run',
usage: 'orca orchestration run-create --objective <text> [--from <handle>] [--json]',
usage:
'orca orchestration run-create --objective <text> [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'],
notes: [
'A Run is a namespace and home inbox. It never schedules or places workers.',
@@ -17,7 +18,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'run-use'],
summary: 'Bind this coordinator terminal to an existing Run',
usage:
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
notes: [
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
@@ -45,7 +46,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'send'],
summary: 'Send an inter-agent message',
usage:
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--json]',
'orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--run <run_id>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--task-id <id>] [--dispatch-id <id>] [--outcome <succeeded|failed>] [--files-modified <csv>] [--report-path <path>] [--phase <text>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'to',
@@ -80,7 +81,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'check'],
summary: 'Check messages for a terminal',
usage:
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]\n' +
'orca orchestration check [--terminal <handle>] [--run <run_id>] [--ack <delivery_id>] [--unread | --peek | --all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--retry-request <id>] [--json]\n' +
" default: return the bound Run's oldest unacknowledged FIFO batch.\n" +
' --ack: acknowledge the prior whole batch before checking/waiting.\n' +
' --peek: return only unread messages without marking them read.\n' +
@@ -114,7 +115,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'reply'],
summary: 'Reply to a message',
usage:
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration reply --id <msg_id> --body <text> [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request']
},
{
@@ -127,7 +128,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'task-create'],
summary: 'Create an orchestration task',
usage:
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration task-create --spec <text> [--task-title <text>] [--display-name <text>] [--deps <json_array>] [--parent <task_id>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'spec',
@@ -152,7 +153,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'task-update'],
summary: 'Update a task status',
usage:
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--json]',
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'],
notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.']
},
@@ -161,7 +162,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'dispatch'],
summary: 'Dispatch a task to a terminal',
usage:
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--json]',
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--run <run_id>] [--inject] [--dry-run] [--return-preamble] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'task',
@@ -185,7 +186,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'ask'],
summary: 'Ask the coordinator a question and block until answered',
usage:
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]',
'orca orchestration ask (--question <text> | --resume <message_id>) [--to <run:id>] [--run <run_id>] [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'to',
@@ -236,14 +237,14 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'gate-create'],
summary: 'Create a decision gate blocking a task',
usage:
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--json]',
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request']
},
{
path: ['orchestration', 'gate-resolve'],
summary: 'Resolve a pending decision gate',
usage:
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--json]',
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request']
},
{
@@ -257,7 +258,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'reset'],
summary: 'Reset one explicit orchestration state scope',
usage: 'orca orchestration reset (--all | --tasks | --messages) [--json]',
usage:
'orca orchestration reset (--all | --tasks | --messages) [--retry-request <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages', 'retry-request']
}
]
+19 -5
View File
@@ -2,7 +2,11 @@ import {
getEphemeralVmRecipeResultConnection,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import {
upsertEphemeralVmRuntime,
upsertEphemeralVmRuntimeRollbackRecovery
} from '../shared/ephemeral-vm-runtime-store'
import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service'
import {
runEphemeralVmRecipeCleanup,
@@ -17,15 +21,15 @@ type FailedStart = {
export async function cleanupFailedEphemeralVmStart(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<void> {
): Promise<boolean> {
const cleanupError = await getCleanupError(args, start)
if (cleanupError === null) {
return
return true
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.recipeResult)
upsertEphemeralVmRuntime(args.userDataPath, {
const recovery: EphemeralVmRuntimeRecord = {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
@@ -42,7 +46,17 @@ export async function cleanupFailedEphemeralVmStart(
createdAt: now,
updatedAt: now,
recipeResult: start.recipeResult
})
}
try {
upsertEphemeralVmRuntime(args.userDataPath, recovery)
} catch (error) {
if (!args.recipe.checkoutMode) {
throw error
}
// Why: cleanup retry metadata must survive even when its feature companion is unreadable.
upsertEphemeralVmRuntimeRollbackRecovery(args.userDataPath, recovery)
}
return false
}
async function getCleanupError(
@@ -0,0 +1,84 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { gitExecFileAsync } from './git/runner'
import { resolveDefaultBaseRefWithLocalGit } from './git/repo'
import { getLocalProjectGitExecOptions } from './project-runtime-git-options'
import { resolveWorktreeCreateBase } from './worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
const PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS = 15_000
export async function resolveProvisionedRootSource(
store: Store,
repo: Repo,
requestedRef?: string,
signal?: AbortSignal
): Promise<{ ref: string; head: string; remoteUrl?: string } | null> {
const gitOptions = {
...getLocalProjectGitExecOptions(store, repo),
...(signal ? { signal } : {})
}
const resolveHead = async (ref: string): Promise<string | null> => {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
{ ...gitOptions, timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS }
)
return stdout.trim() || null
} catch {
return null
}
}
const ref = await resolveWorktreeCreateBase({
requestedBaseBranch: requestedRef?.trim() || undefined,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(gitOptions),
isBaseUsable: async (candidate) => Boolean(await resolveHead(candidate))
})
if (!ref) {
return null
}
const head = await resolveHead(ref)
if (!head) {
return null
}
const remoteSource = await resolveRemoteSource(ref, resolveHead, gitOptions)
return remoteSource ? { ...remoteSource, head } : { ref, head }
}
async function resolveRemoteSource(
ref: string,
resolveHead: (ref: string) => Promise<string | null>,
gitOptions: { cwd: string; wslDistro?: string; signal?: AbortSignal }
): Promise<{ ref: string; remoteUrl: string } | null> {
const qualified = await resolveWorktreeAddBaseRef(ref, async (candidate) =>
Boolean(await resolveHead(candidate))
)
const prefix = 'refs/remotes/'
if (!qualified.startsWith(prefix)) {
return null
}
try {
const { stdout } = await gitExecFileAsync(['remote'], {
...gitOptions,
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
})
const shortRef = qualified.slice(prefix.length)
const remote = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => shortRef.startsWith(`${entry}/`))
.sort((left, right) => right.length - left.length)[0]
const branch = remote ? shortRef.slice(remote.length + 1) : ''
if (!remote || !branch) {
return null
}
const { stdout: remoteUrl } = await gitExecFileAsync(['remote', 'get-url', remote], {
...gitOptions,
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
})
return remoteUrl.trim() ? { ref: `refs/heads/${branch}`, remoteUrl: remoteUrl.trim() } : null
} catch {
return null
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import type {
EphemeralVmRecipeConnection,
EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
type RecipeSshTarget = Extract<EphemeralVmRecipeConnection, { type: 'ssh' }>['target']
describe('getProvisionedRootResumeIntegrityError', () => {
it('allows an SSH endpoint to rotate while preserving its ownership identity', () => {
const previous = provisionedSshResult()
const resumed = provisionedSshResult({ host: 'new-host', port: 2222, label: 'Resumed VM' })
expect(getProvisionedRootResumeIntegrityError(previous, resumed)).toBeNull()
})
it.each([
['config host', { configHost: 'other-alias' }],
['username', { username: 'other-user' }],
['identity file', { identityFile: '/keys/other' }],
['identity agent', { identityAgent: '/agents/other' }],
['identities-only setting', { identitiesOnly: true }],
['proxy command', { proxyCommand: 'ssh proxy' }],
['jump host', { jumpHost: 'bastion' }]
])('rejects changed SSH %s ownership', (_label, target) => {
expect(
getProvisionedRootResumeIntegrityError(provisionedSshResult(), provisionedSshResult(target))
).toContain('SSH ownership changed')
})
it('rejects a resume that changes the connection type', () => {
const resumed: EphemeralVmRecipeResult = {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'orca-server',
pairingCode: 'pairing-code',
projectRoot: '/workspace/orca'
}
}
expect(getProvisionedRootResumeIntegrityError(provisionedSshResult(), resumed)).toContain(
'connection type changed'
)
})
})
function provisionedSshResult(target: Partial<RecipeSshTarget> = {}): EphemeralVmRecipeResult {
return {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/orca',
target: {
label: 'VM',
host: 'host',
port: 22,
username: 'orca',
identityFile: '/keys/orca',
identityAgent: '/agents/orca',
...target
}
}
}
}
+44 -5
View File
@@ -1,19 +1,58 @@
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultProjectRoot,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
export function provisionedRootChangedDuringResume(
export function getProvisionedRootResumeIntegrityError(
previous: EphemeralVmRecipeResult,
resumed: EphemeralVmRecipeResult
): boolean {
): string | null {
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
return false
return null
}
return (
if (
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
)
) {
return 'The provisioned workspace root changed while the runtime was suspended.'
}
const previousConnection = getEphemeralVmRecipeResultConnection(previous)
const resumedConnection = getEphemeralVmRecipeResultConnection(resumed)
if (previousConnection.type !== resumedConnection.type) {
return 'The provisioned workspace connection type changed while the runtime was suspended.'
}
if (
previousConnection.type === 'ssh' &&
resumedConnection.type === 'ssh' &&
sshOwnershipChanged(previousConnection.target, resumedConnection.target)
) {
return 'The provisioned workspace SSH ownership changed while the runtime was suspended.'
}
return null
}
const SSH_OWNERSHIP_FIELDS = [
'configHost',
'username',
'identityFile',
'identityAgent',
'identitiesOnly',
'proxyCommand',
'jumpHost'
] as const
function sshOwnershipChanged(
previous: Extract<
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
{ type: 'ssh' }
>['target'],
resumed: Extract<
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
{ type: 'ssh' }
>['target']
): boolean {
return SSH_OWNERSHIP_FIELDS.some((field) => previous[field] !== resumed[field])
}
@@ -0,0 +1,56 @@
import type { CleanupEphemeralVmRuntimeResult } from './ephemeral-vm-runtime-service'
type CleanupInFlight = {
controller: AbortController
promise: Promise<CleanupEphemeralVmRuntimeResult>
}
const cleanupInFlight = new Map<string, CleanupInFlight>()
export function runControlledEphemeralVmRuntimeCleanup(args: {
userDataPath: string
runtimeId: string
signal?: AbortSignal
run: (signal: AbortSignal) => Promise<CleanupEphemeralVmRuntimeResult>
}): Promise<CleanupEphemeralVmRuntimeResult> {
const key = cleanupKey(args)
const existing = cleanupInFlight.get(key)
if (existing) {
return existing.promise
}
const controller = new AbortController()
const forwardAbort = (): void => controller.abort()
if (args.signal?.aborted) {
forwardAbort()
} else {
args.signal?.addEventListener('abort', forwardAbort, { once: true })
}
const promise = args.run(controller.signal)
const inFlight = { controller, promise }
cleanupInFlight.set(key, inFlight)
const forget = (): void => {
args.signal?.removeEventListener('abort', forwardAbort)
if (cleanupInFlight.get(key) === inFlight) {
cleanupInFlight.delete(key)
}
}
void promise.then(forget, forget)
return promise
}
export function stopEphemeralVmRuntimeCleanup(args: {
userDataPath: string
runtimeId: string
}): Promise<CleanupEphemeralVmRuntimeResult> | null {
const cleanup = cleanupInFlight.get(cleanupKey(args))
if (!cleanup) {
return null
}
cleanup.controller.abort()
return cleanup.promise
}
function cleanupKey(args: { userDataPath: string; runtimeId: string }): string {
return `${args.userDataPath}\0${args.runtimeId}`
}
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto'
import { assertEphemeralVmRuntimeCheckoutModeCanPersist } from '../shared/ephemeral-vm-runtime-feature-store'
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
import {
listEphemeralVmRuntimes,
removeEphemeralVmRuntime,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import type {
ProvisionEphemeralVmRuntimeArgs,
ProvisionEphemeralVmRuntimeResult
} from './ephemeral-vm-runtime-service'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
export type EphemeralVmCompatibilityPersistence = {
instanceId: string
createdAt: number
}
export function prepareEphemeralVmCompatibilityPersistence(
args: ProvisionEphemeralVmRuntimeArgs
): EphemeralVmCompatibilityPersistence | null {
if (!args.recipe.checkoutMode) {
return null
}
listEphemeralVmRuntimes(args.userDataPath)
const compatibility = {
instanceId: `orca-${randomUUID()}`,
createdAt: args.now ?? Date.now()
}
assertEphemeralVmRuntimeCheckoutModeCanPersist(args.userDataPath, {
id: compatibility.instanceId,
recipeId: args.recipe.id,
createdAt: compatibility.createdAt,
checkoutMode: args.recipe.checkoutMode
})
return compatibility
}
export async function persistProvisionedEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs,
start: Extract<ProvisionEphemeralVmRuntimeResult, { ok: true }>['start'],
compatibility: EphemeralVmCompatibilityPersistence | null
): Promise<EphemeralVmRuntimeRecord> {
const now = compatibility?.createdAt ?? args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.result)
try {
return upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'running',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
createdAt: now,
updatedAt: now,
recipeResult: start.result
})
} catch (error) {
if (compatibility) {
const cleaned = await cleanupFailedEphemeralVmStart(args, {
context: start.context,
recipeResult: start.result
})
if (
cleaned &&
listEphemeralVmRuntimes(args.userDataPath).some(
(runtime) => runtime.id === compatibility.instanceId
)
) {
removeEphemeralVmRuntime(args.userDataPath, compatibility.instanceId)
}
}
throw error
}
}
+276 -3
View File
@@ -1,16 +1,22 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import {
getEphemeralVmRuntimeStorePath,
listEphemeralVmRuntimes,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import {
getEphemeralVmRuntimeFeatureStorePath,
MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES
} from '../shared/ephemeral-vm-runtime-feature-store'
import {
cleanupEphemeralVmRuntime,
provisionEphemeralVmRuntime,
resumeEphemeralVmRuntime
resumeEphemeralVmRuntime,
stopEphemeralVmRuntimeCleanup
} from './ephemeral-vm-runtime-service'
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
@@ -154,6 +160,63 @@ describe('ephemeral VM runtime service', () => {
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
})
it('stops a hung destroy and starts a fresh retry', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const cleanupPath = join(repoPath, 'cleanup.js')
const countPath = join(repoPath, 'cleanup-count.txt')
writeFileSync(
cleanupPath,
`require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x'); setInterval(() => {}, 1000)`
)
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: 'unused',
destroy: nodeCommand(cleanupPath)
}
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: recipe.id,
recipe,
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1_000,
updatedAt: 1_000,
recipeResult: {
schemaVersion: 1,
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
const cleanupArgs = {
userDataPath,
repoPath,
recipe,
runtimeId: 'runtime-1'
}
const cleanup = cleanupEphemeralVmRuntime(cleanupArgs)
await vi.waitFor(() => expect(readFileSync(countPath, 'utf8')).toBe('x'))
const stopping = stopEphemeralVmRuntimeCleanup({ userDataPath, runtimeId: 'runtime-1' })
expect(stopping).not.toBeNull()
await expect(stopping).resolves.toMatchObject({
ok: false,
runtime: { status: 'cleanup_failed', cleanupStatus: 'failed' },
error: 'Cleanup stopped by user.'
})
await expect(cleanup).resolves.toMatchObject({ ok: false })
writeFileSync(cleanupPath, `require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x')`)
await expect(cleanupEphemeralVmRuntime(cleanupArgs)).resolves.toMatchObject({
ok: true,
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
})
expect(readFileSync(countPath, 'utf8')).toBe('xx')
})
it('does not persist a runtime when recipe output cannot be parsed', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
@@ -179,6 +242,151 @@ describe('ephemeral VM runtime service', () => {
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('rejects an unwritable feature store before a checkout-mode recipe creates resources', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const markerPath = join(repoPath, 'create-ran.txt')
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(featurePath, '{}')
truncateSync(featurePath, MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES + 1)
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(existsSync(markerPath)).toBe(false)
})
it('rejects an unreadable lifecycle store before a checkout-mode recipe creates resources', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const markerPath = join(repoPath, 'create-ran.txt')
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
writeFileSync(getEphemeralVmRuntimeStorePath(userDataPath), '{')
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
).rejects.toThrow('file is invalid')
expect(existsSync(markerPath)).toBe(false)
})
it('destroys a checkout-mode resource when compatibility persistence fails after create', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
const cleanupMarkerPath = join(repoPath, 'cleanup-ran.txt')
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(
startPath,
[
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
'console.log(JSON.stringify({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/repo",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' }',
'}))'
].join('\n')
)
writeFileSync(
cleanupPath,
`require('fs').writeFileSync(${JSON.stringify(cleanupMarkerPath)}, 'yes')`
)
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(readFileSync(cleanupMarkerPath, 'utf8')).toBe('yes')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('keeps rollback-readable cleanup recovery when post-create destroy also fails', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(
startPath,
[
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
'console.log(JSON.stringify({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/repo",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' },',
' userData: { providerResourceId: "paid-vm" }',
'}))'
].join('\n')
)
writeFileSync(cleanupPath, 'process.exit(1)')
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
},
now: 1_000
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
status: 'cleanup_failed',
cleanupStatus: 'failed',
recipe: expect.not.objectContaining({ checkoutMode: expect.anything() }),
recipeResult: expect.objectContaining({
schemaVersion: 1,
userData: { providerResourceId: 'paid-vm' }
})
})
])
})
it('destroys a provisioned resource when its checkout handshake is incompatible', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
@@ -366,4 +574,69 @@ describe('ephemeral VM runtime service', () => {
}
})
})
it('rejects provisioned-root connection-mode drift during resume', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const resumePath = join(repoPath, 'resume.js')
writeFileSync(
resumePath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'orca-server',
pairingCode: makePairingCode(),
projectRoot: '/workspace/original'
}
})
)})`
)
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: 'unused',
resume: nodeCommand(resumePath),
destroyDisabled: true
}
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: recipe.id,
recipe,
status: 'suspended',
connectionMode: 'ssh',
cleanupStatus: 'disabled',
cleanupDisabled: true,
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/original',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
const resumed = await resumeEphemeralVmRuntime({
userDataPath,
repoPath,
recipe,
runtimeId: 'runtime-1'
})
expect(resumed).toMatchObject({
ok: false,
error: 'The provisioned workspace connection type changed while the runtime was suspended.',
runtime: {
status: 'resume_failed',
connectionMode: 'ssh',
recipeResult: { connection: { type: 'ssh' } }
}
})
})
})
+26 -43
View File
@@ -1,11 +1,9 @@
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus,
upsertEphemeralVmRuntime
updateEphemeralVmRuntimeStatus
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
import {
runEphemeralVmRecipeCleanup,
runEphemeralVmRecipeResume,
@@ -16,7 +14,14 @@ import {
type EphemeralVmRecipeStartSuccess
} from './ephemeral-vm-recipe-runner'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
import {
persistProvisionedEphemeralVmRuntime,
prepareEphemeralVmCompatibilityPersistence
} from './ephemeral-vm-runtime-provisioning-persistence'
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
import { runControlledEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
export { stopEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
@@ -29,6 +34,7 @@ export type ProvisionEphemeralVmRuntimeArgs = {
repoUrl?: string
branch?: string
ref?: string
expectedRefHead?: string
orcaVersion?: string
now?: number
signal?: AbortSignal
@@ -94,11 +100,10 @@ export type ResumeEphemeralVmRuntimeResult =
error: string
}
const cleanupInFlight = new Map<string, Promise<CleanupEphemeralVmRuntimeResult>>()
export async function provisionEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs
): Promise<ProvisionEphemeralVmRuntimeResult> {
const compatibility = prepareEphemeralVmCompatibilityPersistence(args)
const start = await runEphemeralVmRecipeStart({
repoPath: args.repoPath,
recipe: args.recipe,
@@ -109,7 +114,9 @@ export async function provisionEphemeralVmRuntime(
repoUrl: args.repoUrl,
branch: args.branch,
ref: args.ref,
orcaVersion: args.orcaVersion
expectedRefHead: args.expectedRefHead,
orcaVersion: args.orcaVersion,
...(compatibility ? { instanceId: compatibility.instanceId } : {})
},
signal: args.signal,
onStdout: args.onStdout,
@@ -125,24 +132,7 @@ export async function provisionEphemeralVmRuntime(
return { ok: false, start }
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.result)
const runtime = upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'running',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
createdAt: now,
updatedAt: now,
recipeResult: start.result
})
const runtime = await persistProvisionedEphemeralVmRuntime(args, start, compatibility)
return { ok: true, start, runtime }
}
@@ -150,21 +140,12 @@ export async function provisionEphemeralVmRuntime(
export function cleanupEphemeralVmRuntime(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const key = `${args.userDataPath}\0${args.runtimeId}`
const existing = cleanupInFlight.get(key)
if (existing) {
return existing
}
const cleanup = cleanupEphemeralVmRuntimeOnce(args)
cleanupInFlight.set(key, cleanup)
const forget = (): void => {
if (cleanupInFlight.get(key) === cleanup) {
cleanupInFlight.delete(key)
}
}
void cleanup.then(forget, forget)
return cleanup
return runControlledEphemeralVmRuntimeCleanup({
userDataPath: args.userDataPath,
runtimeId: args.runtimeId,
signal: args.signal,
run: (signal) => cleanupEphemeralVmRuntimeOnce({ ...args, signal })
})
}
async function cleanupEphemeralVmRuntimeOnce(
@@ -282,13 +263,15 @@ export async function resumeEphemeralVmRuntime(
return { ok: false, runtime: failed, error: resume.error }
}
if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) {
const error = 'The provisioned workspace root changed while the runtime was suspended.'
const resumeIntegrityError = resume.skipped
? null
: getProvisionedRootResumeIntegrityError(existing.recipeResult, resume.result)
if (resumeIntegrityError) {
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'resume_failed',
updatedAt: Date.now()
})
return { ok: false, runtime: failed, error }
return { ok: false, runtime: failed, error: resumeIntegrityError }
}
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
+120
View File
@@ -42,11 +42,20 @@ import {
parseWorktreeList,
removeWorktree,
WORKTREE_ADD_TIMEOUT_MS,
WORKTREE_ADD_TIMEOUT_MAX_MS,
resolveWorktreeAddTimeoutMs,
WORKTREE_LIST_TIMEOUT_MS
} from './worktree'
beforeEach(() => {
clearGitCapabilityStateForTests()
// Why: addWorktree reads the override at call time, so a developer's ambient value must not leak in.
// `undefined` deletes the key, matching production's unset case rather than an empty string.
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('listWorktrees in-flight sharing', () => {
@@ -814,6 +823,20 @@ describe('addWorktree', () => {
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
})
it('raises the worktree add timeout from ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696)', async () => {
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', '600000')
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
checkoutExistingBranch: true
})
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
)
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 })
})
it('does not write branch base config when no base branch is provided', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
@@ -1875,3 +1898,100 @@ describe('removeWorktree', () => {
])
})
})
describe('resolveWorktreeAddTimeoutMs', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
// Why: pin the literals so a future edit to either bound has to be deliberate.
it('bounds the override to [180s, 30min]', () => {
expect(WORKTREE_ADD_TIMEOUT_MS).toBe(180_000)
expect(WORKTREE_ADD_TIMEOUT_MAX_MS).toBe(1_800_000)
})
it('falls back to the default when the override is unset, blank, or unparseable', () => {
expect(resolveWorktreeAddTimeoutMs({})).toBe(WORKTREE_ADD_TIMEOUT_MS)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'nope' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
it('raises the timeout up to the closed maximum', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '999999999' })).toBe(
1_800_000
)
})
// Why: `=Infinity` is the natural way to say "stop killing my checkout"; collapsing it to the
// default would hand the operator back the exact failure they set the variable to escape.
it('treats an infinite override as the maximum, not the default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'Infinity' })).toBe(
1_800_000
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '1e400' })).toBe(1_800_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-Infinity' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
// Why: `Infinity` is a number that got clamped, so reporting "is not a number" while naming the
// number it used would misdirect exactly the operator this override exists for.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Infinity" is outside [180000, 1800000]ms; using 1800000ms')
)
})
// Why: trimming and fractional truncation alter the value but are not operator error.
it('stays quiet when the value is unset, blank, or lands in range', () => {
resolveWorktreeAddTimeoutMs({})
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600000' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })
expect(warnSpy).not.toHaveBeenCalled()
})
// Why: the seconds/ms mixup is the whole reason the floor exists — it must not be silent.
it('warns with the range when it clamps an out-of-range value', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })
expect(warnSpy).toHaveBeenCalledTimes(1)
// Why: pin the prefix and variable name too — a warning nobody can grep for is not a diagnostic.
expect(warnSpy).toHaveBeenCalledWith(
'[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="300" is outside [180000, 1800000]ms; using 180000ms'
)
})
// Why: `600_000` copied out of worktree.ts is NaN, not out of range — a range complaint would misdirect.
it('warns that an unparseable value is not a number rather than out of range', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600_000' })
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"600_000" is not a number; using 180000ms')
)
})
// Why: `=300` means seconds to most operators; clamping it up keeps every create working instead of failing in 300ms.
it('never lowers the timeout below the stall-guard default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '0' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-1' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
})
+32 -3
View File
@@ -91,13 +91,42 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696).
// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request.
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3.
export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
/**
* `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS},
* {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default.
* Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent.
* `env` is injectable for tests.
*/
export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim()
const requested = Math.floor(Number(raw))
// Why: `=300` reads as seconds to most operators, so clamp rather than obey.
const resolved = Number.isNaN(requested)
? WORKTREE_ADD_TIMEOUT_MS
: Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS)
// Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it.
if (raw && resolved !== requested) {
const problem = Number.isNaN(requested)
? // Why: `600_000` copied out of this file is NaN, not out of range — say which.
'is not a number'
: `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms`
console.warn(
`[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms`
)
}
return resolved
}
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
@@ -984,8 +1013,8 @@ async function performAddWorktree(
}
await gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
// Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292) fails fast instead of hanging.
timeout: WORKTREE_ADD_TIMEOUT_MS
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
timeout: resolveWorktreeAddTimeoutMs()
})
if (options.checkoutExistingBranch) {
@@ -293,6 +293,7 @@ describe('dashboard payload validation', () => {
localWindowsConpty: true,
osRelease: '10.0.22631',
windowsShiftEnterEncoding: 'alt-enter',
windowsInputRecordPasteNewline: 'alt-enter',
ctrlEnterCsiU: false,
kittyKeyboardAdvertised: false
}
@@ -307,6 +308,8 @@ describe('dashboard payload validation', () => {
{ ...terminalInput, localWindowsConpty: 'true' },
{ ...terminalInput, osRelease: 'x'.repeat(1_025) },
{ ...terminalInput, windowsShiftEnterEncoding: 'enter' },
{ ...terminalInput, forceBracketedMultilineTextPaste: false },
{ ...terminalInput, windowsInputRecordPasteNewline: 'enter' },
{ ...terminalInput, ctrlEnterCsiU: 'true' },
{ ...terminalInput, kittyKeyboardAdvertised: 1 }
]) {
@@ -53,6 +53,7 @@ const DASHBOARD_HOST_PLATFORMS = new Set([
'win32'
])
const WINDOWS_SHIFT_ENTER_ENCODINGS = new Set(['alt-enter', 'csi-u'])
const WINDOWS_INPUT_RECORD_PASTE_NEWLINES = new Set(['alt-enter', 'csi-u'])
function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string {
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
@@ -308,6 +309,11 @@ function isDashboardTerminalInput(value: unknown): boolean {
isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) &&
typeof input.windowsShiftEnterEncoding === 'string' &&
WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) &&
(input.forceBracketedMultilineTextPaste === undefined ||
input.forceBracketedMultilineTextPaste === true) &&
(input.windowsInputRecordPasteNewline === undefined ||
(typeof input.windowsInputRecordPasteNewline === 'string' &&
WINDOWS_INPUT_RECORD_PASTE_NEWLINES.has(input.windowsInputRecordPasteNewline))) &&
typeof input.ctrlEnterCsiU === 'boolean' &&
typeof input.kittyKeyboardAdvertised === 'boolean'
)
@@ -0,0 +1,91 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
const { getPathMock, handleMock, resolveProvisionedRootSourceMock } = vi.hoisted(() => ({
getPathMock: vi.fn(),
handleMock: vi.fn(),
resolveProvisionedRootSourceMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, removeHandler: vi.fn() }
}))
vi.mock('../ephemeral-vm-provisioned-root-source', () => ({
resolveProvisionedRootSource: resolveProvisionedRootSourceMock
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
vi.clearAllMocks()
})
it('cancels source resolution before a provisioned-root recipe creates resources', async () => {
const userDataPath = makeTempDir('orca-vm-cancel-user-data-')
const repoPath = makeTempDir('orca-vm-cancel-repo-')
const createMarker = join(repoPath, 'create-ran')
getPathMock.mockReturnValue(userDataPath)
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
writeRecipe(repoPath, createMarker)
resolveProvisionedRootSourceMock.mockImplementation(
(_store, _repo, _ref, signal: AbortSignal | undefined) =>
new Promise((resolve) =>
signal?.addEventListener('abort', () => resolve(null), { once: true })
)
)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const provision = handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
provisionId: 'provision-source-ref'
} as never) as Promise<{ ok: boolean; error?: string }>
await vi.waitFor(() => expect(resolveProvisionedRootSourceMock).toHaveBeenCalledOnce())
const cancelled = await handlers.get('ephemeralVm:cancelProvision')?.(null, {
provisionId: 'provision-source-ref'
} as never)
const result = await provision
expect(cancelled).toEqual({ cancelled: true })
expect(result).toEqual({ ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' })
expect(existsSync(createMarker)).toBe(false)
})
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
function makeStore(repoPath: string) {
const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', addedAt: 0 }
return {
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
}
}
function writeRecipe(repoPath: string, createMarker: string): void {
writeFileSync(
join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: provisioned-root',
` create: ${JSON.stringify(`"${process.execPath}" -e "require('node:fs').writeFileSync('${createMarker}','x')"`)}`,
' destroy: none'
].join('\n')
)
}
@@ -0,0 +1,194 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
const { connectRuntimeOwnedSshTargetMock, getPathMock, handleMock, removeHandlerMock } = vi.hoisted(
() => ({
connectRuntimeOwnedSshTargetMock: vi.fn(),
getPathMock: vi.fn(),
handleMock: vi.fn(),
removeHandlerMock: vi.fn()
})
)
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
}))
vi.mock('../ephemeral-vm-runtime-ssh', () => ({
connectRuntimeOwnedSshTarget: connectRuntimeOwnedSshTargetMock,
disconnectRuntimeOwnedSshTarget: vi.fn(),
removeRuntimeOwnedSshTarget: vi.fn()
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
let userDataPath: string
let repoPath: string
beforeEach(() => {
handlers.clear()
userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-user-data-'))
repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-repo-'))
getPathMock.mockReturnValue(userDataPath)
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
targetId: 'runtime-ssh-ref-test',
target: { id: 'runtime-ssh-ref-test' }
})
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
rmSync(repoPath, { recursive: true, force: true })
vi.clearAllMocks()
})
it('captures the source ref commit before a provisioned-root recipe starts', async () => {
createGitFixtureCommit(repoPath)
const expectedRefHead = createBranchCommit(repoPath, 'selected')
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'selected' })
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(readFileSync(createCountPath, 'utf8')).toBe('x')
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'selected',
refHead: expectedRefHead,
repoUrl: ''
})
})
it('captures the effective default ref when no start ref is selected', async () => {
const expectedRefHead = createGitFixtureCommit(repoPath)
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({})
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'main',
refHead: expectedRefHead,
repoUrl: ''
})
})
it('pairs a multi-remote base with its fetch URL and remote branch ref', async () => {
createGitFixtureCommit(repoPath)
const expectedRefHead = createBranchCommit(repoPath, 'upstream-source')
const remoteUrl = 'https://example.com/acme/upstream.git'
execFileSync('git', ['remote', 'add', 'upstream', remoteUrl], { cwd: repoPath })
execFileSync('git', ['update-ref', 'refs/remotes/upstream/main', expectedRefHead], {
cwd: repoPath
})
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'upstream/main' })
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'refs/heads/main',
refHead: expectedRefHead,
repoUrl: remoteUrl
})
})
it('rejects an unresolved source ref before the recipe creates resources', async () => {
createGitFixtureCommit(repoPath)
const createCountPath = join(repoPath, 'create-count.txt')
writeRecipe(repoPath, createCountPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'refs/heads/missing' })
expect(result).toMatchObject({
ok: false,
error: 'Could not resolve provisioned-root start ref: refs/heads/missing'
})
expect(() => readFileSync(createCountPath, 'utf8')).toThrow()
})
function provision(args: { ref?: string }): Promise<unknown> {
return handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
branch: 'fix-sandbox',
...(args.ref ? { ref: args.ref } : {})
} as never) as Promise<unknown>
}
function makeStore(path: string) {
const repo = { id: 'repo-1', path, displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
return {
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
}
}
function createGitFixtureCommit(path: string): string {
execFileSync('git', ['init'], { cwd: path })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: path })
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: path })
execFileSync('git', ['branch', '-M', 'main'], { cwd: path })
writeFileSync(join(path, 'fixture.txt'), 'fixture')
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
execFileSync('git', ['commit', '-m', 'fixture'], { cwd: path })
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim()
}
function createBranchCommit(path: string, branch: string): string {
execFileSync('git', ['checkout', '-b', branch], { cwd: path })
writeFileSync(join(path, 'fixture.txt'), branch)
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
execFileSync('git', ['commit', '-m', branch], { cwd: path })
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: path,
encoding: 'utf8'
}).trim()
execFileSync('git', ['checkout', 'main'], { cwd: path })
return head
}
function writeRecipe(path: string, createCountPath: string, createEnvPath?: string): void {
const startPath = join(path, 'start.js')
writeFileSync(
startPath,
[
`require('node:fs').appendFileSync(${JSON.stringify(createCountPath)}, 'x')`,
...(createEnvPath
? [
`require('node:fs').writeFileSync(${JSON.stringify(createEnvPath)}, JSON.stringify({ref:process.env.ORCA_REPO_REF,refHead:process.env.ORCA_REPO_REF_HEAD,repoUrl:process.env.ORCA_REPO_URL}))`
]
: []),
'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",',
'connection:{type:"ssh",projectRoot:"/workspace/repo",',
'target:{label:"Sandbox",host:"host",port:22,username:"root"}}}))'
].join('\n')
)
writeFileSync(
join(path, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: provisioned-root',
` create: ${JSON.stringify(`"${process.execPath}" "${startPath}"`)}`,
' destroy: none'
].join('\n')
)
}
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -29,6 +29,10 @@ import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handl
const tempDirs: string[] = []
function nodeCommand(scriptPath: string): string {
return `"${process.execPath}" "${scriptPath}"`
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -47,7 +51,7 @@ afterEach(() => {
}
})
it('removes the hidden SSH target when recipe context is unavailable', async () => {
it('removes the hidden SSH target when provider cleanup cannot start', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
tempDirs.push(userDataPath)
getPathMock.mockReturnValue(userDataPath)
@@ -79,7 +83,68 @@ it('removes the hidden SSH target when recipe context is unavailable', async ()
expect(cleaned).toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'failed',
connectionMode: undefined,
sshTargetId: undefined
})
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-missing-context')
})
it('stops in-flight cleanup and retains the runtime for retry', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
const repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-repo-'))
tempDirs.push(userDataPath, repoPath)
getPathMock.mockReturnValue(userDataPath)
const destroyPath = join(repoPath, 'destroy.js')
const destroyStartedPath = join(repoPath, 'destroy-started.txt')
writeFileSync(
destroyPath,
`require('fs').writeFileSync(${JSON.stringify(destroyStartedPath)}, 'yes'); setInterval(() => {}, 1000)`
)
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-stop',
recipeId: 'cloud-sandbox',
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: 'unused',
destroy: nodeCommand(destroyPath)
},
repoId: 'repo-1',
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 1,
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
registerEphemeralVmRuntimeHandlers({
getRepo: vi.fn(() => ({
id: 'repo-1',
path: repoPath,
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}))
} as never)
const cleanup = handlers.get('ephemeralVm:cleanup')?.(null, {
runtimeId: 'runtime-stop'
}) as Promise<{ status: string }>
await vi.waitFor(() => expect(existsSync(destroyStartedPath)).toBe(true))
const stopped = await handlers.get('ephemeralVm:stopCleanup')?.(null, {
runtimeId: 'runtime-stop'
})
expect(stopped).toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
await expect(cleanup).resolves.toMatchObject({ status: 'cleanup_failed' })
})
+34 -2
View File
@@ -16,6 +16,7 @@ import {
import {
cleanupEphemeralVmRuntime,
resumeEphemeralVmRuntime,
stopEphemeralVmRuntimeCleanup,
suspendEphemeralVmRuntime
} from '../ephemeral-vm-runtime-service'
import { removeEphemeralVmRuntimeSshTarget } from '../ephemeral-vm-runtime-ssh-cleanup'
@@ -44,6 +45,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
ipcMain.removeHandler('ephemeralVm:attachWorkspace')
ipcMain.removeHandler('ephemeralVm:listRuntimes')
ipcMain.removeHandler('ephemeralVm:cleanup')
ipcMain.removeHandler('ephemeralVm:stopCleanup')
ipcMain.removeHandler('ephemeralVm:suspendWorkspace')
ipcMain.removeHandler('ephemeralVm:resumeWorkspace')
ipcMain.removeHandler('ephemeralVm:getCleanupCommand')
@@ -111,8 +113,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
// environment row; users can still remove that manually.
}
}
// Remove even on cleanup_failed (removal is idempotent via the deterministic
// id) so a terminal cleanup never orphans the hidden SSH target.
if (!result.ok) {
return result.runtime
}
return removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime: result.runtime,
@@ -121,6 +124,35 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
}
)
ipcMain.handle(
'ephemeralVm:stopCleanup',
async (_event, args: { runtimeId: string }): Promise<EphemeralVmRuntimeRecord> => {
const userDataPath = app.getPath('userData')
const stopping = stopEphemeralVmRuntimeCleanup({
userDataPath,
runtimeId: args.runtimeId
})
if (stopping) {
return (await stopping).runtime
}
const runtime = listEphemeralVmRuntimes(userDataPath).find(
(entry) => entry.id === args.runtimeId
)
if (!runtime) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (runtime.cleanupStatus !== 'running') {
return runtime
}
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup was interrupted. Retry cleanup to continue.'
})
}
)
ipcMain.handle(
'ephemeralVm:suspendWorkspace',
async (_event, args: { workspaceId: string }): Promise<EphemeralVmRuntimeRecord | null> => {
+4 -6
View File
@@ -498,7 +498,7 @@ describe('registerEphemeralVmHandlers', () => {
expect(listEnvironments(userDataPath)).toEqual([])
})
it('removes the runtime-owned SSH target on cleanup even when destroy fails', async () => {
it('retains the runtime-owned SSH target when destroy fails', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
getPathMock.mockReturnValue(userDataPath)
@@ -523,8 +523,6 @@ describe('registerEphemeralVmHandlers', () => {
'}))'
].join('\n')
)
// Why: a failing destroy drives the cleanup_failed branch; the runtime-owned
// SSH target must still be torn down so it never orphans (see Fix D).
writeFileSync(destroyPath, 'process.exit(1)')
writeFileSync(
join(repoPath, 'orca.yaml'),
@@ -554,9 +552,9 @@ describe('registerEphemeralVmHandlers', () => {
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleanup_failed' }))
expect(cleaned.cleanupLastError).toBeTruthy()
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-orca-instance-1')
expect(cleaned.connectionMode).toBeUndefined()
expect(cleaned.sshTargetId).toBeUndefined()
expect(removeRuntimeOwnedSshTargetMock).not.toHaveBeenCalled()
expect(cleaned.connectionMode).toBe('ssh')
expect(cleaned.sshTargetId).toBe('runtime-ssh-orca-instance-1')
})
it('retries hidden SSH teardown without rerunning completed provider cleanup', async () => {
+33 -5
View File
@@ -35,6 +35,7 @@ import {
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
import type { PluginService } from '../plugins/plugin-service'
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
import { resolveProvisionedRootSource } from '../ephemeral-vm-provisioned-root-source'
const activeProvisionControllers = new Map<string, AbortController>()
@@ -52,6 +53,7 @@ export type EphemeralVmProvisionIpcResult =
connectionType: 'ssh'
runtime: EphemeralVmRuntimeRecord
sshTargetId: string
expectedRefHead?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
}
@@ -128,10 +130,6 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
if (!recipe) {
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
}
const repoUrl = getProvisionedRootRecipeRepoUrl(
recipe.checkoutMode,
repo.repo.gitRemoteIdentity?.remoteUrl
)
const controller = args.provisionId ? new AbortController() : null
if (args.provisionId && controller) {
activeProvisionControllers.set(args.provisionId, controller)
@@ -151,6 +149,34 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
// abort during the up-to-10s SSH connect window. Removing it in the provision
// promise's own .finally() would deregister it before SSH connect even starts.
try {
let recipeRepoUrl = repo.repo.gitRemoteIdentity?.remoteUrl
let sourceRef = args.ref
let expectedRefHead: string | undefined
if (recipe.checkoutMode === 'provisioned-root') {
const source = await resolveProvisionedRootSource(
store,
repo.repo,
args.ref,
controller?.signal
)
if (controller?.signal.aborted) {
return { ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' }
}
if (!source) {
return {
ok: false,
error: args.ref
? `Could not resolve provisioned-root start ref: ${args.ref}`
: 'Could not resolve a default provisioned-root start ref.',
stdout: '',
stderr: ''
}
}
sourceRef = source.ref
expectedRefHead = source.head
recipeRepoUrl = source.remoteUrl ?? recipeRepoUrl
}
const repoUrl = getProvisionedRootRecipeRepoUrl(recipe.checkoutMode, recipeRepoUrl)
const result = await provisionEphemeralVmRuntime({
userDataPath: app.getPath('userData'),
repoPath: repo.repo.path,
@@ -161,7 +187,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
workspaceName: args.workspaceName,
...(repoUrl ? { repoUrl } : {}),
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(sourceRef ? { ref: sourceRef } : {}),
...(expectedRefHead ? { expectedRefHead } : {}),
...(controller ? { signal: controller.signal } : {}),
onStdout: (chunk) => sendProvisionEvent('stdout', chunk),
onStderr: (chunk) => sendProvisionEvent('stderr', chunk)
@@ -194,6 +221,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
connectionType: 'ssh',
runtime,
sshTargetId: ssh.targetId,
...(expectedRefHead ? { expectedRefHead } : {}),
stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr),
warnings: getEphemeralVmRecipeResultWarnings(result.start.result)
}
@@ -73,6 +73,119 @@ describe('adoptProvisionedRootSshCheckout', () => {
})
})
it('rejects a recipe checkout on a branch Orca did not request', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi
.fn()
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]),
exec: sparseCheckoutProbe(false)
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: request(projectRoot),
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('requested branch')
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('rejects a recipe checkout that did not start from the requested ref', async () => {
seedRuntime(userDataPath, projectRoot)
const exec = sparseCheckoutProbe(false)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]),
exec
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: {
...request(projectRoot),
baseBranch: 'v1.2.3',
expectedRefHead: 'expected-head'
},
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('requested ref')
expect(exec).toHaveBeenCalledOnce()
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('accepts an attached requested branch created from a tag or commit ref', async () => {
seedRuntime(userDataPath, projectRoot)
const exec = sparseCheckoutProbe(false)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
exec
} as never)
const { store } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: {
...request(projectRoot),
baseBranch: 'v1.2.3',
expectedRefHead: 'abc123'
},
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } })
})
it('rejects a requested ref without its source-host commit identity', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
exec: sparseCheckoutProbe(false)
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: { ...request(projectRoot), baseBranch: 'missing-ref' },
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('ref identity is missing')
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('uses an explicit branch override as the requested local branch', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi
.fn()
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]),
exec: sparseCheckoutProbe(false)
} as never)
const { store } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: { ...request(projectRoot), branchNameOverride: 'feature/review' },
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } })
})
it('rejects a linked worktree and sparse checkout', async () => {
seedRuntime(userDataPath, projectRoot)
const listWorktrees = vi
+10
View File
@@ -90,6 +90,16 @@ export async function adoptProvisionedRootSshCheckout(args: {
if (gitWorktree.isSparse || sparseCheckoutEnabled) {
throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.')
}
const requestedBranch = request.branchNameOverride ?? request.name
if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) {
throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.")
}
if (request.baseBranch && !request.expectedRefHead) {
throw new Error('The requested provisioned-root ref identity is missing.')
}
if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) {
throw new Error("The recipe projectRoot was not created from Orca's requested ref.")
}
const worktreeId = `${repo.id}::${gitWorktree.path}`
attachEphemeralVmRuntimeToWorkspace({
+54 -4
View File
@@ -310,7 +310,7 @@ type RunListCursor = {
id: string
}
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 Run coordinator handle history.
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity.
const SCHEMA_VERSION = 28
function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void {
@@ -335,6 +335,7 @@ export class OrchestrationDb {
// emptiness so the non-orchestration majority short-circuits the whole
// per-terminal fan-out. Only createDispatchContext flips this false→true.
private hasAnyDispatchContextsCache: boolean | undefined
private localMutationCallerFingerprint: string | undefined
constructor(dbPath: (string & {}) | ':memory:') {
this.db = new Database(dbPath)
@@ -452,6 +453,11 @@ export class OrchestrationDb {
PRIMARY KEY (caller_fingerprint, request_id)
);
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
transport TEXT PRIMARY KEY,
caller_fingerprint TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS worker_dispatches (
dispatch_id TEXT PRIMARY KEY,
runtime_epoch TEXT,
@@ -1067,9 +1073,10 @@ export class OrchestrationDb {
}
if (current < 28) {
this.db.exec(`
INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle)
SELECT id, coordinator_handle FROM runs
WHERE legacy = 0 AND coordinator_handle IS NOT NULL
CREATE TABLE IF NOT EXISTS mutation_caller_identities (
transport TEXT PRIMARY KEY,
caller_fingerprint TEXT NOT NULL UNIQUE
);
`)
}
this.db.exec(`
@@ -1519,6 +1526,34 @@ export class OrchestrationDb {
// ── Durable mutation receipts ──
getOrCreateLocalMutationCallerFingerprint(): string {
if (this.localMutationCallerFingerprint) {
return this.localMutationCallerFingerprint
}
const transport = 'local_authenticated_transport'
const existing = this.db
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
.get(transport) as { caller_fingerprint: string } | undefined
if (existing) {
this.localMutationCallerFingerprint = existing.caller_fingerprint
return this.localMutationCallerFingerprint
}
this.db
.prepare(
`INSERT OR IGNORE INTO mutation_caller_identities (transport, caller_fingerprint)
VALUES (?, ?)`
)
.run(transport, randomBytes(32).toString('hex'))
const created = this.db
.prepare('SELECT caller_fingerprint FROM mutation_caller_identities WHERE transport = ?')
.get(transport) as { caller_fingerprint: string } | undefined
if (!created) {
throw new Error('Failed to create the local orchestration mutation caller identity.')
}
this.localMutationCallerFingerprint = created.caller_fingerprint
return this.localMutationCallerFingerprint
}
beginMutationReceipt(params: {
callerFingerprint: string
requestId: string
@@ -1876,6 +1911,7 @@ export class OrchestrationDb {
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
}
// Why: caller-side fence jurisdiction. Widening this fences more callers, so keep it narrow.
isLegacyCoordinatorHandle(runId: string, terminalHandle: string): boolean {
const principal = this.getLegacyCoordinatorPrincipal(runId)
if (principal) {
@@ -1884,6 +1920,20 @@ export class OrchestrationDb {
return this.getUniqueLegacyCoordinatorHandle(runId) === terminalHandle
}
// Why: recipient-side permit for legacy lifecycle mail. After a takeover revokes the old principal
// the replacement coordinator is only reachable through the Run binding.
isLegacyCoordinatorDeliveryTarget(runId: string, terminalHandle: string): boolean {
if (this.isLegacyCoordinatorHandle(runId, terminalHandle)) {
return true
}
if (this.getRunRaw(runId)?.coordinator_handle !== terminalHandle) {
return false
}
// Why: must match resolveLegacyWorkerCoordinatorDelivery's takeover test. A still-committed
// principal routes legacy_direct to this handle, and no reader can see that mailbox.
return this.getLegacyCoordinatorPrincipal(runId)?.status !== 'committed'
}
findLegacyWorkerCompletion(params: {
principalId: string
taskId: string
@@ -65,6 +65,33 @@ describe('Run coordinator handle history migration', () => {
expect(db.getMessageById(late.id)?.to_handle).toBe(`run:${run.id}`)
})
it('self-converges a v28 database created before coordinator history existed', () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-v28-convergence-'))
const dbPath = join(tempDir, 'orchestration.db')
db = new OrchestrationDb(dbPath)
const run = db.createRun({
objective: 'schema collision',
coordinatorHandle: 'term_existing',
coordinatorPaneKey: 'tab:leaf'
})
db.close()
db = undefined
const v28Db = new Database(dbPath)
v28Db.exec('DROP TABLE run_coordinator_handles')
expect(v28Db.pragma('user_version', { simple: true })).toBe(28)
v28Db.close()
db = new OrchestrationDb(dbPath)
const sqlite = (db as unknown as { db: Database.Database }).db
expect(sqlite.pragma('user_version', { simple: true })).toBe(28)
expect(
sqlite
.prepare('SELECT run_id, terminal_handle FROM run_coordinator_handles WHERE run_id = ?')
.all(run.id)
).toEqual([{ run_id: run.id, terminal_handle: 'term_existing' }])
})
it('records every coordinator rebind performed by an older runtime', () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-old-runtime-'))
const dbPath = join(tempDir, 'orchestration.db')
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { RpcDispatcher } from './dispatcher'
import { defineMethod, InvalidArgumentError, type RpcRequest } from './core'
@@ -37,6 +37,20 @@ const METHODS = [
handler: () => {
throw new InvalidArgumentError('Async validation rejected payload')
}
}),
defineMethod({
name: 'orchestration.inspectCaller',
params: z.object({}),
handler: (_params, { authenticatedCallerFingerprint }) => ({
authenticatedCallerFingerprint
})
}),
defineMethod({
name: 'orchestration.federationInspectCaller',
params: z.object({}),
handler: (_params, { authenticatedCallerFingerprint }) => ({
authenticatedCallerFingerprint
})
})
]
@@ -109,12 +123,16 @@ describe('RpcDispatcher computer-use validation errors', () => {
})
})
it('maps async validation errors to invalid_argument without shadowing Zod formatting', async () => {
it('maps async validation errors over streaming transport without shadowing Zod formatting', async () => {
const messages: string[] = []
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
const response = await dispatcher.dispatch(makeRequest('orchestration.invalidArgument', {}))
await dispatcher.dispatchStreaming(
makeRequest('orchestration.invalidArgument', {}),
(message) => messages.push(message)
)
expect(response).toMatchObject({
expect(JSON.parse(messages[0]!)).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
@@ -122,4 +140,35 @@ describe('RpcDispatcher computer-use validation errors', () => {
}
})
})
it('forwards a paired caller fingerprint without requiring local orchestration state', async () => {
const dispatcher = new RpcDispatcher({ runtime: makeRuntime(), methods: METHODS })
const response = await dispatcher.dispatch(makeRequest('orchestration.inspectCaller', {}), {
authenticatedCallerFingerprint: 'paired-caller'
})
expect(response).toMatchObject({
ok: true,
result: { authenticatedCallerFingerprint: 'paired-caller' }
})
})
it('provides local identity to read-only federation authorization', async () => {
const getOrCreateLocalMutationCallerFingerprint = vi.fn(() => 'local-caller')
const runtime = Object.assign(makeRuntime(), {
getOrchestrationDb: () => ({ getOrCreateLocalMutationCallerFingerprint })
})
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
const response = await dispatcher.dispatch(
makeRequest('orchestration.federationInspectCaller', {})
)
expect(response).toMatchObject({
ok: true,
result: { authenticatedCallerFingerprint: 'local-caller' }
})
expect(getOrCreateLocalMutationCallerFingerprint).toHaveBeenCalledOnce()
})
})
@@ -3,6 +3,7 @@ import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protoc
import type { PairingRpcContext } from './core'
export type RpcDispatchStreamingOptions = {
authenticatedCallerFingerprint?: string
connectionId?: string
signal?: AbortSignal
clientId?: string
+26 -6
View File
@@ -10,12 +10,12 @@ import {
} from './core'
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import { isOrchestrationMutation } from '../../../shared/orchestration-rpc-contract'
import { errorResponse, successResponse } from './errors'
import { ALL_RPC_METHODS } from './methods'
import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe'
import type { OrcaRuntimeService } from '../orca-runtime'
import {
authenticatedCallerFingerprint,
getOrchestrationMutationExecutor,
type OrchestrationMutationExecutor,
type DurableMutationInvocation
@@ -41,7 +41,10 @@ export class RpcDispatcher {
this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime)
}
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
async dispatch(
request: RpcRequest,
options?: { signal?: AbortSignal; authenticatedCallerFingerprint?: string }
): Promise<RpcResponse> {
const meta = this.meta()
const method = this.registry.get(request.method)
if (!method) {
@@ -89,6 +92,11 @@ export class RpcDispatcher {
request,
compatibility.legacyCoordinatorAuthority
)
const authenticatedCallerFingerprint =
options?.authenticatedCallerFingerprint ??
(needsLocalCallerFingerprint(request, effectiveParams)
? this.orchestrationMutations.getLocalAuthenticatedCallerFingerprint()
: undefined)
const invoke = (mutation?: DurableMutationInvocation) => {
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
return method.handler(effectiveParams, {
@@ -97,7 +105,7 @@ export class RpcDispatcher {
requestId: request.id,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint,
recordMutationReceipt: mutation?.recordReceipt,
orchestrationMutation: mutation?.identity,
legacyCoordinatorRunId,
@@ -112,7 +120,7 @@ export class RpcDispatcher {
request,
effectiveParams,
invoke,
legacyCoordinator?.mutationCallerFingerprint
legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint
)
recordRuntimeFeatureInteraction(
this.runtime,
@@ -177,6 +185,11 @@ export class RpcDispatcher {
request,
compatibility.legacyCoordinatorAuthority
)
const authenticatedCallerFingerprint =
options?.authenticatedCallerFingerprint ??
(needsLocalCallerFingerprint(request, effectiveParams)
? this.orchestrationMutations.getLocalAuthenticatedCallerFingerprint()
: undefined)
const invoke = (mutation?: DurableMutationInvocation) => {
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
return method.handler(effectiveParams, {
@@ -190,7 +203,7 @@ export class RpcDispatcher {
clientCapabilities: options?.clientCapabilities,
orchestrationCapability: request.orchestrationCapability,
authenticatedCallerFingerprint:
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint,
recordMutationReceipt: mutation?.recordReceipt,
orchestrationMutation: mutation?.identity,
pairing: options?.pairing,
@@ -208,7 +221,7 @@ export class RpcDispatcher {
request,
effectiveParams,
invoke,
legacyCoordinator?.mutationCallerFingerprint
legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint
)
recordRuntimeFeatureInteraction(
this.runtime,
@@ -291,3 +304,10 @@ export class RpcDispatcher {
return { runtimeId: this.runtime.getRuntimeId() }
}
}
function needsLocalCallerFingerprint(request: RpcRequest, params: unknown): boolean {
return (
request.method.startsWith('orchestration.federation') ||
(!!request.orchestrationRequestId && isOrchestrationMutation(request.method, params))
)
}
@@ -6,11 +6,12 @@ import { OrchestrationDb } from '../../orchestration/db'
import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { authenticatedCallerFingerprint } from '../orchestration-mutation-executor'
import { fingerprintAuthenticatedPairingCredential } from '../orchestration-mutation-executor'
import { ORCHESTRATION_METHODS } from './orchestration'
describe('orchestration federation control mail', () => {
const homeToken = 'run-home-device-token'
const homeFingerprint = fingerprintAuthenticatedPairingCredential(homeToken)
const workerToken = 'worker-local-token'
const workerPeerFingerprint = 'worker-peer'
const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
@@ -55,14 +56,17 @@ describe('orchestration federation control mail', () => {
_meta: { runtimeId: workerRuntime.getRuntimeId() }
}
}
const response = (await workerDispatcher.dispatch({
id: `remote_${method}`,
authToken: homeToken,
method,
params,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId
})) as RuntimeRpcResponse<unknown>
const response = (await workerDispatcher.dispatch(
{
id: `remote_${method}`,
authToken: homeToken,
method,
params,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId
},
{ authenticatedCallerFingerprint: homeFingerprint }
)) as RuntimeRpcResponse<unknown>
return response
}
}
@@ -99,11 +103,6 @@ describe('orchestration federation control mail', () => {
dispatchId = started.dispatch.id
homeDb.markWorkerDispatchReady(dispatchId)
const homeFingerprint = authenticatedCallerFingerprint({
id: 'home',
authToken: homeToken,
method: 'orchestration.federationImport'
})
workerDb.createRemoteDispatchAttachment({
dispatchId,
taskId: task.id,
@@ -182,9 +181,7 @@ describe('orchestration federation control mail', () => {
const waiting = workerDispatcher.dispatch(checkRequest('wait-for-control', true))
await Promise.resolve()
const imported = await workerDispatcher.dispatch(
importRequest('import-control', 1, 'relay-control')
)
const imported = await dispatchImport(importRequest('import-control', 1, 'relay-control'))
expect(imported).toMatchObject({
ok: true,
@@ -201,8 +198,8 @@ describe('orchestration federation control mail', () => {
})
it('accepts a repeated import after a lost acknowledgment without duplicating mail', async () => {
const first = await workerDispatcher.dispatch(importRequest('first-import', 1, 'relay-control'))
const repeated = await workerDispatcher.dispatch(
const first = await dispatchImport(importRequest('first-import', 1, 'relay-control'))
const repeated = await dispatchImport(
importRequest('repeated-import', 1, 'different-message-id')
)
@@ -258,7 +255,7 @@ describe('orchestration federation control mail', () => {
expect(workerDb.getUnreadMessages(`dispatch:${dispatchId}`)).toHaveLength(0)
expect(homeDb.listPendingFederationRelay(dispatchId, 'to_worker')).toHaveLength(1)
await expect(
workerDispatcher.dispatch(importRequest('late-direct-import', 1, 'late-control'))
dispatchImport(importRequest('late-direct-import', 1, 'late-control'))
).resolves.toMatchObject({
ok: false,
error: { code: 'dispatch_inactive' }
@@ -276,9 +273,7 @@ describe('orchestration federation control mail', () => {
const statusWaiter = workerDispatcher.dispatch(checkRequest('wait-status', true, 30, 'status'))
await Promise.resolve()
await workerDispatcher.dispatch(
importRequest('import-escalation', 1, 'relay-escalation', 'escalation')
)
await dispatchImport(importRequest('import-escalation', 1, 'relay-escalation', 'escalation'))
await expect(escalationWaiter).resolves.toMatchObject({
ok: true,
@@ -342,4 +337,10 @@ describe('orchestration federation control mail', () => {
}
}
}
function dispatchImport(request: RpcRequest) {
return workerDispatcher.dispatch(request, {
authenticatedCallerFingerprint: homeFingerprint
})
}
})
@@ -1,4 +1,6 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
import { OrcaRuntimeService } from '../../orca-runtime'
@@ -13,6 +15,7 @@ describe('orchestration new-worktree workers', () => {
let db: OrchestrationDb
let runtime: OrcaRuntimeService
let runId: string
const paths: string[] = []
beforeEach(() => {
db = new OrchestrationDb(':memory:')
@@ -71,7 +74,12 @@ describe('orchestration new-worktree workers', () => {
})
})
afterEach(() => db.close())
afterEach(() => {
db.close()
for (const path of paths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
async function startWorker(overrides: Record<string, unknown> = {}) {
const task = db.createTask({ spec: 'new-worktree task', runId })
@@ -542,7 +550,7 @@ describe('orchestration new-worktree workers', () => {
const pending = dispatcher.dispatch(request)
await vi.waitFor(() => expect(db.getDispatchContext(task.id)).toBeDefined())
const acceptedDispatch = db.getDispatchContext(task.id)!
const callerFingerprint = createHash('sha256').update('caller-token').digest('hex')
const callerFingerprint = db.getOrCreateLocalMutationCallerFingerprint()
const receipt = db.getMutationReceipt(callerFingerprint, 'worker_start_request')
expect(receipt).toMatchObject({
@@ -574,6 +582,101 @@ describe('orchestration new-worktree workers', () => {
})
})
it('replays a dispatch-input failure after restart without creating another worker', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-worker-start-replay-'))
paths.push(dir)
db.close()
db = new OrchestrationDb(join(dir, 'orchestration.db'))
runtime.setOrchestrationDb(db)
runId = db.createRun({
objective: 'Recover dispatch input',
coordinatorHandle: 'term_coord',
coordinatorPaneKey
}).id
mockCreatedWorktree({ hookFound: false })
vi.mocked(runtime.sendTerminalAgentPrompt).mockRejectedValueOnce(
new Error('connection closed before dispatch input was accepted')
)
const task = db.createTask({ spec: 'recover dispatch input', runId })
const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
const request: RpcRequest = {
id: 'rpc_worker_start',
authToken: 'caller-token',
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
orchestrationRequestId: 'worker_start_request',
method: 'orchestration.workerStart',
params: {
task: task.id,
from: 'term_coord',
worktree: 'new-child',
name: 'recover-input-worker',
agent: 'codex'
}
}
const first = await dispatcher.dispatch(request)
if (!first.ok) {
throw new Error(`Initial worker start failed: ${first.error.code}`)
}
const firstReceipt = first.result as {
dispatchId: string
residualResources: unknown[]
}
db.close()
db = new OrchestrationDb(join(dir, 'orchestration.db'))
const restartedRuntime = new OrcaRuntimeService()
restartedRuntime.setOrchestrationDb(db)
vi.spyOn(restartedRuntime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === 'term_coord_reminted'
? `tab_coord_reminted:${coordinatorPaneKey.split(':')[1]}`
: null
)
const recreateWorktree = vi
.spyOn(restartedRuntime, 'createManagedWorktree')
.mockRejectedValue(new Error('replay recreated the worktree'))
const reinjectPrompt = vi
.spyOn(restartedRuntime, 'sendTerminalAgentPrompt')
.mockRejectedValue(new Error('replay reinjected the prompt'))
const restartedDispatcher = new RpcDispatcher({
runtime: restartedRuntime,
methods: ORCHESTRATION_METHODS
})
const replay = await restartedDispatcher.dispatch({
...request,
id: 'rpc_worker_start_retry',
authToken: 'caller-token-after-restart',
params: { ...(request.params as Record<string, unknown>), from: 'term_coord_reminted' }
})
expect(first).toMatchObject({
ok: true,
result: {
state: 'failed',
failedStage: 'dispatch_input',
residualResources: expect.arrayContaining([
expect.objectContaining({ kind: 'worktree', id: 'repo::created' }),
expect.objectContaining({ kind: 'terminal', id: 'term_worker' })
]),
mutation: { requestId: 'worker_start_request', replayed: false }
}
})
expect(replay).toMatchObject({
ok: true,
result: {
dispatchId: firstReceipt.dispatchId,
state: 'failed',
failedStage: 'dispatch_input',
residualResources: firstReceipt.residualResources,
mutation: { requestId: 'worker_start_request', replayed: true }
}
})
expect(runtime.createManagedWorktree).toHaveBeenCalledOnce()
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
expect(recreateWorktree).not.toHaveBeenCalled()
expect(reinjectPrompt).not.toHaveBeenCalled()
})
it('persists pre-effect, post-effect, and post-input stages in order', async () => {
mockCreatedWorktree({ hookFound: false })
let finishWait:
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto'
import { z } from 'zod'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
@@ -73,7 +72,7 @@ describe('orchestration contract fence', () => {
}
})
expect(effect).not.toHaveBeenCalled()
const callerFingerprint = createHash('sha256').update('caller-token').digest('hex')
const callerFingerprint = database.getOrCreateLocalMutationCallerFingerprint()
expect(database.getMutationReceipt(callerFingerprint, 'mutation_1')).toBeUndefined()
}
)
@@ -41,7 +41,7 @@ export async function handleLegacyLifecycleSend(args: {
throw new OrchestrationError('invalid_argument', 'Legacy lifecycle mail requires --to.')
}
const db = runtime.getOrchestrationDb()
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
if (!db.isLegacyCoordinatorDeliveryTarget(dispatch.run_id, params.to)) {
throw new OrchestrationError(
'request_mismatch',
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
@@ -28,7 +28,7 @@ export async function handleLegacyAsk(args: {
if (!params.to) {
throw new OrchestrationError('invalid_argument', 'Legacy ask requires --to.')
}
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
if (!db.isLegacyCoordinatorDeliveryTarget(dispatch.run_id, params.to)) {
throw new OrchestrationError(
'request_mismatch',
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
@@ -0,0 +1,379 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
import Database from '../../sqlite/sync-database'
import { OrcaRuntimeService } from '../orca-runtime'
import { OrchestrationDb } from '../orchestration/db'
import type { RpcRequest } from './core'
import { RpcDispatcher } from './dispatcher'
import { ORCHESTRATION_METHODS } from './methods/orchestration'
const WORKER_HANDLE = 'term_legacy_worker'
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
const COORDINATOR_HANDLE = 'term_legacy_coord'
const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
const CURRENT_COORDINATOR_PANE = 'tab_current:55555555-5555-4555-8555-555555555555'
// The replacement coordinator after a retain/restart: same handle, new pane identity.
const RESTARTED_COORDINATOR_PANE = 'tab_current:99999999-9999-4999-8999-999999999999'
// A coordinator terminal that restarted inside the legacy coordinator's own pane.
const REBOUND_COORDINATOR_HANDLE = 'term_rebound_coord'
type Harness = {
db: OrchestrationDb
dispatcher: RpcDispatcher
runtime: OrcaRuntimeService
adoptedRunId: string
taskId: string
dispatchId: string
}
const tempDirs: string[] = []
const databases: OrchestrationDb[] = []
afterEach(() => {
for (const database of databases.splice(0)) {
database.close()
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function createHarness(): Harness {
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-takeover-delivery-'))
tempDirs.push(dir)
const dbPath = join(dir, 'orchestration.db')
const before = new OrchestrationDb(dbPath)
const task = before.createTask({
spec: 'legacy assignment',
createdByTerminalHandle: COORDINATOR_HANDLE
})
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
before.close()
const raw = new Database(dbPath)
raw.exec(`
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
DROP INDEX IF EXISTS idx_messages_delivery_contract;
DROP TABLE legacy_mail_receipts;
DROP TABLE legacy_operation_receipts;
DROP TABLE legacy_compatibility_principals;
DROP TABLE legacy_adoptions;
`)
raw.pragma('user_version = 18')
raw.close()
const db = new OrchestrationDb(dbPath)
databases.push(db)
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === COORDINATOR_HANDLE
? COORDINATOR_PANE
: handle === WORKER_HANDLE
? WORKER_PANE
: handle === CURRENT_COORDINATOR_HANDLE
? CURRENT_COORDINATOR_PANE
: handle === REBOUND_COORDINATOR_HANDLE
? COORDINATOR_PANE
: null
)
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((proof) => {
const valid =
(proof?.terminalHandle === WORKER_HANDLE && proof.paneKey === WORKER_PANE) ||
(proof?.terminalHandle === COORDINATOR_HANDLE && proof.paneKey === COORDINATOR_PANE) ||
(proof?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
proof.paneKey === CURRENT_COORDINATOR_PANE)
if (!valid || !proof?.launchToken) {
return null
}
return {
hostScope: { kind: 'local', hostId: 'local' },
terminalHandle: proof.terminalHandle as string,
paneKey: proof.paneKey as string,
processIncarnation: 'process-1',
launchTokenHash: createHash('sha256').update(proof.launchToken).digest('hex')
}
})
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
return {
db,
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
runtime,
adoptedRunId,
taskId: task.id,
dispatchId: dispatch.id
}
}
function evidence(
role: 'worker' | 'coordinator' | 'current-coordinator'
): OrchestrationCompatibilityEvidence {
const map = {
worker: { handle: WORKER_HANDLE, pane: WORKER_PANE },
coordinator: { handle: COORDINATOR_HANDLE, pane: COORDINATOR_PANE },
'current-coordinator': { handle: CURRENT_COORDINATOR_HANDLE, pane: CURRENT_COORDINATOR_PANE }
}
const entry = map[role]
return {
terminalHandle: entry.handle,
paneKey: entry.pane,
launchToken: `${role}-token`
}
}
function request(
method: string,
params: unknown,
proof: OrchestrationCompatibilityEvidence,
invocationId: string
): RpcRequest {
return {
id: `rpc_${invocationId}`,
authToken: 'caller-token',
method,
params,
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
orchestrationRequestId: invocationId,
compatibilityInvocationId: invocationId,
orchestrationCompatibilityEvidence: proof
}
}
function commitLegacyCoordinatorPrincipal(harness: Harness): { id: string } {
const { principal } = harness.db.commitLegacyCompatibilityPrincipal({
runId: harness.adoptedRunId,
role: 'coordinator',
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }),
terminalHandle: COORDINATOR_HANDLE,
paneKey: COORDINATOR_PANE,
launchTokenHash: 'coord-hash',
processIncarnation: 'process-1'
})
expect(principal.status).toBe('committed')
return principal
}
// Why: assert the takeover landed here, so a broken bind fails at cause instead of two tests later.
function takeOverWithCommittedPrincipal(harness: Harness): void {
commitLegacyCoordinatorPrincipal(harness)
const bound = harness.db.bindRun({
runId: harness.adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE,
takeoverLegacy: true
})
expect(bound).toMatchObject({
id: harness.adoptedRunId,
coordinator_handle: CURRENT_COORDINATOR_HANDLE,
coordinator_pane_key: CURRENT_COORDINATOR_PANE
})
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
}
describe('legacy coordinator delivery targets after takeover', () => {
it('accepts both the old and new coordinator handles after principal revocation', () => {
const harness = createHarness()
takeOverWithCommittedPrincipal(harness)
// Old handle still routable (retained).
expect(
harness.db.isLegacyCoordinatorDeliveryTarget(harness.adoptedRunId, COORDINATOR_HANDLE)
).toBe(true)
// New handle also routable (current binding).
expect(
harness.db.isLegacyCoordinatorDeliveryTarget(harness.adoptedRunId, CURRENT_COORDINATOR_HANDLE)
).toBe(true)
// Unknown handle not routable.
expect(harness.db.isLegacyCoordinatorDeliveryTarget(harness.adoptedRunId, 'term_unknown')).toBe(
false
)
})
it('keeps the caller-side fence out of the replacement coordinator jurisdiction', async () => {
const harness = createHarness()
takeOverWithCommittedPrincipal(harness)
// The replacement coordinator is a delivery target, but must never become fence jurisdiction:
// fencing it would strand the Run's own owner behind unusable takeover guidance.
expect(
harness.db.isLegacyCoordinatorHandle(harness.adoptedRunId, CURRENT_COORDINATOR_HANDLE)
).toBe(false)
// The pane identity changed under it, so this coordinator can no longer attest.
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskList',
{ callerTerminalHandle: CURRENT_COORDINATOR_HANDLE },
{ ...evidence('current-coordinator'), paneKey: RESTARTED_COORDINATOR_PANE },
'replacement-coordinator-not-fenced'
)
)
// It still owns the Run binding, so it reads its own Run rather than being fenced read-only.
expect(response).toMatchObject({
ok: true,
result: { runId: harness.adoptedRunId, legacyReadOnly: false }
})
})
it('lets a worker send worker_done to the new coordinator after principal revocation', async () => {
const harness = createHarness()
takeOverWithCommittedPrincipal(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.send',
{
from: WORKER_HANDLE,
to: CURRENT_COORDINATOR_HANDLE,
subject: 'Completed',
type: 'worker_done',
payload: JSON.stringify({
taskId: harness.taskId,
dispatchId: harness.dispatchId,
outcome: 'succeeded'
})
},
evidence('worker'),
'worker-done-to-new-coordinator'
)
)
expect(response).toMatchObject({
ok: true,
result: {
message: {
to_handle: `run:${harness.adoptedRunId}`,
delivery_contract: 'current_delivery',
type: 'worker_done'
}
}
})
})
it('lets a worker ask the new coordinator and get an answer after principal revocation', async () => {
const harness = createHarness()
takeOverWithCommittedPrincipal(harness)
const asked = (await harness.dispatcher.dispatch(
request(
'orchestration.ask',
{ from: WORKER_HANDLE, to: CURRENT_COORDINATOR_HANDLE, question: 'Ship it?', timeoutMs: 0 },
evidence('worker'),
'ask-new-coordinator'
)
)) as { ok: boolean; result: { messageId: string } }
expect(asked).toMatchObject({ ok: true, result: { timedOut: true } })
// The ask must reach the Run mailbox as a current-contract question, or nobody can answer it.
expect(harness.db.getMessageById(asked.result.messageId)).toMatchObject({
to_handle: `run:${harness.adoptedRunId}`,
delivery_contract: 'current_delivery',
type: 'question'
})
const replied = await harness.dispatcher.dispatch(
request(
'orchestration.reply',
{ id: asked.result.messageId, from: CURRENT_COORDINATOR_HANDLE, body: 'Yes, ship it.' },
evidence('current-coordinator'),
'reply-from-new-coordinator'
)
)
expect(replied).toMatchObject({ ok: true })
const resumed = await harness.dispatcher.dispatch(
request(
'orchestration.ask',
{ from: WORKER_HANDLE, resume: asked.result.messageId, timeoutMs: 0 },
evidence('worker'),
'ask-resume-after-reply'
)
)
expect(resumed).toMatchObject({ ok: true, result: { answer: 'Yes, ship it.' } })
})
})
// Why: bindRun only revokes a committed principal when it takes over or the legacy work is settled,
// so a coordinator restarting inside the legacy pane rebinds the Run while the principal stays
// committed. Delivery still routes legacy_direct to the addressed handle there, and no reader can
// see that mailbox, so the permit must refuse rather than accept mail nobody will ever read.
describe('legacy coordinator delivery targets without a takeover', () => {
function rebindLegacyPaneWithoutTakeover(harness: Harness): { principalId: string } {
const principal = commitLegacyCoordinatorPrincipal(harness)
const run = harness.db.getRun(harness.adoptedRunId) as { consumer_generation: number }
expect(
harness.db.bindRun({
runId: harness.adoptedRunId,
coordinatorHandle: COORDINATOR_HANDLE,
coordinatorPaneKey: COORDINATOR_PANE,
legacyCoordinatorAuthority: {
runId: harness.adoptedRunId,
principalId: principal.id,
terminalHandle: COORDINATOR_HANDLE,
paneKey: COORDINATOR_PANE,
consumerGeneration: run.consumer_generation
}
})
).toMatchObject({ coordinator_handle: COORDINATOR_HANDLE })
expect(
harness.db.bindRun({
runId: harness.adoptedRunId,
coordinatorHandle: REBOUND_COORDINATOR_HANDLE,
coordinatorPaneKey: COORDINATOR_PANE
})
).toMatchObject({ coordinator_handle: REBOUND_COORDINATOR_HANDLE })
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('committed')
return { principalId: principal.id }
}
it('refuses the rebound coordinator handle while the legacy principal is still committed', () => {
const harness = createHarness()
rebindLegacyPaneWithoutTakeover(harness)
expect(
harness.db.isLegacyCoordinatorDeliveryTarget(harness.adoptedRunId, REBOUND_COORDINATOR_HANDLE)
).toBe(false)
// The still-committed principal's own handle stays reachable.
expect(
harness.db.isLegacyCoordinatorDeliveryTarget(harness.adoptedRunId, COORDINATOR_HANDLE)
).toBe(true)
})
it('rejects worker_done to the rebound handle instead of writing unreadable mail', async () => {
const harness = createHarness()
const { principalId } = rebindLegacyPaneWithoutTakeover(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.send',
{
from: WORKER_HANDLE,
to: REBOUND_COORDINATOR_HANDLE,
subject: 'Completed',
type: 'worker_done',
payload: JSON.stringify({
taskId: harness.taskId,
dispatchId: harness.dispatchId,
outcome: 'succeeded'
})
},
evidence('worker'),
'worker-done-to-rebound-coordinator'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'request_mismatch' } })
// Nothing was written to a mailbox that neither contract can read.
expect(harness.db.getUnreadMessages(REBOUND_COORDINATOR_HANDLE)).toHaveLength(0)
expect(harness.db.getLegacyMailHistory({ principalId }).messages).toHaveLength(0)
})
})
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import { isOrchestrationMutation } from '../../../shared/orchestration-rpc-contract'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { OrcaRuntimeService } from '../orca-runtime'
import { OrchestrationError } from '../orchestration/orchestration-error'
import type { RpcRequest } from './core'
@@ -29,9 +30,17 @@ export class OrchestrationMutationExecutor {
if (!requestId || !isOrchestrationMutation(request.method, params)) {
return await invoke()
}
const callerFingerprint = callerFingerprintOverride ?? authenticatedCallerFingerprint(request)
const callerFingerprint =
callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint()
const payloadHash = createHash('sha256')
.update(JSON.stringify(canonicalize({ method: request.method, params })))
.update(
JSON.stringify(
canonicalize({
method: request.method,
params: replayStableCallerParams(this.runtime, params)
})
)
)
.digest('hex')
const key = `${callerFingerprint}:${requestId}`
const db = this.runtime.getOrchestrationDb()
@@ -109,6 +118,10 @@ export class OrchestrationMutationExecutor {
this.inFlight.delete(key)
}
}
getLocalAuthenticatedCallerFingerprint(): string {
return this.runtime.getOrchestrationDb().getOrCreateLocalMutationCallerFingerprint()
}
}
const executorsByRuntime = new WeakMap<OrcaRuntimeService, OrchestrationMutationExecutor>()
@@ -125,12 +138,31 @@ export function getOrchestrationMutationExecutor(
return executor
}
export function authenticatedCallerFingerprint(request: RpcRequest): string {
const callerToken =
request.authToken ||
(request as RpcRequest & { deviceToken?: string }).deviceToken ||
'authenticated_transport'
return createHash('sha256').update(callerToken).digest('hex')
export function fingerprintAuthenticatedPairingCredential(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function replayStableCallerParams(runtime: OrcaRuntimeService, params: unknown): unknown {
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return params
}
const source = params as Record<string, unknown>
const result = { ...source }
for (const property of ['from', 'callerTerminalHandle'] as const) {
const handle = source[property]
if (typeof handle !== 'string') {
continue
}
const paneKey =
property === 'from' && typeof source.senderPaneKey === 'string'
? source.senderPaneKey
: runtime.getTerminalPaneKey(handle)
if (paneKey) {
const leafId = parsePaneKey(paneKey)?.leafId
result[property] = leafId ? { paneLeafId: leafId } : { paneKey }
}
}
return result
}
function canonicalize(value: unknown): unknown {
@@ -93,7 +93,7 @@ describe('durable orchestration mutation ledger', () => {
db.close()
})
it('applies the same ledger on authenticated WebSocket dispatch', async () => {
it('keys WebSocket replay to the authenticated device across reconnects', async () => {
const { db, dispatcher, effect } = createHarness()
const replies: string[] = []
const firstRequest = request({
@@ -102,10 +102,23 @@ describe('durable orchestration mutation ledger', () => {
subject: 'remote'
}) as RpcRequest & { deviceToken?: string }
firstRequest.authToken = ''
firstRequest.deviceToken = 'paired-device'
await dispatcher.dispatchStreaming(firstRequest, (reply) => replies.push(reply))
const replayRequest = { ...firstRequest, id: 'rpc_2' }
await dispatcher.dispatchStreaming(replayRequest, (reply) => replies.push(reply))
firstRequest.deviceToken = 'untrusted-request-value-a'
await dispatcher.dispatchStreaming(firstRequest, (reply) => replies.push(reply), {
authenticatedCallerFingerprint: 'paired-device-a'
})
const replayRequest = {
...firstRequest,
id: 'rpc_2',
deviceToken: 'untrusted-request-value-b'
}
await dispatcher.dispatchStreaming(replayRequest, (reply) => replies.push(reply), {
authenticatedCallerFingerprint: 'paired-device-a'
})
await dispatcher.dispatchStreaming(
{ ...replayRequest, id: 'rpc_3' },
(reply) => replies.push(reply),
{ authenticatedCallerFingerprint: 'paired-device-b' }
)
expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({
ok: true,
@@ -115,7 +128,12 @@ describe('durable orchestration mutation ledger', () => {
ok: true,
result: { mutation: { replayed: true } }
})
expect(effect).toHaveBeenCalledTimes(1)
expect(JSON.parse(replies[2] ?? '{}')).toMatchObject({
ok: true,
result: { mutation: { replayed: false } }
})
expect(effect).toHaveBeenCalledTimes(2)
expect(db.getInbox(10)).toHaveLength(2)
db.close()
})
@@ -178,10 +196,42 @@ describe('durable orchestration mutation ledger', () => {
second.db.close()
})
it('replays a local mutation after runtime authentication rotates', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-mutation-ledger-'))
paths.push(dir)
const dbPath = join(dir, 'orchestration.db')
const firstRuntime = createHarness(dbPath)
const first = await firstRuntime.dispatcher.dispatch(
request({
rpcId: 'rpc_1',
mutationId: 'mutation_restart',
subject: 'once',
authToken: 'before-restart'
})
)
firstRuntime.db.close()
const restartedRuntime = createHarness(dbPath)
const replay = await restartedRuntime.dispatcher.dispatch(
request({
rpcId: 'rpc_2',
mutationId: 'mutation_restart',
subject: 'once',
authToken: 'after-restart'
})
)
expect(first).toMatchObject({ ok: true, result: { mutation: { replayed: false } } })
expect(replay).toMatchObject({ ok: true, result: { mutation: { replayed: true } } })
expect(firstRuntime.effect).toHaveBeenCalledOnce()
expect(restartedRuntime.effect).not.toHaveBeenCalled()
restartedRuntime.db.close()
})
it('returns unknown for a pending receipt left by a previous process', async () => {
const { db, dispatcher } = createHarness()
db.beginMutationReceipt({
callerFingerprint: createHash('sha256').update('caller-token').digest('hex'),
callerFingerprint: db.getOrCreateLocalMutationCallerFingerprint(),
requestId: 'mutation_1',
method: 'orchestration.send',
payloadHash: createHash('sha256')
@@ -201,7 +251,7 @@ describe('durable orchestration mutation ledger', () => {
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
const params = { dispatch: 'ctx_release' }
const callerFingerprint = createHash('sha256').update('caller-token').digest('hex')
const callerFingerprint = db.getOrCreateLocalMutationCallerFingerprint()
const payloadHash = createHash('sha256')
.update(JSON.stringify({ method: 'orchestration.workerRelease', params }))
.digest('hex')
@@ -249,7 +299,7 @@ describe('durable orchestration mutation ledger', () => {
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
const params = { from: 'term_coord', task: db.createTask({ spec: 'restart' }).id }
const callerFingerprint = createHash('sha256').update('caller-token').digest('hex')
const callerFingerprint = db.getOrCreateLocalMutationCallerFingerprint()
const payloadHash = createHash('sha256')
.update(JSON.stringify({ method: 'orchestration.workerStart', params }))
.digest('hex')
+109
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */
import { existsSync, mkdirSync, mkdtempSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -3483,6 +3484,114 @@ describe('OrcaRuntimeRpcServer', () => {
)
})
it('isolates mutation replay by the authenticated paired device across reconnects', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const db = new OrchestrationDb(':memory:')
runtime.setOrchestrationDb(db)
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const firstDevice = server['deviceRegistry']!.addDevice('first-cli', 'runtime')
const secondDevice = server['deviceRegistry']!.addDevice('second-cli', 'runtime')
const resetMessages = async (id: string, authenticatedToken: string) => {
const replies: Record<string, unknown>[] = []
await server['handleWebSocketMessage'](
JSON.stringify(
withCurrentOrchestrationContract({
id,
method: 'orchestration.reset',
orchestrationRequestId: 'paired-reset-request',
params: { messages: true }
})
),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {},
undefined,
undefined,
authenticatedToken
)
return replies[0]
}
try {
db.insertMessage({ from: 'worker', to: 'coordinator', subject: 'before reset' })
const first = await resetMessages('reset-first', firstDevice.token)
db.insertMessage({ from: 'worker', to: 'coordinator', subject: 'after reset' })
const replay = await resetMessages('reset-replay', firstDevice.token)
expect(first).toMatchObject({
ok: true,
result: { reset: 'messages', mutation: { replayed: false } }
})
expect(replay).toMatchObject({
ok: true,
result: { reset: 'messages', mutation: { replayed: true } }
})
expect(db.getInbox()).toEqual([expect.objectContaining({ subject: 'after reset' })])
const isolated = await resetMessages('reset-second-device', secondDevice.token)
expect(isolated).toMatchObject({
ok: true,
result: { reset: 'messages', mutation: { replayed: false } }
})
expect(db.getInbox()).toEqual([])
} finally {
db.close()
await server.stop()
}
})
it('keeps authenticated paired callers attached to existing federated workers', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const runtime = new OrcaRuntimeService()
const db = new OrchestrationDb(':memory:')
runtime.setOrchestrationDb(db)
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const device = server['deviceRegistry']!.addDevice('existing-cli', 'runtime')
const existingFingerprint = createHash('sha256').update(device.token).digest('hex')
db.createRemoteDispatchAttachment({
dispatchId: 'ctx_existing_remote',
taskId: 'task_existing_remote',
homePeerFingerprint: existingFingerprint,
protocolVersion: 1,
runtimeEpoch: 'runtime_before_upgrade',
mutationReceipt: {
callerFingerprint: existingFingerprint,
requestId: 'request_existing_remote',
method: 'orchestration.federationAttachStart',
payloadHash: 'hash_existing_remote'
}
})
const replies: Record<string, unknown>[] = []
try {
await server['handleWebSocketMessage'](
JSON.stringify(
withCurrentOrchestrationContract({
id: 'show-existing-remote',
method: 'orchestration.federationShow',
params: { dispatchId: 'ctx_existing_remote' }
})
),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {},
undefined,
undefined,
device.token
)
expect(replies[0]).toMatchObject({
ok: true,
result: { dispatchId: 'ctx_existing_remote', attachment: { state: 'starting' } }
})
} finally {
db.close()
await server.stop()
}
})
it('rejects unpaired terminal creates before runtime dispatch', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const createMobileSessionTerminal = vi.fn()
+3
View File
@@ -15,6 +15,7 @@ import {
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest, RpcResponse } from './rpc/core'
import { errorResponse } from './rpc/errors'
import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-mutation-executor'
import type { RpcMessageContext, RpcTransport } from './rpc/transport'
import { UnixSocketTransport } from './rpc/unix-socket-transport'
import { WebSocketTransport } from './rpc/ws-transport'
@@ -1711,6 +1712,8 @@ export class OrcaRuntimeRpcServer {
: undefined
try {
await this.dispatcher.dispatchStreaming(request, replyForRequest, {
// Why: the validated credential preserves existing federation ownership without trusting request fields.
authenticatedCallerFingerprint: fingerprintAuthenticatedPairingCredential(token),
connectionId,
clientId: token,
pairedDeviceId: device.deviceId,
+2
View File
@@ -45,6 +45,7 @@ export type EphemeralVmApi = {
connectionType: 'ssh'
runtime: EphemeralVmRuntimeRecord
sshTargetId: string
expectedRefHead?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
}
@@ -62,6 +63,7 @@ export type EphemeralVmApi = {
suspendWorkspace: (args: { workspaceId: string }) => Promise<EphemeralVmRuntimeRecord | null>
resumeWorkspace: (args: { workspaceId: string }) => Promise<EphemeralVmRuntimeRecord | null>
cleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
stopCleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
getCleanupCommand: (args: { runtimeId: string }) => Promise<{
runtimeId: string
command: string | null
+1
View File
@@ -3047,6 +3047,7 @@ const api = {
suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args),
resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args),
cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args),
stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args),
getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args)
} satisfies PreloadApi['ephemeralVm'],
+41 -2780
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AgentHibernationGate } from '../components/AgentHibernationGate'
import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate'
import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate'
import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
import { useAppStore } from '../store'
const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge'))
/**
* App-level gates that render nothing. Each lives here rather than inside the surface that
* needs it so its high-churn store subscriptions stay out of the App render tree.
*/
export function AppBackgroundServices(): React.JSX.Element {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const dashboardPopoutEnabled = useAppStore(
(s) => s.settings?.experimentalAgentDashboardPopout === true
)
return (
<>
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: plugin language-pack discovery must not re-render the App shell. */}
<MacosTccPromptNoticeHost />
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
<RetainedAgentsSyncGate />
<AiVaultTabTitleSyncGate />
{dashboardPopoutEnabled ? (
<Suspense fallback={null}>
<DashboardPopoutBridge />
</Suspense>
) : null}
<AgentHibernationGate />
</>
)
}
@@ -0,0 +1,368 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'
import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'
import { MarkdownTemplatePicker } from '../components/editor/MarkdownTemplatePicker'
import RecentTabSwitcher from '../components/tab-bar/RecentTabSwitcher'
import { SkillFreshnessUpdateDialog } from '../components/skills/SkillFreshnessUpdateDialog'
import { StarNagCard } from '../components/StarNagCard'
import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from '../components/star-nag/StarNagToastHost'
import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from '../components/ZoomOverlay'
import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility'
import { useAppStore } from '../store'
import type { UpdateStatus } from '../../../shared/update-status-types'
import { useLazyModalMounts } from './use-lazy-modal-mounts'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
import type { OnboardingGate } from './use-onboarding-and-feature-tips'
const QuickOpen = lazy(() => import('../components/QuickOpen'))
const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette'))
const WorkspaceCleanupDialog = lazy(
() => import('../components/workspace-cleanup/WorkspaceCleanupDialog')
)
const StatusBar = lazy(() =>
import('../components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar }))
)
const SetupGuideModal = lazy(() => import('../components/setup-guide/SetupGuideModal'))
const FeatureWallModal = lazy(() => import('../components/feature-wall/FeatureWallModal'))
const FeatureTipsModal = lazy(() => import('../components/feature-tips/FeatureTipsModal'))
const AddRepoDialog = lazy(() => import('../components/sidebar/AddRepoDialog'))
const NonGitFolderDialog = lazy(() => import('../components/sidebar/NonGitFolderDialog'))
const AddProjectFromFolderDialog = lazy(
() => import('../components/sidebar/AddProjectFromFolderDialog')
)
const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog'))
const DeleteWorktreeDialog = lazy(() => import('../components/sidebar/DeleteWorktreeDialog'))
const PreservedBranchBatchReviewModal = lazy(
() => import('../components/sidebar/PreservedBranchBatchReviewModal')
)
const DictationController = lazy(() =>
import('../components/dictation/DictationController').then((module) => ({
default: module.DictationController
}))
)
const SshPassphraseDialog = lazy(() =>
import('../components/settings/SshPassphraseDialog').then((module) => ({
default: module.SshPassphraseDialog
}))
)
const UpdateCard = lazy(() =>
import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
)
const RemoteServerUpdateDialog = lazy(
() => import('../components/settings/RemoteServerUpdateDialog')
)
const ContextualTourOverlay = lazy(() =>
import('../components/contextual-tours/ContextualTourOverlay').then((module) => ({
default: module.ContextualTourOverlay
}))
)
const SetupGuideTelemetryObserver = lazy(() =>
import('../components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({
default: module.SetupGuideTelemetryObserver
}))
)
const FloatingTerminalPanel = lazy(() =>
import('../components/floating-terminal/FloatingTerminalPanel').then((module) => ({
default: module.FloatingTerminalPanel
}))
)
// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on.
const PetOverlay = lazy(() => import('../components/pet/PetOverlay'))
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow'))
type BoundaryProps = {
boundaryId: string
resetKey?: string | number | boolean | null
title?: string
description?: string
children: React.ReactNode
}
function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean {
if (status.state === 'idle') {
return false
}
if (status.state === 'checking' || status.state === 'not-available') {
return status.userInitiated === true
}
return true
}
/**
* Every overlay and modal hosted at the App root, in a fixed sibling order so stacking stays
* stable. Each is gated so its chunk is only fetched once the surface can actually appear.
*/
export function AppRootSurfaces(props: {
floatingWorkspace: FloatingWorkspacePanelState
onboardingGate: OnboardingGate
}): React.JSX.Element {
const { floatingWorkspace, onboardingGate } = props
const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts()
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const settings = useAppStore((s) => s.settings)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const petVisible = useAppStore((s) => s.petVisible)
const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true)
const dictationState = useAppStore((s) => s.dictationState)
const updateStatus = useAppStore((s) => s.updateStatus)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0)
const shouldMountSetupGuideTelemetryObserver = persistedUIReady
const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus)
const shouldMountDictationController =
settings?.voice?.enabled === true || dictationState !== 'idle'
const renderPetOverlay = shouldRenderPetOverlay({ persistedUIReady, petEnabled, petVisible })
return (
<>
{floatingWorkspace.shouldMountPanel ? (
<Suspense fallback={null}>
<OverlayBoundary
boundaryId="overlay.floating-workspace"
resetKey={floatingWorkspace.open}
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
description={translate(
'auto.App.7cbfbf622f',
'Retry the floating workspace or close and reopen it.'
)}
>
<FloatingTerminalPanel
open={floatingWorkspace.open}
onOpenChange={floatingWorkspace.setOpenWithFocus}
tourInteractionSnapshot={floatingWorkspace.tourInteractionSnapshotRef.current}
/>
</OverlayBoundary>
</Suspense>
) : null}
{statusBarVisible ? (
<Suspense
fallback={
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
}
>
<OverlayBoundary
boundaryId="overlay.status-bar"
resetKey={activeView}
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
description={translate(
'auto.App.8a023cea1f',
'Retry the status bar to remount its controls.'
)}
>
<StatusBar floatingTerminalOpen={floatingWorkspace.open} />
</OverlayBoundary>
</Suspense>
) : null}
{/* Why: keep in the entry bundle so a stale/corrupt lazy chunk can't strand users at Create. */}
{activeModal === 'new-workspace-composer' ? (
<ModalBoundary boundaryId="modal.new-workspace-composer" resetKey>
<NewWorkspaceComposerModal />
</ModalBoundary>
) : null}
<Suspense fallback={null}>
{shouldMountAddRepoDialog ? (
<ModalBoundary boundaryId="modal.add-repo" resetKey={activeModal === 'add-repo'}>
<AddRepoDialog />
</ModalBoundary>
) : null}
{/* Why: Settings can start Add Project without Sidebar, so its handoff dialogs must share the root host. */}
{activeModal === 'confirm-non-git-folder' ? (
<ModalBoundary boundaryId="modal.confirm-non-git-folder" resetKey>
<NonGitFolderDialog />
</ModalBoundary>
) : null}
{activeModal === 'confirm-add-project-from-folder' ? (
<ModalBoundary boundaryId="modal.confirm-add-project-from-folder" resetKey>
<AddProjectFromFolderDialog />
</ModalBoundary>
) : null}
{activeModal === 'project-added' ? (
<ModalBoundary boundaryId="modal.project-added" resetKey>
<ProjectAddedDialog />
</ModalBoundary>
) : null}
</Suspense>
{/* Why: root overlays can render Radix <Tooltip>s; keep inside the shared provider so lazy surfaces mount from any entry point. */}
<Suspense fallback={null}>
{mountedLazyModalIds.has('workspace-cleanup') ? (
<ModalBoundary
boundaryId="modal.workspace-cleanup"
resetKey={activeModal === 'workspace-cleanup'}
>
<WorkspaceCleanupDialog />
</ModalBoundary>
) : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? (
<ModalBoundary boundaryId="modal.quick-open" resetKey={activeModal === 'quick-open'}>
<QuickOpen />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('worktree-palette') ? (
<ModalBoundary
boundaryId="modal.worktree-palette"
resetKey={activeModal === 'worktree-palette'}
>
<WorktreeJumpPalette />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('setup-guide') ? (
<ModalBoundary boundaryId="modal.setup-guide" resetKey={activeModal === 'setup-guide'}>
<SetupGuideModal />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('feature-wall') ? (
<ModalBoundary boundaryId="modal.feature-wall" resetKey={activeModal === 'feature-wall'}>
<FeatureWallModal />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('feature-tips') ? (
<ModalBoundary boundaryId="modal.feature-tips" resetKey={activeModal === 'feature-tips'}>
<FeatureTipsModal />
</ModalBoundary>
) : null}
</Suspense>
{shouldMountSetupGuideTelemetryObserver ? (
<Suspense fallback={null}>
<SetupGuideTelemetryObserver />
</Suspense>
) : null}
{activeContextualTourId !== null ? (
<Suspense fallback={null}>
<ContextualTourOverlay />
</Suspense>
) : null}
{/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */}
{renderPetOverlay ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.pet" resetKey={petVisible}>
<PetOverlay />
</OverlayBoundary>
</Suspense>
) : null}
{shouldMountUpdateCard ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.update-card" resetKey={activeView}>
<UpdateCard />
</OverlayBoundary>
</Suspense>
) : null}
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
<StarNagCard />
</OverlayBoundary>
<OverlayBoundary boundaryId="overlay.star-nag-toast" resetKey={activeView}>
<StarNagToastHost />
</OverlayBoundary>
<StarNagAgentValueMomentObserver />
{/* Why: mount at App root to render once per session; internal cohort gate limits it to pre-telemetry users — see telemetry-plan.md §First-launch experience. */}
<OverlayBoundary
boundaryId="overlay.telemetry-first-launch"
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
>
<TelemetryFirstLaunchSurface />
</OverlayBoundary>
<OverlayBoundary boundaryId="overlay.zoom" resetKey={activeView}>
<ZoomOverlay />
</OverlayBoundary>
<Suspense fallback={null}>
{activeModal === 'delete-worktree' ? (
<ModalBoundary boundaryId="modal.delete-worktree" resetKey>
<DeleteWorktreeDialog />
</ModalBoundary>
) : null}
{activeModal === 'preserved-branch-review' ? (
<ModalBoundary boundaryId="modal.preserved-branch-review" resetKey>
<PreservedBranchBatchReviewModal />
</ModalBoundary>
) : null}
</Suspense>
{hasSshCredentialRequest ? (
<Suspense fallback={null}>
<ModalBoundary boundaryId="modal.ssh-passphrase" resetKey={activeModal}>
<SshPassphraseDialog />
</ModalBoundary>
</Suspense>
) : null}
<ModalBoundary boundaryId="modal.markdown-template-picker" resetKey={activeModal}>
<MarkdownTemplatePicker />
</ModalBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.crash-report"
surface="modal"
reportAsCrash={false}
resetKey={activeModal}
compact
title={translate('auto.App.722d03aa62', 'The crash report dialog hit an error.')}
description={translate(
'auto.App.acd66311dc',
'Use the Help menu after retrying if you still need diagnostics.'
)}
>
<CrashReportDialog />
</RecoverableRenderErrorBoundary>
{onboardingGate.onboarding && onboardingGate.shouldRender ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="modal.onboarding"
surface="modal"
title={translate('auto.App.f02d37278a', 'Onboarding hit an error.')}
description={translate(
'auto.App.221a95ba38',
'Retry onboarding or close it and continue in the app.'
)}
>
<OnboardingFlow
onboarding={onboardingGate.onboarding}
onOnboardingChange={onboardingGate.setOnboarding}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{shouldMountDictationController ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.dictation" resetKey={activeView}>
<DictationController />
</OverlayBoundary>
</Suspense>
) : null}
<OverlayBoundary boundaryId="overlay.recent-tab-switcher" resetKey={activeView}>
<RecentTabSwitcher />
</OverlayBoundary>
{/* Why: hosts a live terminal pane needing the link-routing preference context; mounting outside crashes it. */}
<OverlayBoundary boundaryId="overlay.skill-freshness-update-dialog">
<SkillFreshnessUpdateDialog />
</OverlayBoundary>
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.remote-server-update-dialog">
<RemoteServerUpdateDialog />
</OverlayBoundary>
</Suspense>
</>
)
}
@@ -0,0 +1,250 @@
import { Suspense, useRef } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import Sidebar from '../components/Sidebar'
import RightSidebar from '../components/right-sidebar'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'
import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor'
import { TitlebarLeftControls } from './TitlebarLeftControls'
import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
const Landing = lazy(() => import('../components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('../components/worktree-creation/WorktreeCreationPanel')
)
const TaskPage = lazy(() => import('../components/TaskPage'))
const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('../components/settings/Settings'))
const ArtifactsPage = lazy(() => import('../components/artifacts/ArtifactsPage'))
const WorkspaceSpacePage = lazy(() => import('../components/workspace-space/WorkspaceSpacePage'))
const MobilePage = lazy(() => import('../components/mobile/MobilePage'))
const Terminal = lazy(() => import('../components/Terminal'))
type WorktreeSidebarScrollRefs = {
scrollOffsetRef: React.MutableRefObject<number>
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
}
function WorktreeSidebar({
layout,
scrollRefs
}: {
layout: AppChromeLayout
scrollRefs: WorktreeSidebarScrollRefs
}): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={layout.activeView}
title={translate('auto.App.1468601e7b', 'The workspace list hit an error.')}
description={
layout.leftTitlebarChromeLayout.shouldMount
? translate(
'auto.App.bdc71dddc9',
'The active workspace remains open. Retry the list or switch views.'
)
: translate(
'auto.App.cba0fafda5',
'The active page remains open. Retry the list or switch views.'
)
}
>
<Sidebar
worktreeScrollOffsetRef={scrollRefs.scrollOffsetRef}
worktreeScrollAnchorRef={scrollRefs.scrollAnchorRef}
/>
</RecoverableRenderErrorBoundary>
)
}
function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
return (
<>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'artifacts' ? <ArtifactsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && creationLayoutActive && activePendingCreationId ? (
<WorktreeCreationPanel
creationId={activePendingCreationId}
reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating}
/>
) : null}
{activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? <Landing /> : null}
</>
)
}
/** The left sidebar + titlebar + page/workbench content area + right sidebar. */
export function AppWorkspaceShell(props: {
layout: AppChromeLayout
floatingWorkspace: FloatingWorkspacePanelState
}): React.JSX.Element {
const { layout, floatingWorkspace } = props
const titlebarLeftControls = <TitlebarLeftControls layout={layout} />
const titlebarMainStrip = <TitlebarMainStrip layout={layout} />
// Why: keep virtualized scroll memory above the sidebar's workspace/landing remount so the left list doesn't restart at scrollTop 0.
const scrollOffsetRef = useRef(0)
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef }
return (
// Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake.
<RecoverableRenderErrorBoundary
boundaryId="app.workspace-shell"
surface="workspace-shell"
resetKey={layout.activeView}
title={translate('auto.App.df1d56bf87', 'The workspace shell hit an error.')}
description={translate(
'auto.App.8504ddf267',
'The app is still running. Retry the shell or use the menu to report the crash details.'
)}
>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: keep the non-workspace titlebar inside this left+center wrapper so it doesn't span over the right-sidebar column. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: workspace view drops the full-width titlebar so tab groups extend to the top; settings/landing/tasks keep it. */}
{!layout.leftTitlebarChromeLayout.shouldMount ? (
<div className="titlebar">
<div className="flex items-center shrink-0 mr-2">{titlebarLeftControls}</div>
{titlebarMainStrip}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{layout.showSidebar ? (
layout.leftTitlebarChromeLayout.shouldMount ? (
/* Why: when the sidebar is collapsed, take this titlebar-height header out of flex layout so the terminal/editor reclaim the left edge. */
<div
className={`flex min-h-0 flex-col shrink-0${layout.sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: floating titlebar-left occludes the center column's border-l seam; border-r restores that line, w-max sizes it to its own controls.
className={`titlebar-left${
layout.leftTitlebarChromeLayout.isFloating
? ' titlebar-left-floating absolute top-0 left-0 z-10 w-max border-r border-border'
: ''
}`}
style={{
// Why: custom sidebar appearances are scoped to the sidebar root; mirror those vars onto the header in the same left-column panel.
...(layout.sidebarOpen ? layout.leftSidebarStyle : undefined),
// Why: size from the wrapper's live width so the header tracks in-flight drag resizes (persisted to Zustand only on mouseup).
width: layout.sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
{/* Why: flex-1/min-h-0 slot needed under the fixed 36px header, else the sidebar collapses to content height and loses its scroll viewport. */}
<div className="flex min-h-0 flex-1">
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
</div>
</div>
) : (
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
)
) : null}
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */}
{layout.stackedSidebarOpen && layout.activeView !== 'automations' ? (
<div className="titlebar">{titlebarMainStrip}</div>
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: match the RightSidebar header's 36px/top-0 so the toggle's vertical center is identical open vs closed — else the icon jitters. */}
{layout.workspaceChromeActive && !layout.rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: --window-controls-width keeps the toggle clear of the fixed window-controls overlay (138px on custom chrome, 0px otherwise); no internal spacer — one would cover the pane-actions Ellipsis button with an unclickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{layout.showRightSidebarControls ? <RightSidebarToggle /> : null}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{layout.shouldMountTerminalWorkbench ? (
<div
className={
layout.terminalWorkbenchVisible
? 'flex flex-1 min-w-0 min-h-0'
: 'hidden flex-1 min-w-0 min-h-0'
}
>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${layout.activeView}`}
surface="page"
resetKey={layout.activeView}
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
description={translate(
'auto.App.03a14f6b5b',
'Retry the page or navigate to another Orca surface.'
)}
>
<ActivePage layout={layout} />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
{floatingWorkspace.showToggleButton ? (
<FloatingTerminalToggleButton
open={floatingWorkspace.open}
onToggle={() => floatingWorkspace.setOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
</div>
{/* Why: keep the shell mounted for layout stability (heavy panels disconnect while closed); unmount on the distraction-free tasks view. */}
{layout.showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={
layout.rightSidebarTab === 'explorer'
? `${layout.rightSidebarTab}:${layout.rightSidebarExplorerView}`
: layout.rightSidebarTab
}
title={translate('auto.App.ed6b168d00', 'The right sidebar hit an error.')}
description={translate(
'auto.App.8d1e160ed1',
'Retry the sidebar or switch tabs to reload this surface.'
)}
>
<RightSidebar />
</RecoverableRenderErrorBoundary>
) : null}
</div>
</RecoverableRenderErrorBoundary>
)
}
@@ -0,0 +1,153 @@
import { ArrowLeft, ArrowRight, MoreHorizontal, PanelLeft } from 'lucide-react'
import logo from '../../../../resources/logo.svg'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
import {
canGoBackWorktreeHistory,
canGoForwardWorktreeHistory
} from '@/store/slices/worktree-nav-history'
import { useShortcutLabel } from '../hooks/useShortcutLabel'
import { useAppStore } from '../store'
import { hasCustomTitleBar, isMac } from './app-window-chrome'
import type { AppChromeLayout } from './use-app-chrome-layout'
/**
* The titlebar's left cluster: window chrome padding, app name, sidebar toggle, and the
* worktree back/forward pair. Shared by the full-width titlebar and the sidebar-width left
* header so the agent badge popover isn't duplicated.
*/
export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
const updateSettings = useAppStore((s) => s.updateSettings)
const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory)
const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory)
const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle')
const historyBackShortcutLabel = useShortcutLabel('worktree.history.back')
const historyForwardShortcutLabel = useShortcutLabel('worktree.history.forward')
return (
// Why: measure the ENTIRE row so TabGroupPanel's collapse spacer reserves enough width; measuring only the inner cluster left back/forward over the first tab.
// Why: collapsed mode floats in a w-0 wrapper; w-max stops Windows Chromium from shrinking the app name to one glyph.
<div
ref={layout.titlebarLeftControlsRef}
className={`flex h-full shrink-0 items-center${
layout.leftTitlebarChromeLayout.isFloating ? ' w-max' : ' w-full'
}`}
>
<div className="flex h-full items-center">
{isMac && !layout.isFullScreen ? (
<div className="titlebar-traffic-light-pad" />
) : hasCustomTitleBar ? (
/* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */
<>
<img src={logo} alt="" aria-hidden className="titlebar-logo" />
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
aria-label={translate('auto.App.8b0b8eb54f', 'Application menu')}
onClick={() => window.api.ui.popupMenu()}
>
<MoreHorizontal size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.8b0b8eb54f', 'Application menu')}
</TooltipContent>
</Tooltip>
</>
) : (
<div className="pl-2" />
)}
{layout.showSidebar && !hasCustomTitleBar && layout.showTitlebarAppName && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="titlebar-app-name"
aria-label={translate('auto.App.5096cbbc86', 'Orca')}
>
<span className="titlebar-app-name-main">
{translate('auto.App.5096cbbc86', 'Orca')}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
void updateSettings({ showTitlebarAppName: false })
}}
>
{translate('auto.App.e81217c1b7', 'Hide App Name')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{layout.showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={toggleSidebar}
aria-label={translate('auto.App.e4b9e7dff7', 'Toggle sidebar')}
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.ce37cf5279', 'Toggle sidebar ({{value0}})', {
value0: leftSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Why: Back/Forward span worktree + page history, so show the cluster wherever the shortcut is live (hidden in Settings/non-stack views). */}
{shouldShowWorktreeHistoryControls(layout.activeView) && (
// With the sidebar collapsed the header shrink-wraps and ml-auto has no spare width, so keep a fixed gutter before Back.
<div className="ml-auto mr-3 flex items-center pl-2">
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goBackWorktree()}
disabled={!canGoBackWorktree}
aria-label={translate('auto.App.064bd07810', 'Go back')}
>
<ArrowLeft size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.fe21e8f6f5', 'Go back ({{value0}})', {
value0: historyBackShortcutLabel
})}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goForwardWorktree()}
disabled={!canGoForwardWorktree}
aria-label={translate('auto.App.cf9099fe98', 'Go forward')}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.f7aa73e785', 'Go forward ({{value0}})', {
value0: historyForwardShortcutLabel
})}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
)
}
@@ -0,0 +1,80 @@
import { Minimize2, PanelRight } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { ActivityTitlebarControls } from '../components/activity/ActivityTitlebarControls'
import { useShortcutLabel } from '../hooks/useShortcutLabel'
import { useAppStore } from '../store'
import { hasCustomTitleBar } from './app-window-chrome'
import type { AppChromeLayout } from './use-app-chrome-layout'
export function RightSidebarToggle(): React.JSX.Element {
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle')
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle mr-2"
onClick={toggleRightSidebar}
aria-label={translate('auto.App.9e0b441a91', 'Toggle right sidebar')}
>
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
value0: rightSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
)
}
/** The titlebar's center/right strip: the tab-strip portal slot and the trailing chrome buttons. */
export function TitlebarMainStrip({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const handleToggleExpand = (): void => {
if (!layout.effectiveActiveTabId) {
return
}
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: layout.effectiveActiveTabId }
})
)
}
return (
<>
{layout.activeView === 'activity' ? (
<ActivityTitlebarControls />
) : layout.creationLayoutActive ? null : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${!layout.workspaceChromeActive ? ' invisible pointer-events-none' : ''}`}
/>
)}
{layout.showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label={translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
disabled={!layout.activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
</TooltipContent>
</Tooltip>
)}
{/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */}
{layout.showRightSidebarControls && !layout.rightSidebarOpen ? <RightSidebarToggle /> : null}
{/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */}
{hasCustomTitleBar && <div className="window-controls-titlebar-spacer" />}
</>
)
}
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { translate } from '@/i18n/i18n'
// Why: Windows and Linux both remove the native title bar, so we render our own min/max/close buttons (Fluent/Win11-style SVGs).
export function WindowControls(): React.JSX.Element {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
// Why: maximize-changed only fires on transitions; seed from main on mount so a startup-maximized window shows the right icon.
let cancelled = false
void window.api.ui.isMaximized().then((value) => {
if (!cancelled) {
setMaximized(value)
}
})
const unsubscribe = window.api.ui.onMaximizeChanged(setMaximized)
return () => {
cancelled = true
unsubscribe()
}
}, [])
return (
<div className="window-controls">
<button
className="window-controls-btn"
aria-label={translate('auto.App.bbb7f90669', 'Minimize')}
onClick={() => window.api.ui.minimize()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 5h10v1H0z" fill="currentColor" />
</svg>
</button>
<button
className="window-controls-btn"
aria-label={
maximized
? translate('auto.App.66f0a552e5', 'Restore')
: translate('auto.App.c9d6f98459', 'Maximize')
}
onClick={() => window.api.ui.maximize()}
>
{maximized ? (
// Restore icon (two overlapping squares)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M2 0v2H0v8h8V8h2V0H2zm6 9H1V3h7v6zM9 7H8V2H3V1h6v6z" fill="currentColor" />
</svg>
) : (
// Maximize icon (single square outline)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 0v10h10V0H0zm9 9H1V1h8v8z" fill="currentColor" />
</svg>
)}
</button>
<button
className="window-controls-btn window-controls-close"
aria-label={translate('auto.App.e960d18540', 'Close')}
// Why: route close through main so the 'close' event fires the terminal-running confirmation guard; window.close() is unreliable in sandboxed renderers.
onClick={() => window.api.ui.requestClose()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 0L0 1l4 4-4 4 1 1 4-4 4 4 1-1-4-4 4-4-1-1-4 4-4-4z" fill="currentColor" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,248 @@
import { useShallow } from 'zustand/react/shallow'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions'
import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel'
import { useAppStore } from '../store'
import type { usePluginCommands } from '@/store/plugin-panels'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type {
KeybindingActionId,
KeybindingContext,
PhysicalModifierToken
} from '../../../shared/keybindings'
import { shortcutPlatform } from './app-window-chrome'
type AppStoreState = ReturnType<typeof useAppStore.getState>
// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so one dispatch path serves both; KeybindingInput-compatible.
export type ShortcutDispatchInput = {
key?: string
code?: string
altKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
doubleTapModifier?: PhysicalModifierToken
target: EventTarget | null
defaultPrevented: boolean
preventDefault: () => void
}
export type AppShortcutActions = ReturnType<typeof useAppShortcutActions>
export type AppShortcutState = {
activeView: AppStoreState['activeView']
activeWorktreeId: AppStoreState['activeWorktreeId']
actions: AppShortcutActions
creationLayoutActive: boolean
floatingTerminalEnabled: boolean
floatingTerminalOpen: boolean
floatingVisibleTabCount: number
keybindings: AppStoreState['keybindings']
openFloatingWorkspaceMaximized: () => void
pluginCommands: ReturnType<typeof usePluginCommands>
setFloatingTerminalOpen: (open: boolean) => void
terminalShortcutPolicy: NonNullable<AppStoreState['settings']>['terminalShortcutPolicy']
workspaceChromeActive: boolean
}
export function useAppShortcutActions() {
// Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action.
return useAppStore(
useShallow((s) => ({
toggleSidebar: s.toggleSidebar,
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
showRightSidebarFiles: s.showRightSidebarFiles,
showRightSidebarSearch: s.showRightSidebarSearch,
openDiffNotesSendMenuForActiveWorktree: s.openDiffNotesSendMenuForActiveWorktree
}))
)
}
export function getKeybindingContext(target: EventTarget | null): KeybindingContext {
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
? 'terminal'
: 'app'
}
/**
* Builds the app-level handlers for every keybindable action. Each returns whether it claimed
* the chord, so an unavailable surface (settings view, closed floating panel) falls through to
* the terminal or the next handler instead of silently no-opping.
*
* `input` is absent when a command is invoked from the palette/menu rather than a key event.
*/
export function createAppCommandHandlers(
state: AppShortcutState,
input?: ShortcutDispatchInput,
keybindingContext: KeybindingContext = 'app'
): Map<KeybindingActionId, () => boolean> {
const {
activeView,
activeWorktreeId,
actions,
creationLayoutActive,
floatingTerminalEnabled,
floatingTerminalOpen,
keybindings,
openFloatingWorkspaceMaximized,
terminalShortcutPolicy,
workspaceChromeActive
} = state
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
const claim = (actionId: KeybindingActionId, run: () => void): boolean => {
input?.preventDefault()
if (
input &&
keybindingContext === 'terminal' &&
(terminalShortcutPolicy ?? 'orca-first') === 'orca-first'
) {
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
run()
return true
}
const revealRightSidebarTab = (
actionId: KeybindingActionId,
tab: Parameters<AppShortcutActions['setRightSidebarTab']>[0]
): boolean =>
canRevealRightSidebar
? claim(actionId, () => {
actions.setRightSidebarTab(tab)
actions.setRightSidebarOpen(true)
})
: false
return new Map<KeybindingActionId, () => boolean>([
[
'worktree.history.back',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree())
}
],
[
'worktree.history.forward',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.forward', () => useAppStore.getState().goForwardWorktree())
}
],
['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())],
[
'sidebar.sleepingWorkspaces.toggle',
() =>
claim('sidebar.sleepingWorkspaces.toggle', () => {
const store = useAppStore.getState()
const nextShowSleeping = !store.showSleepingWorkspaces
store.setShowSleepingWorkspaces(nextShowSleeping)
if (nextShowSleeping) {
store.setSidebarOpen(true)
}
})
],
[
'floatingWorkspace.maximize',
() => {
if (floatingTerminalOpen || !floatingTerminalEnabled) {
return false
}
return claim('floatingWorkspace.maximize', openFloatingWorkspaceMaximized)
}
],
[
'tab.rename',
() => {
const store = useAppStore.getState()
if (
!workspaceChromeActive ||
floatingWorkspaceFocused ||
store.activeTabType !== 'terminal' ||
!store.activeTabId
) {
return false
}
return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!))
}
],
[
'workspace.rename',
() => {
if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) {
return false
}
return claim('workspace.rename', () => {
useAppStore.getState().setSidebarOpen(true)
requestScrollToCurrentWorkspaceRevealAndRename()
})
}
],
[
'workspace.openBoard',
() => {
if (activeView === 'settings') {
return false
}
return claim('workspace.openBoard', () => {
useAppStore.getState().setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT))
})
}
],
[
'view.tasks',
() => {
const store = useAppStore.getState()
if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) {
return false
}
return claim('view.tasks', () => store.openTaskPage())
}
],
[
'sidebar.right.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.right.toggle', () => actions.toggleRightSidebar())
: false
],
[
'sidebar.explorer.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles())
: false
],
[
'sidebar.search.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch())
: false
],
[
'sidebar.sourceControl.toggle',
() =>
// Why: the terminal's own find bar owns this chord while it is open.
document.querySelector('[data-terminal-search-root]')
? false
: revealRightSidebarTab('sidebar.sourceControl.toggle', 'source-control')
],
['sidebar.checks.toggle', () => revealRightSidebarTab('sidebar.checks.toggle', 'checks')],
['sidebar.ports.toggle', () => revealRightSidebarTab('sidebar.ports.toggle', 'ports')]
])
}
@@ -0,0 +1,13 @@
import {
isPairedWebClientWindow,
shouldRenderDesktopWindowChrome
} from '@/lib/desktop-window-chrome'
export const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
export const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
// Why: Windows and Linux remove the native title bar so the renderer draws its own chrome; paired web clients run in a browser tab and must not.
export const hasCustomTitleBar = shouldRenderDesktopWindowChrome({
platform: shortcutPlatform,
isWebClient: isPairedWebClientWindow()
})
@@ -0,0 +1,147 @@
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome'
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
import { useAppStore } from '../store'
import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector'
import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark'
import {
hasRequestedBackgroundTerminalWorktreeMount,
subscribeBackgroundTerminalWorktreeMountRequests
} from '../components/terminal/background-terminal-worktree-mount'
export type AppChromeLayout = ReturnType<typeof useAppChromeLayout>
/**
* Derives the App shell's layout decisions — which titlebar variant mounts, whether the
* workspace owns the tab strip, and which surfaces stay mounted — from store state.
*/
export function useAppChromeLayout() {
const activeView = useAppStore((s) => s.activeView)
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView)
const isFullScreen = useAppStore((s) => s.isFullScreen)
const settings = useAppStore((s) => s.settings)
const activePendingCreationId = useAppStore((s) => s.activePendingCreationId)
// Why: the creation surface owns the tab strip from the first pending frame; gating on the delayed loader flag swapped the tab bar mid-create.
const activePendingCreationExists = useAppStore(
(s) =>
s.activePendingCreationId !== null &&
s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined
)
const {
activeWorktreeId,
tabCount,
effectiveActiveTabId,
activeTabCanExpand,
effectiveActiveTabExpanded
} = useAppStore(useShallow(selectActiveTerminalChromeState))
const backgroundTerminalMountRequested = useSyncExternalStore(
subscribeBackgroundTerminalWorktreeMountRequests,
hasRequestedBackgroundTerminalWorktreeMount,
hasRequestedBackgroundTerminalWorktreeMount
)
const systemPrefersDark = useSystemPrefersDark()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
) as React.CSSProperties | undefined
const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested
// Why a latch in state, not a ref: the write has to be visible to the next render, and a
// render-phase ref write would also survive a render React discards. Setting state during
// render is the supported way to derive it, and the `||` below keeps this render correct.
const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)
if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) {
setHasMountedTerminalWorkbench(true)
}
// Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null.
const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench
// Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome.
const creationLayoutActive = shouldShowWorktreeCreationSurface({
activeView,
activePendingCreationId,
hasActivePendingCreation: activePendingCreationExists
})
const workspaceChromeActive =
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
const hasTabBar = tabCount >= 2
// Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there.
const showSidebar =
activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
// Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it).
const stackedSidebarOpen =
!workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen
// Visible creation keeps only the top-left window chrome; tabs and right-sidebar chrome stay gated by workspaceChromeActive.
const leftTitlebarChromeLayout = resolveLeftTitlebarChromeLayout({
workspaceChromeActive,
stackedSidebarOpen,
creationLayoutActive,
sidebarOpen
})
// Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient.
useLayoutEffect(() => {
window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT))
}, [sidebarOpen, rightSidebarOpen])
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
useLayoutEffect(() => {
const controls = titlebarLeftControlsRef.current
if (!controls) {
return
}
const updateWidth = (): void => {
setCollapsedSidebarHeaderWidth(controls.getBoundingClientRect().width)
}
updateWidth()
const observer = new ResizeObserver(() => {
updateWidth()
})
observer.observe(controls)
return () => observer.disconnect()
}, [
isFullScreen,
settings?.showTitlebarAppName,
showSidebar,
leftTitlebarChromeLayout.isFloating,
sidebarOpen
])
return {
activeView,
activeWorktreeId,
activePendingCreationId,
activeTabCanExpand,
effectiveActiveTabId,
collapsedSidebarHeaderWidth,
creationLayoutActive,
isFullScreen,
leftSidebarStyle,
leftTitlebarChromeLayout,
rightSidebarExplorerView,
rightSidebarOpen,
rightSidebarTab,
shouldMountTerminalWorkbench,
showSidebar,
// Full-page navigation surfaces own the whole content area, so suppress right-sidebar controls.
showRightSidebarControls: !creationLayoutActive && canShowRightSidebarForView(activeView),
showTitlebarAppName: settings?.showTitlebarAppName !== false,
showTitlebarExpandButton: workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded,
sidebarOpen,
stackedSidebarOpen,
// Why: the workbench stays mounted while hidden, so visibility tracks the same condition separately.
terminalWorkbenchVisible: workspaceChromeActive,
titlebarLeftControlsRef,
workspaceChromeActive
}
}
@@ -0,0 +1,198 @@
import { useEffect } from 'react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../store'
import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents'
import { createSessionWriteSubscriber } from '../lib/session-write-subscriber'
import { buildActiveViewUnloadPatch } from '../lib/active-view-persist'
import {
isIntentionalAppRestartInProgress,
registerUpdaterBeforeUnloadBypass
} from '../lib/updater-beforeunload'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '../lib/workspace-session'
import {
buildWorkspaceSessionHostSnapshots,
patchWorkspaceSessionByHost
} from '../lib/workspace-session-host-persistence'
import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard
} from '../lib/shutdown-checkpoint-guard'
import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures'
import { dispatchWindowCloseRequest } from '../components/window-close-request-coordinator'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
} from '../../../shared/updater-renderer-events'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
import type { RemoteWorkspacePatchResult } from '../../../shared/remote-workspace-types'
// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible.
const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000
function applyRemoteWorkspacePatchStatus(
targetId: string,
result: RemoteWorkspacePatchResult
): void {
const store = useAppStore.getState()
if (result.ok) {
store.setRemoteWorkspaceSyncStatus(targetId, {
phase: 'synced',
direction: 'push',
revision: result.snapshot.revision,
updatedAt: result.snapshot.updatedAt,
lastSyncedAt: Date.now(),
message: translate('auto.App.332dbfa497', 'Workspace uploaded')
})
return
}
store.setRemoteWorkspaceSyncStatus(targetId, {
phase: result.reason === 'stale-revision' ? 'conflict' : 'offline',
direction: 'push',
revision: result.snapshot?.revision,
updatedAt: result.snapshot?.updatedAt,
lastSyncedAt: Date.now(),
message:
result.message ??
(result.reason === 'stale-revision'
? translate(
'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice',
'Workspace changed on another device'
)
: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable'))
})
}
/**
* Writes durable renderer session state to disk: the debounced per-host writer, the remote
* workspace upload chain, and the synchronous shutdown checkpoint.
*/
export function useAppSessionPersistence(): void {
useEffect(() => registerUpdaterBeforeUnloadBypass(), [])
// Why: session persistence only writes to disk; a Zustand subscribe() outside React drops ~15 render-cycle subscriptions and their re-renders on every tab/file/browser change.
useEffect(() => {
return createSessionWriteSubscriber({
store: useAppStore,
shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(),
persist: ({ patch }) => {
const state = useAppStore.getState()
// Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering.
const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state)
void localWrite
const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter(
(targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict'
)
if (hydratedTargetIds.length > 0) {
void localWrite
.then(() => window.api.remoteWorkspace?.setForConnectedTargets({ hydratedTargetIds }))
.then((results) => {
for (const { targetId, result } of results ?? []) {
applyRemoteWorkspacePatchStatus(targetId, result)
}
})
.catch((err) => {
for (const targetId of hydratedTargetIds) {
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
phase: 'error',
direction: 'push',
message: err instanceof Error ? err.message : 'Workspace upload failed'
})
}
})
}
}
})
}, [])
// On shutdown, capture terminal scrollback buffers and flush all durable
// renderer state through one synchronous main-process checkpoint.
useEffect(() => {
// Why: beforeunload fires twice during a manual quit — once from the
// synthetic dispatch in the onWindowCloseRequested handler (captures
// good data while TerminalPanes are still mounted), and again from the
// native window close triggered by confirmWindowClose(). Between these
// two firings, PTY exit events can arrive and unmount TerminalPanes,
// emptying shutdownBufferCaptures. The guard prevents the second call
// from overwriting the good session data with an empty snapshot.
const shutdownCheckpoint = createShutdownCheckpointGuard(() => {
const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState())
if (shouldCaptureSession) {
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
}
}
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions('quit')
}
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The earlier read is only for the
// gating flags and would miss those updates.
const freshState = useAppStore.getState()
let sessionSnapshots: ReturnType<typeof buildWorkspaceSessionHostSnapshots> = []
try {
sessionSnapshots = shouldCaptureSession
? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)
: []
} catch (error) {
// Why: dirty drafts exist only in the full session snapshot.
if (
!isIntentionalAppRestartInProgress() ||
freshState.openFiles.some((file) => file.isDirty)
) {
throw error
}
console.error('[app] Full renderer session snapshot failed; using durable session', error)
window.api.app.stageBeforeUnloadSync({
sessions: [],
ui: buildActiveViewUnloadPatch(freshState)
})
return
}
window.api.app.stageBeforeUnloadSync({
sessions: sessionSnapshots,
ui: buildActiveViewUnloadPatch(freshState)
})
})
const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)
window.addEventListener('beforeunload', persistBeforeUnload)
window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
return () => {
window.removeEventListener('beforeunload', persistBeforeUnload)
window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.removeEventListener(
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
shutdownCheckpoint.reset
)
window.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
}
}, [])
// Why: beforeunload never fires on a hard kill (crash, forced update, TerminateProcess), so periodically capture agent session ids (not scrollback) so live agents keep a resume record.
useEffect(() => {
const timer = window.setInterval(() => {
if (!shouldPersistWorkspaceSession(useAppStore.getState())) {
return
}
useAppStore.getState().captureAllSleepingAgentSessions('periodic')
}, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [])
// Why: subscribe at the always-mounted App root — Terminal owns the confirm flow but isn't mounted on the landing page, so subscribing there left File→Exit / Ctrl+Q with no listener (#5144).
useEffect(() => {
return window.api.ui.onWindowCloseRequested(dispatchWindowCloseRequest)
}, [])
// Why no periodic scrollback save: the old 3-min re-serialize (#461) stalled the main thread for seconds; the out-of-process daemon (#729) is the durable replacement, non-daemon users lose in-session scrollback on unexpected exit.
}
@@ -0,0 +1,49 @@
import { useAppStore } from '../store'
import { useIpcEvents } from '../hooks/useIpcEvents'
import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents'
import { useAutoAckViewedAgent } from '../hooks/useAutoAckViewedAgent'
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
import { useGlobalFileDrop } from '../hooks/useGlobalFileDrop'
import { useAppMenuPaste } from '../hooks/useAppMenuPaste'
import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions'
import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste'
import {
resolvePrimarySelectionMiddleClickPaste,
usePrimarySelectionPaste
} from '../hooks/usePrimarySelectionPaste'
import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerEventsRecovery'
import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling'
import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice'
import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync'
import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers'
/**
* App-level subscriptions that must outlive any individual surface. Each one is here because
* the component that consumes its result unmounts (right sidebar, explorer, terminal) or is
* absent entirely on the landing path.
*/
export function useAppShellServices(): void {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const primarySelectionMiddleClickPaste = useAppStore((s) =>
resolvePrimarySelectionMiddleClickPaste(s.settings?.primarySelectionMiddleClickPaste)
)
useRadixBodyPointerEventsRecovery()
useWebSessionTabsSync()
// Subscribe to IPC push events
useIpcEvents()
useRemoteRuntimeRecoveryTriggers()
useAutomationDispatchEvents()
// Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint.
useGitStatusPolling({ enabled: workspaceSessionReady })
// Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks).
useEditorExternalWatch()
useGlobalFileDrop()
useAutoAckViewedAgent()
useAppMenuPaste()
useAppMenuSelectionActions()
useLargeTextControlPaste()
usePrimarySelectionPaste(primarySelectionMiddleClickPaste)
useOsc52ClipboardDefaultOnNotice(persistedUIReady)
}
@@ -0,0 +1,335 @@
import { useEffect, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler'
import { useAppStore } from '../store'
import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees'
import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence'
import {
collectFolderWorkspaceKeysFromSession,
collectWorktreeHydrationRepoIdsFromSession
} from '../lib/workspace-session-hydration-keys'
import { hydratePersistedUIAfterStartupRead } from '../lib/startup-ui-hydration'
import {
logRendererStartupDiagnostic,
timeRendererStartupStep,
timeRendererStartupSyncStep
} from '../startup/startup-diagnostics'
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
import { getSystemPrefersDark } from '../lib/terminal-theme'
import {
collectTerminalProviderSnapshotPtyIds,
refreshTerminalProviderSnapshotCapabilities
} from '../components/terminal/terminal-provider-snapshot-capability'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { mapWithConcurrency } from '../../../shared/map-with-concurrency'
import type { OnboardingState } from '../../../shared/onboarding-state-types'
async function listRuntimeSessionHostIdsForStartup(): Promise<ExecutionHostId[]> {
try {
return (await window.api.runtimeEnvironments.list()).map((environment) =>
toRuntimeExecutionHostId(environment.id)
)
} catch (err) {
console.warn('Failed to list runtime session hosts for startup:', err)
return []
}
}
function useStartupActions() {
// Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action.
return useAppStore(
useShallow((s) => ({
fetchReposForAllHosts: s.fetchReposForAllHosts,
awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement,
fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts,
fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktrees: s.fetchWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchOrcaProfiles: s.fetchOrcaProfiles,
fetchSettings: s.fetchSettings,
fetchKeybindings: s.fetchKeybindings,
initGitHubCache: s.initGitHubCache,
hydrateWorkspaceSession: s.hydrateWorkspaceSession,
hydrateTabsSession: s.hydrateTabsSession,
hydrateEditorSession: s.hydrateEditorSession,
hydrateBrowserSession: s.hydrateBrowserSession,
fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles,
reconnectPersistedTerminals: s.reconnectPersistedTerminals,
setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets,
setSshConnectionState: s.setSshConnectionState,
hydratePersistedUI: s.hydratePersistedUI,
setHydrationSucceeded: s.setHydrationSucceeded,
pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps,
seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing
}))
)
}
/**
* Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog,
* the workspace session, SSH reconnect, and terminal restoration — then unlocks the session
* writer. A failure anywhere leaves disk state untouched and boots in degraded no-save mode.
*/
export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingState) => void): void {
const actions = useStartupActions()
// Why a ref: the boot chain must not restart if a caller passes a new callback identity.
// Synced in an effect (declared before the chain below, so it lands first on mount) because
// a render-phase write can leak from a render React discards.
const onOnboardingLoadedRef = useRef(onOnboardingLoaded)
useEffect(() => {
onOnboardingLoadedRef.current = onOnboardingLoaded
}, [onOnboardingLoaded])
useEffect(() => installCodexDetachedPaneRestartExecutor(), [])
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
let cancelled = false
// Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs.
const abortController = new AbortController()
// Why (issue #1158): hydrate persisted UI right after ui.get() succeeds; the UI writer is gated only on persistedUIReady, so later default fallback would serialize defaults to disk.
let uiHydrated = false
// Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice.
let reconnectStarted = false
void (async () => {
const startupStartedAt = performance.now()
logRendererStartupDiagnostic('startup-chain-start')
try {
// Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings.
void actions.fetchOrcaProfiles()
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state.
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data.
publishTerminalViewAttributesAtAppStart(
useAppStore.getState().settings,
getSystemPrefersDark()
)
// Why: start keybindings + onboarding now so their IPC overlaps the local catalog scans; await them at their original spots. The .catch marks rejections handled if an earlier await throws first.
// Why: browser session profiles are NOT started early — on a remote runtime the RPC may be unconnected and a failed fetch clears the list.
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
actions.fetchKeybindings()
)
keybindingsPromise.catch(() => {})
const onboardingPromise = timeRendererStartupStep('onboarding-get', () =>
window.api.onboarding.get()
)
onboardingPromise.catch(() => {})
// Why: await ui.get() (not overlap) so persisted view settings hydrate before the local catalog/session steps and first paint reflects them.
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
hydratePersistedUIAfterStartupRead({
persistedUI,
cancelled,
hydratePersistedUI: actions.hydratePersistedUI
})
)
// Why: list-runtime-session-hosts reads no repo state, so overlap it with the repo scan
// instead of paying its IPC round-trip serially before repos. .catch marks rejections handled
// if an earlier await throws first; the value is awaited below and surfaces any error there.
const runtimeHostsPromise = timeRendererStartupStep(
'list-runtime-session-hosts',
listRuntimeSessionHostIdsForStartup
)
runtimeHostsPromise.catch(() => {})
// Why: saved remote runtimes can spend the full connect timeout; load only the local catalog for first paint and refresh remotes after hydration.
await timeRendererStartupStep('fetch-repos-local', () =>
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('repo-catalog-settlement', () =>
actions.awaitLocalRepoCatalogSettlement()
)
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
// so keep this chain ordered while overlapping it with session-scoped hydration.
const localCatalogChain = (async () => {
await timeRendererStartupStep('fetch-project-groups-local', () =>
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('fetch-folder-workspaces-local', () =>
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
)
})()
const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) =>
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionWithRuntimeHostOwners(
window.api.session,
useAppStore.getState().repos,
startupRuntimeHostIds
)
)
)
const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => {
const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession(
sessionRead.session,
sessionRead.runtimeHostIdByWorkspaceSessionKey
)
const hydrationRepoIdSet = new Set(hydrationRepoIds)
const hydrationRepos = useAppStore.getState().repos.filter(
(repo) =>
hydrationRepoIdSet.has(repo.id) &&
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
await timeRendererStartupStep('first-window-services-await', () =>
window.api.app.awaitFirstWindowStartupServices()
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
)
)
return sessionRead
})
// Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward.
const [sessionOutcome, catalogOutcome] = await Promise.allSettled([
hydrationSessionChain,
localCatalogChain
])
if (sessionOutcome.status === 'rejected') {
throw sessionOutcome.reason
}
if (catalogOutcome.status === 'rejected') {
throw catalogOutcome.reason
}
const sessionRead = sessionOutcome.value
await keybindingsPromise
await timeRendererStartupStep('repo-catalog-final-settlement', () =>
actions.awaitLocalRepoCatalogSettlement()
)
if (!cancelled) {
const sessionHydrationOptions = {
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
}
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(sessionRead.session, {
...sessionHydrationOptions,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey
})
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
})
// Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing.
// See docs/cmd-j-empty-query-ordering.md.
timeRendererStartupSyncStep('visit-timestamp-prune', () => {
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
const onboardingState = await onboardingPromise
if (!cancelled) {
onOnboardingLoadedRef.current(onboardingState)
}
// Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs.
// Why: never dial runtime-owned (ephemeral-VM) targets from the renderer — ssh.connect would dispose the runtime layer's live relay session; filter them out here too.
const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter(
(targetId) => !isRuntimeOwnedSshTargetId(targetId)
)
if (connectionIds.length > 0) {
try {
await restoreSshConnectionsForStartup({
connectionIds,
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
publishSshConnectionState: actions.setSshConnectionState
})
} catch (err) {
console.warn('SSH startup reconnect failed:', err)
}
} else {
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => {
return refreshTerminalProviderSnapshotCapabilities(
collectTerminalProviderSnapshotPtyIds(useAppStore.getState())
)
})
reconnectStarted = true
await timeRendererStartupStep('reconnect-terminals', () =>
actions.reconnectPersistedTerminals(abortController.signal)
)
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
// Why here: reconnect just published restored PTY ids; sweeping them now
// re-offers stale Codex panes whose tabs never mount this session.
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
syncZoomCSSVar()
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
void (async () => {
try {
try {
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
})
} catch (err) {
console.warn('Remote startup catalog refresh failed:', err)
}
if (!cancelled) {
try {
await timeRendererStartupStep('remote-worktree-refresh', async () => {
// Why: the full scan is not required for session recovery, so keep it off the startup-critical path.
await actions.fetchAllWorktrees()
// Why: the startup prune only saw session-referenced repos; use the deferred scan's
// authoritative results to drop deleted-worktree visit timestamps that would
// otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept).
actions.pruneLastVisitedTimestamps()
await actions.fetchWorktreeLineage()
})
} catch (err) {
console.warn('Deferred startup worktree refresh failed:', err)
}
}
} finally {
if (!cancelled) {
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
}
}
})()
}
} catch (error) {
await recoverFromDegradedStartup({
error,
uiHydrated,
reconnectStarted,
isCancelled: () => cancelled,
hydratePersistedUI: actions.hydratePersistedUI,
reconnectPersistedTerminals: actions.reconnectPersistedTerminals,
abortSignal: abortController.signal
})
}
void actions.initGitHubCache()
})()
return () => {
cancelled = true
abortController.abort()
}
}, [actions])
}
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { buildAppFontFamily } from '@/lib/app-font-family'
import { applyDocumentTheme } from '../lib/document-theme'
import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph'
import { useAppStore } from '../store'
/** Applies the settings-driven theme and app font to the document root. */
export function useDocumentAppearance(): void {
const settings = useAppStore((s) => s.settings)
useEffect(() => {
if (!settings) {
return
}
if (settings.theme === 'dark') {
applyDocumentTheme('dark')
return undefined
} else if (settings.theme === 'light') {
applyDocumentTheme('light')
return undefined
}
// system
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDocumentTheme('system')
const handler = (): void => {
applyDocumentTheme('system')
// System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish.
scheduleRuntimeGraphSync()
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [settings])
useEffect(() => {
document.documentElement.style.setProperty(
'--app-font-family',
buildAppFontFamily(settings?.appFontFamily)
)
}, [settings?.appFontFamily])
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
} from '@/lib/floating-terminal'
import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot'
import {
persistFloatingTerminalPanelOpen,
readPersistedFloatingTerminalPanelViewState
} from '../components/floating-terminal/floating-terminal-panel-view-state'
import { useAppStore } from '../store'
import { selectFloatingVisibleTabCount } from '../store/selectors'
export type FloatingWorkspacePanelState = ReturnType<typeof useFloatingWorkspacePanel>
/**
* Owns the floating workspace overlay's open state: persistence, the focus it hands back on
* close, and the toggle/disable paths that can flip it from outside React.
*/
export function useFloatingWorkspacePanel() {
// Why restored: leaving the panel closed forces the user to reopen and re-maximize it,
// and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state).
const [open, setOpen] = useState(
() => readPersistedFloatingTerminalPanelViewState()?.open === true
)
const tourInteractionSnapshotRef = useRef<{
wasPreviouslyInteracted?: boolean
persisted?: Promise<void>
recordFeatureInteractionForTour: boolean
} | null>(null)
const enabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true)
// Why tracked separately: the flag reads false while settings are still loading, and a
// false read at boot must not be treated as the user disabling the feature. The store
// initializes `settings` to null (not undefined) - fetchSettings replaces it atomically.
const settingsHydrated = useAppStore((s) => s.settings != null)
const triggerLocation = useAppStore(
(s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button'
)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const visibleTabCount = useAppStore(selectFloatingVisibleTabCount)
// Why: floating workspace is a transient overlay; hotkey minimize returns focus to the surface the user came from.
const returnFocusRef = useRef<HTMLElement | null>(null)
const returnFocusFrameRef = useRef<number | null>(null)
const cancelReturnFocusFrame = useCallback((): void => {
if (returnFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(returnFocusFrameRef.current)
returnFocusFrameRef.current = null
}, [])
const rememberReturnFocus = useCallback((): void => {
const active = document.activeElement
if (!(active instanceof HTMLElement)) {
returnFocusRef.current = null
return
}
if (
active.closest('[data-floating-terminal-panel]') ||
active.closest('[data-floating-terminal-toggle]')
) {
return
}
returnFocusRef.current = active
}, [])
const restoreReturnFocus = useCallback((): void => {
const target = returnFocusRef.current
returnFocusRef.current = null
if (!target || !document.contains(target)) {
return
}
cancelReturnFocusFrame()
returnFocusFrameRef.current = requestAnimationFrame(() => {
returnFocusFrameRef.current = null
if (!document.contains(target)) {
return
}
target.focus({ preventScroll: true })
})
}, [cancelReturnFocusFrame])
const setOpenWithFocus = useCallback(
(nextOpen: SetStateAction<boolean>): void => {
const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(open) : nextOpen
// Why: recordFeatureInteraction updates Zustand subscribers; running it inside the state updater logs a render-phase update warning.
if (resolvedOpen && !open) {
tourInteractionSnapshotRef.current = createFloatingWorkspaceTourInteractionSnapshot(
useAppStore.getState()
)
rememberReturnFocus()
} else if (!resolvedOpen && open) {
restoreReturnFocus()
}
setOpen(resolvedOpen)
// Why gated on the flag: `settings` is undefined until it hydrates, so the
// feature-off effect force-closes the panel on every boot. Persisting there would
// overwrite the user's restored `open` with a value they never chose.
if (enabled) {
persistFloatingTerminalPanelOpen(resolvedOpen)
}
},
[enabled, open, rememberReturnFocus, restoreReturnFocus]
)
const openMaximized = useCallback((): void => {
requestFloatingTerminalOpenMaximized()
setOpenWithFocus(true)
}, [setOpenWithFocus])
useEffect(() => {
const toggleFloatingTerminal = (): void => {
if (enabled) {
setOpenWithFocus((current) => !current)
}
}
window.addEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal)
return () => window.removeEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal)
}, [enabled, setOpenWithFocus])
useEffect(() => {
// Why the hydration gate: this effect fires on every boot while settings are still
// undefined, and closing there discards the restored open state before the real flag
// value arrives. Only a hydrated flag-off is an actual disable.
if (settingsHydrated && !enabled) {
setOpenWithFocus(false)
}
}, [settingsHydrated, enabled, setOpenWithFocus])
return {
cancelReturnFocusFrame,
enabled,
open,
openMaximized,
setOpenWithFocus,
// Why: once the floating workspace owns tabs, keep it mounted while closed so hidden terminal/browser/editor panes retain local state.
shouldMountPanel: enabled && (open || visibleTabCount > 0),
showToggleButton: enabled && (triggerLocation === 'floating-button' || !statusBarVisible),
tourInteractionSnapshotRef,
visibleTabCount
}
}
@@ -0,0 +1,322 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { isEditableTarget } from '../lib/editable-target'
import { getSelectedTextForFileSearch } from '../lib/file-search-selection'
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
import { executePluginCommand } from '@/lib/plugin-command-execution'
import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings'
import {
isFloatingWorkspacePanelFocused,
isFloatingWorkspaceTerminalInputTarget,
matchFloatingWorkspacePanelChord,
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut
} from '@/lib/floating-workspace-terminal-actions'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import {
folderRelativePathToIncludeGlob,
selectedExplorerFolderRelativePath
} from '../components/right-sidebar/file-search-include-pattern'
import { usePluginCommands } from '@/store/plugin-panels'
import { useAppStore } from '../store'
import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingMatchOptions
} from '../../../shared/keybindings'
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/plugin-command-actions'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../../shared/modifier-double-tap-detector'
import { shortcutPlatform } from './app-window-chrome'
import {
createAppCommandHandlers,
getKeybindingContext,
useAppShortcutActions,
type AppShortcutState,
type ShortcutDispatchInput
} from './app-command-handlers'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
/**
* Registers the window-level shortcut listeners and the app command dispatcher.
*
* Window key listeners are global and long-lived: one registration, but the handler reads
* current shortcut state each key event through a ref.
*/
export function useGlobalKeybindings(args: {
layout: AppChromeLayout
floatingWorkspace: FloatingWorkspacePanelState
}): void {
const { layout, floatingWorkspace } = args
const actions = useAppShortcutActions()
const keybindings = useAppStore((s) => s.keybindings)
const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy)
const pluginCommands = usePluginCommands()
const shortcutState: AppShortcutState = {
activeView: layout.activeView,
activeWorktreeId: layout.activeWorktreeId,
actions,
creationLayoutActive: layout.creationLayoutActive,
floatingTerminalEnabled: floatingWorkspace.enabled,
floatingTerminalOpen: floatingWorkspace.open,
floatingVisibleTabCount: floatingWorkspace.visibleTabCount,
keybindings,
openFloatingWorkspaceMaximized: floatingWorkspace.openMaximized,
pluginCommands,
setFloatingTerminalOpen: floatingWorkspace.setOpenWithFocus,
terminalShortcutPolicy,
workspaceChromeActive: layout.workspaceChromeActive
}
const shortcutStateRef = useRef(shortcutState)
// Why useLayoutEffect: the mirror must be current before any key event can read it, and a
// render-phase write would also publish state from a render React discards. Key events are
// discrete, so they always observe the committed value.
useLayoutEffect(() => {
shortcutStateRef.current = shortcutState
})
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
(createAppCommandHandlers(shortcutStateRef.current).get(actionId) ?? (() => false))()
)
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
const state = shortcutStateRef.current
const {
activeView,
activeWorktreeId,
actions,
creationLayoutActive,
floatingTerminalEnabled,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
openFloatingWorkspaceMaximized,
pluginCommands,
setFloatingTerminalOpen,
terminalShortcutPolicy
} = state
// Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act.
if (input.defaultPrevented) {
return
}
// The Settings shortcut recorder captures existing shortcuts, so global handlers must not fire while its button has focus.
if (
input.target instanceof Element &&
input.target.closest('[data-shortcut-recorder-active]') !== null
) {
return
}
const context = getKeybindingContext(input.target)
// Note: some shortcuts are also intercepted in createMainWindow.ts before-input-event (for browser-guest focus); the renderer keeps handlers for local focus.
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy
})
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
if (context !== 'terminal' || (terminalShortcutPolicy ?? 'orca-first') !== 'orca-first') {
return
}
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) {
// With a folder selected in the explorer, Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern with it, not a text search.
const selectedFolderRelativePath =
document.activeElement instanceof Element
? selectedExplorerFolderRelativePath(document.activeElement)
: null
if (selectedFolderRelativePath !== null && activeWorktreeId) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.showRightSidebarSearch({
includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath)
})
return
}
const selectedText = getSelectedTextForFileSearch()
if (selectedText) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.showRightSidebarSearch({ query: selectedText })
return
}
}
// An empty floating workspace has no tab to close, so Cmd/Ctrl+W hides the overlay before other surfaces act.
if (
keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, {
context: 'app'
}) &&
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
floatingTerminalOpen,
floatingVisibleTabCount
})
) {
input.preventDefault()
setFloatingTerminalOpen(false)
return
}
// Floating panel closed → its keydown handler is gone, so honor the maximize chord here by opening it pre-maximized (no-op while it's open).
if (
!floatingTerminalOpen &&
matchShortcut('floatingWorkspace.maximize') &&
floatingTerminalEnabled
) {
input.preventDefault()
openFloatingWorkspaceMaximized()
return
}
// Skip editable surfaces so TipTap's Cmd+B bold works; this renderer-side fallback covers the blur→press IPC race (docs/markdown-cmd-b-bold-design.md).
if (isEditableTarget(input.target)) {
return
}
// Let floating-terminal SSH/tmux control chords reach the terminal (xterm's helper textarea isn't a generic editable target).
if (isFloatingWorkspaceTerminalInputTarget(input.target)) {
return
}
// Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel.
if (isFloatingWorkspacePanelFocused()) {
const floatingMatchOptions: KeybindingMatchOptions = { context, terminalShortcutPolicy }
if (
matchFloatingWorkspacePanelChord(
input,
shortcutPlatform,
null,
keybindings,
floatingMatchOptions
) !== null
) {
return
}
}
// Plugin chords are user-reviewed instructional content. They win over
// built-in defaults only in app focus; terminal/editor/browser handlers
// retain their own shortcut authority.
if (context === 'app') {
const pluginCommand = findPluginCommandForKeybinding(
pluginCommands,
input,
shortcutPlatform,
keybindings,
Boolean(activeWorktreeId)
)
if (pluginCommand) {
input.preventDefault()
void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => {
toast.error(
translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.')
)
})
return
}
}
const handlers = createAppCommandHandlers(state, input, context)
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
return
}
}
// Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes.
if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) {
if (actions.openDiffNotesSendMenuForActiveWorktree()) {
input.preventDefault()
notifyTerminalCapture('sourceControl.sendReviewNotes')
}
}
}
const onKeyDown = (e: KeyboardEvent): void => {
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyDown',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey,
isAutoRepeat: e.repeat
}),
Date.now()
)
if (e.repeat) {
return
}
if (detected) {
// Synthetic input: no key/modifier flags, so only DoubleTap bindings match.
dispatchShortcutInput({
doubleTapModifier: detected.modifier,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
return
}
dispatchShortcutInput({
key: e.key,
code: e.code,
altKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
}
const onKeyUp = (e: KeyboardEvent): void => {
doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyUp',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey
}),
Date.now()
)
}
// Why: a window blur mid-gesture must not leave the detector armed.
const onBlur = (): void => doubleTapDetector.reset()
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
return () => {
unregisterAppCommandDispatcher()
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
}
}, [])
}
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react'
import { resolveMountedLazyModalIds, type LazyModalId } from '../lazy-modal-mount-state'
import { useAppStore } from '../store'
/**
* Tracks which lazy modal chunks are mounted. Modals load on first use and then stay mounted
* so repeat opens preserve state and avoid re-fetch flashes.
*/
export function useLazyModalMounts(): {
mountedLazyModalIds: ReadonlySet<LazyModalId>
shouldMountAddRepoDialog: boolean
} {
const activeModal = useAppStore((s) => s.activeModal)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState<Set<LazyModalId>>(() => new Set())
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false)
const unmountAddRepoDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (activeModal === 'add-repo') {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
setShouldMountAddRepoDialog(true)
return
}
if (shouldMountAddRepoDialog && !unmountAddRepoDialogTimerRef.current) {
// Why: AddRepoDialog's close effect aborts in-flight clone work; keep one closed render before unmounting hidden SSH/remote subscriptions.
unmountAddRepoDialogTimerRef.current = setTimeout(() => {
setShouldMountAddRepoDialog(false)
unmountAddRepoDialogTimerRef.current = null
}, 0)
}
return () => {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
}
}, [activeModal, shouldMountAddRepoDialog])
const resolvedMountedLazyModalIds = resolveMountedLazyModalIds(activeModal, mountedLazyModalIds)
if (resolvedMountedLazyModalIds !== mountedLazyModalIds) {
setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds))
}
return { mountedLazyModalIds: resolvedMountedLazyModalIds, shouldMountAddRepoDialog }
}
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { onOnboardingReopened } from '../components/onboarding/show-onboarding-event'
import { shouldShowOnboarding } from '../components/onboarding/should-show-onboarding'
import {
getFeatureTipsAppOpenDecision,
isCliFeatureTipCompleted
} from '../components/feature-tips/feature-tip-startup-gate'
import {
trackCmdJPaletteFeatureTipShown,
trackOrcaCliFeatureTipShown
} from '../components/feature-tips/feature-tip-telemetry'
import { useAppStore } from '../store'
import type { OnboardingState } from '../../../shared/onboarding-state-types'
export type OnboardingGate = ReturnType<typeof useOnboardingAndFeatureTips>
/**
* Owns first-run education: the onboarding flow's visibility plus the one-per-session
* feature-tip prompt, which stays suppressed until onboarding's state is known.
*/
export function useOnboardingAndFeatureTips() {
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const [onboardingLoaded, setOnboardingLoaded] = useState(false)
const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState<boolean | null>(null)
const promptedThisSessionRef = useRef(false)
const suppressedByOnboardingThisSessionRef = useRef(false)
const activeModal = useAppStore((s) => s.activeModal)
const settings = useAppStore((s) => s.settings)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds)
const featureInteractions = useAppStore((s) => s.featureInteractions)
const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible)
const actions = useAppStore(
useShallow((s) => ({
openModal: s.openModal,
markFeatureTipsSeen: s.markFeatureTipsSeen,
setContextualToursAutoEligible: s.setContextualToursAutoEligible,
setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible
}))
)
const applyStartupOnboardingState = useCallback((state: OnboardingState): void => {
setOnboarding(state)
setOnboardingLoaded(true)
}, [])
useEffect(() => {
return onOnboardingReopened(setOnboarding)
}, [])
useEffect(() => {
// Why: suppress tours until onboarding state is known (null = loading) so a first-run user can't mark a tour seen before onboarding appears.
const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding)
actions.setContextualToursOnboardingVisible(suppressTours)
}, [actions, onboarding, onboardingLoaded])
useEffect(() => {
if (!persistedUIReady || !onboardingLoaded || contextualToursAutoEligible !== null) {
return
}
// Why: rollout targets first-run onboarding users; existing profiles are classified once and never auto-toured.
actions.setContextualToursAutoEligible(shouldShowOnboarding(onboarding))
}, [actions, contextualToursAutoEligible, onboarding, onboardingLoaded, persistedUIReady])
useEffect(() => {
if (!persistedUIReady) {
return
}
let cancelled = false
void window.api.cli
.getInstallStatus()
.then((status) => {
if (cancelled) {
return
}
setFeatureTipCliInstalled(isCliFeatureTipCompleted(status))
})
.catch(() => {
if (!cancelled) {
setFeatureTipCliInstalled(true)
}
})
return () => {
cancelled = true
}
}, [persistedUIReady])
useEffect(() => {
const featureTipsDecision = getFeatureTipsAppOpenDecision({
activeModal,
cliInstalled: featureTipCliInstalled,
featureTipsSeenIds,
featureInteractions,
onboarding,
persistedUIReady,
promptedThisSession: promptedThisSessionRef.current,
settings,
suppressedByOnboardingThisSession: suppressedByOnboardingThisSessionRef.current
})
if (featureTipsDecision.kind === 'suppress-for-onboarding') {
// Why: first-run users should finish onboarding without a second education modal in the same session.
suppressedByOnboardingThisSessionRef.current = true
return
}
if (featureTipsDecision.kind !== 'open') {
return
}
promptedThisSessionRef.current = true
if (featureTipsDecision.tipId === 'orca-cli') {
trackOrcaCliFeatureTipShown('app_open')
} else if (featureTipsDecision.tipId === 'cmd-j-palette') {
trackCmdJPaletteFeatureTipShown('app_open')
}
// Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch.
actions.markFeatureTipsSeen([featureTipsDecision.tipId])
actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId })
}, [
activeModal,
actions,
featureTipCliInstalled,
featureInteractions,
featureTipsSeenIds,
onboarding,
persistedUIReady,
settings
])
return {
applyStartupOnboardingState,
onboarding,
setOnboarding,
shouldRender: onboarding !== null && shouldShowOnboarding(onboarding)
}
}
@@ -0,0 +1,70 @@
import { useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../store'
/**
* Mirrors the sidebar/right-sidebar/filter preferences into the durable UI file.
*
* Why (#9002): activeView is deliberately kept off this debounced writer. It used to ride the
* same 150ms save (#8265), so every top-level view switch scheduled a full durable-state write.
*/
export function usePersistedUIWriter(): void {
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const activeView = useAppStore((s) => s.activeView)
const ui = useAppStore(
useShallow((s) => ({
sidebarWidth: s.sidebarWidth,
rightSidebarOpen: s.rightSidebarOpen,
rightSidebarTab: s.rightSidebarTab,
rightSidebarExplorerView: s.rightSidebarExplorerView,
rightSidebarWidth: s.rightSidebarWidth,
markdownTocPanelWidth: s.markdownTocPanelWidth,
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
groupBy: s.groupBy,
sortBy: s.sortBy,
projectOrderBy: s.projectOrderBy,
showSleepingWorkspaces: s.showSleepingWorkspaces,
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree: s.showDotfilesByWorktree,
filterRepoIds: s.filterRepoIds,
// Why: rides the same debounced save so dashboard auto-acks (which fire on focus/
// visibility) and the in-memory ack cleanup paths in agent-status.ts (close/dismiss)
// both flow to disk through map identity changes. Without persisting, agent rows that
// survive restart come back bold even when the user had already visited them.
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
}))
)
useEffect(() => {
if (!persistedUIReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.ui.set({
...ui,
showActiveOnly: false,
hideSleepingWorkspaces: !ui.showSleepingWorkspaces,
// Why: the store keeps this readonly for identity stability, but PersistedUI crosses to
// main, which owns a mutable array — copy at the boundary rather than widening the wire type.
filterRepoIds: [...ui.filterRepoIds]
})
}, 150)
return () => window.clearTimeout(timer)
}, [persistedUIReady, ui])
// Why (#9002): activeView has its own tiny profile preference, so it can track
// every switch without scheduling the multi-MB durable-state writer.
useEffect(() => {
if (!persistedUIReady) {
return
}
void window.api.ui.set({ activeView })
}, [activeView, persistedUIReady])
}
@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { getSystemPrefersDarkSnapshot } from '../components/terminal-pane/use-system-prefers-dark'
import {
canSkipRuntimeMobileSessionSyncKeyBuild,
getRuntimeMobileSessionSyncKey,
runtimeMobileSessionSyncKeysEqual,
scheduleRuntimeGraphSync,
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from '../runtime/sync-runtime-graph'
import { useAppStore } from '../store'
/** Keeps the mobile runtime graph republished as the store's session-visible state changes. */
export function useRuntimeGraphSync(): void {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
useEffect(() => {
setRuntimeGraphStoreStateGetter(useAppStore.getState)
return () => {
setRuntimeGraphStoreStateGetter(null)
}
}, [])
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {
// Why: this fires on every store mutation; read the cached prefers-dark snapshot instead of allocating a throwaway MediaQueryList via matchMedia each tick.
const systemPrefersDark = getSystemPrefersDarkSnapshot()
// Why: skip the key build when every input is reference-unchanged; the gate mirrors every field getRuntimeMobileSessionSyncKey uses.
if (
canSkipRuntimeMobileSessionSyncKeyBuild(
state,
previousState,
systemPrefersDark,
previousKey.systemPrefersDark
)
) {
return
}
const nextKey = getRuntimeMobileSessionSyncKey(
state,
previousState,
previousKey,
systemPrefersDark
)
if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) {
return
}
previousKey = nextKey
scheduleRuntimeGraphSync()
})
}, [])
useEffect(() => {
setRuntimeGraphSyncEnabled(workspaceSessionReady)
return () => {
setRuntimeGraphSyncEnabled(false)
}
}, [workspaceSessionReady])
}
@@ -0,0 +1,48 @@
import { useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { useAppStore } from '../store'
import { isMac } from './app-window-chrome'
/** Window-visibility reactions that must run app-wide, not per-surface. */
export function useWindowVisibilityEffects(): void {
const actions = useAppStore(
useShallow((s) => ({
refreshAllGitHub: s.refreshAllGitHub,
reportVisibleGitHubPRRefreshCandidates: s.reportVisibleGitHubPRRefreshCandidates,
bumpGitHubPRVisibleRefreshGeneration: s.bumpGitHubPRVisibleRefreshGeneration
}))
)
// Refresh GitHub data (PR/issue status) when window regains focus
useEffect(() => {
const handler = (): void => {
if (document.visibilityState === 'visible') {
actions.refreshAllGitHub()
actions.bumpGitHubPRVisibleRefreshGeneration()
} else {
actions.reportVisibleGitHubPRRefreshCandidates([], Date.now())
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [actions])
// Why (STA-2383): macOS throttles the backgrounded window; on occlusion-uncover only `focus`
// fires (invalidate-only), so the app-shell's dvh height stays stale and the bottom status bar
// is clipped off-screen until a manual resize. Relay the genuine hidden→visible reveal so main
// runs the same full repaint (size jiggle) that show/restore/resume get, recomputing the layout.
useEffect(() => {
if (!isMac || isPairedWebClientWindow()) {
return
}
const handler = (): void => {
if (document.visibilityState !== 'visible') {
return
}
window.api?.ui?.notifyWindowRevealed?.()
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [])
}
+125 -115
View File
@@ -2,9 +2,25 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
function readSource(relativePath: string): string {
return readFileSync(join(process.cwd(), relativePath), 'utf8')
}
const APP_PATH = 'src/renderer/src/App.tsx'
const STARTUP_HYDRATION_PATH = 'src/renderer/src/app-shell/use-app-startup-hydration.ts'
const DEGRADED_RECOVERY_PATH = 'src/renderer/src/startup/startup-degraded-recovery.ts'
const CHROME_LAYOUT_PATH = 'src/renderer/src/app-shell/use-app-chrome-layout.ts'
const SHELL_SERVICES_PATH = 'src/renderer/src/app-shell/use-app-shell-services.ts'
const BACKGROUND_SERVICES_PATH = 'src/renderer/src/app-shell/AppBackgroundServices.tsx'
const WORKSPACE_SHELL_PATH = 'src/renderer/src/app-shell/AppWorkspaceShell.tsx'
const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx'
const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts'
const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts'
const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts'
describe('renderer startup runtime routing', () => {
it('hydrates persisted UI before local catalog and worktree hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const startupBlockStart = source.indexOf('void (async () => {')
// Why: concurrent startup branches all settle before hydrate-session-stores.
const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
@@ -99,7 +115,7 @@ describe('renderer startup runtime routing', () => {
})
it('refreshes remote catalogs after startup hydration succeeds', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const hydrationDoneIndex = source.indexOf(
"logRendererStartupDiagnostic('startup-hydration-done'"
)
@@ -122,12 +138,13 @@ describe('renderer startup runtime routing', () => {
'actions.fetchReposForAllHosts()'
)
const startupFailureIndex = source.indexOf(
const degradedSource = readSource(DEGRADED_RECOVERY_PATH)
const startupFailureIndex = degradedSource.indexOf(
'[startup] Workspace session hydration failed; leaving disk state untouched:'
)
expect(startupFailureIndex).toBeGreaterThanOrEqual(0)
expect(
source.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
degradedSource.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
).toBeGreaterThan(startupFailureIndex)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchProjectGroupsForAllHosts()'
@@ -138,23 +155,17 @@ describe('renderer startup runtime routing', () => {
})
it('waits for first-window startup services before terminal reconnect', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const servicesIndex = source.indexOf('await window.api.app.awaitFirstWindowStartupServices()')
const source = readSource(STARTUP_HYDRATION_PATH)
const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'")
const preReconnectRecoveryIndex = source.indexOf(
'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()',
servicesIndex
"timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'"
)
const capabilityRefreshIndex = source.indexOf(
'refreshTerminalProviderSnapshotCapabilities(',
preReconnectRecoveryIndex
)
const reconnectIndex = source.indexOf(
'actions.reconnectPersistedTerminals(abortController.signal)',
preReconnectRecoveryIndex
"timeRendererStartupStep('terminal-provider-snapshot-capabilities'"
)
const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'")
const postReconnectRecoveryIndex = source.indexOf(
'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()',
reconnectIndex
"timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect'"
)
expect(servicesIndex).toBeGreaterThanOrEqual(0)
@@ -165,7 +176,7 @@ describe('renderer startup runtime routing', () => {
})
it('refreshes terminal snapshot capability before degraded reconnect', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(DEGRADED_RECOVERY_PATH)
const degradedStart = source.indexOf(
'[startup] Workspace session hydration failed; leaving disk state untouched:'
)
@@ -181,10 +192,7 @@ describe('renderer startup runtime routing', () => {
'refreshTerminalProviderSnapshotCapabilities(',
recoveryIndex
)
const reconnectIndex = source.indexOf(
'actions.reconnectPersistedTerminals(abortController.signal)',
recoveryIndex
)
const reconnectIndex = source.indexOf('reconnectPersistedTerminals(abortSignal)', recoveryIndex)
expect(degradedStart).toBeGreaterThanOrEqual(0)
expect(servicesIndex).toBeGreaterThan(degradedStart)
@@ -194,10 +202,7 @@ describe('renderer startup runtime routing', () => {
})
it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/automations/AutomationsPage.tsx')
const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef')
const fullRefreshEffect = source.slice(
fullRefreshStart,
@@ -210,24 +215,29 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import the floating terminal panel on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const shellSource = readSource(WORKSPACE_SHELL_PATH)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
expect(source).toContain(
"import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'"
expect(shellSource).toContain(
"import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'"
)
expect(source).toContain("import('./components/floating-terminal/FloatingTerminalPanel').then")
expect(source).not.toContain("from './components/floating-terminal/FloatingTerminalPanel'")
expect(surfacesSource).toContain(
"import('../components/floating-terminal/FloatingTerminalPanel').then"
)
for (const source of [shellSource, surfacesSource]) {
expect(source).not.toContain("from '../components/floating-terminal/FloatingTerminalPanel'")
}
})
it('does not eagerly import idle optional overlay surfaces on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/UpdateCard').then")
expect(source).toContain("import('./components/contextual-tours/ContextualTourOverlay').then")
expect(source).toContain("import('./components/setup-guide/SetupGuideTelemetryObserver').then")
expect(source).not.toContain("from './components/UpdateCard'")
expect(source).not.toContain("from './components/contextual-tours/ContextualTourOverlay'")
expect(source).not.toContain("from './components/setup-guide/SetupGuideTelemetryObserver'")
expect(source).toContain("import('../components/UpdateCard').then")
expect(source).toContain("import('../components/contextual-tours/ContextualTourOverlay').then")
expect(source).toContain("import('../components/setup-guide/SetupGuideTelemetryObserver').then")
expect(source).not.toContain("from '../components/UpdateCard'")
expect(source).not.toContain("from '../components/contextual-tours/ContextualTourOverlay'")
expect(source).not.toContain("from '../components/setup-guide/SetupGuideTelemetryObserver'")
expect(source).toContain('const shouldMountSetupGuideTelemetryObserver = persistedUIReady')
expect(source).not.toContain(
"const shouldMountSetupGuideTelemetryObserver = persistedUIReady && activeModal === 'setup-guide'"
@@ -235,16 +245,15 @@ describe('renderer startup runtime routing', () => {
})
it('keeps crash-report listeners eager while lazy-loading the dialog surface', () => {
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const hostSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'),
'utf8'
)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx')
expect(appSource).toContain(
"import { CrashReportDialog } from './components/crash-report/CrashReportDialog'"
expect(surfacesSource).toContain(
"import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'"
)
expect(surfacesSource).not.toContain(
"from '../components/crash-report/CrashReportDialogSurface'"
)
expect(appSource).not.toContain("from './components/crash-report/CrashReportDialogSurface'")
expect(hostSource).toContain("import('./CrashReportDialogSurface').then")
expect(hostSource).toContain('window.api.crashReports.getLatestPending()')
expect(hostSource).toContain('window.api.ui.onOpenCrashReport')
@@ -254,10 +263,7 @@ describe('renderer startup runtime routing', () => {
})
it('clears stale crash-report state before opening the lazy manual report surface', () => {
const hostSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'),
'utf8'
)
const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx')
const manualOpenStart = hostSource.indexOf('return window.api.ui.onOpenCrashReport(() => {')
const manualOpenEnd = hostSource.indexOf(' }, [loadCrashReport])', manualOpenStart)
const manualOpenBlock = hostSource.slice(manualOpenStart, manualOpenEnd)
@@ -272,32 +278,34 @@ describe('renderer startup runtime routing', () => {
})
it('loads dictation only when voice is enabled or a session is active', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/dictation/DictationController').then")
expect(source).not.toContain("from './components/dictation/DictationController'")
expect(source).toContain("import('../components/dictation/DictationController').then")
expect(source).not.toContain("from '../components/dictation/DictationController'")
expect(source).toContain("settings?.voice?.enabled === true || dictationState !== 'idle'")
expect(source).toContain('shouldMountDictationController ?')
})
it('loads the SSH passphrase dialog only when a credential request is queued', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/settings/SshPassphraseDialog').then")
expect(source).not.toContain("from './components/settings/SshPassphraseDialog'")
expect(source).toContain("import('../components/settings/SshPassphraseDialog').then")
expect(source).not.toContain("from '../components/settings/SshPassphraseDialog'")
expect(source).toContain('s.sshCredentialQueue.length > 0')
expect(source).toContain('hasSshCredentialRequest ?')
})
it('defers background polling until the workspace session is ready', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
expect(source).toContain('useGitStatusPolling({ enabled: workspaceSessionReady })')
expect(source).toContain('<WorkspacePortScanner enabled={workspaceSessionReady} />')
expect(readSource(SHELL_SERVICES_PATH)).toContain(
'useGitStatusPolling({ enabled: workspaceSessionReady })'
)
expect(readSource(BACKGROUND_SERVICES_PATH)).toContain(
'<WorkspacePortScanner enabled={workspaceSessionReady} />'
)
})
it('prefetches terminal snapshot capabilities before reconnect unlocks cold activation', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const capabilityIndex = source.indexOf(
"timeRendererStartupStep('terminal-provider-snapshot-capabilities'"
)
@@ -308,54 +316,61 @@ describe('renderer startup runtime routing', () => {
})
it('does not load the terminal workbench on the no-workspace landing path', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const shellSource = readSource(WORKSPACE_SHELL_PATH)
const layoutSource = readSource(CHROME_LAYOUT_PATH)
expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal'))")
expect(source).not.toContain("from './components/Terminal'")
expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)')
expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true')
expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested')
expect(source).toContain('backgroundTerminalMountRequested ||')
expect(source).toContain('hasMountedTerminalWorkbenchRef.current')
expect(source).toContain('shouldMountTerminalWorkbench ?')
expect(shellSource).toContain("const Terminal = lazy(() => import('../components/Terminal'))")
expect(shellSource).not.toContain("from '../components/Terminal'")
expect(layoutSource).toContain(
'const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested'
)
// Why pin the latch: once the workbench has mounted it must stay mounted, so hidden
// terminal/browser/editor panes survive activeWorktreeId briefly going null.
expect(layoutSource).toContain(
'const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)'
)
expect(layoutSource).toContain('setHasMountedTerminalWorkbench(true)')
expect(layoutSource).toContain(
'const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench'
)
expect(shellSource).toContain('shouldMountTerminalWorkbench ?')
})
it('keeps the new-workspace composer eager because it is a critical create surface', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const lazyModalSource = readFileSync(
join(process.cwd(), 'src/renderer/src/lazy-modal-mount-state.ts'),
'utf8'
)
const source = readSource(ROOT_SURFACES_PATH)
const lazyModalSource = readSource('src/renderer/src/lazy-modal-mount-state.ts')
expect(source).toContain(
"import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'"
"import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'"
)
expect(source).not.toContain("import('./components/NewWorkspaceComposerModal')")
expect(source).not.toContain("import('../components/NewWorkspaceComposerModal')")
expect(source).toContain("activeModal === 'new-workspace-composer'")
expect(lazyModalSource).not.toContain("'new-workspace-composer'")
})
it('does not eagerly import inactive sidebar dialog flows on startup', () => {
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const sidebarSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/sidebar/index.tsx'),
'utf8'
)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
const mountSource = readSource(LAZY_MODAL_MOUNTS_PATH)
const sidebarSource = readSource('src/renderer/src/components/sidebar/index.tsx')
expect(appSource).toContain("lazy(() => import('./components/sidebar/AddRepoDialog'))")
expect(appSource).toContain("lazy(() => import('./components/sidebar/NonGitFolderDialog'))")
expect(appSource).toContain("import('./components/sidebar/AddProjectFromFolderDialog')")
expect(appSource).toContain("lazy(() => import('./components/sidebar/ProjectAddedDialog'))")
expect(appSource).toContain("activeModal === 'add-repo'")
expect(appSource).toContain("activeModal === 'confirm-non-git-folder'")
expect(appSource).toContain("activeModal === 'confirm-add-project-from-folder'")
expect(appSource).toContain("activeModal === 'project-added'")
expect(appSource).toContain('shouldMountAddRepoDialog ? (')
expect(appSource).toContain('boundaryId="modal.add-repo"')
expect(appSource).toContain('boundaryId="modal.confirm-non-git-folder"')
expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"')
expect(appSource).toContain('boundaryId="modal.project-added"')
expect(appSource).toContain('setTimeout(() =>')
expect(surfacesSource).toContain("lazy(() => import('../components/sidebar/AddRepoDialog'))")
expect(surfacesSource).toContain(
"lazy(() => import('../components/sidebar/NonGitFolderDialog'))"
)
expect(surfacesSource).toContain("import('../components/sidebar/AddProjectFromFolderDialog')")
expect(surfacesSource).toContain(
"lazy(() => import('../components/sidebar/ProjectAddedDialog'))"
)
expect(surfacesSource).toContain("activeModal === 'add-repo'")
expect(surfacesSource).toContain("activeModal === 'confirm-non-git-folder'")
expect(surfacesSource).toContain("activeModal === 'confirm-add-project-from-folder'")
expect(surfacesSource).toContain("activeModal === 'project-added'")
expect(surfacesSource).toContain('shouldMountAddRepoDialog ? (')
expect(surfacesSource).toContain('boundaryId="modal.add-repo"')
expect(surfacesSource).toContain('boundaryId="modal.confirm-non-git-folder"')
expect(surfacesSource).toContain('boundaryId="modal.confirm-add-project-from-folder"')
expect(surfacesSource).toContain('boundaryId="modal.project-added"')
expect(mountSource).toContain('setTimeout(() =>')
expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))")
expect(sidebarSource).not.toContain("from './AddRepoDialog'")
expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))")
@@ -379,10 +394,7 @@ describe('renderer startup runtime routing', () => {
})
it('loads Linear agent setup implementation only after the prompt opens it', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx')
expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')")
expect(source).not.toContain("from './LinearAgentSkillSetupDialog'")
@@ -391,10 +403,7 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import optional status-bar segments on startup', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/status-bar/StatusBar.tsx')
expect(source).toContain("import('./ResourceUsageStatusSegment').then")
expect(source).toContain("import('./PortsStatusSegment').then")
@@ -407,16 +416,16 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import the status bar shell on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/status-bar/StatusBar').then")
expect(source).not.toContain("from './components/status-bar/StatusBar'")
expect(source).toContain("import('../components/status-bar/StatusBar').then")
expect(source).not.toContain("from '../components/status-bar/StatusBar'")
expect(source).toContain('statusBarVisible ? (')
expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border')
})
it('keeps activeView off the 150ms debounced UI writer hot path (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(PERSISTED_UI_WRITER_PATH)
const writerStart = source.indexOf('const timer = window.setTimeout(() => {')
const writerEnd = source.indexOf('}, 150)', writerStart)
const writerBlock = source.slice(writerStart, writerEnd)
@@ -437,7 +446,7 @@ describe('renderer startup runtime routing', () => {
})
it('persists activeView through its narrow preference on every switch (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(PERSISTED_UI_WRITER_PATH)
const preferenceEffect = [
'// Why (#9002): activeView has its own tiny profile preference',
@@ -452,21 +461,22 @@ describe('renderer startup runtime routing', () => {
})
it('arms the OSC 52 default-on notice behind a statically mounted Toaster (#10567)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
// Why pin the call site: the hook is the only caller, so deleting this line silences
// the migration notice on desktop with every unit suite still green.
expect(source).toContain('useOsc52ClipboardDefaultOnNotice(persistedUIReady)')
expect(readSource(SHELL_SERVICES_PATH)).toContain(
'useOsc52ClipboardDefaultOnNotice(persistedUIReady)'
)
// Why pin the static import and the unconditional mount: sonner drops a toast enqueued
// before any Toaster subscribes, and never replays it — a lazy Toaster would burn the
// profile's one notice with its callbacks never firing, so it could never re-arm.
expect(source).toContain("import { Toaster } from '@/components/ui/sonner'")
expect(source).not.toContain("import('@/components/ui/sonner')")
expect(source).toContain('<Toaster closeButton')
const appSource = readSource(APP_PATH)
expect(appSource).toContain("import { Toaster } from '@/components/ui/sonner'")
expect(appSource).not.toContain("import('@/components/ui/sonner')")
expect(appSource).toContain('<Toaster closeButton')
})
it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(SESSION_PERSISTENCE_PATH)
const checkpointStart = source.indexOf(
'const shutdownCheckpoint = createShutdownCheckpointGuard('
)
@@ -420,6 +420,37 @@ describe('AgentTerminalPreview', () => {
expect(input).toHaveBeenCalledWith('pty-1', 'clip-text')
})
it('encodes a leading newline for a remote Windows Codex preview without submitting', async () => {
readClipboardText.mockResolvedValueOnce('\nsecond line')
const view = render(
<AgentTerminalPreview
ptyId="remote:windows-box@@pty-1"
terminalInput={{
hostPlatform: 'win32',
localWindowsConpty: false,
windowsShiftEnterEncoding: 'alt-enter',
windowsInputRecordPasteNewline: 'alt-enter',
ctrlEnterCsiU: false,
kittyKeyboardAdvertised: false
}}
/>
)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
const focusTarget = document.createElement('input')
host.appendChild(focusTarget)
focusTarget.focus()
act(() => emitAppMenuPaste!())
await waitFor(() =>
expect(input).toHaveBeenCalledWith('remote:windows-box@@pty-1', '\x1b\rsecond line')
)
expect(terminal.input).toHaveBeenCalledWith('\x1b\rsecond line')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('handles app-menu selection actions while the preview owns focus', async () => {
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
@@ -201,6 +201,7 @@ export function AgentTerminalPreview({
ptyId,
container,
getTerminal: () => terminal,
getTerminalInput: () => terminalInputRef.current,
isDisposed: () => disposed
})
@@ -6,6 +6,8 @@ import {
} from '@/components/terminal-pane/terminal-paste-coordinator'
import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime'
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
/**
* Clipboard paste for the preview terminal, on the pane's coordinator: large
@@ -16,6 +18,7 @@ export function createPreviewClipboardPaster(deps: {
ptyId: string
container: HTMLElement
getTerminal: () => Terminal | null
getTerminalInput: () => DashboardCardTerminalInput | null
isDisposed: () => boolean
}): (activeElementAtDispatch: Element | null, source: 'keyboard' | 'app-menu') => Promise<void> {
return async (activeElementAtDispatch, source) => {
@@ -38,7 +41,8 @@ export function createPreviewClipboardPaster(deps: {
if (!targetIsCurrent()) {
return
}
const platform = getShortcutPlatform()
const terminalInput = deps.getTerminalInput()
const platform = terminalInput?.hostPlatform ?? getShortcutPlatform()
const plan = await planTerminalPasteWithYield({
text,
source,
@@ -49,11 +53,13 @@ export function createPreviewClipboardPaster(deps: {
ptyId: deps.ptyId,
runtime: resolveTerminalPasteRuntime({ platform, ptyId: deps.ptyId })
},
forceBracketedPasteForMultiline: terminalInput?.forceBracketedMultilineTextPaste,
windowsInputRecordNewline: terminalInput?.windowsInputRecordPasteNewline,
terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode
})
await executeTerminalPastePlan(plan, {
// Why: stream large pastes so the renderer never emits one huge IPC payload.
pasteText: (pasteText) => pasteTerminal.paste(pasteText),
pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options),
writePty: (data) => window.api.terminalPreview.input(deps.ptyId, data),
isTargetCurrent: targetIsCurrent,
// Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
@@ -10,12 +10,12 @@ function source(relativePath: string): string {
describe('agent dashboard performance isolation', () => {
it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => {
const app = source('App.tsx')
const backgroundServices = source('app-shell/AppBackgroundServices.tsx')
const sidebar = source('components/sidebar/index.tsx')
const nav = source('components/sidebar/SidebarNav.tsx')
expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/)
expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')")
expect(backgroundServices).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/)
expect(backgroundServices).toContain("import('../components/dashboard/DashboardPopoutBridge')")
expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/)
expect(sidebar).toContain("import('./AgentDashboardSidebarHost')")
expect(nav).not.toContain('useAgentBucketCounts')
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
resolveDashboardCardTerminalInput,
type DashboardCardTerminalInputState
@@ -36,6 +37,19 @@ function stateWith(
} as Partial<DashboardCardTerminalInputState>
}
function codexEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
agentType: 'codex',
paneKey: MAC_ARGS.paneKey,
...overrides
}
}
describe('resolveDashboardCardTerminalInput', () => {
it('resolves a local macOS pane to client-host routing with kitty advertised', () => {
expect(resolveDashboardCardTerminalInput(stateWith(), MAC_ARGS)).toEqual({
@@ -127,6 +141,50 @@ describe('resolveDashboardCardTerminalInput', () => {
expect(resolveDashboardCardTerminalInput(state, MAC_ARGS).hostPlatform).toBe('win32')
})
it('relays Windows input-record paste encoding for a confirmed remote Codex pane', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } }]
]),
agentStatusByPaneKey: { [MAC_ARGS.paneKey]: codexEntry() }
} as unknown as Partial<DashboardCardTerminalInputState>),
{ ...MAC_ARGS, ptyId: 'remote:windows-box@@pty-1' }
)
expect(profile.windowsInputRecordPasteNewline).toBe('alt-enter')
expect(profile.forceBracketedMultilineTextPaste).toBeUndefined()
})
it('relays bracketed multiline paste for a confirmed non-Windows Codex pane', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
agentStatusByPaneKey: { [MAC_ARGS.paneKey]: codexEntry() }
}),
MAC_ARGS
)
expect(profile.forceBracketedMultilineTextPaste).toBe(true)
expect(profile.windowsInputRecordPasteNewline).toBeUndefined()
})
it('does not relay stale restored agent paste authority', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } }]
]),
agentStatusByPaneKey: {
[MAC_ARGS.paneKey]: codexEntry({ restoredUnconfirmed: true })
}
} as unknown as Partial<DashboardCardTerminalInputState>),
{ ...MAC_ARGS, ptyId: 'remote:windows-box@@pty-1' }
)
expect(profile.forceBracketedMultilineTextPaste).toBeUndefined()
expect(profile.windowsInputRecordPasteNewline).toBeUndefined()
})
it("keeps the live SSH PTY's host after the worktree owner changes", () => {
const state = stateWith({
sshConnectionStates: new Map([['conn-live', { remotePlatform: 'win32' }]])
@@ -11,6 +11,7 @@ import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/ter
import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter'
import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { resolveProtectedMultilinePasteOptionsForAgentEvidence } from '@/components/terminal-pane/terminal-agent-paste-bracketing'
/** Store slices the pane's own input helpers read. */
export type DashboardCardTerminalInputState = Pick<
@@ -29,6 +30,7 @@ export type DashboardCardTerminalInputState = Pick<
| 'runtimeEnvironmentCatalogHydrated'
| 'removedRuntimeEnvironmentIds'
| 'paneForegroundAgentByPaneKey'
| 'agentStatusByPaneKey'
| 'agentLaunchConfigByPaneKey'
>
@@ -55,6 +57,7 @@ function withHydratedSlices(
runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated ?? false,
removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds ?? new Set(),
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? EMPTY_RECORD,
agentStatusByPaneKey: state.agentStatusByPaneKey ?? EMPTY_RECORD,
agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? EMPTY_RECORD
}
}
@@ -99,30 +102,43 @@ export function resolveDashboardCardTerminalInput(
shellOverride: args.shellOverride,
executionHostId
}
const hostPlatform = resolveTerminalInputHostPlatform({
clientPlatform: args.clientPlatform,
state,
worktreeId: args.worktreeId,
transport: {
getConnectionId: () => connectionId,
getPtyId: () => args.ptyId,
getExecutionHostId: () => executionHostId,
// Why: gated exactly like the pane transport's own accessor — without
// it a WSL pty on a Windows client resolves to win32 and Shift+Enter
// would encode CSI-u where the pane sends alt-enter.
getLocalSessionMetadata: () =>
connectionId
? null
: {
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.shellOverride ? { shellOverride: args.shellOverride } : {})
}
}
})
const protectedPaste = resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient: args.clientPlatform === 'win32',
hostPlatform,
foregroundAgent: state.paneForegroundAgentByPaneKey[args.paneKey]?.agent,
entry: state.agentStatusByPaneKey[args.paneKey]
})
return {
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: args.clientPlatform,
state,
worktreeId: args.worktreeId,
transport: {
getConnectionId: () => connectionId,
getPtyId: () => args.ptyId,
getExecutionHostId: () => executionHostId,
// Why: gated exactly like the pane transport's own accessor — without
// it a WSL pty on a Windows client resolves to win32 and Shift+Enter
// would encode CSI-u where the pane sends alt-enter.
getLocalSessionMetadata: () =>
connectionId
? null
: {
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.shellOverride ? { shellOverride: args.shellOverride } : {})
}
}
}),
hostPlatform,
localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext),
...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }),
windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey),
...(protectedPaste?.forceBracketedPasteForMultiline
? { forceBracketedMultilineTextPaste: true as const }
: {}),
...(protectedPaste?.windowsInputRecordNewline
? { windowsInputRecordPasteNewline: protectedPaste.windowsInputRecordNewline }
: {}),
ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey),
kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({
...windowsPtyContext,
@@ -719,7 +719,7 @@ describe('native chat PTY session options', () => {
expect(surface.getSnapshot()[0]).toMatchObject({
id: 'model',
valueSource: 'default',
kind: { currentValue: 'grok-4.5' }
kind: { currentValue: 'grok-4.6' }
})
// Regression: the effort row hangs off that default model, so resolving the apply
@@ -735,9 +735,9 @@ describe('native chat PTY session options', () => {
it('does not adopt grok’s unprobed seed default as a persisted launch model', async () => {
// Regression: setting an option under the CLI default wrote `model` into settings,
// so every later grok launch app-wide emitted `-m grok-4.5` — on an account without
// so every later grok launch app-wide emitted `-m grok-4.6` — on an account without
// that model, a fatal launch the user never opted into. No discovery has run here,
// so `grok-4.5` is still only the seed's guess.
// so `grok-4.6` is still only the seed's guess.
let persisted: PersistedNativeChatSessionOptions = {}
const surface = createNativeChatPtySessionOptions({
agent: 'grok',
@@ -760,7 +760,7 @@ describe('native chat PTY session options', () => {
expect(persisted.grok?.model).toBeUndefined()
// The scoped value is still remembered for a later explicit pick of that model.
expect(persisted.grok?.valuesByModel?.['grok-4.5']?.effort).toBe('low')
expect(persisted.grok?.valuesByModel?.['grok-4.6']?.effort).toBe('low')
expect(resolveNativeChatSessionOptionDefaults(persisted, 'grok')).toBeUndefined()
// A model the user actually picks still becomes the launch default.
@@ -0,0 +1,79 @@
import { Loader2 } from 'lucide-react'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
type EphemeralVmCleanupStopDialogProps = {
runtime: EphemeralVmRuntimeRecord | null
isStopping: boolean
onCancel: () => void
onConfirm: () => void
}
export function EphemeralVmCleanupStopDialog({
runtime,
isStopping,
onCancel,
onConfirm
}: EphemeralVmCleanupStopDialogProps): React.JSX.Element {
return (
<Dialog
open={runtime !== null}
onOpenChange={(open) => {
if (!open && !isStopping) {
onCancel()
}
}}
>
<DialogContent className="max-w-md" showCloseButton={!isStopping}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.title',
'Stop cleanup?'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.description',
'The VM may remain running and incur charges. You can retry cleanup later.'
)}
</DialogDescription>
</DialogHeader>
{runtime ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<div className="truncate">{runtime.workspaceName || runtime.recipeId}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isStopping}>
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.cancel',
'Keep cleaning'
)}
</Button>
<Button onClick={onConfirm} disabled={isStopping}>
{isStopping ? <Loader2 className="animate-spin" /> : null}
{isStopping
? translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.stopping',
'Stopping…'
)
: translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.confirm',
'Stop cleanup'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,117 @@
import { AlertTriangle, Copy, Loader2, Square, Trash2 } from 'lucide-react'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultProjectRoot } from '../../../../shared/ephemeral-vm-recipes'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
type EphemeralVmRuntimeRowProps = {
runtime: EphemeralVmRuntimeRecord
statusLabel: string
cleanupFailed: boolean
cleanupRunning: boolean
isStopping: boolean
disabled: boolean
onCleanup: () => void
onStopCleanup: () => void
onCopyCleanupCommand: () => void
}
export function EphemeralVmRuntimeRow({
runtime,
statusLabel,
cleanupFailed,
cleanupRunning,
isStopping,
disabled,
onCleanup,
onStopCleanup,
onCopyCleanupCommand
}: EphemeralVmRuntimeRowProps): React.JSX.Element {
const hasError = cleanupFailed || runtime.status === 'failed'
return (
<div className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'size-2 shrink-0 rounded-full',
hasError ? 'bg-destructive' : 'bg-muted-foreground/40'
)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{runtime.workspaceName || runtime.recipeId}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
{hasError ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}
</p>
{runtime.cleanupLastError ? (
<p className="mt-0.5 truncate text-xs text-destructive">{runtime.cleanupLastError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{runtime.cleanupStatus === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCopyCleanupCommand}
disabled={disabled}
>
<Copy className="size-3" />
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.copyCleanup',
'Copy command'
)}
</Button>
) : null}
{cleanupRunning ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onStopCleanup}
disabled={disabled || isStopping}
>
{isStopping ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Square className="size-3" />
)}
{isStopping
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.stoppingCleanup',
'Stopping…'
)
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.stopCleanup',
'Stop cleanup'
)}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCleanup}
disabled={disabled}
>
<Trash2 className="size-3" />
{cleanupFailed
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.retry',
'Retry cleanup'
)
: translate('auto.components.settings.EphemeralVmRuntimesSection.cleanup', 'Cleanup')}
</Button>
)}
</div>
</div>
)
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
@@ -22,6 +22,18 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => (
<div data-slot="dialog-content">{children}</div>
),
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
const roots: Root[] = []
function makeRuntime(overrides: Partial<EphemeralVmRuntimeRecord> = {}): EphemeralVmRuntimeRecord {
@@ -81,6 +93,14 @@ describe('EphemeralVmRuntimesSection helpers', () => {
expect(getEphemeralVmRuntimeStatusLabel(makeRuntime({ cleanupStatus: 'failed' }))).toBe(
'Cleanup failed'
)
expect(
getEphemeralVmRuntimeStatusLabel(
makeRuntime({
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
)
).toBe('Cleanup stopped')
expect(getEphemeralVmRuntimeStatusLabel(makeRuntime({ cleanupStatus: 'disabled' }))).toBe(
'Cleanup disabled'
)
@@ -108,6 +128,13 @@ describe('EphemeralVmRuntimesSection', () => {
status: 'cleaned',
cleanupStatus: 'succeeded'
})
),
stopCleanup: vi.fn().mockResolvedValue(
makeRuntime({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
)
},
ui: {
@@ -122,6 +149,7 @@ describe('EphemeralVmRuntimesSection', () => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.useRealTimers()
})
it('renders active Cloud VM runtimes and cleans one up', async () => {
@@ -178,6 +206,65 @@ describe('EphemeralVmRuntimesSection', () => {
expect(toastMocks.success).not.toHaveBeenCalled()
})
it('stops running cleanup from a persistent runtime row', async () => {
const running = makeRuntime({ status: 'cleanup_pending', cleanupStatus: 'running' })
const stopped = makeRuntime({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
window.api.ephemeralVm.listRuntimes = vi
.fn()
.mockResolvedValueOnce([running])
.mockResolvedValue([stopped])
window.api.ephemeralVm.stopCleanup = vi.fn().mockResolvedValue(stopped)
const container = await renderSection()
await vi.waitFor(() => expect(container.textContent).toContain('Stop cleanup'))
const stopButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Stop cleanup'
)
await act(async () => {
stopButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await vi.waitFor(() => expect(document.body.textContent).toContain('The VM may remain running'))
const dialog = document.body.querySelector('[data-slot="dialog-content"]')
const confirmButton = [...(dialog?.querySelectorAll('button') ?? [])].find(
(button) => button.textContent === 'Stop cleanup'
)
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await vi.waitFor(() =>
expect(window.api.ephemeralVm.stopCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
)
await vi.waitFor(() => expect(container.textContent).toContain('Retry cleanup'))
expect(container.textContent).toContain('Cleanup stopped by user.')
})
it('does not repeat load-error toasts while polling a running cleanup', async () => {
vi.useFakeTimers()
window.api.ephemeralVm.listRuntimes = vi
.fn()
.mockResolvedValueOnce([makeRuntime({ status: 'cleanup_pending', cleanupStatus: 'running' })])
.mockRejectedValue(new Error('IPC unavailable'))
const container = await renderSection()
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(container.textContent).toContain('Stop cleanup')
toastMocks.error.mockClear()
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(toastMocks.error).not.toHaveBeenCalled()
})
it('copies a manual cleanup command for failed runtimes', async () => {
window.api.ephemeralVm.listRuntimes = vi.fn().mockResolvedValue([
makeRuntime({
@@ -1,13 +1,14 @@
import { AlertTriangle, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { Loader2, RefreshCw } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultProjectRoot } from '../../../../shared/ephemeral-vm-recipes'
import { EPHEMERAL_VM_CLEANUP_STOPPED_ERROR } from '../../../../shared/ephemeral-vm-recipe-destroy-result'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { cn } from '@/lib/utils'
import { EphemeralVmCleanupStopDialog } from './EphemeralVmCleanupStopDialog'
import { EphemeralVmRuntimeRow } from './EphemeralVmRuntimeRow'
function hasCleanupFailed(runtime: EphemeralVmRuntimeRecord): boolean {
return (
@@ -17,6 +18,17 @@ function hasCleanupFailed(runtime: EphemeralVmRuntimeRecord): boolean {
)
}
function isCleanupRunning(runtime: EphemeralVmRuntimeRecord): boolean {
return runtime.cleanupStatus === 'running' || runtime.status === 'cleanup_pending'
}
function hasCleanupStopped(runtime: EphemeralVmRuntimeRecord): boolean {
return (
runtime.cleanupStatus === 'failed' &&
runtime.cleanupLastError === EPHEMERAL_VM_CLEANUP_STOPPED_ERROR
)
}
export function getVisibleEphemeralVmRuntimes(
runtimes: readonly EphemeralVmRuntimeRecord[]
): EphemeralVmRuntimeRecord[] {
@@ -26,6 +38,12 @@ export function getVisibleEphemeralVmRuntimes(
}
export function getEphemeralVmRuntimeStatusLabel(runtime: EphemeralVmRuntimeRecord): string {
if (hasCleanupStopped(runtime)) {
return translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupStopped',
'Cleanup stopped'
)
}
if (hasCleanupFailed(runtime)) {
return translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailed',
@@ -53,47 +71,84 @@ export function getEphemeralVmRuntimeStatusLabel(runtime: EphemeralVmRuntimeReco
return runtime.status
}
export function EphemeralVmRuntimesSection(): React.JSX.Element {
export function EphemeralVmRuntimesSection({
active = true
}: {
active?: boolean
}): React.JSX.Element {
const [runtimes, setRuntimes] = useState<EphemeralVmRuntimeRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const [cleaningId, setCleaningId] = useState<string | null>(null)
const [stoppingId, setStoppingId] = useState<string | null>(null)
const [pendingStop, setPendingStop] = useState<EphemeralVmRuntimeRecord | null>(null)
const mountedRef = useMountedRef()
const refresh = useCallback(async (): Promise<void> => {
if (mountedRef.current) {
setIsLoading(true)
}
try {
const nextRuntimes = await window.api.ephemeralVm.listRuntimes()
if (mountedRef.current) {
setRuntimes(getVisibleEphemeralVmRuntimes(nextRuntimes))
const refresh = useCallback(
async (showLoading = true, reportError = true): Promise<void> => {
if (mountedRef.current && showLoading) {
setIsLoading(true)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
'Couldn’t load Cloud VM runtimes.'
)
)
try {
const nextRuntimes = await window.api.ephemeralVm.listRuntimes()
if (mountedRef.current) {
setRuntimes(getVisibleEphemeralVmRuntimes(nextRuntimes))
}
} catch (error) {
if (mountedRef.current && reportError) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
'Couldn’t load Cloud VM runtimes.'
)
)
}
} finally {
if (mountedRef.current && showLoading) {
setIsLoading(false)
}
}
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
}, [mountedRef])
},
[mountedRef]
)
useEffect(() => {
void refresh()
}, [refresh])
if (active) {
void refresh()
}
}, [active, refresh])
const hasRunningCleanup = runtimes.some(isCleanupRunning) || cleaningId !== null
useEffect(() => {
if (!active || !hasRunningCleanup) {
return
}
let cancelled = false
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (): Promise<void> => {
await refresh(false, false)
if (!cancelled) {
timer = setTimeout(() => void poll(), 1_000)
}
}
timer = setTimeout(() => void poll(), 1_000)
return () => {
cancelled = true
if (timer) {
clearTimeout(timer)
}
}
}, [active, hasRunningCleanup, refresh])
const cleanupRuntime = async (runtime: EphemeralVmRuntimeRecord): Promise<void> => {
setCleaningId(runtime.id)
try {
const cleaned = await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
if (hasCleanupStopped(cleaned)) {
await refresh(false)
return
}
if (hasCleanupFailed(cleaned)) {
throw new Error(
cleaned.cleanupLastError ??
@@ -170,6 +225,33 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
}
}
const stopCleanup = async (runtime: EphemeralVmRuntimeRecord): Promise<void> => {
setStoppingId(runtime.id)
try {
await window.api.ephemeralVm.stopCleanup({ runtimeId: runtime.id })
if (mountedRef.current) {
setPendingStop(null)
await refresh(false)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.stopCleanupFailed',
'Couldn’t stop Cloud VM cleanup.'
)
)
await refresh(false)
}
} finally {
if (mountedRef.current) {
setStoppingId(null)
}
}
}
const hasRuntimes = runtimes.length > 0
return (
<div className="space-y-3 pt-2" data-settings-section="temporary-vm-runtimes">
@@ -226,92 +308,33 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
<EphemeralVmRuntimeRow
key={runtime.id}
runtime={runtime}
isCleaning={cleaningId === runtime.id}
disabled={cleaningId !== null || isLoading}
statusLabel={getEphemeralVmRuntimeStatusLabel(runtime)}
cleanupFailed={hasCleanupFailed(runtime)}
cleanupRunning={isCleanupRunning(runtime) || cleaningId === runtime.id}
isStopping={stoppingId === runtime.id}
disabled={
isLoading ||
(cleaningId !== null && cleaningId !== runtime.id) ||
(stoppingId !== null && stoppingId !== runtime.id)
}
onCleanup={() => void cleanupRuntime(runtime)}
onStopCleanup={() => setPendingStop(runtime)}
onCopyCleanupCommand={() => void copyCleanupCommand(runtime)}
/>
))}
</div>
)}
</div>
</div>
)
}
function EphemeralVmRuntimeRow({
runtime,
isCleaning,
disabled,
onCleanup,
onCopyCleanupCommand
}: {
runtime: EphemeralVmRuntimeRecord
isCleaning: boolean
disabled: boolean
onCleanup: () => void
onCopyCleanupCommand: () => void
}): React.JSX.Element {
const statusLabel = getEphemeralVmRuntimeStatusLabel(runtime)
const cleanupFailed = hasCleanupFailed(runtime)
const hasError = cleanupFailed || runtime.status === 'failed'
return (
<div className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'size-2 shrink-0 rounded-full',
hasError ? 'bg-destructive' : 'bg-muted-foreground/40'
)}
<EphemeralVmCleanupStopDialog
runtime={pendingStop}
isStopping={stoppingId !== null}
onCancel={() => setPendingStop(null)}
onConfirm={() => {
if (pendingStop) {
void stopCleanup(pendingStop)
}
}}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{runtime.workspaceName || runtime.recipeId}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
{hasError ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}
</p>
{runtime.cleanupLastError ? (
<p className="mt-0.5 truncate text-xs text-destructive">{runtime.cleanupLastError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{runtime.cleanupStatus === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCopyCleanupCommand}
disabled={disabled}
>
<Copy className="size-3" />
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.copyCleanup',
'Copy command'
)}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCleanup}
disabled={disabled}
>
{isCleaning ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
{cleanupFailed
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.retry',
'Retry cleanup'
)
: translate('auto.components.settings.EphemeralVmRuntimesSection.cleanup', 'Cleanup')}
</Button>
</div>
</div>
)
}
@@ -1123,7 +1123,7 @@ export function RuntimeEnvironmentsPane({
<div className={cn('space-y-5 pt-2', visibleWorkflow !== 'cloud-vm' && 'hidden')}>
<CloudVmSetupGuide />
<EphemeralVmRuntimesSection />
<EphemeralVmRuntimesSection active={visibleWorkflow === 'cloud-vm'} />
</div>
<div
@@ -0,0 +1,94 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
const mocks = vi.hoisted(() => ({
state: {
activeModal: 'confirm-remove-folder' as string | null,
modalData: {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
} as Record<string, unknown>,
repos: [] as Repo[],
sshTargetLabels: new Map<string, string>(),
removedSshTargetLabels: new Map<string, string>(),
closeModal: vi.fn(),
removeProject: vi.fn()
}
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children: ReactNode }) => <button>{children}</button>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replace(`{{${key}}}`, value),
fallback
)
}))
import RemoveFolderDialog from './RemoveFolderDialog'
function repo(connectionId: string | null, executionHostId: Repo['executionHostId']): Repo {
return {
id: 'repo-1',
path: '/workspace/example',
displayName: 'Example',
badgeColor: '#000',
addedAt: 1,
kind: 'git',
connectionId,
executionHostId
}
}
describe('RemoveFolderDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.modalData = {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
}
mocks.state.sshTargetLabels = new Map([['target-1', 'Persistent host']])
mocks.state.removedSshTargetLabels = new Map()
})
it('warns that VM recipe cleanup controls file deletion', () => {
mocks.state.modalData.hostId = 'ssh:runtime-ssh-runtime-1'
mocks.state.repos = [repo('runtime-ssh-runtime-1', 'ssh:runtime-ssh-runtime-1')]
const html = renderToStaticMarkup(<RemoveFolderDialog />)
expect(html).toContain('Its VM recipe determines whether the environment')
expect(html).toContain('files are permanently deleted')
expect(html).not.toContain('Its files stay on')
})
it('keeps the file-preservation promise for ordinary SSH projects', () => {
mocks.state.repos = [repo('target-1', 'ssh:target-1')]
const html = renderToStaticMarkup(<RemoveFolderDialog />)
expect(html).toContain('Its files stay on Persistent host')
expect(html).not.toContain('VM recipe')
})
})
@@ -10,7 +10,11 @@ import {
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
type ExecutionHostId
} from '../../../../shared/execution-host'
// Why: interpolated into the sentence so locales control where the name sits;
// U+0000 cannot appear in a real project name, so the split is unambiguous.
@@ -31,34 +35,43 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
// user's — "still on your disk" would be misleading. Name the host (using the
// removed-target label when it's a ghost) so the user knows where it remains
// and that re-adding that host recovers it.
const sshConnectionId = useAppStore(
(s) =>
s.repos
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
?.connectionId?.trim() ?? null
)
const sshHostLabel = useAppStore((s) => {
const connectionId = s.repos
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
?.connectionId?.trim()
if (!connectionId) {
if (!sshConnectionId) {
return null
}
return (
s.sshTargetLabels.get(connectionId) ??
s.removedSshTargetLabels.get(connectionId) ??
connectionId
s.sshTargetLabels.get(sshConnectionId) ??
s.removedSshTargetLabels.get(sshConnectionId) ??
sshConnectionId
)
})
// Why: fragment concatenation around the styled name cannot be reordered by
// SOV locales (#9294). Translate one full sentence with the name as a
// sentinel token, then split on it to re-apply the inline emphasis.
const description = sshHostLabel
const description = isRuntimeOwnedSshTargetId(sshConnectionId)
? translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh',
'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.',
{ name: NAME_TOKEN, host: sshHostLabel }
)
: translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal',
'This only removes {{name}} from Orca. It is still on your disk.',
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe',
'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.',
{ name: NAME_TOKEN }
)
: sshHostLabel
? translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh',
'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.',
{ name: NAME_TOKEN, host: sshHostLabel }
)
: translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal',
'This only removes {{name}} from Orca. It is still on your disk.',
{ name: NAME_TOKEN }
)
const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN)
const handleConfirm = useCallback(() => {
@@ -36,6 +36,7 @@ function setState(overrides: Record<string, unknown> = {}): void {
hideWorkspacesFromOtherDevices: false,
setHideWorkspacesFromOtherDevices: vi.fn(),
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
alwaysShowDefaultBranchWorkspace: true,
setAlwaysShowDefaultBranchWorkspace: vi.fn(),
...overrides
@@ -96,8 +97,15 @@ describe('SidebarWorkspaceFilterSection', () => {
expect(rowLabels()).toContain('Hide other-client workspaces')
})
it('keeps the other-client filter visible while the remote catalog loads', () => {
setState({ runtimeEnvironmentCatalogHydrated: false })
render()
expect(rowLabels()).toContain('Hide other-client workspaces')
})
it('hides the other-client filter for local-only clients', () => {
setState()
setState({ runtimeEnvironmentCatalogHydrated: true })
render()
expect(rowLabels()).not.toContain('Hide other-client workspaces')
@@ -27,11 +27,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices)
const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const setAlwaysShowDefaultBranchWorkspace = useAppStore(
(s) => s.setAlwaysShowDefaultBranchWorkspace
)
const showOtherClientFilter = runtimeEnvironments.length > 0 || hideWorkspacesFromOtherDevices
const showOtherClientFilter =
!runtimeEnvironmentCatalogHydrated ||
runtimeEnvironments.length > 0 ||
hideWorkspacesFromOtherDevices
return (
<>
@@ -195,6 +195,8 @@ import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim'
import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay'
import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner'
import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation'
import {
updateTerminalRemoteRuntimeRecoveryUiState,
@@ -1936,6 +1938,7 @@ function TerminalPane(
},
forceBracketedPaste: options?.forceBracketedPaste,
forceBracketedPasteForMultiline: options?.forceBracketedPasteForMultiline,
windowsInputRecordNewline: options?.windowsInputRecordNewline,
terminalBracketedPasteMode: pane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
@@ -1966,6 +1969,28 @@ function TerminalPane(
}
}
// Why: resolved per pane and PTY host; split siblings and remote hosts can need
// different multiline paste protocols.
const resolvePaneProtectedMultilinePasteOptions = (
pane: ManagedPane
): TerminalPasteTextOptions | undefined => {
const state = useAppStore.getState()
const transport = paneTransportsRef.current.get(pane.id) ?? null
return resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: shortcutPlatform,
state,
worktreeId,
transport
}),
agentStatusByPaneKey: state.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey,
tabId,
leafId: pane.leafId
})
}
const pasteFromClipboard = (
pane: ManagedPane,
source: Extract<TerminalPasteSource, 'keyboard' | 'paste-event'>,
@@ -1983,7 +2008,7 @@ function TerminalPane(
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -2130,7 +2155,7 @@ function TerminalPane(
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -2707,6 +2732,7 @@ function TerminalPane(
? 'win32'
: 'linux'
const connectionId = getConnectionId(worktreeId) ?? null
const pasteState = useAppStore.getState()
const targetStillMounted = (): boolean => {
const manager = managerRef.current
return Boolean(
@@ -2738,6 +2764,19 @@ function TerminalPane(
transport
})
},
...resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: shortcutPlatform,
state: pasteState,
worktreeId,
transport: transport ?? null
}),
agentStatusByPaneKey: pasteState.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: pasteState.paneForegroundAgentByPaneKey,
tabId,
leafId: clickedPane.leafId
}),
terminalBracketedPasteMode: clickedPane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
@@ -2754,7 +2793,7 @@ function TerminalPane(
recordTerminalUserInputForLeaf(tabId, clickedPane.leafId)
})
},
[getPrimarySelectionMiddleClickPane, tabId, worktreeId]
[getPrimarySelectionMiddleClickPane, forceBracketedMultilineTextPaste, tabId, worktreeId]
)
const handlePrimarySelectionAuxClick = useCallback(
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { pasteTerminalText } from './terminal-bracketed-paste'
import {
executeTerminalPastePlan,
planTerminalPaste,
type TerminalPasteTarget,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
const TAB_ID = 'tab-1'
const AGENT_LEAF = '11111111-1111-4111-8111-111111111111'
const SHELL_LEAF = '22222222-2222-4222-8222-222222222222'
const AGENT_PANE_KEY = `${TAB_ID}:${AGENT_LEAF}`
function agentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
stateHistory: [],
agentType: 'codex',
paneKey: AGENT_PANE_KEY,
...overrides
}
}
function foregroundEntry(
overrides: Partial<PaneForegroundAgentEntry> = {}
): PaneForegroundAgentEntry {
return { agent: null, shellForeground: false, ...overrides }
}
const CODEX_ON_AGENT_LEAF = { [AGENT_PANE_KEY]: agentEntry() }
function decide(
args: Partial<Parameters<typeof resolveProtectedMultilinePasteOptionsForPane>[0]> = {}
): TerminalPasteTextOptions | undefined {
return resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: false,
hostPlatform: 'linux',
agentStatusByPaneKey: CODEX_ON_AGENT_LEAF,
paneForegroundAgentByPaneKey: {},
tabId: TAB_ID,
leafId: AGENT_LEAF,
...args
})
}
describe('resolveProtectedMultilinePasteOptionsForPane', () => {
it('brackets an agent pane on a non-Windows host', () => {
expect(decide()).toEqual({ forceBracketedPasteForMultiline: true })
})
it('leaves a plain shell pane alone so ESC[200~ never reaches a non-TUI program', () => {
expect(decide({ leafId: SHELL_LEAF })).toBeUndefined()
})
it('gates per leaf, not per tab', () => {
expect(decide({ tabId: 'other-tab' })).toBeUndefined()
})
it('keeps the existing Windows-client behaviour for non-agent panes', () => {
expect(decide({ isWindowsClient: true, agentStatusByPaneKey: {}, leafId: SHELL_LEAF })).toEqual(
{ forceBracketedPasteForMultiline: true }
)
})
it('ignores a pane whose agentType is not a TUI agent', () => {
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({
agentType: 'not-an-agent' as AgentStatusEntry['agentType']
})
}
})
).toBeUndefined()
})
it('brackets on a process-confirmed agent even with no status row', () => {
expect(
decide({
agentStatusByPaneKey: {},
paneForegroundAgentByPaneKey: {
[AGENT_PANE_KEY]: foregroundEntry({ agent: 'codex', shellForeground: false })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('vetoes a row rehydrated from disk across an app restart', () => {
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ restoredUnconfirmed: true })
}
})
).toBeUndefined()
})
it('still brackets a long-idle agent parked at done', () => {
// Why: an idle-but-live agent sits at `done` with an old updatedAt. Gating on
// state or the 30-minute freshness TTL would strip bracketing from exactly the
// pane that needs it most.
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ state: 'done', updatedAt: 0, stateStartedAt: 0 })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('degrades to the pre-fix path instead of throwing on a malformed pane key', () => {
// Why: makePaneKey throws on a non-UUID leaf or a tabId containing ':'. The throw
// would escape before the paste helper's catch is attached, making the paste a
// silent no-op with no error surface.
expect(() => decide({ leafId: 'not-a-uuid' })).not.toThrow()
expect(decide({ leafId: 'not-a-uuid' })).toBeUndefined()
expect(() => decide({ tabId: 'tab:with:colons' })).not.toThrow()
expect(decide({ tabId: 'tab:with:colons' })).toBeUndefined()
})
it('still brackets when shellForeground is latched true while an agent owns the pane', () => {
// Why: shellForeground is republished only at OSC 133 boundaries, so a shell with
// no 133 integration leaves it true while an agent runs. Measured live: vetoing on
// it reinstated the submit bug.
expect(
decide({
paneForegroundAgentByPaneKey: {
[AGENT_PANE_KEY]: foregroundEntry({ shellForeground: true })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('uses modified Enter for an agent that reads Windows input records', () => {
expect(decide({ hostPlatform: 'win32' })).toEqual({
windowsInputRecordNewline: 'alt-enter'
})
})
it('does not change unverified Windows agent paste protocols', () => {
expect(
decide({
hostPlatform: 'win32',
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ agentType: 'pi' })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
})
describe('leading-newline paste into a remote agent pane', () => {
// Regression: a mac client on a remote Windows host pasted a block starting with "\n".
// ConPTY never forwarded DECSET 2004, so xterm rewrote the newline to CR and codex
// submitted the draft parked in its composer.
const PASTED = '\nRemember: At the end of the day, we want the best possible code.'
function remoteWindowsTarget(): TerminalPasteTarget {
return {
kind: 'terminal',
paneId: 1,
leafId: AGENT_LEAF,
ptyId: 'remote:host-abc/pty-1',
runtime: resolveTerminalPasteRuntime({
platform: 'win32',
ptyId: 'remote:host-abc/pty-1',
isWindowsConpty: false
})
}
}
it('encodes the leading newline as modified Enter instead of submit', async () => {
const terminal = {
modes: { bracketedPasteMode: false },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(),
paste: vi.fn()
}
const plan = planTerminalPaste({
text: PASTED,
source: 'keyboard',
target: remoteWindowsTarget(),
terminalBracketedPasteMode: false,
...decide({ hostPlatform: 'win32' })
})
await executeTerminalPastePlan(plan, {
pasteText: (text, options) => pasteTerminalText(terminal, text, options)
})
expect(plan.payload.lineCount).toBe(2)
expect(plan.mode).toBe('windows-input-record')
expect(plan.bracketed).toBe(false)
expect(terminal.input).toHaveBeenCalledWith(`\x1b\r${PASTED.slice(1)}`)
expect(terminal.paste).not.toHaveBeenCalled()
})
it('a single-line paste stays on the direct path', () => {
const plan = planTerminalPaste({
text: 'no newline here',
source: 'keyboard',
target: remoteWindowsTarget(),
terminalBracketedPasteMode: false,
...decide({ hostPlatform: 'win32' })
})
expect(plan.mode).toBe('direct')
})
})
@@ -0,0 +1,93 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
import type { TerminalPasteTextOptions } from './terminal-paste-model'
/**
* Why: xterm brackets a paste only after seeing DECSET 2004, which can be lost by
* remote replay or ConPTY. Unprotected CR submits an agent's parked draft.
*
* Why keyed on the pane's agent and not on the mode bit itself: "we never observed
* DECSET 2004" is not usable evidence. Measured in real ptys — zsh 5.9, fish 4.8.1 and
* bash >= 5.1 emit `?2004h` at the prompt and `?2004l` just before exec, but macOS
* /bin/bash 3.2, /bin/sh, bash 4.4, and any shell with bracketed paste turned off in
* .inputrc/zle emit *nothing at all*. A deliberate opt-out is byte-identical to a bare
* `cat`. So silence cannot be read as "nobody has an opinion".
*
* Agent identity disambiguates silence because a TUI agent enables bracketed paste.
* A verified Windows input-record agent cannot receive paste frames; for that explicit
* capability, use the same modified-Enter newline contract as Shift+Enter.
*
* Why this diverges from terminal-ctrl-enter / terminal-windows-shift-enter, which veto
* on shellForeground/routingRevoked and require routingTrusted: those resolvers decide
* where to ROUTE input bytes, so a forged identity misdelivers keystrokes. This one only
* decides how to encode a user-requested paste, whose ESC bytes are sanitized downstream.
*/
export function resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient,
hostPlatform,
agentStatusByPaneKey,
paneForegroundAgentByPaneKey,
tabId,
leafId
}: {
isWindowsClient: boolean
hostPlatform: NodeJS.Platform
agentStatusByPaneKey: Record<string, AgentStatusEntry>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
tabId: string
leafId: string
}): TerminalPasteTextOptions | undefined {
let paneKey: string
try {
paneKey = makePaneKey(tabId, leafId)
} catch {
// Why: unreachable today (pane.leafId is a minted UUID), but a legacy/malformed
// layout must degrade to the pre-fix path, not throw out of the paste handler --
// the throw would escape before pasteTerminalClipboard's catch is attached and
// the paste would be a silent no-op with no error surface.
return isWindowsClient ? { forceBracketedPasteForMultiline: true } : undefined
}
return resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient,
hostPlatform,
foregroundAgent: paneForegroundAgentByPaneKey[paneKey]?.agent,
entry: agentStatusByPaneKey[paneKey]
})
}
export function resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient,
hostPlatform,
foregroundAgent,
entry
}: {
isWindowsClient: boolean
hostPlatform: NodeJS.Platform
foregroundAgent: unknown
entry: AgentStatusEntry | undefined
}): TerminalPasteTextOptions | undefined {
// Why: a row rehydrated from disk across a restart describes the previous process,
// not the shell now attached to this pane.
const agent = isTuiAgent(foregroundAgent)
? foregroundAgent
: entry?.restoredUnconfirmed !== true && isTuiAgent(entry?.agentType)
? entry.agentType
: null
// Why NOT vetoed on shellForeground: that flag is republished only at OSC 133
// boundaries, so a shell without 133 integration leaves it latched true while an
// agent owns the foreground. Vetoing on it silently reinstates the submit bug —
// measured live. An idle agent also sits at `done` past the 30-minute freshness
// TTL, so neither state nor TTL can gate this either. A false negative sends the
// user's parked draft; a false positive only changes encoding within this paste.
const windowsInputRecordPasteNewline = agent
? TUI_AGENT_CONFIG[agent].windowsInputRecordPasteNewline
: undefined
if (hostPlatform === 'win32' && windowsInputRecordPasteNewline) {
return {
windowsInputRecordNewline: windowsInputRecordPasteNewline
}
}
return isWindowsClient || agent ? { forceBracketedPasteForMultiline: true } : undefined
}
@@ -111,6 +111,17 @@ describe('terminal bracketed paste policy', () => {
expect(terminal.paste).not.toHaveBeenCalled()
})
it('encodes Windows input-record newlines atomically and sanitizes escape bytes', () => {
const terminal = createTerminal(false)
pasteTerminalText(terminal, '\none\r\ntwo\x1b[201~', {
windowsInputRecordNewline: 'alt-enter'
})
expect(terminal.input).toHaveBeenCalledWith('\x1b\rone\x1b\rtwo␛[201~')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('does not change paste behavior when Ctrl+C happened outside bracketed paste mode', () => {
const terminal = createTerminal(false)
const observedIgnoreValues: (boolean | undefined)[] = []
@@ -1,4 +1,5 @@
import type { Terminal } from '@xterm/xterm'
import type { WindowsInputRecordNewline } from './terminal-paste-model'
type BracketedPasteTerminal = {
modes: {
@@ -14,6 +15,7 @@ type PasteTerminal = BracketedPasteTerminal & {
type PasteTerminalTextOptions = {
forceBracketedPaste?: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
}
const interruptedBracketedPasteTerminals = new WeakSet<object>()
@@ -77,6 +79,28 @@ export function wrapTerminalBracketedPasteText(text: string): string {
return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}`
}
export function encodeWindowsInputRecordPasteText(
text: string,
newline: WindowsInputRecordNewline
): string {
const newlineSequence = newline === 'csi-u' ? '\x1b[13;2u' : '\x1b\r'
let encoded = ''
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (char === '\r') {
encoded += newlineSequence
if (text[index + 1] === '\n') {
index += 1
}
} else if (char === '\n') {
encoded += newlineSequence
} else {
encoded += char === ESCAPE ? '\u241b' : char
}
}
return encoded
}
function forceBracketedPaste(terminal: PasteTerminal, text: string): void {
// Why: forced callers already built the exact paste protocol bytes. Send
// them as PTY input so xterm's DOM/native paste machinery cannot defer them.
@@ -110,6 +134,12 @@ export function pasteTerminalText(
text: string,
options?: PasteTerminalTextOptions
): void {
if (options?.windowsInputRecordNewline) {
// Why: input-record TUIs see bracket markers as keys; modified Enter preserves
// pasted newlines without turning the first one into submit.
terminal.input(encodeWindowsInputRecordPasteText(text, options.windowsInputRecordNewline))
return
}
if (options?.forceBracketedPaste) {
// Why: generated image paths are paste payloads, even when they are a
// single line, so they must bypass stale Ctrl+C plain-text suppression.
@@ -308,6 +308,21 @@ describe('terminal clipboard paste', () => {
})
})
it('delegates Windows input-record newline protection without scanning text', async () => {
const pasteText = vi.fn()
await pasteTerminalClipboard({
readClipboardText: vi.fn().mockResolvedValue('\nline two'),
saveClipboardImageAsTempFile: vi.fn(),
pasteText,
protectedMultilineTextPasteOptions: { windowsInputRecordNewline: 'alt-enter' }
})
expect(pasteText).toHaveBeenCalledWith('\nline two', {
windowsInputRecordNewline: 'alt-enter'
})
})
it('keeps single-line text on the ordinary paste path when Windows multi-line protection is on', async () => {
const saveClipboardImageAsTempFile = vi.fn()
const pasteText = vi.fn()
@@ -22,6 +22,7 @@ type PasteTerminalClipboardDeps = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
forceBracketedMultilineTextPaste?: boolean
protectedMultilineTextPasteOptions?: TerminalPasteTextOptions
onTextPasteError?: (error: unknown) => void
onImagePasteError?: (error: unknown) => void
}
@@ -46,6 +47,7 @@ export async function pasteTerminalClipboard({
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste = false,
protectedMultilineTextPasteOptions,
onTextPasteError,
onImagePasteError
}: PasteTerminalClipboardDeps): Promise<TerminalClipboardPasteResult> {
@@ -62,9 +64,10 @@ export async function pasteTerminalClipboard({
}
if (text) {
try {
const result = await (forceBracketedMultilineTextPaste
? pasteText(text, { forceBracketedPasteForMultiline: true })
: pasteText(text))
const textOptions =
protectedMultilineTextPasteOptions ??
(forceBracketedMultilineTextPaste ? { forceBracketedPasteForMultiline: true } : undefined)
const result = await (textOptions ? pasteText(text, textOptions) : pasteText(text))
if (result === false) {
return { status: 'skipped', reason: 'text-paste-rejected' }
}
@@ -17,15 +17,19 @@ export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] {
}
export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Generator<string> {
const maxChunkBytes = Math.max(4, plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES)
const maxChunkBytes = Math.max(
plan.windowsInputRecordNewline === 'csi-u' ? 8 : 4,
plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES
)
if (plan.bracketed) {
yield BRACKETED_PASTE_START
}
yield* iterateTextByUtf8Bytes(
plan.payload.plainText,
maxChunkBytes,
plan.bracketed,
plan.newlinePolicy === 'terminal-cr'
plan.bracketed || plan.newlinePolicy === 'windows-input-record',
plan.newlinePolicy,
plan.windowsInputRecordNewline
)
if (plan.bracketed) {
yield BRACKETED_PASTE_END
@@ -36,7 +40,8 @@ function* iterateTextByUtf8Bytes(
text: string,
maxBytes: number,
sanitizeEscapes: boolean,
normalizeLineEndings: boolean
newlinePolicy: TerminalPastePlan['newlinePolicy'],
windowsInputRecordNewline?: TerminalPastePlan['windowsInputRecordNewline']
): Generator<string> {
let chunk = ''
let chunkBytes = 0
@@ -45,26 +50,34 @@ function* iterateTextByUtf8Bytes(
const codeUnitLength = codePoint > 0xffff ? 2 : 1
// Why: iterator normalization avoids a full-size copy and keeps CRLF atomic across chunks.
if (
normalizeLineEndings &&
newlinePolicy !== 'preserve' &&
codePoint === LINE_FEED_CODE_POINT &&
index > 0 &&
text.charCodeAt(index - 1) === CARRIAGE_RETURN_CODE_POINT
) {
continue
}
const normalizedCodePoint =
normalizeLineEndings && codePoint === LINE_FEED_CODE_POINT
? CARRIAGE_RETURN_CODE_POINT
: codePoint
const isLineEnding =
newlinePolicy !== 'preserve' &&
(codePoint === LINE_FEED_CODE_POINT || codePoint === CARRIAGE_RETURN_CODE_POINT)
const normalizedCodePoint = isLineEnding ? CARRIAGE_RETURN_CODE_POINT : codePoint
const sanitizedEscape = sanitizeEscapes && codePoint === TERMINAL_PASTE_ESCAPE_CODE_POINT
const next = sanitizedEscape
? TERMINAL_PASTE_INERT_ESCAPE
: normalizedCodePoint === codePoint
? text.slice(index, index + codeUnitLength)
: '\r'
const nextBytes = getUtf8ByteLengthForCodePoint(
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint
)
const next =
isLineEnding && newlinePolicy === 'windows-input-record'
? windowsInputRecordNewline === 'csi-u'
? '\x1b[13;2u'
: '\x1b\r'
: sanitizedEscape
? TERMINAL_PASTE_INERT_ESCAPE
: normalizedCodePoint === codePoint
? text.slice(index, index + codeUnitLength)
: '\r'
const nextBytes =
isLineEnding && newlinePolicy === 'windows-input-record'
? next.length
: getUtf8ByteLengthForCodePoint(
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint
)
if (chunk && chunkBytes + nextBytes > maxBytes) {
yield chunk
chunk = next
@@ -242,6 +242,64 @@ describe('terminal paste coordinator', () => {
expect(chunks.join('')).not.toContain('\n')
})
it('streams large Windows input-record paste with atomic modified Enter newlines', () => {
const plan = planTerminalPaste({
text: '\nabc\r\ndef\x1b[201~ghi',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
terminalBracketedPasteMode: true,
maxDirectBytes: 4,
maxChunkBytes: 4
})
const chunks = chunkTerminalPastePlan(plan)
expect(plan.mode).toBe('chunked')
expect(plan.bracketed).toBe(false)
expect(plan.newlinePolicy).toBe('windows-input-record')
expect(chunks.join('')).toBe('\x1b\rabc\x1b\rdef␛[201~ghi')
expect(chunks).not.toContain('\x1b[200~')
expect(chunks).not.toContain('\x1b[201~')
})
it('accounts for modified-Enter expansion before choosing bounded chunks', () => {
const plan = planTerminalPaste({
text: '\n\n\n\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
maxDirectBytes: 6,
maxBytes: 16
})
expect(plan.mode).toBe('chunked')
expect(chunkTerminalPastePlan(plan).join('')).toBe('\x1b\r\x1b\r\x1b\r\x1b\r')
})
it('counts CRLF by its exact encoded size at paste limits', () => {
const altEnter = planTerminalPaste({
text: 'a\r\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
maxDirectBytes: 3,
maxBytes: 3
})
const csiU = planTerminalPaste({
text: 'a\r\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'csi-u',
maxDirectBytes: 8,
maxBytes: 8
})
expect(altEnter.mode).toBe('windows-input-record')
expect(chunkTerminalPastePlan(altEnter).join('')).toBe('a\x1b\r')
expect(csiU.mode).toBe('windows-input-record')
expect(chunkTerminalPastePlan(csiU).join('')).toBe('a\x1b[13;2u')
})
it('chunks escape-heavy bracketed paste without per-character string sanitizer scans', () => {
const text = Array.from({ length: 64 }, (_value, index) => `part-${index}\x1b[201~`).join('')
const plan = planTerminalPaste({

Some files were not shown because too many files have changed in this diff Show More