Merge stably/main into cloud/integration

# Conflicts:
#	src/renderer/src/components/native-chat-recovery/NativeChatResumeRuntimeModal.tsx
#	src/renderer/src/components/settings/page/settings-page-renderer.tsx
#	src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx
#	src/renderer/src/i18n/locales/en.json
This commit is contained in:
Neil
2026-11-09 18:02:21 -08:00
1124 changed files with 18720 additions and 22242 deletions
@@ -19,7 +19,6 @@ on:
# Contracts without the .performance suffix. Keep in sync with config/vitest.performance.config.ts;
# without these a rename lands green and only breaks the next nightly.
- 'src/main/runtime/session-tabs/session-tabs-phone-layout-moves.test.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection/startup/saved-resume-cancellation.test.ts'
- 'src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-terminal-writer-acquire.test.ts'
- 'src/shared/agent-launch/permissions/tui-agent-permission-choice.test.ts'
- 'src/main/runtime/rpc/methods/projects/repo.test.ts'
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Why: import.meta.url, not dirname — electron-vite's config bundle rewrites only the former.
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
export const UNCOMPILED_LIST = 'config/react-compiler-uncompiled-files.txt'
// Hand-kept list plus every file still in a hazard ratchet; compiled, these would run differently.
export const UNCOMPILED_FILE_LISTS = [
UNCOMPILED_LIST,
'config/render-path-translate-baseline.txt',
'config/react-compiler-hazard-baseline.txt'
]
/** Repo-relative paths from a list or count baseline: `[count ]path[#detail][ # note]`. */
export function parseListedFiles(text) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) =>
line
.replace(/^\d+\s+/, '')
.split('#')[0]
.trim()
)
}
export function readListedFiles(list, root = REPO_ROOT) {
return parseListedFiles(readFileSync(resolve(root, list), 'utf8'))
}
export function reactCompilerExcludedFiles(root = REPO_ROOT) {
return [...new Set(UNCOMPILED_FILE_LISTS.flatMap((list) => readListedFiles(list, root)))]
}
+1 -21
View File
@@ -1,25 +1,5 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Options } from '@vitejs/plugin-react'
// Hand-kept list plus every file still in a hazard ratchet; compiled, these would run differently.
const UNCOMPILED_FILE_LISTS = [
'config/react-compiler-uncompiled-files.txt',
'config/render-path-translate-baseline.txt',
'config/react-compiler-hazard-baseline.txt'
]
function listedFiles(list: string): string[] {
return readFileSync(resolve(list), 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => line.replace(/^\d+\s+/, '').split('#')[0])
}
export function reactCompilerExcludedFiles(): string[] {
return [...new Set(UNCOMPILED_FILE_LISTS.flatMap(listedFiles))]
}
import { reactCompilerExcludedFiles } from './react-compiler-uncompiled-files.mjs'
export function isReactCompilerEnabled(): boolean {
return process.env.ORCA_REACT_COMPILER !== '0'
+1 -1
View File
@@ -12,7 +12,7 @@
3 src/renderer/src/components/LinearIssueTextEditor.tsx
3 src/renderer/src/components/LinearIssueWorkspace.tsx
5 src/renderer/src/components/LinearItemDrawer.tsx
1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx
1 src/renderer/src/components/native-chat-recovery/tree/NativeChatResumeOnRestartGroups.tsx
1 src/renderer/src/components/activity/ActivityPrototypePage.tsx
1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx
2 src/renderer/src/components/activity/activity-thread-actions.ts
+1 -1
View File
@@ -206,7 +206,7 @@
1 src/renderer/src/components/mobile/WindowsFirewallNotice.tsx
2 src/renderer/src/components/mobile/use-mobile-page-paired-devices.ts
1 src/renderer/src/components/mobile/use-mobile-pairing-generation.ts
1 src/renderer/src/components/native-chat-restart-runtimes.ts
1 src/renderer/src/components/native-chat-recovery/offers/native-chat-restart-runtimes.ts
1 src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
8 src/renderer/src/components/native-chat/attachments/chips/use-native-chat-composer-attachments.ts
2 src/renderer/src/components/native-chat/attachments/intake/use-native-chat-resolved-path-attachments.ts
+1 -1
View File
@@ -1,5 +1,5 @@
# Grandfathered code React Compiler would run differently (<file>#<function>:<hazard>).
# Generated; never hand-edit. Lower after a fix: pnpm check:react-compiler-compat-ratchet --prune
1 src/renderer/src/components/agent/agent-launch-picker-drafts.ts#useAgentPickerSetup:discarded-subscription
1 src/renderer/src/components/native-chat-restart-runtimes.ts#useNativeChatRestartRuntimes:discarded-subscription
1 src/renderer/src/components/native-chat-recovery/offers/native-chat-restart-runtimes.ts#useNativeChatRestartRuntimes:discarded-subscription
1 src/renderer/src/components/right-sidebar/source-control/ai/use-source-control-action-agent-choice.ts#useSourceControlActionAgentChoice:store-read-in-memo-block
File diff suppressed because one or more lines are too long
@@ -578,7 +578,9 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE
export async function buildMobileWebAppBundle({
appDir,
outDir = defaultOutDir,
pageRoutes = MOBILE_WEB_PAGE_ROUTES
pageRoutes = MOBILE_WEB_PAGE_ROUTES,
// Only the shipped tree pays for brotli-11; tests and verify's scratch builds read raw assets.
brotli = false
} = {}) {
const [
desktopVersion,
@@ -624,7 +626,8 @@ export async function buildMobileWebAppBundle({
written: [indexAsset, ...written],
desktopVersion,
protocolWindow,
routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes)
routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes),
brotli
})
return {
manifest,
@@ -649,7 +652,7 @@ if (isDirectInvocation(import.meta.url, process.argv[1])) {
}
try {
const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } =
await buildMobileWebAppBundle()
await buildMobileWebAppBundle({ brotli: true })
console.log(
`[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` +
`${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` +
@@ -15,7 +15,11 @@ const PIXEL_PNG = Buffer.from(
'base64'
)
export async function writeMobileWebBundleFixtureTree({ outDir, body = 'fixture' }) {
export async function writeMobileWebBundleFixtureTree({
outDir,
body = 'fixture',
brotli = false
}) {
const [desktopVersion, protocolWindow] = await Promise.all([
readDesktopVersion(),
readProtocolWindow()
@@ -49,6 +53,7 @@ export async function writeMobileWebBundleFixtureTree({ outDir, body = 'fixture'
image
],
desktopVersion,
protocolWindow
protocolWindow,
brotli
})
}
@@ -3,10 +3,13 @@ import { readFile, mkdir, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { brotliCompress, constants as zlibConstants } from 'node:zlib'
const { darkBuildMarkers } = createRequire(import.meta.url)('../../../dark-build-switches.cjs')
const projectDir = fileURLToPath(new URL('../../../..', import.meta.url))
const brotliCompressAsync = promisify(brotliCompress)
export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1
export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html'
@@ -101,6 +104,31 @@ export async function readDesktopVersion() {
return packageJson.version
}
// Mirrors mobileWebBundleBrotliPath in src/shared/mobile-web-bundle/manifest-contract.ts.
export function mobileWebBundleBrotliPath(assetPath) {
return `${assetPath}.br`
}
// Largest first so the slowest file starts first on zlib's thread pool; keep only copies that shrink.
async function compressWithBrotli(written) {
const largestFirst = [...written].sort((left, right) => right.byteLength - left.byteLength)
const copies = await Promise.all(
largestFirst.map((asset) =>
brotliCompressAsync(asset.bytes, {
params: {
[zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: asset.byteLength
}
})
)
)
return new Map(
largestFirst.flatMap((asset, index) =>
copies[index].byteLength < asset.byteLength ? [[asset.path, copies[index]]] : []
)
)
}
// Single manifest producer keeps builder/contract/packaging guard shape aligned.
export async function writeMobileWebBundleTree({
outDir,
@@ -109,10 +137,22 @@ export async function writeMobileWebBundleTree({
protocolWindow,
// Empty route list makes shell render every screen natively.
routes = [],
// Whole-file brotli copies for phones that inflate natively.
brotli = false,
env = process.env
}) {
const brotliCopies = brotli ? await compressWithBrotli(written) : new Map()
const assets = written
.map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType }))
.map(({ path, sha256, byteLength, contentType }) => {
const copy = brotliCopies.get(path)
return {
path,
sha256,
byteLength,
contentType,
...(copy === undefined ? {} : { brotliByteLength: copy.byteLength })
}
})
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
const manifest = {
schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION,
@@ -133,6 +173,9 @@ export async function writeMobileWebBundleTree({
for (const asset of written) {
await writeFile(join(outDir, asset.path), asset.bytes)
}
for (const [path, copy] of brotliCopies) {
await writeFile(join(outDir, mobileWebBundleBrotliPath(path)), copy)
}
await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
// Keep switch metadata outside manifest so compiled page bytes/buildId remain unchanged.
for (const marker of darkBuildMarkers(env)) {
@@ -4641,9 +4641,9 @@
"src/renderer/src/components/JiraIssueWorkspace.optimistic-edit.test.tsx": 2340,
"src/renderer/src/components/JiraIssueWorkspace.status-queue.test.tsx": 1852,
"src/renderer/src/components/JiraIssueWorkspace.transition-source.test.tsx": 1843,
"src/renderer/src/components/NativeChatRemoteResume.test.tsx": 8774,
"src/renderer/src/components/NativeChatResumeOnRestartGroups.selection.test.tsx": 2633,
"src/renderer/src/components/NativeChatResumeOnRestartGroups.test.tsx": 3472,
"src/renderer/src/components/native-chat-recovery/integration/NativeChatRemoteResume.test.tsx": 8774,
"src/renderer/src/components/native-chat-recovery/tree/NativeChatResumeOnRestartGroups.selection.test.tsx": 2633,
"src/renderer/src/components/native-chat-recovery/tree/NativeChatResumeOnRestartGroups.test.tsx": 3472,
"src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx": 4557,
"src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx": 1301,
"src/renderer/src/components/QuickOpen.selection.test.tsx": 1046,
@@ -5135,15 +5135,15 @@
"src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx": 691,
"src/renderer/src/components/mobile/paired-mobile-devices.test.ts": 192,
"src/renderer/src/components/mobile/use-mobile-page-paired-devices.test.ts": 475,
"src/renderer/src/components/native-chat-launch-resume-decision.test.tsx": 1844,
"src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx": 3261,
"src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts": 1954,
"src/renderer/src/components/native-chat-resume-failure-guidance.test.ts": 240,
"src/renderer/src/components/native-chat-resume-finished-run.test.tsx": 4173,
"src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts": 117,
"src/renderer/src/components/native-chat-resume-result-view.test.tsx": 2723,
"src/renderer/src/components/native-chat-resume-run.test.ts": 65,
"src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx": 3009,
"src/renderer/src/components/native-chat-recovery/offers/native-chat-launch-resume-decision.test.tsx": 1844,
"src/renderer/src/components/native-chat-recovery/integration/native-chat-resume-bulk-progress.test.tsx": 3261,
"src/renderer/src/components/native-chat-recovery/offers/native-chat-resume-failed-chat-watch.test.ts": 1954,
"src/renderer/src/components/native-chat-recovery/failures/native-chat-resume-failure-guidance.test.ts": 240,
"src/renderer/src/components/native-chat-recovery/integration/native-chat-resume-finished-run.test.tsx": 4173,
"src/renderer/src/components/native-chat-recovery/tree/native-chat-resume-on-restart-grouping.test.ts": 117,
"src/renderer/src/components/native-chat-recovery/integration/native-chat-resume-result-view.test.tsx": 2723,
"src/renderer/src/components/native-chat-recovery/runs/native-chat-resume-run.test.ts": 65,
"src/renderer/src/components/native-chat-recovery/integration/native-chat-resume-skipped-progress.test.tsx": 3009,
"src/renderer/src/components/native-chat/messages/rows/NativeChatAgentMessageCard.test.tsx": 3700,
"src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTaskRun.test.tsx": 242,
"src/renderer/src/components/native-chat/tools/tasks/NativeChatBackgroundTasksStatus.test.tsx": 782,
@@ -5155,7 +5155,7 @@
"src/renderer/src/components/native-chat/attachments/chips/NativeChatImageAttachmentPreview.test.tsx": 2818,
"src/renderer/src/components/native-chat/media/visuals/NativeChatInlineVisual.link-placement.test.tsx": 2477,
"src/renderer/src/components/native-chat/prompts/NativeChatInteractiveCard.test.tsx": 2881,
"src/renderer/src/components/native-chat/usage/NativeChatInterruptedContinue.test.tsx": 2719,
"src/renderer/src/components/native-chat/viewport/scroll/NativeChatJumpToLatest.test.tsx": 477,
"src/renderer/src/components/native-chat/markdown/code/NativeChatMarkdown.code-wrap.test.tsx": 3123,
"src/renderer/src/components/native-chat/media/images/NativeChatMarkdown.image-preview.test.tsx": 4346,
@@ -5802,9 +5802,6 @@
"src/renderer/src/components/terminal-pane/layout/close/RunningTerminalCloseDialog.test.tsx": 2303,
"src/renderer/src/components/terminal-pane/session-fork/TerminalAgentSessionForkDialog.test.tsx": 266,
"src/renderer/src/components/terminal-pane/titles/TerminalPaneHeaderOverlay.test.tsx": 1104,
"src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.test.tsx": 670,
"src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalRemoteRuntimeReconnectBanner.test.tsx": 812,
"src/renderer/src/components/terminal-pane/pty-transport/recovery/ssh/TerminalSshReconnectOverlay.test.tsx": 2354,
"src/renderer/src/components/terminal-pane/account-restart/detached/account-restart-queue-progress.test.ts": 1988,
"src/renderer/src/components/terminal-pane/agent-completion/agent-completion-coordinator-attention-dispatch.test.ts": 301,
"src/renderer/src/components/terminal-pane/agent-completion/agent-completion-coordinator-completion-replay-guard.test.ts": 318,
@@ -5897,7 +5894,6 @@
"src/renderer/src/components/terminal-pane/pty-connection/spawn/main-pane-start-wait.test.ts": 76,
"src/renderer/src/components/terminal-pane/pty-connection/serialization/pane-serializer-register.test.ts": 123,
"src/renderer/src/components/terminal-pane/pty-connection/host-ownership/pty-kitty-file-reader.test.ts": 297,
"src/renderer/src/components/terminal-pane/pty-connection/startup/saved-resume-cancellation.test.ts": 1882,
"src/renderer/src/components/terminal-pane/pty-connection/output-restore/snapshot-covered-terminal-queries.test.ts": 150,
"src/renderer/src/components/terminal-pane/pty-connection/output/terminal-input-echo-delay.test.ts": 132,
"src/renderer/src/components/terminal-pane/pty-connection/host-ownership/unserved-ssh-pane-bridge.test.ts": 70,
@@ -5925,7 +5921,6 @@
"src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-pi-coalesce.test.ts": 683,
"src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-reattach-admission.test.ts": 1636,
"src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-recycled-pty-incarnation.test.ts": 787,
"src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-spawn-errors.test.ts": 1218,
"src/renderer/src/components/terminal-pane/input/focus/regular-terminal-focus-ownership.test.ts": 208,
"src/renderer/src/components/terminal-pane/pty-connection/output-restore/remote-hidden-output-restore-outcomes.test.ts": 2794,
"src/renderer/src/components/terminal-pane/pty-connection/output-restore/remote-hidden-output-restore-unavailable-banner.repro.test.ts": 2355,
@@ -5992,7 +5987,6 @@
"src/renderer/src/components/terminal-pane/emulation/terminal-capability-replies.test.ts": 131,
"src/renderer/src/components/terminal-pane/clipboard/terminal-clipboard-paste.test.ts": 76,
"src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx": 701,
"src/renderer/src/components/terminal-pane/pty-transport/delivery/terminal-delivery-watchdog.test.ts": 111,
"src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-handler.test.ts": 638,
"src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-image-path.test.ts": 61,
"src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-internal-handler.test.ts": 800,
@@ -6265,8 +6259,8 @@
"src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx": 2358,
"src/renderer/src/hooks/useMetadataListRequest.test.tsx": 266,
"src/renderer/src/hooks/useModalReturnFocus.test.tsx": 1938,
"src/renderer/src/hooks/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 388,
"src/renderer/src/hooks/usePrimarySelectionPaste.test.tsx": 411,
"src/renderer/src/hooks/clipboard/primary-selection/usePrimarySelectionPaste.terminal-native-paste.test.tsx": 388,
"src/renderer/src/hooks/clipboard/primary-selection/usePrimarySelectionPaste.test.tsx": 411,
"src/renderer/src/hooks/useReadOnScreenAttention.away.test.ts": 2420,
"src/renderer/src/hooks/useReadOnScreenAttention.clock-skew.test.ts": 2093,
"src/renderer/src/hooks/useReadOnScreenAttention.floating-panel.test.ts": 2157,
@@ -6340,9 +6334,9 @@
"src/renderer/src/lib/agent-session/resume/history/ai-vault-session-resume-preparation.test.ts": 408,
"src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync-inputs.test.ts": 476,
"src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-sync.test.ts": 925,
"src/renderer/src/lib/app-command-dispatch.test.ts": 64,
"src/renderer/src/lib/app-keyboard-router.test.ts": 187,
"src/renderer/src/lib/app-menu-paste.test.ts": 229,
"src/renderer/src/lib/app-commands/app-command-dispatch.test.ts": 64,
"src/renderer/src/lib/keyboard/app-keyboard-router.test.ts": 187,
"src/renderer/src/lib/clipboard/menu/app-menu-paste.test.ts": 229,
"src/renderer/src/lib/automation-session-reuse.test.ts": 70,
"src/renderer/src/lib/automation-terminal-ownership.test.ts": 1795,
"src/renderer/src/lib/bounded-generation-map.test.ts": 35,
@@ -6358,7 +6352,7 @@
"src/renderer/src/lib/source-control/checks/check-run-details-log-normalization.test.ts": 75,
"src/renderer/src/lib/claude-account-switch-sessions.test.ts": 782,
"src/renderer/src/lib/claude-session-account-move.test.ts": 1816,
"src/renderer/src/lib/clipboard-image-copy.test.ts": 243,
"src/renderer/src/lib/clipboard/images/clipboard-image-copy.test.ts": 243,
"src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts": 526,
"src/renderer/src/lib/cmd-j-linear-issue-intent.test.ts": 42,
"src/renderer/src/lib/code-host-error-copy.test.ts": 297,
@@ -6367,8 +6361,7 @@
"src/renderer/src/lib/agent-session/accounts/codex/codex-session-restart.test.ts": 2039,
"src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1823,
"src/renderer/src/lib/codex-stale-pane-sweep.test.ts": 1817,
"src/renderer/src/lib/comment-body-line-count.test.ts": 84,
"src/renderer/src/lib/comment-body-submit-state.test.ts": 55,
"src/renderer/src/lib/source-control/review/comments/comment-body-submit-state.test.ts": 55,
"src/renderer/src/lib/composer-submit-cancellation.test.ts": 41,
"src/renderer/src/lib/workspace/ownership/connection-context.test.ts": 1817,
"src/renderer/src/lib/crash-diagnostics.test.ts": 176,
@@ -6385,15 +6378,14 @@
"src/renderer/src/runtime/hosts/platform/execution-host-facts.test.ts": 1796,
"src/renderer/src/lib/feature-education-telemetry.test.ts": 52,
"src/renderer/src/lib/feedback-image-attachments.test.ts": 557,
"src/renderer/src/lib/browser/documents/file-preview-link-open.test.ts": 2311,
"src/renderer/src/lib/file-search-selection.test.ts": 51,
"src/renderer/src/lib/find-query-bounds.test.ts": 45,
"src/renderer/src/lib/finished-agent-resume-resurrection.test.ts": 1872,
"src/renderer/src/lib/focus-terminal-tab-surface.split-focus.test.ts": 331,
"src/renderer/src/lib/foreground-terminal-tabs.test.ts": 55,
"src/renderer/src/lib/github-links.test.ts": 76,
"src/renderer/src/lib/github-source-runtime-context.test.ts": 62,
"src/renderer/src/lib/gitlab-links.test.ts": 56,
"src/shared/new-workspace/github-links.test.ts": 76,
"src/renderer/src/lib/work-items/github/github-source-runtime-context.test.ts": 62,
"src/shared/new-workspace/gitlab-links.test.ts": 56,
"src/renderer/src/lib/hook-command-delayed-delivery.test.ts": 1904,
"src/renderer/src/lib/host-chat-program.test.ts": 2419,
"src/renderer/src/lib/host-mirror-handle-gap-drain.test.ts": 1706,
@@ -6401,11 +6393,10 @@
"src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts": 1814,
"src/renderer/src/lib/browser/navigation/http-link-completion.test.ts": 1760,
"src/renderer/src/lib/browser/navigation/http-link-routing-store.test.ts": 2211,
"src/renderer/src/lib/image-blob-png.test.ts": 253,
"src/renderer/src/lib/ime-composition-keyboard-event.test.ts": 274,
"src/renderer/src/lib/ime-text-field.test.tsx": 739,
"src/renderer/src/lib/text-input/ime/ime-composition-keyboard-event.test.ts": 274,
"src/renderer/src/lib/text-input/ime/ime-text-field.test.tsx": 739,
"src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts": 48,
"src/renderer/src/lib/large-text-control-paste.test.ts": 220,
"src/renderer/src/lib/text-input/paste/large-text-control-paste.test.ts": 220,
"src/renderer/src/lib/agent-session/launch/background/launch-agent-background-session-launch-parity.test.ts": 2792,
"src/renderer/src/lib/agent-session/launch/background/launch-agent-background-session.test.ts": 1040,
"src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts": 1821,
@@ -6422,11 +6413,10 @@
"src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 194,
"src/renderer/src/lib/lazy-with-retry.preload.test.tsx": 208,
"src/renderer/src/lib/lazy-with-retry.test.ts": 282,
"src/renderer/src/lib/linear-board-drag-payload.test.ts": 58,
"src/renderer/src/lib/work-items/linear/linear-issue-context-snapshot.test.ts": 55,
"src/renderer/src/lib/work-items/linear/linear-board-drag-payload.test.ts": 58,
"src/renderer/src/lib/work-items/linear/linear-issue-workspace-attachment.test.ts": 99,
"src/renderer/src/lib/link-source-local-open-owner.test.ts": 433,
"src/renderer/src/lib/linked-work-item-context.test.ts": 64,
"src/renderer/src/lib/work-items/linked-work-item-context.test.ts": 64,
"src/renderer/src/lib/editor/monaco/monaco-editor-gutter-disposal.test.ts": 295,
"src/renderer/src/lib/monaco-languages/markdown-heading-outline.test.ts": 70,
"src/renderer/src/lib/monaco-languages/monarch-embed-entry-recursion.test.ts": 697,
@@ -6510,14 +6500,14 @@
"src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts": 53,
"src/renderer/src/lib/pane-spawn-deferral-drain.test.ts": 61,
"src/renderer/src/lib/parked-terminal-host-hydration.test.ts": 274,
"src/renderer/src/lib/paste-payload-metadata.test.ts": 53,
"src/renderer/src/lib/text-input/paste/paste-payload-metadata.test.ts": 53,
"src/renderer/src/lib/path-head-elision.test.ts": 53,
"src/renderer/src/lib/path.test.ts": 55,
"src/renderer/src/lib/plugin-command-keybindings.test.ts": 1909,
"src/renderer/src/lib/plugin-command-replacements.test.ts": 95,
"src/renderer/src/lib/plugin-icons/plugin-icon-svg.test.ts": 231,
"src/renderer/src/lib/plugin-monaco-token-rules.test.ts": 51,
"src/renderer/src/lib/pr-bot-author-overrides.test.ts": 349,
"src/renderer/src/lib/source-control/review/pr-bot-author-overrides.test.ts": 349,
"src/renderer/src/lib/source-control/review/comments/pr-comment-thread-resolution.test.ts": 48,
"src/renderer/src/lib/project-clone-url-prefill.test.ts": 61,
"src/renderer/src/lib/project-host-workspace-target.jira-mapping.test.ts": 76,
@@ -6579,8 +6569,8 @@
"src/renderer/src/lib/task-provider-error-copy.test.ts": 363,
"src/renderer/src/lib/terminal-app-theme.test.ts": 301,
"src/renderer/src/lib/terminal-input-activity-coalescing.test.ts": 43,
"src/renderer/src/lib/text-control-paste-ownership.test.ts": 217,
"src/renderer/src/lib/text-control-paste.test.ts": 222,
"src/renderer/src/lib/text-input/paste/text-control-paste-ownership.test.ts": 217,
"src/renderer/src/lib/text-input/paste/text-control-paste.test.ts": 222,
"src/renderer/src/lib/text-generation-error-copy.test.ts": 466,
"src/renderer/src/lib/typing-latency/echo-instrumentation.test.ts": 145,
"src/renderer/src/lib/typing-latency/renderer-long-tasks.test.ts": 75,
@@ -6836,7 +6826,6 @@
"src/renderer/src/store/slices/editor/git/editor-git-status-reconciliation.test.ts": 870,
"src/renderer/src/store/slices/editor/tabs/editor-layout-owner-records.test.ts": 2006,
"src/renderer/src/store/slices/editor/markdown/editor-markdown-link-activation.test.ts": 953,
"src/renderer/src/store/slices/editor/markdown/editor-markdown-view-state.test.ts": 801,
"src/renderer/src/store/slices/editor/git/editor-open-diff.test.ts": 1987,
"src/renderer/src/store/slices/editor/tabs/editor-open-link-placement.test.ts": 1987,
"src/renderer/src/store/slices/editor/tabs/editor-preview-tab-setting.test.ts": 1952,
@@ -1,6 +1,7 @@
const { createHash } = require('node:crypto')
const { readFileSync, readdirSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
const { brotliDecompressSync } = require('node:zlib')
const { darkBuildMarkerFileNames } = require('../../../dark-build-switches.cjs')
@@ -98,6 +99,12 @@ function parseManifest(bundleDir) {
if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) {
throw failure(`manifest asset ${asset.path} has no contentType`)
}
if (asset.brotliByteLength !== undefined) {
assertInteger(asset.brotliByteLength, `assets[${asset.path}].brotliByteLength`)
if (asset.brotliByteLength >= asset.byteLength) {
throw failure(`manifest asset ${asset.path} declares a brotli copy no smaller than itself`)
}
}
}
if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) {
throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`)
@@ -132,13 +139,33 @@ function listBundleFiles(directory, prefix = '') {
return found
}
// Mirrors mobileWebBundleBrotliPath in src/shared/mobile-web-bundle/manifest-contract.ts.
function brotliPath(asset) {
return `${asset.path}.br`
}
function brotliCopies(manifest) {
return manifest.assets.filter((asset) => asset.brotliByteLength !== undefined)
}
function statSize(bundleDir, path) {
try {
return statSync(join(bundleDir, path)).size
} catch (error) {
throw failure(
`manifest lists ${path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
)
}
}
// Reject unlisted on-disk assets: content-addressed stale files survive interrupted builds and would ship unreachable/unverified in asar.
function assertNoUnlistedFiles(bundleDir, manifest) {
// A dark build's marker is accounted for here; packaging refuses it through assertNotDarkBuild.
const listed = new Set([
'manifest.json',
...darkBuildMarkerFileNames(),
...manifest.assets.map((asset) => asset.path)
...manifest.assets.map((asset) => asset.path),
...brotliCopies(manifest).map((asset) => brotliPath(asset))
])
const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path))
if (strays.length > 0) {
@@ -155,14 +182,7 @@ function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
assertNoUnlistedFiles(bundleDir, manifest)
for (const asset of manifest.assets) {
const assetPath = join(bundleDir, asset.path)
let size
try {
size = statSync(assetPath).size
} catch (error) {
throw failure(
`manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
)
}
const size = statSize(bundleDir, asset.path)
if (size !== asset.byteLength) {
throw failure(
`${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}`
@@ -175,6 +195,20 @@ function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
)
}
}
// Phones verify only the inflated bytes, so a copy that inflates to anything else fails every download.
for (const asset of brotliCopies(manifest)) {
const path = brotliPath(asset)
const size = statSize(bundleDir, path)
if (size !== asset.brotliByteLength) {
throw failure(
`${path} is ${String(size)} bytes on disk, manifest says ${String(asset.brotliByteLength)}. ${REMEDY}`
)
}
const inflated = brotliDecompressSync(readFileSync(join(bundleDir, path)))
if (createHash('sha256').update(inflated).digest('hex') !== asset.sha256) {
throw failure(`${path} does not inflate to ${asset.path}. ${REMEDY}`)
}
}
console.log(
`[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` +
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes`
@@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { brotliCompressSync } from 'node:zlib'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { writeMobileWebBundleFixtureTree } from '../../build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs'
@@ -10,11 +11,11 @@ const { assertMobileWebBundleBuilt } = require('./verify-packaged-mobile-web-bun
const electronBuilderConfig = require('../../../electron-builder.config.cjs')
const { darkBuildMarkers } = require('../../../dark-build-switches.cjs')
async function withBundle(run) {
async function withBundle(run, { brotli = false } = {}) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-'))
const bundleDir = join(scratch, 'mobile-web')
try {
const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir, brotli })
await run({ bundleDir, manifest })
} finally {
await rm(scratch, { recursive: true, force: true })
@@ -65,6 +66,25 @@ describe('assertMobileWebBundleBuilt', () => {
})
})
it('accepts the brotli copies the manifest lists, and fails one that inflates to other bytes', async () => {
await withBundle(
async ({ bundleDir, manifest }) => {
const asset = manifest.assets.find((entry) => entry.brotliByteLength !== undefined)
expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow()
const forged = brotliCompressSync(Buffer.from('not the asset'))
await writeFile(join(bundleDir, `${asset.path}.br`), forged)
await rewriteManifest(bundleDir, (rewritten) => {
rewritten.assets.find((entry) => entry.path === asset.path).brotliByteLength =
forged.byteLength
})
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(
`${asset.path}.br does not inflate to ${asset.path}`
)
},
{ brotli: true }
)
})
it('refuses an asset path that escapes the bundle directory', async () => {
await withBundle(async ({ bundleDir }) => {
await rewriteManifest(bundleDir, (manifest) => {
@@ -27,6 +27,10 @@ import {
renderLiveReadLines,
renderStoreReadLines
} from './react-compiler-hazards.mjs'
import {
UNCOMPILED_LIST,
readListedFiles
} from '../../../../build-plugins/react-compiler-uncompiled-files.mjs'
// Runs React Compiler (analysis only, nothing is written) over the renderer so code that compiles
// today keeps compiling, and code that would change behavior once compiled can't be added.
@@ -34,8 +38,6 @@ import {
const SCAN_ROOT = 'src/renderer/src'
const BAILOUT_BASELINE = 'config/react-compiler-bailout-baseline.txt'
const HAZARD_BASELINE = 'config/react-compiler-hazard-baseline.txt'
// The build leaves these files uncompiled, so their hazards cannot run.
const UNCOMPILED_LIST = 'config/react-compiler-uncompiled-files.txt'
const COMMAND = 'pnpm check:react-compiler-compat-ratchet'
const NOT_SHIPPED =
/(?:^|\/)__(?:mocks|fixtures|tests)__\/|\.(?:test|spec|bench)\.[cm]?tsx?$|[.-]test-|[.-]fixtures?\.tsx?$/
@@ -101,18 +103,31 @@ function addTo(map, key, value) {
map.set(key, [...(map.get(key) ?? []), value])
}
// The build leaves these files uncompiled, so their hazards cannot run.
function readUncompiledFiles(root) {
const file = path.join(root, UNCOMPILED_LIST)
if (!fs.existsSync(file)) {
return new Set()
return fs.existsSync(path.join(root, UNCOMPILED_LIST))
? new Set(readListedFiles(UNCOMPILED_LIST, root))
: new Set()
}
// Why: the build matches the exact id, so `\`, `../`, a directory or another case would silently compile.
function isCanonicalFile(root, rel) {
try {
const file = path.join(root, rel)
return (
!rel.includes('\\') &&
path.posix.normalize(rel) === rel &&
fs.statSync(file).isFile() &&
fs.realpathSync.native(file) === path.join(fs.realpathSync.native(root), rel)
)
} catch {
return false
}
return new Set(
fs
.readFileSync(file, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
)
}
/** Listed paths that name no renderer file; a renamed file would silently compile. */
export function findStaleUncompiledFiles(uncompiled, exists) {
return [...uncompiled].filter((rel) => !rel.startsWith(`${SCAN_ROOT}/`) || !exists(rel))
}
/** Virtualizer hooks plus every hook that returns one, followed through wrappers of wrappers. */
@@ -210,12 +225,14 @@ async function scanFile(rel, text, result) {
export async function scanRenderer(root = process.cwd()) {
const sources = [...readTrackedSources(root, [SCAN_ROOT], isScannedPath)]
const uncompiled = readUncompiledFiles(root)
const result = {
files: 0,
compiled: 0,
diagnostics: new Map(),
hazards: new Map(),
uncompiled: readUncompiledFiles(root),
uncompiled,
staleUncompiled: findStaleUncompiledFiles(uncompiled, (rel) => isCanonicalFile(root, rel)),
virtualizerHooks: collectVirtualizerHooks(sources),
liveReaders: collectLiveReaders(sources)
}
@@ -341,8 +358,15 @@ export async function main(root = process.cwd(), arg = undefined) {
)
return 0
}
for (const rel of scan.staleUncompiled) {
console.error(
`::error::${UNCOMPILED_LIST}: ${rel} is not a renderer file. ` +
'Point it at the renamed file, or remove it once the file is gone.'
)
}
const touched = listTouchedFiles(root)
const ok = [
scan.staleUncompiled.length === 0,
check(root, 'bailout(s)', BAILOUT_BASELINE, bailouts, scan.diagnostics, touched),
check(root, 'hazard(s)', HAZARD_BASELINE, hazards, scan.hazards, touched)
].every(Boolean)
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { parseListedFiles } from '../../../../build-plugins/react-compiler-uncompiled-files.mjs'
import { findStaleUncompiledFiles } from './check-react-compiler-compat-ratchet.mjs'
describe('uncompiled file list', () => {
it('reads paths from list, count and hazard lines, dropping notes', () => {
const text = [
'# header',
'src/renderer/src/a.tsx',
'src/renderer/src/b.tsx # why it stays uncompiled',
'2 src/renderer/src/c.ts',
'1 src/renderer/src/d.ts#useD:store-read-in-memo-block',
''
].join('\n')
expect(parseListedFiles(text)).toEqual([
'src/renderer/src/a.tsx',
'src/renderer/src/b.tsx',
'src/renderer/src/c.ts',
'src/renderer/src/d.ts'
])
})
it('reports listed paths that no longer name a renderer file', () => {
const files = new Set([
'src/renderer/src/kept.tsx',
'src/renderer/src/notification-test-delivery.ts'
])
expect(
findStaleUncompiledFiles(
new Set([
'src/renderer/src/kept.tsx',
'src/renderer/src/notification-test-delivery.ts',
'src/renderer/src/renamed.tsx'
]),
(rel) => files.has(rel)
)
).toEqual(['src/renderer/src/renamed.tsx'])
})
})
@@ -36,6 +36,7 @@ const NODE_RUNTIME_INCLUDE = [
// Asserts an observer survives a forced collection; conservative retention would hide a lost one.
'config/scripts/test-runtime/dom/happy-dom-mutation-observer-retention.test.ts',
// These reach Node SQLite through a shared helper or a dynamic import, which the scan skips.
'src/main/agent-hooks/server/testing/providers/codex/server-codex-session-start.test.ts',
'src/main/claude/claude-transcript-catch-up.test.ts',
'src/main/claude/claude-transcript-duration.test.ts',
'tests/e2e/native-chat-startup-option-pickers.unit.test.ts',
-1
View File
@@ -6,7 +6,6 @@ import { nodeRuntimePool } from './scripts/test-runtime/node-worker/vitest-node-
const contracts = [
'src/main/runtime/session-tabs/session-tabs-phone-layout-moves.test.ts',
'src/renderer/src/components/terminal-pane/pty-connection/startup/saved-resume-cancellation.test.ts',
'src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-terminal-writer-acquire.test.ts',
'src/shared/agent-launch/permissions/tui-agent-permission-choice.test.ts',
'src/main/runtime/rpc/methods/projects/repo.test.ts',
+1 -1
View File
@@ -44,7 +44,7 @@ Tokens pair a **surface** with a **foreground** that meets contrast on it; alway
- Body letter-spacing is set globally on `body`; don't override per component.
- Use Tailwind's scale. Common: 14px body/default buttons, 13px sidebar items and dense rows, 12px sub-text and paths, 11px uppercase meta/captions.
- Use `text-2xs` (11px) and `text-3xs` (10px), not `text-[11px]` / `text-[10px]`.
- Category labels: 11px, `font-semibold`, `uppercase`, `tracking-wider` (0.05em).
- Category labels: 11px, `font-semibold`, `uppercase`, `tracking-wider` (0.05em). Settings content uses sentence case without tracking: 14px row labels, `text-settings-detail` (12.5px) help, and `text-settings-heading` (15px) semibold section headings. Settings form rows use about 9px vertical padding (44px minimum), selects are 28px, and the centered content column is at most 720px with at least 32px side gutters. These density rules are scoped to `.settings-view-shell`; shared primitives retain their defaults elsewhere.
- Native chat has its own lint-enforced scale and colours: see [native-chat.md](./styleguide/native-chat.md).
## Radius and elevation
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 162m">
<title>downloads: 162m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 163m">
<title>downloads: 163m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">162m</text>
<text x="93.5" y="14">162m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">163m</text>
<text x="93.5" y="14">163m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 473 KiB

@@ -15,6 +15,8 @@ android {
}
dependencies {
// Android ships no brotli decoder; Google's pure-Java one, as okhttp-brotli uses.
implementation 'org.brotli:dec:0.1.2'
testImplementation 'junit:junit:4.13.2'
// The android.jar used by JVM unit tests stubs org.json; the shared vectors are JSON.
testImplementation 'org.json:json:20240303'
@@ -0,0 +1,31 @@
package expo.modules.orcagziputf8codec
import java.io.ByteArrayInputStream
import java.io.IOException
import org.brotli.dec.BrotliInputStream
class BoundedBrotliException(message: String) : Exception(message)
/** Inflate one brotli stream into at most outputLimit bytes; callers pass one byte beyond the length they
* accept and check the result's length and hash, so truncated or overlong input fails there. */
fun brotliDecompressBounded(input: ByteArray, outputLimit: Int): ByteArray {
if (outputLimit <= 0 || input.isEmpty()) {
throw BoundedBrotliException("nothing to inflate")
}
val output = ByteArray(outputLimit)
var produced = 0
try {
BrotliInputStream(ByteArrayInputStream(input)).use { stream ->
while (produced < outputLimit) {
val count = stream.read(output, produced, outputLimit - produced)
if (count < 0) {
break
}
produced += count
}
}
} catch (error: IOException) {
throw BoundedBrotliException(error.message ?: "invalid brotli data")
}
return if (produced == outputLimit) output else output.copyOf(produced)
}
@@ -15,5 +15,10 @@ class OrcaGzipUtf8CodecModule : Module() {
Function("decodeUtf8") { input: ByteArray ->
decodeStrictUtf8(input)
}
// Async: a whole bundle asset can take tens of ms, too long to hold the JS thread.
AsyncFunction("brotliDecompress") { input: ByteArray, outputLimit: Int ->
brotliDecompressBounded(input, outputLimit)
}
}
}
@@ -0,0 +1,28 @@
import Compression
import Foundation
struct BoundedBrotliError: Error, CustomStringConvertible {
let description: String
}
/// Inflate one brotli stream into at most outputLimit bytes; callers pass one byte beyond the length they
/// accept and check the result's length and hash. Compression returns nothing for a stream that is invalid,
/// truncated or longer than the buffer, so all three throw here.
func brotliDecompressBounded(_ input: Data, outputLimit: Int) throws -> Data {
guard outputLimit > 0, !input.isEmpty else {
throw BoundedBrotliError(description: "nothing to inflate")
}
var output = Data(count: outputLimit)
let produced = input.withUnsafeBytes { (inBytes: UnsafeRawBufferPointer) -> Int in
output.withUnsafeMutableBytes { (outBytes: UnsafeMutableRawBufferPointer) -> Int in
compression_decode_buffer(
outBytes.bindMemory(to: UInt8.self).baseAddress!, outputLimit,
inBytes.bindMemory(to: UInt8.self).baseAddress!, inBytes.count,
nil, COMPRESSION_BROTLI)
}
}
guard produced > 0 else {
throw BoundedBrotliError(description: "not a whole brotli stream within the output limit")
}
return produced == outputLimit ? output : output.prefix(produced)
}
@@ -1,7 +1,7 @@
Pod::Spec.new do |s|
s.name = 'OrcaGzipUtf8Codec'
s.version = '0.0.1'
s.summary = 'Bounded gunzip and strict UTF-8 decode'
s.summary = 'Bounded gunzip and brotli inflate, and strict UTF-8 decode'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
@@ -11,6 +11,6 @@ Pod::Spec.new do |s|
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.libraries = 'z'
s.libraries = 'z', 'compression'
s.source_files = '**/*.swift'
end
@@ -12,5 +12,10 @@ public class OrcaGzipUtf8CodecModule: Module {
Function("decodeUtf8") { (input: Data) -> String? in
decodeStrictUtf8(input)
}
// Async: a whole bundle asset can take tens of ms, too long to hold the JS thread.
AsyncFunction("brotliDecompress") { (input: Data, outputLimit: Int) throws -> Data in
try brotliDecompressBounded(input, outputLimit: outputLimit)
}
}
}
@@ -5,6 +5,8 @@ export type OrcaGzipUtf8CodecModule = {
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
/** Well-formed UTF-8 with no leading BOM, decoded; null otherwise. */
decodeUtf8(input: Uint8Array): string | null
/** Inflate one brotli stream into at most outputLimit bytes, off the JS thread; throws on bad input. */
brotliDecompress(input: Uint8Array, outputLimit: number): Promise<Uint8Array<ArrayBuffer>>
}
/** Null in a binary built before the module existed. */
+9 -1
View File
@@ -22,6 +22,8 @@ type MobileStateViewProps = {
loading?: boolean
/** Keeps the content centred above a floating bottom bar. */
bottomInset?: number
/** Full-width content under the message, such as a list of choices. */
children?: ReactNode
}
/** Every screen's loading, empty, no-match, error and offline state: icon, title, one sentence, one action. */
@@ -33,7 +35,8 @@ export function MobileStateView({
details,
action,
loading = false,
bottomInset = 0
bottomInset = 0,
children
}: MobileStateViewProps) {
useMobileColorScheme()
return (
@@ -49,6 +52,7 @@ export function MobileStateView({
{message ? <Text style={styles.message}>{message}</Text> : null}
{details ? <MobileStateDetails details={details} /> : null}
{action ? <MobileStateButton label={action.label} onPress={action.onPress} /> : null}
{children ? <View style={styles.children}>{children}</View> : null}
</View>
)
}
@@ -187,6 +191,10 @@ export function MobileNoMatchState({
}
const styles = themedStyleSheet(() => ({
children: {
alignSelf: 'stretch',
marginTop: spacing.md
},
state: {
flex: 1,
alignItems: 'center',
@@ -64,6 +64,7 @@ vi.mock('../host-screen/host-list-queries', () => ({
import '../transport/hosts/host-descriptor-store'
import { FakeSession } from '../transport/connection/mobile-endpoint-supervisor-test-fakes'
import { markRpcDeliveryUnknown } from '../transport/rpc/rpc-delivery-ambiguity'
import '../../../src/shared/execution-host'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
@@ -194,6 +195,108 @@ describe('NewWorktreeModal project targets', () => {
).toBe('/srv/orca')
})
it("offers only the folder host's copy of a repo when opened from a folder", async () => {
const serverRepo = { ...repos[0]!, path: '/srv/orca' }
const listing = (listed: unknown[]) =>
vi.fn((method: string) =>
method === 'repo.list' ? replied({ repos: listed }) : new Promise<never>(() => {})
)
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client: requestsOnly(listing(repos)),
serverClients: new Map<ExecutionHostId, RpcClient>([
['runtime:env-1', requestsOnly(listing([serverRepo]))]
]),
hostId: 'host-1',
onCreated: () => {},
onClose: () => {},
openExternalUrl: () => {},
folderLaunch: {
repoId: 'repo-1',
executionHostId: 'runtime:env-1',
name: 'payments',
parentWorkspace: 'folder:f-1',
submit: { begin: () => null, presenting: () => true }
}
})
)
})
await flushUpdates()
// The desktop's same-id repo is not offered: its store doesn't hold this folder.
expect(pickerItems(renderer, 'Run on').map((item) => item.detail)).toEqual(['/srv/orca'])
expect(runTargetPicker(renderer).props.selectedId).toBe('runtime:env-1')
})
async function submitFromFolder(createReply: () => Promise<RpcResponse>) {
const serverRepo = { ...repos[0]!, path: '/srv/orca' }
const server = requestsOnly(
vi.fn((method: string) =>
method === 'repo.list'
? replied({ repos: [serverRepo] })
: method === 'worktree.create'
? createReply()
: Promise.reject(new Error(`unexpected ${method}`))
)
)
const settle = vi.fn()
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client: requestsOnly(() => new Promise<never>(() => {})),
serverClients: new Map<ExecutionHostId, RpcClient>([['runtime:env-1', server]]),
hostId: 'host-1',
onCreated: () => {},
onClose: () => {},
openExternalUrl: () => {},
folderLaunch: {
repoId: 'repo-1',
executionHostId: 'runtime:env-1',
name: 'payments',
parentWorkspace: 'folder:f-1',
submit: { begin: () => ({ settle }), presenting: () => true }
}
})
)
})
await flushUpdates()
const form = renderer.root.find((node) => typeof node.props.onCreate === 'function')
await act(async () => form.props.onCreate())
await flushUpdates()
const creates = vi
.mocked(server.sendRequest)
.mock.calls.filter(([method]) => method === 'worktree.create')
return { creates: creates.map(([, params]) => params), settle }
}
it('creates from a folder attached to that folder, on its host', async () => {
const { creates, settle } = await submitFromFolder(() =>
replied({ worktree: { id: 'repo-1::/srv/w/payments', displayName: 'payments' } })
)
expect(creates).toEqual([
expect.objectContaining({
repo: 'id:repo-1',
parentWorkspace: 'folder:f-1',
parentWorkspaceOrigin: 'manual'
})
])
expect(settle).toHaveBeenCalledWith('created')
})
it("doesn't try another name when a folder create's reply is lost", async () => {
const { creates, settle } = await submitFromFolder(() =>
Promise.reject(markRpcDeliveryUnknown(new Error('connection lost')))
)
expect(creates).toHaveLength(1)
expect(settle).toHaveBeenCalledWith('uncertain')
})
it('ignores a stale repo list after the client changes', async () => {
let resolveOldList: ((value: unknown) => void) | undefined
const oldList = new Promise((resolve) => {
+30 -4
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Keyboard } from 'react-native'
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host'
@@ -71,14 +71,20 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
existingWorktrees,
openExternalUrl,
onCreated,
onClose
onClose,
folderLaunch
} = props
const { repos, allRepos, selectedRepo, setSelectedRepo, loading, loadError, retryLoad } =
useNewWorkspaceRepositories({
client: desktopClient,
serverClients,
hostId,
visible
visible,
...(folderLaunch
? {
initialRepo: { id: folderLaunch.repoId, executionHostId: folderLaunch.executionHostId }
}
: {})
})
// Route picked-repo operations to its host; desktop settings/accounts stay on desktop.
const repoServer = selectedRepo ? serverOf(selectedRepo) : undefined
@@ -126,6 +132,14 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
worktreeBranches: selectedRepoWorktreeBranches,
onError: setError
})
// The folder's name, seeded once per opening; the user's edits stand.
const seededFolderName = useRef(false)
useEffect(() => {
if (folderLaunch && !seededFolderName.current) {
seededFolderName.current = true
composer.setName(folderLaunch.name)
}
}, [composer, folderLaunch])
const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true
useWorktreeCreateBasePrefetch({
client: visible ? client : null,
@@ -179,7 +193,19 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
setNote((current) => (current === note ? '' : current))
onCreated(worktreeId, name, warning, repoServer)
},
onClose
onClose,
...(folderLaunch
? {
folderLaunch: {
...folderLaunch.submit,
executionHostId: folderLaunch.executionHostId,
parent: {
parentWorkspace: folderLaunch.parentWorkspace,
parentWorkspaceOrigin: 'manual' as const
}
}
}
: {})
})
const sourceAvailability: SmartModeAvailabilityInput = {
@@ -0,0 +1,22 @@
import { translate } from '../i18n/mobile-i18n'
/** A create whose reply was lost, or whose resend was refused: it may exist, so another could be a second one. */
export function uncertainCreateMessage(inFolder: boolean): string {
return inFolder
? translate(
'components.newWorkspaceCreateSubmit.uncertainFolderCreate',
'This worktree may have been created. Check the folder before creating another.'
)
: translate(
'components.newWorkspaceCreateSubmit.uncertainCreate',
'This workspace may have been created. Check your workspaces before creating another.'
)
}
/** The folder screen still owns a create for this repository. */
export function folderCreateBusyMessage(): string {
return translate(
'components.newWorkspaceCreateSubmit.folderCreateBusy',
'A worktree in this repository is still being created or checked. It shows in the folder once it lands.'
)
}
@@ -18,6 +18,30 @@ export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'
>
>
/** Opened from a folder's empty state: that repo and name, created attached to the folder. */
export type NewWorktreeFolderLaunch = {
repoId: string
/** The repo's host, so a same-id copy on another host isn't picked. */
executionHostId: ExecutionHostId
name: string
/** The folder's workspace key, `folder:<id>`. */
parentWorkspace: string
submit: NewWorktreeFolderSubmit
}
/** A folder's create, kept from admission until its outcome is known. */
export type NewWorktreeFolderSubmission = {
settle: (outcome: 'created' | 'uncertain' | 'failed' | 'cancelled') => void
}
/** How the folder screen owns its composer's creates. */
export type NewWorktreeFolderSubmit = {
/** Admits a create for the picked repo before anything is sent; null while another is out for it. */
begin: (repo: MobileWorkspaceRepo) => NewWorktreeFolderSubmission | null
/** False once the folder screen is left: the create runs on, without haptics. */
presenting: () => boolean
}
export type NewWorktreeModalProps = {
visible: boolean
client: RpcClient | null
@@ -37,6 +61,7 @@ export type NewWorktreeModalProps = {
executionHost?: ExecutionHostId
) => void
onClose: () => void
folderLaunch?: NewWorktreeFolderLaunch
}
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
@@ -3,7 +3,9 @@ import { useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
import { folderCreateBusyMessage, uncertainCreateMessage } from './new-workspace-create-copy'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { WorktreeCreateParent } from '../tasks/worktree-create-retry'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import {
@@ -24,7 +26,14 @@ import {
type NewWorktreeAgentOption,
type NewWorktreeRuntimeSettings
} from './new-worktree-agent-selection'
import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-types'
import type {
MobileWorkspaceRepo,
NewWorktreeFolderSubmission,
NewWorktreeFolderSubmit,
SetupRunPolicy
} from './new-worktree-modal-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import { isRpcDeliveryUnknown } from '../transport/rpc/rpc-delivery-ambiguity'
import type { OrcaHookTrustPrompt } from './OrcaHookTrustDrawer'
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
@@ -65,6 +74,11 @@ export function useNewWorkspaceCreateSubmit(args: {
setError: Dispatch<SetStateAction<string>>
onCreated: (worktreeId: string, name: string, warning?: string) => void
onClose: () => void
/** Opened from a folder: creates on that folder's host, attached to it, owned by the folder screen. */
folderLaunch?: NewWorktreeFolderSubmit & {
executionHostId: ExecutionHostId
parent: WorktreeCreateParent
}
}): {
creating: boolean
setupTrustPrompt: OrcaHookTrustPrompt | null
@@ -86,7 +100,20 @@ export function useNewWorkspaceCreateSubmit(args: {
createInFlightRef.current = true
setCreating(true)
args.setError('')
const folder = args.folderLaunch
// Admitted for the repo actually picked, before anything is sent; the folder screen owns it until it settles.
const admission = folder
? getRepoExecutionHostId(selectedRepo) === folder.executionHostId
? folder.begin(selectedRepo)
: null
: undefined
const presenting = (): boolean => folder?.presenting() ?? true
let outcome: Parameters<NewWorktreeFolderSubmission['settle']>[0] = 'cancelled'
try {
if (admission === null) {
args.setError(folderCreateBusyMessage())
return
}
if (args.sshGate.requiresConnection) {
args.setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
return
@@ -178,7 +205,8 @@ export function useNewWorkspaceCreateSubmit(args: {
agentLaunchSupported: args.getAgentLaunchSupport(),
branchIntentSupported: args.getBranchIntentSupport(
selection.kind === 'branch' && selection.reuse ? 'open-existing-v2' : 'create-new'
)
),
parent: folder?.parent
})
: await createBlankWorkspace({
client,
@@ -189,20 +217,43 @@ export function useNewWorkspaceCreateSubmit(args: {
comment: trimmedNote,
setupDecision,
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport(),
agentLaunchSupported: args.getAgentLaunchSupport()
agentLaunchSupported: args.getAgentLaunchSupport(),
parent: folder?.parent
})
if ('error' in result) {
triggerError()
// Refused on a resend after a lost reply: the first send may have created it.
outcome = result.replayed ? 'uncertain' : 'failed'
if (outcome === 'uncertain') {
// Not a failure to retry: creating again could make a second one.
args.setError(uncertainCreateMessage(folder !== undefined))
return
}
if (presenting()) {
triggerError()
}
args.setError(result.error)
return
}
triggerSuccess()
outcome = 'created'
if (presenting()) {
triggerSuccess()
}
args.onClose()
args.onCreated(result.worktreeId, result.name, result.warning)
} catch (error) {
triggerError()
// A lost reply may still have created it.
outcome = isRpcDeliveryUnknown(error) ? 'uncertain' : 'failed'
if (folder && outcome === 'uncertain') {
// The folder screen refreshes and holds it until its list shows it; sending again could create a second one.
args.setError(uncertainCreateMessage(true))
return
}
if (presenting()) {
triggerError()
}
args.setError(error instanceof Error ? error.message : 'Failed to create workspace')
} finally {
admission?.settle(outcome)
createInFlightRef.current = false
setCreating(false)
}
@@ -0,0 +1,74 @@
import { act } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { FakeSession } from '../transport/connection/mobile-endpoint-supervisor-test-fakes'
import { hookMount, settleQueryNotifications } from '../test-support/rpc-recording/hook-mount'
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
import { useNewWorkspaceRepositories } from './use-new-workspace-repositories'
vi.mock('react-native', () => ({ AppState: {} }))
const servers = vi.hoisted(() => {
const state: { repos: ReadonlyMap<string, unknown[]> } = { repos: new Map() }
return state
})
vi.mock('../worktree/use-server-repos', () => ({ useServerRepos: () => servers.repos }))
vi.mock('../worktree/use-last-visited-worktree-repo', () => ({
useLastVisitedWorktreeRepoId: () => ({ loaded: true, repoId: 'api' })
}))
const SERVER: ExecutionHostId = 'runtime:env-1'
const repo = (executionHostId?: ExecutionHostId): MobileWorkspaceRepo => ({
id: 'api',
displayName: 'api',
path: '/src/api',
kind: 'git',
...(executionHostId ? { executionHostId } : {})
})
function desktopListing(repos: MobileWorkspaceRepo[]) {
const client = new FakeSession('connected')
client.sendRequest.mockResolvedValue({
id: '1',
ok: true,
result: { repos },
_meta: { runtimeId: 'r' }
})
return client
}
let mounted: ReturnType<typeof hookMount> | null = null
afterEach(async () => {
await mounted?.unmount()
mounted = null
servers.repos = new Map()
})
describe('useNewWorkspaceRepositories initial repo', () => {
it.each([
{ order: 'desktop first', desktopFirst: true },
{ order: 'server first', desktopFirst: false }
])(
'picks the named host’s copy of a repo id two hosts share ($order)',
async ({ desktopFirst }) => {
const client = desktopListing(desktopFirst ? [repo()] : [])
let picked: MobileWorkspaceRepo | null = null
mounted = hookMount(() => {
picked = useNewWorkspaceRepositories({
client,
hostId: 'host-1',
visible: true,
initialRepo: { id: 'api', executionHostId: SERVER }
}).selectedRepo
})
mounted.mount()
await settleQueryNotifications()
// The desktop's same-id repo must not stand in while the server's listing is on its way.
expect(picked).toBeNull()
servers.repos = new Map([[SERVER, [repo(SERVER)]]])
act(() => mounted?.update())
await settleQueryNotifications()
expect(picked).toMatchObject({ id: 'api', executionHostId: SERVER })
}
)
})
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
import { cachedHostRepos } from '../host-screen/host-list-queries'
import { listHostRepos } from '../worktree/server-repos'
@@ -21,6 +21,8 @@ export function useNewWorkspaceRepositories(args: {
serverClients?: ReadonlyMap<ExecutionHostId, RpcClient>
hostId?: string
visible: boolean
/** Picked once it is listed, in place of the last visited repo. */
initialRepo?: { id: string; executionHostId: ExecutionHostId }
}): {
repos: MobileWorkspaceRepo[]
allRepos: MobileWorkspaceRepo[]
@@ -31,7 +33,7 @@ export function useNewWorkspaceRepositories(args: {
loadError: string | null
retryLoad: () => void
} {
const { client, serverClients = NO_SERVER_CLIENTS, hostId, visible } = args
const { client, serverClients = NO_SERVER_CLIENTS, hostId, visible, initialRepo } = args
const queryClient = useQueryClient()
// The host list's repos, so the form opens with them while repo.list answers.
const [initialRepos] = useState(() => cachedHostRepos<MobileWorkspaceRepo>(queryClient, hostId))
@@ -51,7 +53,15 @@ export function useNewWorkspaceRepositories(args: {
: [...desktopRepos, ...[...serverRepos.values()].flat()],
[desktopRepos, serverRepos]
)
const repos = useMemo(() => getNewWorkspaceDialogEligibleRepos(allRepos), [allRepos])
const initialRepoId = initialRepo?.id
const initialRepoHost = initialRepo?.executionHostId
// Opened for one host's folder: every picker offers only that host, whose store holds the folder.
const repos = useMemo(() => {
const eligible = getNewWorkspaceDialogEligibleRepos(allRepos)
return initialRepoHost
? eligible.filter((repo) => getRepoExecutionHostId(repo) === initialRepoHost)
: eligible
}, [allRepos, initialRepoHost])
// Derive the pick so it follows updated listings and disappears when its host stops listing it.
const selectedRepo = useMemo(
() => refreshMobileNewWorkspaceDialogSelectedRepo(repos, pickedRepo),
@@ -59,7 +69,26 @@ export function useNewWorkspaceRepositories(args: {
)
useEffect(() => {
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
if (!visible || selectedRepo || !initialRepoId) {
return
}
// Waits for its host's listing: a same-id repo on another host is a different repo.
const initial = repos.find(
(repo) => repo.id === initialRepoId && getRepoExecutionHostId(repo) === initialRepoHost
)
if (initial) {
setSelectedRepo(initial)
}
}, [initialRepoHost, initialRepoId, repos, selectedRepo, visible])
useEffect(() => {
if (
!visible ||
initialRepoId ||
!lastVisitedRepo.loaded ||
selectedRepo ||
repos.length === 0
) {
return
}
const preferredRepoId = resolveNewWorkspaceDialogRepoId({
@@ -70,7 +99,7 @@ export function useNewWorkspaceRepositories(args: {
if (preferredRepo) {
setSelectedRepo(preferredRepo)
}
}, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
}, [initialRepoId, lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
useEffect(() => {
if (!visible || !client) {
+16 -1
View File
@@ -1493,6 +1493,11 @@
"offlineAsOfTime": {
"yesterday": "yesterday, {{time}}"
},
"newWorkspaceCreateSubmit": {
"uncertainFolderCreate": "This worktree may have been created. Check the folder before creating another.",
"uncertainCreate": "This workspace may have been created. Check your workspaces before creating another.",
"folderCreateBusy": "A worktree in this repository is still being created or checked. It shows in the folder once it lands."
},
"smartRailRow": {
"title": "Smart rail",
"subtitle": "Shows keys for what your agent is doing"
@@ -3675,7 +3680,17 @@
"emptyTitle": "No attached worktrees yet",
"emptyCopy": "Worktrees created from this workspace will show up here.",
"statusUnavailable": "Unavailable",
"backToFolder": "Back to folder"
"backToFolder": "Back to folder",
"createSection": "Create a worktree",
"createIn": "Create a worktree in {{repo}}",
"created": "Created worktree {{name}} in {{repo}}",
"createdNamed": "Created worktree {{name}}",
"createFailed": "Could not create a worktree in {{repo}}.",
"loadReposRetry": "Load repositories",
"setupChoiceTitle": "Retry without setup?",
"setupChoiceMessage": "Orca couldn't confirm the last create in {{repo}}. Retrying it needs a setup choice; it will be created without running the repository's setup.",
"setupChoiceSkip": "Retry without setup",
"checking": "A worktree from {{repo}} may already have been created. Checking the folder…"
},
"hostedReviewCopy": {
"updatingBranch": "Updating branch...",
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createGenerationStore, MAX_CACHED_HOSTS } from './generation-store'
import { createGenerationStore } from './generation-store'
import { deriveHostCacheKey } from './host-cache-key'
import type { MobileWebBundleFetchResult } from '../transport/web-bundle/mobile-web-bundle-fetch'
import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract'
@@ -59,343 +59,6 @@ async function activate(
await store.commitGeneration(await store.stageGeneration(hostKey, result))
}
function hostKeys(count: number): string[] {
return Array.from({ length: count }, (_, index) => deriveHostCacheKey(`host-${index}`))
}
describe('generation store', () => {
it('refuses an asset that is missing or the wrong length, leaving no generation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const missing = buildResult({ bytes: new Map([['index.html', new Uint8Array(4)]]) })
const short = buildResult({
bytes: new Map([
['index.html', new Uint8Array(4)],
['assets/app.js', new Uint8Array(1)]
])
})
await expect(store.stageGeneration(HOST, missing)).rejects.toThrow('assets/app.js is absent')
await expect(store.stageGeneration(HOST, short)).rejects.toThrow("not the manifest's 2")
expect(fs.paths()).toEqual([])
expect(await store.readActiveGeneration(HOST)).toBeNull()
})
it('drops the staged tree when a write fails', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.failWritesAt(`${HOST}/tmp/${BUILD}/assets/app.js`)
await expect(store.stageGeneration(HOST, buildResult({}))).rejects.toThrow('disk-full')
expect(fs.paths().some((path) => path.includes(`tmp/${BUILD}`))).toBe(false)
expect(await store.readActiveGeneration(HOST)).toBeNull()
})
it('leaves no generation and no tmp for any host when a download is interrupted', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const other = deriveHostCacheKey('host-b')
// The manifest is written last, so a download cut off mid-stage leaves assets without one.
fs.seed(`${HOST}/tmp/${BUILD}/index.html`, { kind: 'file', bytes: new Uint8Array(4) })
fs.seed(`${other}/tmp/${BUILD}/index.html`, { kind: 'file', bytes: new Uint8Array(4) })
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(await store.readActiveGeneration(other)).toBeNull()
expect(fs.paths().some((path) => path.includes('/tmp'))).toBe(false)
})
it('reads two generations as no activation and drops the host tree', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.seed(`${HOST}/generations/${'c'.repeat(64)}/manifest.json`, {
kind: 'file',
bytes: new TextEncoder().encode('{}')
})
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
})
it('reads an unparseable or mismatched manifest as no activation and drops the host tree', async () => {
for (const body of [
'not json',
JSON.stringify({ ...buildResult({}).manifest, buildId: 'd'.repeat(64) })
]) {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.seed(`${HOST}/generations/${BUILD}/manifest.json`, {
kind: 'file',
bytes: new TextEncoder().encode(body)
})
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
}
})
it('evicts the least recently activated host past the ceiling', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS + 1)
for (const host of hosts) {
await activate(store, host)
}
expect(await store.readActiveGeneration(hosts[0])).toBeNull()
expect(fs.paths().some((path) => path.startsWith(hosts[0]))).toBe(false)
for (const host of hosts.slice(1)) {
expect((await store.readActiveGeneration(host))?.buildId).toBe(BUILD)
}
expect(fs.text('hosts.json')).toBe(JSON.stringify(hosts.slice(1)))
})
it('evicts a host with no index entry before the least recently activated one', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const [oldest, ...rest] = hostKeys(MAX_CACHED_HOSTS - 1)
const orphan = deriveHostCacheKey('orphan')
for (const host of [oldest, ...rest]) {
await activate(store, host)
}
await activate(store, orphan)
const order = [oldest, ...rest]
fs.seed('hosts.json', { kind: 'file', bytes: new TextEncoder().encode(JSON.stringify(order)) })
await activate(store, deriveHostCacheKey('one-too-many'))
expect(fs.paths().some((path) => path.startsWith(orphan))).toBe(false)
expect((await store.readActiveGeneration(oldest))?.buildId).toBe(BUILD)
})
it('never counts or evicts a host that is only mid-download', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const hosts = hostKeys(MAX_CACHED_HOSTS)
const oldest = hosts[0]
const downloading = deriveHostCacheKey('downloading')
for (const host of hosts) {
await activate(store, host)
}
const staged = await store.stageGeneration(downloading, buildResult({}))
await activate(store, deriveHostCacheKey('one-too-many'))
// Count activations only; eviction must leave staged downloads intact.
expect(fs.paths().some((path) => path.startsWith(oldest))).toBe(false)
expect(fs.text(`${staged.directory.slice(ROOT.length + 1)}/manifest.json`)).not.toBeNull()
await store.commitGeneration(staged)
expect((await store.readActiveGeneration(downloading))?.buildId).toBe(BUILD)
})
it('serializes two stage calls for one host and build', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
// Give one build id conflicting asset lists to expose interleaved writes into the same staging directory.
const staging = `${HOST}/tmp/${BUILD}`
const earlier = buildResult({
buildId: BUILD,
assets: [{ path: 'assets/earlier.js', byteLength: 2 }]
})
const later = buildResult({
buildId: BUILD,
assets: [{ path: 'assets/later.js', byteLength: 3 }]
})
const [first, second] = await Promise.all([
store.stageGeneration(HOST, earlier),
store.stageGeneration(HOST, later)
])
expect(first.directory).toBe(second.directory)
expect(fs.writes).toEqual([
`${staging}/assets/earlier.js`,
`${staging}/manifest.json`,
`${staging}/assets/later.js`,
`${staging}/manifest.json`
])
expect(fs.paths().filter((path) => path.startsWith(`${staging}/assets/`))).toEqual([
`${staging}/assets/later.js`
])
})
it('refuses a path that escapes the staged tree, and a host key that is not one', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const escapes = [
'../outside.js',
'assets/../../outside.js',
'/etc/passwd',
'assets//app.js',
'manifest.json',
'Manifest.JSON'
]
for (const path of escapes) {
const result = buildResult({ assets: [{ path, byteLength: 1 }] })
await expect(store.stageGeneration(HOST, result)).rejects.toThrow('refuses to stage')
}
await expect(store.stageGeneration('host-a', buildResult({}))).rejects.toThrow(
'not a host cache key'
)
expect(fs.paths()).toEqual([])
})
it('refuses a rename that did not carry the tree, as Android below API 26 can', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.loseContentsOnMove()
const staged = await store.stageGeneration(HOST, buildResult({}))
await expect(store.commitGeneration(staged)).rejects.toThrow('did not carry its manifest')
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.includes('generations/'))).toBe(false)
})
it('keeps the host tree when the manifest read fails, and drops it when it is missing', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
const manifest = `${HOST}/generations/${BUILD}/manifest.json`
await activate(store, HOST)
const before = fs.paths().filter((path) => !path.endsWith('/tmp'))
fs.failReadsAt(manifest)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths()).toEqual(before)
fs.failReadsAt(null)
await fs.delete(`${ROOT}/${manifest}`)
expect(await store.readActiveGeneration(HOST)).toBeNull()
expect(fs.paths().some((path) => path.startsWith(HOST))).toBe(false)
})
it('replaces an entry named for the build id that is not a readable generation', async () => {
const build = BUILD
// Model a crash between rename and the post-rename manifest check.
for (const seeded of [
{ kind: 'directory' },
{ kind: 'file', bytes: new Uint8Array(1) }
] as const) {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
fs.seed(`${HOST}/generations/${build}`, seeded)
await activate(store, HOST)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(build)
expect(fs.text(`${HOST}/generations/${build}/index.html`)).not.toBeNull()
}
})
it('refuses a handle whose staged tree is gone without touching the activation', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const staged = await store.stageGeneration(HOST, buildResult(SECOND))
await store.abortStagedGeneration(staged)
await expect(store.commitGeneration(staged)).rejects.toThrow('no longer on disk')
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('refuses a staged handle it did not issue', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
const before = fs.paths()
const forged = {
hostKey: HOST,
buildId: SECOND_BUILD,
directory: `${ROOT}/${HOST}/generations/${BUILD}`,
manifest: buildResult({}).manifest
}
await expect(store.commitGeneration(forged)).rejects.toThrow('did not issue')
await expect(store.abortStagedGeneration(forged)).rejects.toThrow('did not issue')
expect(fs.paths()).toEqual(before)
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
})
it('opens builds kept under the old root without a download once the root moves', async () => {
const fs = createFakeFileSystem()
const legacyRootUri = 'file:///caches/mobile-web'
await activate(createGenerationStore({ fileSystem: { ...fs, rootUri: legacyRootUri } }), HOST)
const store = createGenerationStore({ fileSystem: fs, legacyRootUri })
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(BUILD)
expect(await fs.list(legacyRootUri)).toEqual([])
})
})
describe('reusable assets for the next download', () => {
const NEXT = buildResult({
assets: [
{ path: 'index.html', byteLength: 8 },
{ path: 'assets/app-2.js', byteLength: 2 }
]
})
const NEXT_BUILD = NEXT.manifest.buildId
const [PAGE, SCRIPT] = NEXT.manifest.assets
const DOWNLOADED = new Map([['index.html', new Uint8Array(8).fill(9)]])
const LEFT_TO_STAGE = { ...NEXT, assets: DOWNLOADED, reused: ['assets/app-2.js'] }
it.each([HOST, deriveHostCacheKey('second-host')])(
'matches cached assets by content for %s',
async (opened) => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
expect((await store.openReusableAssets(HOST)).has(SCRIPT!)).toBe(false)
await activate(store, HOST)
const reusable = await store.openReusableAssets(opened)
expect(reusable.has(SCRIPT!)).toBe(true)
expect(reusable.has(PAGE!)).toBe(false)
expect(reusable.has({ ...SCRIPT!, sha256: 'f'.repeat(64) })).toBe(false)
expect(await reusable.check(SCRIPT!)).toBe(true)
fs.seed(`${HOST}/generations/${BUILD}/assets/app.js`, {
kind: 'file',
bytes: new Uint8Array(1)
})
expect(await reusable.check(SCRIPT!)).toBe(false)
}
)
it('copies the reused assets beside the downloaded ones and commits the whole build', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
await activate(store, HOST, LEFT_TO_STAGE)
const generation = `${HOST}/generations/${NEXT_BUILD}`
expect(fs.bytes(`${generation}/index.html`)).toEqual(new Uint8Array(8).fill(9))
expect(fs.bytes(`${generation}/assets/app-2.js`)).toEqual(new Uint8Array(2).fill(7))
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(NEXT_BUILD)
})
it('refuses to stage a reused asset the active generation no longer holds', async () => {
const fs = createFakeFileSystem()
const store = createGenerationStore({ fileSystem: fs })
await activate(store, HOST)
fs.failReadsAt(`${HOST}/generations/${BUILD}/assets/app.js`)
await expect(store.stageGeneration(HOST, LEFT_TO_STAGE)).rejects.toThrow('unreadable')
await store.deleteHostCache(HOST)
fs.failReadsAt(null)
await expect(store.stageGeneration(HOST, LEFT_TO_STAGE)).rejects.toThrow(
'no longer holds the reused asset assets/app-2.js'
)
expect(fs.paths().some((path) => path.includes(`tmp/${NEXT_BUILD}`))).toBe(false)
})
})
// Commit deletes the old build first, so it must not run under a page still loading that build's chunks.
describe('an update staged behind a live page', () => {
it('stays staged while a page reads the old build, and the next read after it commits', async () => {
@@ -412,45 +75,4 @@ describe('an update staged behind a live page', () => {
expect((await store.readActiveGeneration(HOST))?.buildId).toBe(SECOND_BUILD)
expect(await fs.fileExists(`${ROOT}/${HOST}/generations/${BUILD}/index.html`)).toBe(false)
})
it('lets a page that waited on another page fetch the build itself when that page gives up', async () => {
const store = createGenerationStore({ fileSystem: createFakeFileSystem() })
const first = await store.claimUpdate(HOST, SECOND_BUILD)
const second = store.claimUpdate(HOST, SECOND_BUILD)
first?.()
expect(await second).not.toBeNull()
})
it('commits at the next launch when the app ends while the page still reads the old build', async () => {
const fs = createFakeFileSystem()
const first = createGenerationStore({ fileSystem: fs })
await activate(first, HOST)
first.holdGeneration(HOST)
first.deferCommit(await first.stageGeneration(HOST, buildResult(SECOND)))
const relaunched = createGenerationStore({ fileSystem: fs })
expect((await relaunched.readActiveGeneration(HOST))?.buildId).toBe(SECOND_BUILD)
expect(await fs.fileExists(`${ROOT}/${HOST}/generations/${SECOND_BUILD}/index.html`)).toBe(true)
})
it('lets a reader of a host’s build go before a commit or a removal deletes it', async () => {
const fs = createFakeFileSystem()
const onDisk: boolean[] = []
let reading = BUILD
const store = createGenerationStore({
fileSystem: fs,
beforeHostFilesDeleted: async (hostKey) => {
onDisk.push(await fs.fileExists(`${ROOT}/${hostKey}/generations/${reading}/manifest.json`))
}
})
await activate(store, HOST)
onDisk.length = 0
await activate(store, HOST, buildResult(SECOND))
reading = SECOND_BUILD
await store.deleteHostCache(HOST)
expect(onDisk).toEqual([true, true])
})
})
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: '' } }))
vi.mock('./generation-store-file-system', async () => {
const { createFakeGenerationFileSystem } = await import('./generation-file-system-fake')
const fileSystem = createFakeGenerationFileSystem()
return { createExpoGenerationFileSystem: () => fileSystem }
})
import { createGenerationStore } from './generation-store'
import { createExpoGenerationFileSystem } from './generation-store-file-system'
import { forgetHostUpdateFailures } from './removed-host-shell-cache'
function store() {
return createGenerationStore({ fileSystem: createExpoGenerationFileSystem('') })
}
async function seed(hostId: string): Promise<void> {
await store().recordUpdateFailure({
hostId,
at: 1,
reason: 'connection-lost',
hostCode: null,
offeredBuildId: null,
cachedBuildId: null,
outcome: 'opened-cached',
wall: null
})
}
describe('forgetHostUpdateFailures', () => {
beforeEach(async () => {
await store().forgetHostUpdateFailures('host-1')
await store().forgetHostUpdateFailures('host-2')
})
it("drops the removed host's records and keeps the rest", async () => {
await seed('host-1')
await seed('host-2')
await forgetHostUpdateFailures('host-1')
expect((await store().readUpdateFailures()).map((entry) => entry.hostId)).toEqual(['host-2'])
})
it('writes nothing when nothing was recorded', async () => {
const fileSystem = createExpoGenerationFileSystem('')
expect(await fileSystem.readText(`${fileSystem.rootUri}/update-failures.json`)).toBeNull()
const write = vi.spyOn(fileSystem, 'writeText')
try {
await forgetHostUpdateFailures('host-1')
expect(write).not.toHaveBeenCalled()
expect(await fileSystem.readText(`${fileSystem.rootUri}/update-failures.json`)).toBeNull()
} finally {
write.mockRestore()
}
})
})
@@ -10,6 +10,8 @@ export function MobileSourceControlRoutePanel(props: MobileSourceControlPanelPro
if (parseWorkspaceKey(props.worktreeId)?.type === 'folder') {
return (
<MobileFolderSourceControlPanel
// One folder per mount: its creates' late answers can't reach another folder's screen.
key={`${props.hostId}\0${props.worktreeId}`}
hostId={props.hostId}
folderWorktreeId={props.worktreeId}
name={props.name ?? ''}
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { ActivityIndicator, ScrollView, View } from 'react-native'
import { FolderGit2, GitBranch, Plus } from 'lucide-react-native'
import type { Repo } from '../../../../src/shared/repo-types'
import { projectGroupIdFromRepoId } from '../../../../src/shared/folder-workspaces/folder-workspace-worktree'
import { getRepoExecutionHostId } from '../../../../src/shared/execution-host'
import { WORKTREE_CREATE_EXACT_NAME_RUNTIME_CAPABILITY } from '../../../../src/shared/worktree-runtime-capabilities'
import { ListGroup, ListRow, SectionHeader } from '../../components/list/ListGroup'
import { MobileStateView } from '../../components/MobileStateView'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { useHostQuery } from '../../query/host-query'
import { colors, spacing } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useHostClient } from '../../transport/connection/host-client-hooks'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import {
scopeRpcClientToExecutionHost,
workspaceRouteExecutionHost
} from '../../worktree/execution-host-scoped-rpc-client'
import type { FolderListRow } from './mobile-folder-attached-worktrees'
import {
isFolderRepoCreateBusy,
subscribeFolderRepoCreates,
type FolderRepoTarget
} from './folder-repo-worktree-create'
import {
folderSourceCatalogQuery,
selectFolderSourceRepos,
serverFolderSourceCatalogQuery
} from './folder-source-repos-query'
const NO_INPUT = {}
type Props = {
hostId: string
folder: FolderListRow
/** The paired desktop's capabilities. */
desktopCapabilities: readonly string[]
/** The capabilities of the host the folder lives on: the desktop's, or its server's. */
folderHostCapabilities: readonly string[]
/** On screen and on top; the repository read waits for it. */
observing: boolean
/** The header's refresh reads the repositories too. */
refreshers: Set<() => void>
onCreate: (repo: Repo, target: FolderRepoTarget, client: RpcClient, name: string) => void
bottomInset: number
}
/** A folder with no attached worktrees: desktop's copy, then one create card per repository. */
export function MobileFolderEmptyState(props: Props) {
const { hostId, folder, desktopCapabilities, folderHostCapabilities, observing, refreshers } =
props
const t = useMobileTranslation()
const { client: desktopClient, state } = useHostClient(hostId)
// A relayed server's folder is created on that server, through the desktop.
const routeHost = workspaceRouteExecutionHost(desktopClient, desktopCapabilities, folder.hostId)
const server = routeHost?.startsWith('runtime:') ? routeHost : undefined
// Only exact-name creates land as named; a server's rows also need a desktop new enough to relay its folder lineage.
const canCreate =
state === 'connected' &&
routeHost !== null &&
folderHostCapabilities.includes(WORKTREE_CREATE_EXACT_NAME_RUNTIME_CAPABILITY) &&
(!server || desktopCapabilities.includes(WORKTREE_CREATE_EXACT_NAME_RUNTIME_CAPABILITY))
const createClient = useMemo(
() =>
desktopClient && server
? scopeRpcClientToExecutionHost(desktopClient, server)
: desktopClient,
[desktopClient, server]
)
const catalog = useHostQuery(
server ? serverFolderSourceCatalogQuery : folderSourceCatalogQuery,
{ hostId, client: canCreate ? createClient : null },
{ exec: server ?? 'local' },
NO_INPUT,
{ enabled: canCreate, subscribed: observing }
)
const refetchCatalog = catalog.refetch
useEffect(() => {
if (!canCreate) {
return
}
const refresh = () => void refetchCatalog()
refreshers.add(refresh)
return () => {
refreshers.delete(refresh)
}
}, [canCreate, refetchCatalog, refreshers])
const repos = useMemo(
() =>
canCreate
? selectFolderSourceRepos(catalog.data, projectGroupIdFromRepoId(folder.repoId))
: [],
[canCreate, catalog.data, folder.repoId]
)
const name = folder.displayName.trim()
const catalogFailed = canCreate && catalog.data === undefined && catalog.error !== null
return (
<ScrollView contentContainerStyle={styles.scroll}>
<MobileStateView
icon={GitBranch}
title={t('sourceControl.folder.emptyTitle', 'No attached worktrees yet')}
message={t(
'sourceControl.folder.emptyCopy',
'Worktrees created from this workspace will show up here.'
)}
action={
catalogFailed
? {
label: t('sourceControl.folder.loadReposRetry', 'Load repositories'),
onPress: () => void refetchCatalog()
}
: null
}
bottomInset={props.bottomInset}
>
{repos.length > 0 && createClient && name ? (
<View>
<SectionHeader title={t('sourceControl.folder.createSection', 'Create a worktree')} />
<View style={styles.cards}>
{repos.map((repo) => {
const target: FolderRepoTarget = {
hostId,
executionHost: server,
folderWorktreeId: folder.worktreeId,
repoId: repo.id
}
return (
<FolderRepoCreateCard
key={`${getRepoExecutionHostId(repo)}\0${repo.id}`}
target={target}
repo={repo}
onPress={() => props.onCreate(repo, target, createClient, name)}
/>
)
})}
</View>
</View>
) : null}
</MobileStateView>
</ScrollView>
)
}
function FolderRepoCreateCard(props: {
target: FolderRepoTarget
repo: Repo
onPress: () => void
}) {
const t = useMobileTranslation()
const busy = useSyncExternalStore(subscribeFolderRepoCreates, () =>
isFolderRepoCreateBusy(props.target)
)
return (
<ListGroup>
<ListRow
title={props.repo.displayName}
leading={<FolderGit2 size={18} color={colors.textSecondary} strokeWidth={1.9} />}
accessory={
busy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Plus size={18} color={colors.textSecondary} strokeWidth={2.2} />
)
}
busy={busy}
onPress={props.onPress}
accessibilityRole="button"
accessibilityLabel={t('sourceControl.folder.createIn', 'Create a worktree in {{repo}}', {
repo: props.repo.displayName
})}
/>
</ListGroup>
)
}
const styles = themedStyleSheet(() => ({
scroll: { flexGrow: 1 },
cards: { gap: spacing.sm }
}))
@@ -0,0 +1,126 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FolderListRow } from './mobile-folder-attached-worktrees'
const seen = vi.hoisted(() => {
const state: { covered: boolean; membersObserving: boolean[]; rowsSubscribed: boolean[] } = {
covered: false,
membersObserving: [],
rowsSubscribed: []
}
return state
})
vi.mock('react-native', () => ({ ScrollView: 'ScrollView', View: 'View' }))
vi.mock('lucide-react-native', () => ({ GitBranch: 'GitBranch' }))
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView',
useSafeAreaInsets: () => ({ bottom: 0 })
}))
vi.mock('../../components/MobileStateView', () => ({
MobileLoadErrorState: 'MobileLoadErrorState',
MobileOfflineState: 'MobileOfflineState',
MobileStateView: 'MobileStateView'
}))
vi.mock('../../transport/connection/connection-retry-action', () => ({
connectionRetryAction: () => () => {}
}))
vi.mock('../../transport/connection/host-client-hooks', () => ({
useForceReconnect: () => () => {}
}))
vi.mock('../../components/bottom-drawer/BottomDrawer', () => ({
OptionPager: ({ main }: { main: ReactNode }) => main,
useOptionPager: () => ({ page: null, open: () => {}, close: () => {} })
}))
vi.mock('../../components/list/ListGroup', () => ({ ListGroup: 'ListGroup' }))
vi.mock('../../i18n/use-mobile-translation', () => ({
useMobileTranslation: () => (_key: string, fallback: string) => fallback
}))
vi.mock('../../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('../../navigation/workspace-route-handoff', () => ({
useWorkspaceRouteHandoff: () => ({ back: () => {} })
}))
vi.mock('../../navigation/workspace-route', () => ({
WorkspaceExecutionScope: 'WorkspaceExecutionScope'
}))
vi.mock('../../theme/mobile-theme', () => ({ colors: {}, spacing: { lg: 16 } }))
vi.mock('../../theme/themed-styles', () => ({
themedStyleSheet: (styles: () => unknown) => styles()
}))
vi.mock('../../theme/use-mobile-color-scheme', () => ({ useMobileColorScheme: () => {} }))
vi.mock('../MobileSourceControlHeader', () => ({
MobileSourceControlHeader: 'MobileSourceControlHeader'
}))
vi.mock('../MobileSourceControlPanel', () => ({ MobileSourceControlPanel: 'Panel' }))
vi.mock('../mobile-source-control-styles', () => ({ styles: {} }))
vi.mock('./MobileFolderEmptyState', () => ({ MobileFolderEmptyState: 'EmptyState' }))
vi.mock('../../components/host-protocol-gates-context', () => ({
useOptionalHostProtocolGates: () => null
}))
vi.mock('./use-folder-create-flow', () => ({
useFolderCreateFlow: () => ({
onCreate: async () => {},
covered: seen.covered,
composerElement: null,
toastElement: null
})
}))
vi.mock('./MobileFolderWorktreeRow', () => ({
MobileFolderWorktreeRow: (props: { subscribed: boolean }) => {
seen.rowsSubscribed.push(props.subscribed)
return null
}
}))
vi.mock('./use-mobile-folder-attached-worktrees', () => ({
useMobileFolderAttachedWorktrees: (_hostId: string, _folderId: string, observing: boolean) => {
seen.membersObserving.push(observing)
return {
folder: { worktreeId: 'folder:f-1', repoId: 'folder-workspace:f-1', displayName: 'payments' },
worktrees: [ROW],
desktopCapabilities: [],
serverHosts: [],
connState: 'connected',
loadError: null,
refetch: () => {}
}
}
}))
import { MobileFolderSourceControlPanel } from './MobileFolderSourceControlPanel'
const ROW: FolderListRow = {
worktreeId: 'repo-1::/w/payments',
repoId: 'repo-1',
repo: 'orca',
displayName: 'payments',
branch: 'payments'
}
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
seen.covered = false
seen.membersObserving = []
seen.rowsSubscribed = []
})
describe('MobileFolderSourceControlPanel', () => {
it('pauses row status under the create form while the folder members stay live', () => {
seen.covered = true
act(() => {
renderer = create(
createElement(MobileFolderSourceControlPanel, {
hostId: 'host-1',
folderWorktreeId: 'folder:f-1',
name: 'payments',
embedded: true
})
)
})
expect(seen.rowsSubscribed.at(-1)).toBe(false)
expect(seen.membersObserving.at(-1)).toBe(true)
})
})
@@ -24,9 +24,14 @@ import { getWorktreeRowIdentity } from '../../worktree/worktree-host-row-identit
import { MobileSourceControlHeader } from '../MobileSourceControlHeader'
import { MobileSourceControlPanel } from '../MobileSourceControlPanel'
import { styles as sourceControlStyles } from '../mobile-source-control-styles'
import { MobileFolderEmptyState } from './MobileFolderEmptyState'
import { useFolderCreateFlow } from './use-folder-create-flow'
import { MobileFolderWorktreeRow } from './MobileFolderWorktreeRow'
import { useOptionalHostProtocolGates } from '../../components/host-protocol-gates-context'
import { useMobileFolderAttachedWorktrees } from './use-mobile-folder-attached-worktrees'
const NO_CAPABILITIES: readonly string[] = []
type Props = {
hostId: string
folderWorktreeId: string
@@ -61,8 +66,22 @@ export function MobileFolderSourceControlPanel({
const observing = focused && !pageOpen
const { folder, worktrees, desktopCapabilities, serverHosts, connState, loadError, refetch } =
useMobileFolderAttachedWorktrees(hostId, folderWorktreeId, observing)
// In a server's scope these are the server's own; otherwise the desktop's.
const folderHostGates = useOptionalHostProtocolGates()
const forceReconnect = useForceReconnect()
const [refreshers] = useState(() => new Set<() => void>())
const createFlow = useFolderCreateFlow({
hostId,
folderWorktreeId,
executionHost: folder?.hostId?.startsWith('runtime:') ? folder.hostId : undefined,
desktopCapabilities,
members: worktrees,
observing,
refetch,
bottomInset: insets.bottom
})
// Rows and repositories under a create form wait; the folder's members stay live so creates still reconcile.
const statusObserving = observing && !createFlow.covered
const refresh = () => {
refetch()
for (const refreshNow of refreshers) {
@@ -118,13 +137,17 @@ export function MobileFolderSourceControlPanel({
title={t('sourceControl.panel.unavailableTitle', 'Source Control Unavailable')}
/>
) : worktrees.length === 0 ? (
<MobileStateView
icon={GitBranch}
title={t('sourceControl.folder.emptyTitle', 'No attached worktrees yet')}
message={t(
'sourceControl.folder.emptyCopy',
'Worktrees created from this workspace will show up here.'
)}
<MobileFolderEmptyState
hostId={hostId}
folder={folder}
desktopCapabilities={desktopCapabilities}
folderHostCapabilities={folderHostGates?.hostCapabilities ?? NO_CAPABILITIES}
observing={statusObserving}
refreshers={refreshers}
onCreate={(repo, target, client, createName) =>
void createFlow.onCreate(repo, target, client, createName)
}
bottomInset={insets.bottom}
/>
) : (
// One card per attached worktree, as desktop shows them.
@@ -141,7 +164,7 @@ export function MobileFolderSourceControlPanel({
serverHosts.find((server) => server.hostId === worktree.hostId)?.relay ===
'update-needed'
}
subscribed={observing}
subscribed={statusObserving}
refreshers={refreshers}
onOpen={open}
/>
@@ -160,6 +183,8 @@ export function MobileFolderSourceControlPanel({
)}
{/* Every state shares the sheet surface the cards sit on, as the phone's option sheets do. */}
<View style={styles.body}>{body}</View>
{createFlow.toastElement}
{createFlow.composerElement}
</View>
)
return (
@@ -0,0 +1,332 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FakeSession } from '../../transport/connection/mobile-endpoint-supervisor-test-fakes'
import { markRpcDeliveryUnknown } from '../../transport/rpc/rpc-delivery-ambiguity'
import type { RpcResponse } from '../../transport/rpc/rpc-wire-types'
import {
beginFolderComposerSubmission,
createFolderRepoWorktree,
isFolderRepoCreateBusy,
reconcileFolderRepoCreates,
releaseFolderComposerReservation,
type FolderRepoTarget
} from './folder-repo-worktree-create'
const idempotency = vi.hoisted(() => {
const state: { support: { dedupeTtlMs: number } | false } = { support: false }
return state
})
vi.mock('../../tasks/worktree-create-capability', () => ({
readNewWorktreeRuntimeCapabilities: async () => ({
worktreeCreateIdempotency: idempotency.support
})
}))
const FOLDER = 'folder:f-1'
const TAKEN = 'Branch payments already exists\nworktree_create_exact_name_taken'
afterEach(() => {
vi.useRealTimers()
idempotency.support = false
})
let repoCount = 0
// Each test's own repository: the registry is shared, as it is across screens.
function target(executionHost?: string): FolderRepoTarget {
repoCount += 1
return { hostId: 'host-1', executionHost, folderWorktreeId: FOLDER, repoId: `repo-${repoCount}` }
}
function refused(message: string): RpcResponse {
return { id: '1', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'r' } }
}
const CREATED: RpcResponse = {
id: '1',
ok: true,
result: { worktree: { id: 'repo::/w/payments', displayName: 'payments' } },
_meta: { runtimeId: 'r' }
}
function host(...replies: (RpcResponse | Error)[]): FakeSession {
const client = new FakeSession('connected')
for (const reply of replies) {
if (reply instanceof Error) {
client.sendRequest.mockRejectedValueOnce(reply)
} else {
client.sendRequest.mockResolvedValueOnce(reply)
}
}
return client
}
const create = (repo: FolderRepoTarget, client: FakeSession) =>
createFolderRepoWorktree({ target: repo, client, name: 'payments' })
describe('createFolderRepoWorktree', () => {
it('sends one exact-name create attached to the folder', async () => {
const repo = target()
const client = host(CREATED)
await expect(create(repo, client)).resolves.toEqual({ kind: 'created', name: 'payments' })
expect(client.sendRequest.mock.calls).toEqual([
[
'worktree.create',
{
repo: `id:${repo.repoId}`,
name: 'payments',
displayName: 'payments',
displayNameKind: 'user',
branchIntent: 'create-exact',
setupDecision: 'inherit',
parentWorkspace: FOLDER,
parentWorkspaceOrigin: 'manual'
},
expect.anything()
]
])
})
it('sends nothing for a second tap while the first create runs', async () => {
const repo = target()
const client = new FakeSession('connected')
let answer: (response: RpcResponse) => void = () => {}
client.sendRequest.mockReturnValue(new Promise<RpcResponse>((resolve) => (answer = resolve)))
const first = create(repo, client)
await expect(create(repo, client)).resolves.toEqual({ kind: 'already-running' })
answer(CREATED)
await first
expect(client.sendRequest).toHaveBeenCalledTimes(1)
})
it.each([
['the name is taken', TAKEN],
['the repo asks about setup', 'Setup decision required for this repository']
])('keeps the repository for the composer when %s', async (_case, message) => {
const repo = target()
const outcome = await create(repo, host(refused(message)))
expect(outcome.kind).toBe('needs-composer')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
if (outcome.kind === 'needs-composer') {
releaseFolderComposerReservation(repo, outcome.reservation)
}
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it('treats a lost reply as possibly created, past the busy backstop, and never offers the composer', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
const lost = markRpcDeliveryUnknown(new Error('connection lost'))
await expect(create(repo, host(lost))).resolves.toEqual({ kind: 'maybe-created' })
expect(isFolderRepoCreateBusy(repo)).toBe(true)
await expect(create(repo, host(CREATED))).resolves.toEqual({ kind: 'already-running' })
// The list never showed it; the card takes taps again but the create is still unresolved.
vi.advanceTimersByTime(60_000)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
const retry = host(refused(TAKEN))
await expect(create(repo, retry)).resolves.toEqual({ kind: 'maybe-created' })
expect(retry.sendRequest).toHaveBeenCalledTimes(1)
})
it('retries an unconfirmed create as the name it was sent as, even after the folder is renamed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const retry = host(refused(TAKEN))
await createFolderRepoWorktree({ target: repo, client: retry, name: 'billing' })
expect(retry.sendRequest.mock.calls[0]?.[1]).toMatchObject({ name: 'payments' })
})
it('asks for setup when an unconfirmed create is refused for it, and resends the same exact name', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const asked = await create(repo, host(refused('Setup decision required for this repository')))
expect(asked.kind).toBe('needs-setup-choice')
expect(isFolderRepoCreateBusy(repo)).toBe(false)
const hold = asked.kind === 'needs-setup-choice' ? asked.hold : -1
const resent = host(refused(TAKEN))
await expect(
createFolderRepoWorktree({
target: repo,
client: resent,
name: 'billing',
skipSetupFor: hold
})
).resolves.toEqual({ kind: 'maybe-created' })
expect(resent.sendRequest.mock.calls[0]?.[1]).toMatchObject({
name: 'payments',
branchIntent: 'create-exact',
setupDecision: 'skip'
})
})
it('sends nothing for a setup choice once its create is listed or replaced', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const ask = async (repo: FolderRepoTarget) => {
await create(repo, host(markRpcDeliveryUnknown(new Error('connection lost'))))
vi.advanceTimersByTime(60_000)
const asked = await create(repo, host(refused('Setup decision required for this repository')))
return asked.kind === 'needs-setup-choice' ? asked.hold : -1
}
const listed = target()
const listedHold = await ask(listed)
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: listed.repoId }])
const replaced = target()
const replacedHold = await ask(replaced)
await ask(replaced)
const late = host(CREATED)
for (const [repo, hold] of [
[listed, listedHold],
[replaced, replacedHold]
] as const) {
await expect(
createFolderRepoWorktree({
target: repo,
client: late,
name: 'billing',
skipSetupFor: hold
})
).resolves.toEqual({ kind: 'checking' })
}
expect(late.sendRequest).not.toHaveBeenCalled()
})
})
describe('createFolderRepoWorktree after a lost reply on a host that dedupes', () => {
it.each([
['answers with the worktree', CREATED, { kind: 'created', name: 'payments' }],
['refuses the name as taken', refused(TAKEN), { kind: 'maybe-created' }]
])('resends the same create once reconnected; the host %s', async (_case, replay, expected) => {
idempotency.support = { dedupeTtlMs: 60_000 }
const repo = target()
const client = new FakeSession('connected')
client.sendRequest
.mockImplementationOnce(async () => {
client.publishState('reconnecting')
setTimeout(() => client.publishState('connected'), 0)
throw markRpcDeliveryUnknown(new Error('connection lost'))
})
.mockResolvedValueOnce(replay)
await expect(create(repo, client)).resolves.toEqual(expected)
const [first, second] = client.sendRequest.mock.calls.map(([, params]) => params)
expect(second).toEqual(first)
expect(first).toHaveProperty('clientMutationId')
})
})
describe('reconcileFolderRepoCreates', () => {
it('forgets a create once the folder lists its worktree on the same host, not another', async () => {
const repo = target('runtime:env-1')
await create(repo, host(CREATED))
const folder = { hostId: 'host-1', executionHost: 'runtime:env-1', folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId, hostId: 'runtime:env-2' }])
expect(isFolderRepoCreateBusy(repo)).toBe(true)
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId, hostId: 'runtime:env-1' }])
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it('keeps a create still on its way even if the list already shows the repository', async () => {
const repo = target()
const client = new FakeSession('connected')
let answer: (response: RpcResponse) => void = () => {}
client.sendRequest.mockReturnValue(new Promise<RpcResponse>((resolve) => (answer = resolve)))
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
const members = [{ repoId: repo.repoId }]
const pending = create(repo, client)
reconcileFolderRepoCreates(folder, members)
expect(isFolderRepoCreateBusy(repo)).toBe(true)
answer(CREATED)
await pending
reconcileFolderRepoCreates(folder, members)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
it("doesn't let an earlier create's timer end a newer one", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const repo = target()
await create(repo, host(markRpcDeliveryUnknown(new Error('lost'))))
vi.advanceTimersByTime(10_000)
const folder = { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER }
reconcileFolderRepoCreates(folder, [{ repoId: repo.repoId }])
await create(repo, host(CREATED))
vi.advanceTimersByTime(10_000)
expect(isFolderRepoCreateBusy(repo)).toBe(true)
})
})
describe('beginFolderComposerSubmission', () => {
async function composing() {
const repo = target()
const outcome = await create(repo, host(refused(TAKEN)))
if (outcome.kind !== 'needs-composer') {
throw new Error('expected the composer')
}
return { repo, launch: { target: repo, reservation: outcome.reservation, name: 'payments' } }
}
it('admits one create per repository while it is out, even after the form closes', async () => {
const { repo, launch } = await composing()
let formOpen = true
const submission = beginFolderComposerSubmission(repo, launch, () => formOpen)
expect(submission).not.toBeNull()
formOpen = false
releaseFolderComposerReservation(repo, launch.reservation)
expect(beginFolderComposerSubmission(repo, launch, () => true)).toBeNull()
expect(await create(repo, host(CREATED))).toEqual({ kind: 'already-running' })
submission?.settle('uncertain')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
})
it('gives a refused create back to the open form, and a same-host switch its own admission', async () => {
const { repo, launch } = await composing()
const other = { ...repo, repoId: `${repo.repoId}-other` }
beginFolderComposerSubmission(repo, launch, () => true)?.settle('failed')
expect(isFolderRepoCreateBusy(repo)).toBe(true)
const switched = beginFolderComposerSubmission(other, launch, () => true)
expect(switched).not.toBeNull()
expect(beginFolderComposerSubmission(other, launch, () => true)).toBeNull()
switched?.settle('created')
releaseFolderComposerReservation(repo, launch.reservation)
expect(isFolderRepoCreateBusy(repo)).toBe(false)
expect(isFolderRepoCreateBusy(other)).toBe(true)
})
it("never sends anything from a card for a form's unconfirmed create, whatever name it used", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { repo, launch } = await composing()
// The form may have sent another name or branch than the card's.
beginFolderComposerSubmission(repo, launch, () => true)?.settle('uncertain')
vi.advanceTimersByTime(60_000)
expect(beginFolderComposerSubmission(repo, launch, () => true)).toBeNull()
const tap = host(CREATED)
await expect(create(repo, tap)).resolves.toEqual({ kind: 'checking' })
await expect(
createFolderRepoWorktree({
target: repo,
client: tap,
name: 'payments',
skipSetupFor: 0
})
).resolves.toEqual({ kind: 'checking' })
expect(tap.sendRequest).not.toHaveBeenCalled()
expect(isFolderRepoCreateBusy(repo)).toBe(false)
})
})
@@ -0,0 +1,310 @@
import { WORKTREE_CREATE_EXACT_NAME_TAKEN_CODE } from '../../../../src/shared/new-workspace/worktree-create-collision'
import { hasRuntimeRpcErrorCode } from '../../../../src/shared/runtime-rpc-error-code'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import { isRpcDeliveryUnknown } from '../../transport/rpc/rpc-delivery-ambiguity'
import { readNewWorktreeRuntimeCapabilities } from '../../tasks/worktree-create-capability'
import { createWorktreeWithNameRetry } from '../../tasks/worktree-create-retry'
// The host's refusal before creating, when the repo asks to run or skip setup; desktop matches the same text.
const SETUP_DECISION_REQUIRED = 'Setup decision required for this repository'
// How long a created or unconfirmed card stays busy waiting for the list; the outcome itself is kept until it shows.
const HELD_BUSY_MS = 15_000
/** One repository of one folder on one host. */
export type FolderRepoTarget = {
hostId: string
/** The folder's server; undefined for the paired desktop's own. */
executionHost: string | undefined
/** `folder:<id>`. */
folderWorktreeId: string
repoId: string
}
type Phase =
/** The direct exact-name create is out. */
| 'creating'
/** Refused before creating; a composer for it is open and nothing has been sent from it. */
| 'composing'
/** The composer sent its create. */
| 'submitting'
/** Created, or possibly created: kept until the folder's members show it. */
| 'held'
/**
* `name` is the exact name a direct create was sent as, so an unresolved one is retried as itself.
* A composer's create may have used another name or branch, so it is never resent from a card.
*/
type Operation = FolderRepoTarget & {
id: number
phase: Phase
busy: boolean
name: string
origin: 'direct' | 'composer'
}
// In memory and shared across screens, so a remount or a reopened composer can't send a second create.
const operations = new Map<string, Operation>()
const listeners = new Set<() => void>()
let nextId = 1
let version = 0
function notify(): void {
version += 1
listeners.forEach((listener) => listener())
}
export function folderRepoCreateKey(target: FolderRepoTarget): string {
return [target.hostId, target.executionHost ?? '', target.folderWorktreeId, target.repoId].join(
'\0'
)
}
export function subscribeFolderRepoCreates(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function getFolderRepoCreatesVersion(): number {
return version
}
/** The card shows a spinner and takes no taps while its repository's create is busy. */
export function isFolderRepoCreateBusy(target: FolderRepoTarget): boolean {
return operations.get(folderRepoCreateKey(target))?.busy === true
}
function start(
target: FolderRepoTarget,
phase: Phase,
name: string,
origin: Operation['origin']
): Operation {
const operation = { ...target, id: nextId++, phase, busy: true, name, origin }
operations.set(folderRepoCreateKey(target), operation)
notify()
return operation
}
function isCurrent(operation: Operation): boolean {
return operations.get(folderRepoCreateKey(operation))?.id === operation.id
}
function release(operation: Operation): void {
if (isCurrent(operation)) {
operations.delete(folderRepoCreateKey(operation))
notify()
}
}
function hold(operation: Operation, busy = true): number | undefined {
if (!isCurrent(operation)) {
return undefined
}
// A new identity, so an earlier hold's timer can't end this one.
const held = { ...operation, id: nextId++, phase: 'held' as const, busy }
operations.set(folderRepoCreateKey(held), held)
notify()
if (!busy) {
return held.id
}
setTimeout(() => {
if (isCurrent(held)) {
operations.set(folderRepoCreateKey(held), { ...held, busy: false })
notify()
}
}, HELD_BUSY_MS)
return held.id
}
/** Whether `hold` is still this repository's unconfirmed direct create, waiting for a setup choice. */
export function isFolderRepoHoldPending(target: FolderRepoTarget, hold: number): boolean {
const operation = operations.get(folderRepoCreateKey(target))
return operation?.id === hold && operation.phase === 'held' && operation.origin === 'direct'
}
/** Forgets created or unconfirmed creates once the folder lists a worktree from that repository on that host. */
export function reconcileFolderRepoCreates(
folder: Pick<FolderRepoTarget, 'hostId' | 'executionHost' | 'folderWorktreeId'>,
members: readonly { repoId: string; hostId?: string }[]
): void {
let changed = false
for (const [key, operation] of operations) {
if (
operation.phase === 'held' &&
operation.hostId === folder.hostId &&
operation.executionHost === folder.executionHost &&
operation.folderWorktreeId === folder.folderWorktreeId &&
members.some(
(member) =>
member.repoId === operation.repoId &&
(member.hostId?.startsWith('runtime:') ? member.hostId : undefined) ===
operation.executionHost
)
) {
operations.delete(key)
changed = true
}
}
if (changed) {
notify()
}
}
export type FolderRepoCreateOutcome =
| { kind: 'created'; name: string; warning?: string }
/** Refused before creating: a branch with that name exists, or the repo asks to run or skip setup. The repository stays reserved for the composer. */
| { kind: 'needs-composer'; reservation: number }
/** The host may have created it: the reply was lost, or a resend was refused as taken. */
| { kind: 'maybe-created' }
/** An unconfirmed create's resend needs a setup choice; it is resent as the same exact name with it. */
| { kind: 'needs-setup-choice'; hold: number }
/** A composer's create is unconfirmed: nothing is sent, the folder is checked until it lists it. */
| { kind: 'checking' }
| { kind: 'failed'; message: string }
| { kind: 'already-running' }
/**
* Creates a worktree named after the folder in one of its repositories, attached to the folder, the
* way desktop's empty state does. Only for hosts that advertise exact-name creation.
*/
export async function createFolderRepoWorktree(args: {
target: FolderRepoTarget
/** The client that creates: the desktop's own, or scoped to the folder's server. */
client: RpcClient
name: string
/** Resends this unconfirmed hold without setup; nothing is sent once the folder shows it or it is replaced. */
skipSetupFor?: number
}): Promise<FolderRepoCreateOutcome> {
const existing = operations.get(folderRepoCreateKey(args.target))
const staleResend =
args.skipSetupFor !== undefined && !isFolderRepoHoldPending(args.target, args.skipSetupFor)
if (!staleResend && existing && (existing.phase !== 'held' || existing.busy)) {
return { kind: 'already-running' }
}
if (staleResend || existing?.origin === 'composer') {
return { kind: 'checking' }
}
// An unconfirmed create is retried as the same exact name, even after a rename: a taken name may be its own.
const unresolved = existing !== undefined
const name = existing?.name ?? args.name
const operation = start(args.target, 'creating', name, 'direct')
const settleUnresolved = (outcome: FolderRepoCreateOutcome): FolderRepoCreateOutcome => {
hold(operation)
return outcome
}
try {
const result = await createWorktreeWithNameRetry({
client: args.client,
baseName: name,
// One exact name: a numbered suffix would be a worktree the user didn't ask for.
maxAttempts: 1,
branchIntentSupported: true,
worktreeCreateIdempotency: readNewWorktreeRuntimeCapabilities(args.client).then(
(capabilities) => capabilities?.worktreeCreateIdempotency ?? false
),
buildParams: (name) => ({
repo: `id:${args.target.repoId}`,
name,
displayName: name,
displayNameKind: 'user',
branchIntent: 'create-exact',
setupDecision: args.skipSetupFor === undefined ? 'inherit' : 'skip',
parentWorkspace: args.target.folderWorktreeId,
parentWorkspaceOrigin: 'manual'
})
})
if (!('error' in result)) {
hold(operation)
return {
kind: 'created',
name: result.name,
...(result.warning ? { warning: result.warning } : {})
}
}
const taken = hasRuntimeRpcErrorCode(result.error, WORKTREE_CREATE_EXACT_NAME_TAKEN_CODE)
if (taken && (result.replayed || unresolved)) {
return settleUnresolved({ kind: 'maybe-created' })
}
const needsSetup = result.error.includes(SETUP_DECISION_REQUIRED)
if (unresolved && needsSetup) {
// That refusal did no git work, but says nothing about the earlier create; it stays unconfirmed.
const held = hold(operation, false)
return held === undefined
? { kind: 'maybe-created' }
: { kind: 'needs-setup-choice', hold: held }
}
if (unresolved) {
return settleUnresolved({ kind: 'failed', message: result.error })
}
if (taken || needsSetup) {
const reserved = { ...operation, phase: 'composing' as const }
operations.set(folderRepoCreateKey(reserved), reserved)
notify()
return { kind: 'needs-composer', reservation: reserved.id }
}
release(operation)
return { kind: 'failed', message: result.error }
} catch (error) {
// Delivery unknown: the host may have created it, and a fresh create would be refused as taken.
if (isRpcDeliveryUnknown(error) || unresolved) {
return settleUnresolved({ kind: 'maybe-created' })
}
release(operation)
return { kind: 'failed', message: error instanceof Error ? error.message : String(error) }
}
}
/** A composer closed before sending anything gives its repository back. */
export function releaseFolderComposerReservation(
target: FolderRepoTarget,
reservation: number
): void {
const operation = operations.get(folderRepoCreateKey(target))
if (operation?.id === reservation && operation.phase === 'composing') {
release(operation)
}
}
export type FolderComposerSubmission = {
/** `created` and `uncertain` are kept until the folder shows them; `failed` and `cancelled` give the repository back. */
settle: (outcome: 'created' | 'uncertain' | 'failed' | 'cancelled') => void
}
/**
* Admits the composer's create for the repository it actually picked, before anything is sent.
* Null while another create for that repository is out.
*/
export function beginFolderComposerSubmission(
target: FolderRepoTarget,
launch: { target: FolderRepoTarget; reservation: number; name: string },
/** Whether the composer is still open to retry a refused create. */
formOpen: () => boolean
): FolderComposerSubmission | null {
const key = folderRepoCreateKey(target)
const existing = operations.get(key)
const ownsReservation =
key === folderRepoCreateKey(launch.target) &&
existing?.id === launch.reservation &&
existing.phase === 'composing'
if (existing && !ownsReservation) {
return null
}
const operation = start(target, 'submitting', launch.name, 'composer')
return {
settle: (outcome) => {
if (outcome === 'created' || outcome === 'uncertain') {
hold(operation)
return
}
// A refusal leaves the form to try again; the reservation is back with the launch's repository.
if (ownsReservation && formOpen() && isCurrent(operation)) {
const reserved = { ...operation, phase: 'composing' as const, id: launch.reservation }
operations.set(key, reserved)
notify()
return
}
release(operation)
}
}
}
@@ -0,0 +1,124 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stubRpcClient } from '../../transport/rpc/rpc-client-test-stub'
import { useHostQuery } from '../../query/host-query'
import { scopeRpcClientToExecutionHost } from '../../worktree/execution-host-scoped-rpc-client'
import {
documentQueryClient,
settleQueryNotifications
} from '../../test-support/rpc-recording/hook-mount'
import { FakeSession } from '../../transport/connection/mobile-endpoint-supervisor-test-fakes'
import {
folderSourceCatalogQuery,
serverFolderSourceCatalogQuery,
selectFolderSourceRepos
} from './folder-source-repos-query'
vi.mock('react-native', () => ({ AppState: {} }))
const GROUP = {
id: 'group-1',
name: 'payments',
parentPath: '/src/payments',
parentGroupId: null,
createdFrom: 'folder',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
function hostWith(repos: unknown[]): FakeSession {
const client = new FakeSession('connected')
client.sendRequest.mockImplementation(async (method: string) => ({
id: '1',
ok: true,
result: method === 'repo.list' ? { repos } : { groups: [GROUP] },
_meta: { runtimeId: 'r' }
}))
return client
}
const repo = (id: string, path: string) => ({
id,
path,
displayName: id,
kind: 'git',
badgeColor: 'gray',
addedAt: 1
})
describe('folder source repositories', () => {
it("offers a relayed server's repos in its folder", async () => {
const client = hostWith([repo('api', '/src/payments/api'), repo('elsewhere', '/src/other')])
const catalog = await serverFolderSourceCatalogQuery.fetch!(
client,
{ exec: 'runtime:env-1' },
{},
undefined,
{ live: false, previousFromStore: false }
)
expect(selectFolderSourceRepos(catalog, 'group-1').map((found) => found.id)).toEqual(['api'])
})
})
describe('folder source catalog reads', () => {
let renderer: ReactTestRenderer | null = null
const queries = documentQueryClient()
afterEach(async () => {
act(() => renderer?.unmount())
renderer = null
await queries.end()
})
it.each([true, false])(
"reads a server folder's catalog on the desktop's one event stream (subscribed: %s)",
async (subscribed) => {
let eventStreams = 0
const desktop = stubRpcClient({
getLastConnectedAt: () => 1,
sendRequest: async (method) => ({
id: '1',
ok: true,
result: method === 'repo.list' ? { repos: [] } : { groups: [GROUP] },
_meta: { runtimeId: 'r' }
}),
subscribe: (method) => {
if (method === 'runtime.clientEvents.subscribe') {
eventStreams += 1
}
return () => {}
}
})
const server = scopeRpcClientToExecutionHost(desktop, 'runtime:env-1')
function Harness(): null {
useHostQuery(
folderSourceCatalogQuery,
{ hostId: 'host-1', client: desktop },
{ exec: 'local' },
{},
{
subscribed: true
}
)
useHostQuery(
serverFolderSourceCatalogQuery,
{ hostId: 'host-1', client: server },
{ exec: 'runtime:env-1' },
{},
{ subscribed }
)
return null
}
await act(async () => {
renderer = create(queries.wrap(createElement(Harness)))
})
await settleQueryNotifications()
expect(eventStreams).toBe(1)
}
)
})
@@ -0,0 +1,73 @@
import type { Repo } from '../../../../src/shared/repo-types'
import type { ProjectGroup } from '../../../../src/shared/project-group-types'
import { getFolderSourceRepos } from '../../../../src/shared/folder-workspaces/folder-workspace-source-repos'
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getSshTargetIdForExecutionHost
} from '../../../../src/shared/execution-host'
import { defineHostQuery } from '../../query/host-query'
import type { HostQueryTarget } from '../../query/host-query-key'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import { hostProjectGroupsRead } from '../../worktree/project-sections-operations'
import { listHostRepos, listServerRepos } from '../../worktree/server-repos'
export type FolderSourceCatalog = { repos: Repo[]; groups: ProjectGroup[] }
const NO_REPOS: Repo[] = []
const fetchFolderSourceCatalog = async (
client: RpcClient,
target: HostQueryTarget
): Promise<FolderSourceCatalog> => {
const server = target.exec === LOCAL_EXECUTION_HOST_ID ? null : target.exec
const [repos, groupsRead] = await Promise.all([
server ? listServerRepos<Repo>(server, client) : listHostRepos<Repo>(client),
hostProjectGroupsRead.request(client).then((reply) => hostProjectGroupsRead.interpret(reply))
])
if (!repos || !groupsRead.accepted) {
throw new Error('Folder repositories unavailable')
}
// Repos from a server come stamped with it; its groups must match, or the host check drops every repo.
const groups = server
? groupsRead.value.map((group) => ({ ...group, executionHostId: server }))
: groupsRead.value
return { repos, groups }
}
/** The paired desktop's own repositories and project groups, re-read when its repos change. */
export const folderSourceCatalogQuery = defineHostQuery<Record<string, never>, FolderSourceCatalog>(
{
family: 'folder.sourceCatalog',
fetch: fetchFolderSourceCatalog,
staleTime: 60_000,
refetchOnWindowFocus: true,
signal: { events: ['reposChanged'] }
}
)
// A relayed server's changes are not signalled, so its reads hold no event stream; focus and refresh read again.
export const serverFolderSourceCatalogQuery = defineHostQuery<
Record<string, never>,
FolderSourceCatalog
>({
family: 'folder.sourceCatalog.server',
fetch: fetchFolderSourceCatalog,
staleTime: 60_000,
refetchOnWindowFocus: true
})
/** The folder's repositories a worktree can be created in, as desktop's empty state lists them. */
export function selectFolderSourceRepos(
catalog: FolderSourceCatalog | undefined,
projectGroupId: string | null
): Repo[] {
if (!catalog || !projectGroupId) {
return NO_REPOS
}
const group = catalog.groups.find((candidate) => candidate.id === projectGroupId) ?? null
// Direct SSH hosts can't create worktrees from here (managed hosts route as runtimes).
return getFolderSourceRepos(catalog.repos, catalog.groups, group).filter(
(repo) => !getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo))
)
}
@@ -11,7 +11,7 @@ import { serializeFolderLineage } from './folder-lineage-serialization'
/** What a folder list row shows of a host row. */
export type FolderListRow = Pick<
Worktree,
'worktreeId' | 'hostId' | 'repo' | 'displayName' | 'branch'
'worktreeId' | 'hostId' | 'repoId' | 'repo' | 'displayName' | 'branch'
>
type FolderLineageRow = MobileLineageRow & Pick<Worktree, 'createdAt'>
@@ -36,6 +36,7 @@ export function toFolderListRow(row: Worktree): FolderListRow {
listRow = {
worktreeId: row.worktreeId,
hostId: row.hostId,
repoId: row.repoId,
repo: row.repo,
displayName: row.displayName,
branch: row.branch
@@ -0,0 +1,343 @@
import { createElement, Fragment } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FakeSession } from '../../transport/connection/mobile-endpoint-supervisor-test-fakes'
import { markRpcDeliveryUnknown } from '../../transport/rpc/rpc-delivery-ambiguity'
import type { RpcResponse } from '../../transport/rpc/rpc-wire-types'
import type { NewWorktreeModalProps } from '../../components/new-worktree-modal-types'
import type { FolderListRow } from './mobile-folder-attached-worktrees'
import { isFolderRepoCreateBusy, type FolderRepoTarget } from './folder-repo-worktree-create'
import { useFolderCreateFlow, type FolderCreateRepo } from './use-folder-create-flow'
const seen = vi.hoisted(() => {
const state: {
composer: NewWorktreeModalProps | null
confirm: { visible: boolean; onConfirm?: () => void; onCancel: () => void } | null
toasts: string[]
haptics: number
} = { composer: null, confirm: null, toasts: [], haptics: 0 }
return state
})
vi.mock('react-native', () => ({ AppState: {} }))
vi.mock('../../components/NewWorktreeModalController', () => ({
NewWorktreeModalController: (props: NewWorktreeModalProps) => {
seen.composer = props
return null
}
}))
vi.mock('../../components/ConfirmModal', () => ({
ConfirmModal: (props: { visible: boolean; onConfirm?: () => void; onCancel: () => void }) => {
seen.confirm = props
return null
}
}))
vi.mock('../../components/TransientToast', () => ({
TransientToast: () => null,
useTransientToast: () => ({
message: null,
showToast: (text: string) => seen.toasts.push(text)
})
}))
vi.mock('../../platform/haptics', () => ({
triggerSuccess: () => (seen.haptics += 1),
triggerError: () => (seen.haptics += 1)
}))
vi.mock('../../platform/external-link', () => ({ openExternalLink: () => {} }))
vi.mock('../../transport/connection/host-client-hooks', () => ({
useHostClient: () => ({ client: null, state: 'connected' })
}))
vi.mock('../../tasks/worktree-create-capability', () => ({
readNewWorktreeRuntimeCapabilities: async () => ({ worktreeCreateIdempotency: false })
}))
vi.mock('../../i18n/use-mobile-translation', () => ({
useMobileTranslation: () => (_key: string, fallback: string) => fallback
}))
const FOLDER = 'folder:f-1'
const TAKEN = 'Branch payments already exists\nworktree_create_exact_name_taken'
const CREATED: RpcResponse = {
id: '1',
ok: true,
result: { worktree: { id: 'repo::/w/payments', displayName: 'payments' } },
_meta: { runtimeId: 'r' }
}
let repoCount = 0
function repoIn(): { repo: FolderCreateRepo; target: FolderRepoTarget } {
repoCount += 1
const repoId = `flow-repo-${repoCount}`
return {
repo: { id: repoId, displayName: repoId },
target: { hostId: 'host-1', executionHost: undefined, folderWorktreeId: FOLDER, repoId }
}
}
function replying(reply: RpcResponse) {
const client = new FakeSession('connected')
let answer: () => void = () => {}
client.sendRequest.mockReturnValue(
new Promise<RpcResponse>((resolve) => (answer = () => resolve(reply)))
)
return { client, answer: () => answer() }
}
type Flow = ReturnType<typeof useFolderCreateFlow>
let renderer: ReactTestRenderer | null = null
function mountFlow(refetch: () => void) {
const flow: { current: Flow | null } = { current: null }
let members: FolderListRow[] = []
function Harness() {
flow.current = useFolderCreateFlow({
hostId: 'host-1',
folderWorktreeId: FOLDER,
executionHost: undefined,
desktopCapabilities: [],
members,
observing: true,
refetch,
bottomInset: 0
})
return createElement(Fragment, null, flow.current.composerElement)
}
act(() => {
renderer = create(createElement(Harness))
})
return {
flow,
rerender: (next: FolderListRow[]) => {
members = next
act(() => renderer?.update(createElement(Harness)))
}
}
}
const composer = (): NewWorktreeModalProps => {
if (!seen.composer) {
throw new Error('the composer never rendered')
}
return seen.composer
}
afterEach(() => {
vi.useRealTimers()
act(() => renderer?.unmount())
renderer = null
seen.composer = null
seen.confirm = null
seen.toasts = []
seen.haptics = 0
})
describe('useFolderCreateFlow', () => {
it("keeps an open form when another repository's create lands", async () => {
const { flow, rerender } = mountFlow(() => {})
const b = repoIn()
const refusal = replying({ id: '1', ok: false, error: { code: 'x', message: TAKEN } })
const opening = flow.current!.onCreate(b.repo, b.target, refusal.client, 'payments')
refusal.answer()
await act(() => opening)
expect(composer().folderLaunch?.repoId).toBe(b.target.repoId)
const a = repoIn()
const creating = replying(CREATED)
const created = flow.current!.onCreate(a.repo, a.target, creating.client, 'payments')
creating.answer()
await act(() => created)
rerender([
{
worktreeId: 'a-child',
repoId: a.target.repoId,
repo: 'a',
displayName: 'payments',
branch: 'payments'
}
])
expect(composer().visible).toBe(true)
expect(composer().folderLaunch?.repoId).toBe(b.target.repoId)
})
it('shows nothing for a create that lands after the screen is left, and still refreshes', async () => {
const refetch = vi.fn()
const { flow } = mountFlow(refetch)
const a = repoIn()
const creating = replying(CREATED)
const created = flow.current!.onCreate(a.repo, a.target, creating.client, 'payments')
act(() => renderer?.unmount())
renderer = null
creating.answer()
await created
expect(refetch).toHaveBeenCalled()
expect(seen.toasts).toEqual([])
expect(seen.haptics).toBe(0)
})
async function openComposer(flow: { current: Flow | null }, repo = repoIn()) {
const refusal = replying({ id: '1', ok: false, error: { code: 'x', message: TAKEN } })
const opening = flow.current!.onCreate(repo.repo, repo.target, refusal.client, 'payments')
refusal.answer()
await act(() => opening)
return composer()
}
it("doesn't let an earlier form's close end a newer one", async () => {
const { flow } = mountFlow(() => {})
const open = () => openComposer(flow)
const first = await open()
act(() => first.onClose())
const second = await open()
act(() => first.onClose())
expect(composer().visible).toBe(true)
expect(composer().folderLaunch?.repoId).toBe(second.folderLaunch?.repoId)
})
it("shows nothing for an earlier form's create once a newer form is open", async () => {
const { flow } = mountFlow(() => {})
const first = await openComposer(flow)
act(() => first.onClose())
const second = await openComposer(flow)
expect(first.folderLaunch?.submit.presenting()).toBe(false)
act(() => first.onCreated('repo::/w/payments-2', 'payments-2'))
expect(seen.toasts).toEqual([])
expect(second.folderLaunch?.submit.presenting()).toBe(true)
})
it('gives the repository back when the screen is left with an unsent form', async () => {
const { flow } = mountFlow(() => {})
const repo = repoIn()
await openComposer(flow, repo)
expect(isFolderRepoCreateBusy(repo.target)).toBe(true)
act(() => renderer?.unmount())
renderer = null
expect(isFolderRepoCreateBusy(repo.target)).toBe(false)
})
it('resends an unconfirmed create without setup once the user agrees', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { flow } = mountFlow(() => {})
const a = repoIn()
const lost = new FakeSession('connected')
lost.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('connection lost')))
await act(() => flow.current!.onCreate(a.repo, a.target, lost, 'payments'))
vi.advanceTimersByTime(60_000)
const retry = new FakeSession('connected')
retry.sendRequest
.mockResolvedValueOnce({
id: '1',
ok: false,
error: { code: 'x', message: 'Setup decision required for this repository' }
})
.mockResolvedValueOnce(CREATED)
await act(() => flow.current!.onCreate(a.repo, a.target, retry, 'payments'))
expect(seen.confirm?.visible).toBe(true)
await act(async () => seen.confirm?.onConfirm?.())
expect(retry.sendRequest.mock.calls[1]?.[1]).toMatchObject({
name: 'payments',
branchIntent: 'create-exact',
setupDecision: 'skip'
})
})
it("refreshes the folder when a form's create may have landed", async () => {
const refetch = vi.fn()
const { flow } = mountFlow(refetch)
const b = repoIn()
const form = await openComposer(flow, b)
form.folderLaunch?.submit.begin({ ...b.repo, path: '/w/b' })?.settle('uncertain')
expect(refetch).toHaveBeenCalledTimes(1)
})
async function setupRefusedRetry(flow: { current: Flow | null }) {
const a = repoIn()
const lost = new FakeSession('connected')
lost.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('connection lost')))
await act(() => flow.current!.onCreate(a.repo, a.target, lost, 'payments'))
vi.advanceTimersByTime(60_000)
const retry = replying({
id: '1',
ok: false,
error: { code: 'x', message: 'Setup decision required for this repository' }
})
return { a, pending: flow.current!.onCreate(a.repo, a.target, retry.client, 'payments'), retry }
}
it("doesn't open a setup choice over another form, and the repository can retry after", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { flow } = mountFlow(() => {})
const { a, pending, retry } = await setupRefusedRetry(flow)
const form = await openComposer(flow)
retry.answer()
await act(() => pending)
expect(seen.confirm?.visible).toBe(false)
expect(composer().folderLaunch?.repoId).toBe(form.folderLaunch?.repoId)
expect(isFolderRepoCreateBusy(a.target)).toBe(false)
})
it("doesn't open a composer over a setup choice, and covers the list while it is open", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { flow } = mountFlow(() => {})
const { pending, retry } = await setupRefusedRetry(flow)
retry.answer()
await act(() => pending)
expect(seen.confirm?.visible).toBe(true)
expect(flow.current!.covered).toBe(true)
const b = repoIn()
const refusal = replying({ id: '1', ok: false, error: { code: 'x', message: TAKEN } })
const opening = flow.current!.onCreate(b.repo, b.target, refusal.client, 'payments')
refusal.answer()
await act(() => opening)
expect(composer().visible).toBe(false)
expect(isFolderRepoCreateBusy(b.target)).toBe(false)
act(() => seen.confirm?.onCancel())
expect(flow.current!.covered).toBe(false)
})
it('closes a setup choice once the folder lists its create, and its stale confirm sends nothing', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const { flow, rerender } = mountFlow(() => {})
const { a, pending, retry } = await setupRefusedRetry(flow)
retry.answer()
await act(() => pending)
const staleConfirm = seen.confirm?.onConfirm
expect(seen.confirm?.visible).toBe(true)
rerender([
{
worktreeId: 'a-child',
repoId: a.target.repoId,
repo: 'a',
displayName: 'payments',
branch: 'payments'
}
])
expect(seen.confirm?.visible).toBe(false)
await act(async () => staleConfirm?.())
expect(retry.client.sendRequest).toHaveBeenCalledTimes(1)
expect(seen.composer?.visible).toBe(false)
})
it("shows nothing for an older form's create once a setup choice opens, and still refreshes", async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] })
const refetch = vi.fn()
const { flow } = mountFlow(refetch)
const form = await openComposer(flow)
act(() => form.onClose())
const { pending, retry } = await setupRefusedRetry(flow)
retry.answer()
await act(() => pending)
expect(seen.confirm?.visible).toBe(true)
refetch.mockClear()
expect(form.folderLaunch?.submit.presenting()).toBe(false)
act(() => form.onCreated('repo::/w/payments', 'payments'))
expect(seen.toasts).toEqual([])
expect(refetch).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import type { Repo } from '../../../../src/shared/repo-types'
import { getRepoExecutionHostId } from '../../../../src/shared/execution-host'
import { ConfirmModal } from '../../components/ConfirmModal'
import { NewWorktreeModalController } from '../../components/NewWorktreeModalController'
import type { MobileWorkspaceRepo } from '../../components/new-worktree-modal-types'
import { TransientToast, useTransientToast } from '../../components/TransientToast'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { openExternalLink } from '../../platform/external-link'
import { triggerError, triggerSuccess } from '../../platform/haptics'
import { useHostClient } from '../../transport/connection/host-client-hooks'
import type { RpcClient } from '../../transport/rpc/rpc-client-contract'
import type { FolderListRow } from './mobile-folder-attached-worktrees'
import {
beginFolderComposerSubmission,
createFolderRepoWorktree,
getFolderRepoCreatesVersion,
isFolderRepoHoldPending,
reconcileFolderRepoCreates,
releaseFolderComposerReservation,
subscribeFolderRepoCreates,
type FolderRepoTarget
} from './folder-repo-worktree-create'
const NO_LABELS = new Map()
/** What a create reads of a repository: its id, name and host. */
export type FolderCreateRepo = Pick<Repo, 'id' | 'displayName' | 'connectionId' | 'executionHostId'>
type SetupChoice = {
/** The unconfirmed create it resends; once that is gone the dialog closes and sends nothing. */
hold: number
repo: FolderCreateRepo
target: FolderRepoTarget
client: RpcClient
name: string
}
type ComposerLaunch = {
target: FolderRepoTarget
reservation: number
executionHostId: ReturnType<typeof getRepoExecutionHostId>
name: string
}
/**
* The folder screen's creates: direct exact-name creates, the composer they fall back to, and their
* toasts. Kept above the empty state, so another repository's new card can't close a form being edited.
*/
export function useFolderCreateFlow(args: {
hostId: string
folderWorktreeId: string
/** The folder's server; undefined for the paired desktop's own. */
executionHost: string | undefined
desktopCapabilities: readonly string[]
members: readonly FolderListRow[]
observing: boolean
refetch: () => void
bottomInset: number
}) {
const { hostId, folderWorktreeId, executionHost, members, observing, refetch } = args
const t = useMobileTranslation()
const { client: desktopClient } = useHostClient(hostId)
const { message, showToast } = useTransientToast()
const [composer, setComposer] = useState<ComposerLaunch | null>(null)
const [setupChoice, setSetupChoice] = useState<SetupChoice | null>(null)
// The one form on screen, composer or setup choice. Set as forms open and close, not after render, so
// two answers landing together can't both open one.
const openRef = useRef<ComposerLaunch | SetupChoice | null>(null)
// The newest form opened, still set after it closes: an older form's late answer shows nothing over it.
const latestRef = useRef<ComposerLaunch | SetupChoice | null>(null)
// Late answers show nothing once this screen is left or covered; the create itself still settles.
const presentingRef = useRef(observing)
useEffect(() => {
presentingRef.current = observing
}, [observing])
useEffect(
() => () => {
presentingRef.current = false
latestRef.current = null
const open = openRef.current
openRef.current = null
// Left without sending from the form: its repository takes taps again.
if (open && 'reservation' in open) {
releaseFolderComposerReservation(open.target, open.reservation)
}
},
[]
)
const presenting = useCallback(() => presentingRef.current, [])
// A created or unconfirmed create is done once the folder lists a worktree from its repository.
const createsVersion = useSyncExternalStore(
subscribeFolderRepoCreates,
getFolderRepoCreatesVersion
)
useEffect(() => {
reconcileFolderRepoCreates({ hostId, executionHost, folderWorktreeId }, members)
}, [createsVersion, executionHost, folderWorktreeId, hostId, members])
const onCreate = useCallback(
async (
repo: FolderCreateRepo,
target: FolderRepoTarget,
client: RpcClient,
name: string,
skipSetupFor?: number
) => {
const outcome = await createFolderRepoWorktree({ target, client, name, skipSetupFor })
if (
outcome.kind === 'created' ||
outcome.kind === 'maybe-created' ||
outcome.kind === 'checking'
) {
refetch()
}
const opensForm = outcome.kind === 'needs-composer' || outcome.kind === 'needs-setup-choice'
if (opensForm && (!presenting() || openRef.current)) {
// Left, or another form is open: keep that form and leave this repository to try again.
if (outcome.kind === 'needs-composer') {
releaseFolderComposerReservation(target, outcome.reservation)
}
return
}
if (!presenting()) {
return
}
if (outcome.kind === 'created') {
triggerSuccess()
showToast(
outcome.warning ??
t('sourceControl.folder.created', 'Created worktree {{name}} in {{repo}}', {
name: outcome.name,
repo: repo.displayName
})
)
} else if (outcome.kind === 'checking') {
showToast(
t(
'sourceControl.folder.checking',
'A worktree from {{repo}} may already have been created. Checking the folder…',
{ repo: repo.displayName }
)
)
} else if (outcome.kind === 'needs-setup-choice') {
const choice = { hold: outcome.hold, repo, target, client, name }
openRef.current = choice
latestRef.current = choice
setSetupChoice(choice)
} else if (outcome.kind === 'needs-composer') {
const launch = {
target,
reservation: outcome.reservation,
executionHostId: getRepoExecutionHostId(repo),
name
}
openRef.current = launch
latestRef.current = launch
setComposer(launch)
} else if (outcome.kind === 'failed') {
triggerError()
showToast(
outcome.message ||
t('sourceControl.folder.createFailed', 'Could not create a worktree in {{repo}}.', {
repo: repo.displayName
})
)
}
},
[presenting, refetch, showToast, t]
)
const launch = composer
const presentingLaunch = (opened: ComposerLaunch | null) =>
presenting() && opened !== null && latestRef.current === opened
const composerElement = (
<NewWorktreeModalController
visible={launch !== null}
client={desktopClient}
hostCapabilities={args.desktopCapabilities}
hostId={hostId}
hostLabelById={NO_LABELS}
openExternalUrl={openExternalLink}
{...(launch
? {
folderLaunch: {
repoId: launch.target.repoId,
executionHostId: launch.executionHostId,
name: launch.name,
parentWorkspace: folderWorktreeId,
submit: {
begin: (repo: MobileWorkspaceRepo) => {
const submission = beginFolderComposerSubmission(
{ ...launch.target, repoId: repo.id },
launch,
() => openRef.current === launch
)
return (
submission && {
settle: (outcome) => {
submission.settle(outcome)
// A lost reply may have created it: the list shows it, or the form keeps it from being sent twice.
if (outcome === 'uncertain') {
refetch()
}
}
}
)
},
presenting: () => presentingLaunch(launch)
}
}
}
: {})}
onCreated={(_worktreeId, createdName, warning) => {
refetch()
if (presentingLaunch(launch)) {
showToast(
warning ??
t('sourceControl.folder.createdNamed', 'Created worktree {{name}}', {
name: createdName
})
)
}
}}
onClose={() => {
// Only this opening's close: an older submission settling can't close a newer form.
if (launch) {
releaseFolderComposerReservation(launch.target, launch.reservation)
if (openRef.current === launch) {
openRef.current = null
}
}
setComposer((current) => (current === launch ? null : current))
}}
/>
)
const choice = setupChoice
// Only this dialog's own answer: it closes itself and nothing newer.
const closeChoice = useCallback((closing: SetupChoice | null) => {
if (openRef.current === closing) {
openRef.current = null
}
setSetupChoice((current) => (current === closing ? null : current))
}, [])
// The folder showed it, or another create replaced it: there is nothing left to retry.
useEffect(() => {
if (choice && !isFolderRepoHoldPending(choice.target, choice.hold)) {
closeChoice(choice)
}
}, [choice, closeChoice, createsVersion])
const setupChoiceElement = (
<ConfirmModal
visible={setupChoice !== null}
title={t('sourceControl.folder.setupChoiceTitle', 'Retry without setup?')}
message={t(
'sourceControl.folder.setupChoiceMessage',
"Orca couldn't confirm the last create in {{repo}}. Retrying it needs a setup choice; it will be created without running the repository's setup.",
{ repo: choice?.repo.displayName ?? '' }
)}
confirmLabel={t('sourceControl.folder.setupChoiceSkip', 'Retry without setup')}
onConfirm={() => {
if (choice && openRef.current === choice) {
closeChoice(choice)
void onCreate(choice.repo, choice.target, choice.client, choice.name, choice.hold)
}
}}
onCancel={() => closeChoice(choice)}
/>
)
const toastElement = <TransientToast message={message} bottomInset={args.bottomInset} />
return {
onCreate,
/** A form covers the screen: the list under it waits. */
covered: composer !== null || setupChoice !== null,
composerElement: (
<>
{composerElement}
{setupChoiceElement}
</>
),
toastElement
}
}
+7 -1
View File
@@ -1,6 +1,10 @@
import type { TuiAgent } from '../../../src/shared/agent-launch/catalog/tui-agent'
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import {
createWorktreeWithNameRetry,
type WorktreeCreateParent,
type WorktreeCreateResult
} from './worktree-create-retry'
import type { WorktreeCreateAgentLaunch } from './agent-launch-request'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import {
@@ -20,6 +24,7 @@ export async function createBlankWorkspace(args: {
nameWasGenerated: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
parent?: WorktreeCreateParent
}): Promise<WorktreeCreateResult> {
const agentLaunch: WorktreeCreateAgentLaunch | undefined = args.createdWithAgentId
? { agent: args.createdWithAgentId, supported: args.agentLaunchSupported }
@@ -28,6 +33,7 @@ export async function createBlankWorkspace(args: {
client: args.client,
baseName: args.baseName,
nameWasGenerated: args.nameWasGenerated,
parent: args.parent,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
...(agentLaunch ? { agentLaunch } : {}),
buildParams: (name) => {
@@ -0,0 +1,55 @@
import type { MobileLinkedWorkItem } from './mobile-composer-source-types'
import type { WorkspaceCreateTaskItem } from './workspace-create-params'
/** The composer's linked issue, PR or ticket, as the host's task-workspace source. */
export function toTaskItem(
item: MobileLinkedWorkItem,
targetRepoId: string
): WorkspaceCreateTaskItem {
if (item.provider === 'github') {
return {
provider: 'github',
source: {
type: item.type === 'pr' ? 'pr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'gitlab') {
return {
provider: 'gitlab',
source: {
type: item.type === 'mr' ? 'mr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'jira') {
return {
provider: 'jira',
source: {
key: item.jiraIdentifier ?? '',
title: item.title,
url: item.url
}
}
}
return {
provider: 'linear',
source: {
identifier: item.linearIdentifier ?? '',
title: item.title,
url: item.url,
...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}),
...(item.linearOrganizationUrlKey
? { organizationUrlKey: item.linearOrganizationUrlKey }
: {})
}
}
}
+16 -56
View File
@@ -1,20 +1,21 @@
import type { TuiAgent } from '../../../src/shared/agent-launch/catalog/tui-agent'
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem
} from './mobile-composer-source-types'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
import { toTaskItem } from './composer-work-item-task'
import {
startupAgentCreateFields,
buildTaskWorkspaceCreateParams,
type WorkspaceCreateParams,
type WorkspaceCreateSetupDecision,
type WorkspaceCreateTaskItem
type WorkspaceCreateSetupDecision
} from './workspace-create-params'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import {
createWorktreeWithNameRetry,
type WorktreeCreateParent,
type WorktreeCreateResult
} from './worktree-create-retry'
import type { WorktreeCreateAgentLaunch } from './agent-launch-request'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import { getClientWorktreeCreateCandidate } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
@@ -36,6 +37,7 @@ export type CreateWorkspaceFromComposerArgs = {
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
branchIntentSupported?: boolean | Promise<boolean>
parent?: WorktreeCreateParent
}
export async function createWorkspaceFromComposerSource(
@@ -57,55 +59,6 @@ function resolveComposerAgentLaunch(
return agentId ? { agent: agentId, supported } : undefined
}
function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem {
if (item.provider === 'github') {
return {
provider: 'github',
source: {
type: item.type === 'pr' ? 'pr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'gitlab') {
return {
provider: 'gitlab',
source: {
type: item.type === 'mr' ? 'mr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'jira') {
return {
provider: 'jira',
source: {
key: item.jiraIdentifier ?? '',
title: item.title,
url: item.url
}
}
}
return {
provider: 'linear',
source: {
identifier: item.linearIdentifier ?? '',
title: item.title,
url: item.url,
...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}),
...(item.linearOrganizationUrlKey
? { organizationUrlKey: item.linearOrganizationUrlKey }
: {})
}
}
}
async function createWorkItemWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
@@ -116,6 +69,7 @@ async function createWorkItemWorkspace(args: {
nameIsAutoManaged?: boolean
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
parent?: WorktreeCreateParent
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
@@ -157,6 +111,7 @@ async function createWorkItemWorkspace(args: {
const baseName = String(params.name)
// Keep linked work items on terminals: structured sessions cannot hold their unsent URL draft and would submit it as a turn.
return createWorktreeWithNameRetry({
parent: args.parent,
client,
baseName,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
@@ -176,6 +131,7 @@ async function createBranchWorkspace(args: {
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
branchIntentSupported?: boolean | Promise<boolean>
parent?: WorktreeCreateParent
}): Promise<WorktreeCreateResult> {
const {
client,
@@ -206,6 +162,7 @@ async function createBranchWorkspace(args: {
fallback: selection.localBranchName
})
return createWorktreeWithNameRetry({
parent: args.parent,
client,
baseName,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
@@ -232,6 +189,7 @@ async function createBranchWorkspace(args: {
fallback: selection.branchNameOverride || selection.localBranchName
})
return createWorktreeWithNameRetry({
parent: args.parent,
client,
baseName,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
@@ -270,6 +228,7 @@ async function createNewBranchWorkspace(args: {
note: string | undefined
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
agentLaunchSupported: WorktreeCreateAgentLaunch['supported']
parent?: WorktreeCreateParent
}): Promise<WorktreeCreateResult> {
const {
client,
@@ -287,6 +246,7 @@ async function createNewBranchWorkspace(args: {
const comment = note?.trim()
// Preserve typed git branch names, including slashes; sanitize only the folder, and retry suffixes against the branch name.
return createWorktreeWithNameRetry({
parent: args.parent,
client,
baseName: selection.branchName,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import { FakeSession } from '../transport/connection/mobile-endpoint-supervisor-test-fakes'
import type { RpcClient } from '../transport/rpc/rpc-client-contract'
import type { RpcResponse } from '../transport/rpc/rpc-wire-types'
import { markRpcDeliveryUnknown } from '../transport/rpc/rpc-delivery-ambiguity'
import { createWorktreeWithNameRetry } from './worktree-create-retry'
type Attempt = { method: string; params: unknown }
function scriptedClient(
outcomes: ({ id: string; displayName?: string; warning?: string } | { errorMessage: string })[],
attempts: Attempt[]
): RpcClient {
let call = 0
const client = new FakeSession('connected')
client.sendRequest.mockImplementation(async (method: string, params?: unknown) => {
attempts.push({ method, params: params ?? {} })
const outcome = outcomes[Math.min(call, outcomes.length - 1)]!
call += 1
if ('errorMessage' in outcome) {
return {
id: '1',
ok: false,
error: { code: 'x', message: outcome.errorMessage },
_meta: { runtimeId: 'r' }
}
}
return {
id: '1',
ok: true,
result: {
worktree: {
id: outcome.id,
...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {})
},
...(outcome.warning !== undefined ? { warning: outcome.warning } : {})
},
_meta: { runtimeId: 'r' }
}
})
return client
}
describe('createWorktreeWithNameRetry parent', () => {
it('attaches every collision candidate to the same parent', async () => {
const attempts: Attempt[] = []
const client = scriptedClient(
[{ errorMessage: 'already exists locally' }, { id: 'wt-child' }],
attempts
)
await createWorktreeWithNameRetry({
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
parent: { parentWorkspace: 'folder:f-1', parentWorkspaceOrigin: 'manual' },
worktreeCreateIdempotency: false
})
expect(attempts.map((attempt) => attempt.params)).toEqual([
{
repo: 'id:r',
name: 'topic',
parentWorkspace: 'folder:f-1',
parentWorkspaceOrigin: 'manual'
},
{
repo: 'id:r',
name: 'topic-2',
parentWorkspace: 'folder:f-1',
parentWorkspaceOrigin: 'manual'
}
])
})
})
describe('createWorktreeWithNameRetry after a lost reply', () => {
it('tries no other name when the resend is refused as taken', async () => {
const client = new FakeSession('connected')
client.sendRequest
.mockImplementationOnce(async () => {
client.publishState('reconnecting')
setTimeout(() => client.publishState('connected'), 0)
throw markRpcDeliveryUnknown(new Error('connection lost'))
})
.mockResolvedValue({
id: '1',
ok: false,
error: { code: 'x', message: 'already exists locally' },
_meta: { runtimeId: 'r' }
})
const result = await createWorktreeWithNameRetry({
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 60_000 }
})
expect(result).toMatchObject({ replayed: true })
expect(client.sendRequest.mock.calls.map(([, params]) => params)).toEqual([
expect.objectContaining({ name: 'topic' }),
expect.objectContaining({ name: 'topic' })
])
})
it('tries no other name when a fallback create after a refused agent launch is resent and refused', async () => {
const client = new FakeSession('connected')
const taken: RpcResponse = {
id: '1',
ok: false,
error: { code: 'x', message: 'already exists locally' },
_meta: { runtimeId: 'r' }
}
client.sendRequest
.mockResolvedValueOnce({
id: '1',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'r' }
})
.mockImplementationOnce(async () => {
client.publishState('reconnecting')
setTimeout(() => client.publishState('connected'), 0)
throw markRpcDeliveryUnknown(new Error('connection lost'))
})
.mockResolvedValue(taken)
const result = await createWorktreeWithNameRetry({
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 60_000 },
agentLaunch: { agent: 'claude', supported: { replay: false } }
})
expect(result).toMatchObject({ replayed: true })
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'agent.launch',
'worktree.create',
'worktree.create'
])
expect(client.sendRequest.mock.calls.slice(1).map(([, params]) => params)).toEqual([
expect.objectContaining({ name: 'topic' }),
expect.objectContaining({ name: 'topic' })
])
})
})
+26 -6
View File
@@ -35,7 +35,12 @@ import {
// Branches and remote PRs outlive worktrees and escape local preflight dedupe; retry host collision errors with numeric suffixes.
export type WorktreeCreateResult =
| { worktreeId: string; name: string; warning?: string }
| { error: string }
/** `replayed`: the refusal answered a resend after a lost reply, so the first send may have created it. */
| { error: string; replayed?: true }
export type WorktreeCreateParent = Required<
Pick<WorkspaceCreateParams, 'parentWorkspace' | 'parentWorkspaceOrigin'>
>
export type CreateWorktreeWithNameRetryArgs = {
client: RpcClient
@@ -46,6 +51,8 @@ export type CreateWorktreeWithNameRetryArgs = {
branchIntentSupported?: boolean | Promise<boolean>
agentLaunch?: WorktreeCreateAgentLaunch
maxAttempts?: number
/** The workspace the new worktree is attached to, on every attempt. */
parent?: WorktreeCreateParent
mintMutationId?: () => string
mintLaunchOperationId?: () => string
}
@@ -62,11 +69,12 @@ export async function createWorktreeWithNameRetry(
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
const mintLaunchOperationId = args.mintLaunchOperationId ?? structuredSessionOperationId
let lastError: string | null = null
let lastReplayed = false
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidateName = args.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(baseName, attempt)
: getClientWorktreeCreateCandidate(baseName, attempt)
const candidateParams = buildParams(candidateName, attempt)
const candidateParams = { ...buildParams(candidateName, attempt), ...args.parent }
if (attempt === 0 && candidateParams.branchIntent) {
if ((await args.branchIntentSupported) !== true) {
return {
@@ -90,6 +98,7 @@ export async function createWorktreeWithNameRetry(
worktreeCreateIdempotency
)
let response = sent.response
let replayed = sent.replayed
if (
!response.ok &&
!sent.replayed &&
@@ -100,9 +109,15 @@ export async function createWorktreeWithNameRetry(
) {
// Method refusal can mean the client advertisement lagged the probe; downgrade permanently for this create.
launch = null
response = (
await sendWorktreeCreateResilient(client, null, null, params, worktreeCreateIdempotency)
).response
const fallback = await sendWorktreeCreateResilient(
client,
null,
null,
params,
worktreeCreateIdempotency
)
response = fallback.response
replayed = fallback.replayed
}
// Ledger refusal may follow creation; never retry unnamed. Inspect raw refusal messages for legacy collision handling.
if (response.ok) {
@@ -118,7 +133,10 @@ export async function createWorktreeWithNameRetry(
break
}
lastError = response.error.message
lastReplayed = replayed
// A replayed refusal may follow a create whose reply was lost; another name could be a second worktree.
if (
replayed ||
candidateParams.branchIntent === 'open-existing-v2' ||
launch?.replay ||
!isRetryableWorktreeCreateConflict(lastError ?? '')
@@ -128,7 +146,9 @@ export async function createWorktreeWithNameRetry(
}
return {
error:
lastError ?? translate('tasks.worktreeCreateRetry.createFailed', 'Failed to create workspace')
lastError ??
translate('tasks.worktreeCreateRetry.createFailed', 'Failed to create workspace'),
...(lastReplayed ? { replayed: true as const } : {})
}
}
@@ -102,7 +102,9 @@ export function workspaceSettingsMounts(
getAgentLaunchSupport: async () => false,
transitionDrawer: (view: unknown) => context.effect('drawer', view),
onCreated: (id: unknown, name: unknown) => context.effect('created', { id, name }),
onClose: () => context.effect('close', null)
onClose: () => context.effect('close', null),
// Not opened from a folder.
folderLaunch: undefined
})
let state: ReturnType<typeof useSubmit>
const hook = hookMount(() => {
@@ -1,4 +1,4 @@
import { gunzipSync } from 'node:zlib'
import { brotliDecompressSync, gunzipSync } from 'node:zlib'
import sodium from 'react-native-libsodium'
import { beforeAll, describe, expect, it, vi } from 'vitest'
@@ -38,6 +38,8 @@ function libsodiumBindings(): PairedE2EENativeBindings {
},
codec: {
gunzip: (input, outputLimit) => new Uint8Array(gunzipSync(input)).slice(0, outputLimit),
brotliDecompress: async (input, outputLimit) =>
new Uint8Array(brotliDecompressSync(input)).slice(0, outputLimit),
decodeUtf8: (input) => {
if (input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf) {
return null
@@ -1,4 +1,4 @@
// Move frame seal/decode and bundle gunzip off Hermes’s JS thread when native bindings are available.
// Move frame seal/decode and bundle gunzip/brotli off Hermes’s JS thread when native bindings are available.
export type PairedE2EENativeCodec = {
/** Canonical padded base64 only; null for anything else, including what `atob` forgives. */
decodeBase64(value: string): Uint8Array | null
@@ -10,6 +10,8 @@ export type PairedE2EENativeCodec = {
sealSecretbox(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array
/** Throws on a body that is not one well-formed gzip member. See orca-gzip-utf8-codec. */
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
/** Off the JS thread; Hermes has no brotli, so there is no JS fallback. */
brotliDecompress(input: Uint8Array, outputLimit: number): Promise<Uint8Array<ArrayBuffer>>
/** Null where only TextDecoder's replacement or BOM rules give the answer, or native failed. */
decodeUtf8(input: Uint8Array): string | null
}
@@ -25,6 +27,7 @@ export type PairedE2EENativeBindings = {
codec: {
gunzip(input: Uint8Array, outputLimit: number): Uint8Array
decodeUtf8(input: Uint8Array): string | null
brotliDecompress(input: Uint8Array, outputLimit: number): Promise<Uint8Array<ArrayBuffer>>
}
sodium: LibsodiumBindings
}
@@ -96,6 +99,7 @@ function detectPairedE2EENativeCodec(): PairedE2EENativeCodec | null {
sodium.secretboxSeal(wholeBuffer(message), wholeBuffer(nonce), wholeBuffer(key))
),
gunzip: (input, outputLimit) => module.gunzip(input, outputLimit),
brotliDecompress: (input, outputLimit) => module.brotliDecompress(input, outputLimit),
decodeUtf8: (input) => {
// Why: the frame is already open and the counter advanced, so a throw here must not drop it.
try {
@@ -0,0 +1,116 @@
import { sha256 } from '@noble/hashes/sha256'
import { brotliCompressSync, brotliDecompressSync } from 'node:zlib'
import { describe, expect, it, vi } from 'vitest'
import { computeMobileWebBundleId } from '../../../../src/shared/mobile-web-bundle/manifest-contract'
import { fetchMobileWebBundle, type MobileWebBundleFetchProgress } from './mobile-web-bundle-fetch'
import type { RpcClient } from '../rpc/rpc-client-contract'
import type { RpcResponse } from '../rpc/rpc-wire-types'
vi.mock('expo-crypto', async () => {
const { sha256: hash } = await import('@noble/hashes/sha256')
return {
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer
}
})
// The native codec as a phone binary has it; node's brotli stands in for the platform decoder.
vi.mock('../encryption/paired-e2ee-native-codec', () => ({
pairedE2EENativeCodec: () => ({
decodeBase64: (value: string) => new Uint8Array(Buffer.from(value, 'base64')),
brotliDecompress: async (input: Uint8Array, outputLimit: number) =>
new Uint8Array(brotliDecompressSync(input)).slice(0, outputLimit)
})
}))
const toHex = (bytes: Uint8Array): string =>
Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
// Small enough that the 46-byte brotli copy spans three windows.
const RANGE_BYTES = 16
const SCRIPT = 'export const greeting = "hello";\n'.repeat(40)
const MARK = 'a short mark'
function brotliHost() {
const files = new Map([
['assets/script.js', new TextEncoder().encode(SCRIPT)],
['index.html', new TextEncoder().encode(MARK)]
])
const copy = new Uint8Array(brotliCompressSync(files.get('assets/script.js')!))
const assets = [...files].map(([path, content]) => ({
path,
sha256: toHex(sha256(content)),
byteLength: content.byteLength,
contentType: 'text/plain',
...(path === 'assets/script.js' ? { brotliByteLength: copy.byteLength } : {})
}))
const buildId = computeMobileWebBundleId(assets)
const manifest = {
schemaVersion: 1,
buildId,
desktopVersion: '1.4.300',
minCompatibleRuntimeProtocolVersion: 2,
runtimeProtocolVersion: 2,
entrypoint: 'index.html',
totalBytes: assets.reduce((total, entry) => total + entry.byteLength, 0),
assets
}
const methods: string[] = []
const answer = (method: string, params: Record<string, unknown>): unknown => {
if (method === 'mobileWeb.bundle.manifest') {
return { manifest, chunkBytes: RANGE_BYTES, rangeBytes: RANGE_BYTES }
}
const path = String(params.path)
const offset = Number(params.offset)
const content = files.get(path)!
const stream = method === 'mobileWeb.bundle.brotliRange' ? copy : content
const slice = stream.subarray(offset, offset + RANGE_BYTES)
return {
buildId,
path,
offset,
assetByteLength: content.byteLength,
sha256: toHex(sha256(content)),
...(method === 'mobileWeb.bundle.range' ? { encoding: 'identity' } : {}),
dataBase64: Buffer.from(slice).toString('base64'),
eof: offset + slice.byteLength >= stream.byteLength
}
}
const client: RpcClient = {
sendRequest: vi.fn(async (method: string, params?: unknown): Promise<RpcResponse> => {
methods.push(method)
const fields: Record<string, unknown> = Object(params)
return { id: 'rpc-1', ok: true, result: answer(method, fields), _meta: { runtimeId: 'r' } }
}),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
return { client, methods }
}
describe('fetchMobileWebBundle from a host that ships brotli copies', () => {
it('reads the copy where the manifest lists one, inflates it, and reports progress in asset bytes', async () => {
const host = brotliHost()
const progress: MobileWebBundleFetchProgress[] = []
const fetched = await fetchMobileWebBundle({
client: host.client,
onProgress: (update) => progress.push(update)
})
expect(new TextDecoder().decode(fetched.assets.get('assets/script.js'))).toBe(SCRIPT)
expect(new TextDecoder().decode(fetched.assets.get('index.html'))).toBe(MARK)
expect(host.methods.filter((method) => method === 'mobileWeb.bundle.brotliRange')).toHaveLength(
3
)
expect(host.methods.filter((method) => method === 'mobileWeb.bundle.range')).toHaveLength(1)
expect(progress.at(-1)).toMatchObject({ receivedBytes: 1332, totalBytes: 1332 })
})
})
@@ -10,11 +10,15 @@ import type { RpcClient } from '../rpc/rpc-client-contract'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import { markRpcDeliveryUnknown } from '../rpc/rpc-delivery-ambiguity'
import { runRpcOperation } from '../rpc/rpc-operation'
import { mobileWebBundleWindowReader } from './mobile-web-bundle-window-reader'
import {
mobileWebBundleWindowReader,
type MobileWebBundleWindowReader
} from './mobile-web-bundle-window-reader'
import {
assertWindowDescribesAsset,
assertWindowFillsItsSlot,
windowSlotBytes
windowSlotBytes,
type MobileWebBundleWindowStream
} from './mobile-web-bundle-window-checks'
// The host admits four window reads per connection; one reader per asset would serialize large scripts.
@@ -56,6 +60,7 @@ export type MobileWebBundleFetchResult = {
type AssetReassembly = {
readonly entry: MobileWebBundleAssetRead
readonly stream: MobileWebBundleWindowStream
readonly whole: Uint8Array<ArrayBuffer>
outstandingWindows: number
}
@@ -154,9 +159,9 @@ export async function fetchMobileWebBundle(args: {
return
}
assertWindowDescribesAsset(reply.header, asset.entry, manifest.buildId, offset)
const bytes = reply.bytes(windowSlotBytes(asset.entry, offset, reader.windowBytes))
const bytes = reply.bytes(windowSlotBytes(asset.stream, offset, reader.windowBytes))
assertWindowFillsItsSlot(
asset.entry,
asset.stream,
offset,
bytes.byteLength,
reply.header.eof,
@@ -165,7 +170,7 @@ export async function fetchMobileWebBundle(args: {
asset.whole.set(bytes, offset)
asset.outstandingWindows -= 1
if (asset.outstandingWindows === 0) {
const verified = await verifyReassembledAsset(asset)
const verified = await verifyReassembledAsset(asset, reader)
throwIfCallerAborted(args.signal)
// A sibling failed while this hashed; the fetch already rejected with that failure.
if (stopped.signal.aborted) {
@@ -175,12 +180,12 @@ export async function fetchMobileWebBundle(args: {
downloadedAssets += 1
}
// Per window, not per asset: the largest asset goes first, so asset completions bunch at the end.
receivedBytes += bytes.byteLength
receivedBytes += assetBytesCredited(asset, offset, bytes.byteLength)
reportProgress()
}
const readWindows = async (entries: readonly MobileWebBundleAssetRead[]): Promise<void> => {
const queue = planWindowReads(entries, reader.windowBytes)
const queue = planWindowReads(entries, reader)
if (queue.length === 0) {
return
}
@@ -270,30 +275,61 @@ export async function fetchMobileWebBundle(args: {
// Plan all grid offsets largest-asset first so its tail cannot serialize the end; still validate eof replies.
function planWindowReads(
entries: readonly MobileWebBundleAssetRead[],
windowBytes: number
reader: MobileWebBundleWindowReader
): WindowRead[] {
const largestFirst = [...entries].sort((left, right) => right.byteLength - left.byteLength)
return largestFirst.flatMap((entry) => {
const count = Math.max(1, Math.ceil(entry.byteLength / windowBytes))
const { windowBytes } = reader
const streams = entries.map((entry) => ({
entry,
stream: { path: entry.path, byteLength: reader.streamLength(entry) }
}))
const largestFirst = streams.sort(
(left, right) => right.stream.byteLength - left.stream.byteLength
)
return largestFirst.flatMap(({ entry, stream }) => {
const count = Math.max(1, Math.ceil(stream.byteLength / windowBytes))
const asset: AssetReassembly = {
entry,
whole: new Uint8Array(entry.byteLength),
stream,
whole: new Uint8Array(stream.byteLength),
outstandingWindows: count
}
return Array.from({ length: count }, (_, index) => ({ asset, offset: index * windowBytes }))
})
}
async function verifyReassembledAsset(asset: AssetReassembly): Promise<Uint8Array> {
// Progress stays in asset bytes, the unit of the session's total: a window of a brotli copy counts for its
// share of the asset, and an asset's windows add up to exactly its byteLength.
function assetBytesCredited(asset: AssetReassembly, offset: number, byteLength: number): number {
const streamBytes = asset.stream.byteLength
if (streamBytes === asset.entry.byteLength) {
return byteLength
}
const at = (position: number): number =>
Math.floor((asset.entry.byteLength * position) / streamBytes)
return at(offset + byteLength) - at(offset)
}
async function verifyReassembledAsset(
asset: AssetReassembly,
reader: MobileWebBundleWindowReader
): Promise<Uint8Array> {
const whole = await reader.inflate(asset.entry, asset.whole)
const { path, byteLength } = asset.entry
if (whole.byteLength !== byteLength) {
throw new MobileWebBundleFetchError(
whole.byteLength > byteLength ? 'asset-overlong' : 'asset-short',
`bundle asset ${path} inflated to ${whole.byteLength} of ${byteLength} declared bytes`
)
}
// Native: hashing in JS held the JS thread for seconds, and React's progress renders waited on it.
const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, asset.whole)))
const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, whole)))
if (hashed !== asset.entry.sha256) {
throw new MobileWebBundleFetchError(
'asset-checksum-mismatch',
`bundle asset ${asset.entry.path} hashed ${hashed}, not ${asset.entry.sha256}`
)
}
return asset.whole
return whole
}
/** Only the caller's abort surfaces as `fetch-stopped`; an internal stop rejects with the failure
@@ -1,11 +1,13 @@
import {
MobileWebBundleErrorCodeSchema,
MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD,
MOBILE_WEB_BUNDLE_CHUNK_METHOD,
MOBILE_WEB_BUNDLE_MANIFEST_METHOD,
MOBILE_WEB_BUNDLE_RANGE_METHOD,
type MobileWebBundleErrorCode
} from '../../../../src/shared/mobile-web-bundle/bundle-rpc-contract'
import {
MobileWebBundleBrotliRangeReplySchema,
MobileWebBundleChunkReplySchema,
MobileWebBundleManifestReplySchema,
MobileWebBundleRangeReplySchema
@@ -43,6 +45,15 @@ export const mobileWebBundleRangeRead = defineRpcOperation({
read: rpcResultVariant('mobile-web-bundle-range', MobileWebBundleRangeReplySchema)
})
// Only for assets whose manifest entry names a brotli copy, so only hosts that ship one are asked.
export const mobileWebBundleBrotliRangeRead = defineRpcOperation({
name: 'mobileWeb.bundle-brotli-range',
method: MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD,
acceptance: 'require-result-or-throw',
barrier: 'on-settle',
read: rpcResultVariant('mobile-web-bundle-brotli-range', MobileWebBundleBrotliRangeReplySchema)
})
/** A code is a bare snake_case token, so only the two positions one can occupy are read. */
const LEADING_CODE_TOKEN = /^[a-z][a-z0-9_]*/
@@ -38,7 +38,15 @@ const assetSchema = z.looseObject({
path: MobileWebBundleAssetPathSchema,
sha256: z.string().regex(SHA256_PATTERN),
byteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES),
contentType: z.string().min(1)
contentType: z.string().min(1),
// An unreadable length means no brotli copy, never a refused bundle; the reader also requires it under byteLength.
brotliByteLength: z
.number()
.int()
.nonnegative()
.max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES)
.optional()
.catch(undefined)
})
// Read unknown schema versions as numbers so compatibility can name the required update.
@@ -115,6 +123,12 @@ export const MobileWebBundleRangeReplySchema = z.looseObject({
dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH)
})
// Windows of the brotli copy are its raw bytes; the header still names the inflated asset.
export const MobileWebBundleBrotliRangeReplySchema = z.looseObject({
...windowHeaderFields,
dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH)
})
export type MobileWebBundleManifestReply = z.output<typeof MobileWebBundleManifestReplySchema>
export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest']
export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number]
@@ -2,8 +2,11 @@ import type { MobileWebBundleAssetRead } from './mobile-web-bundle-reply-schemas
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import type { MobileWebBundleWindowHeader } from './mobile-web-bundle-window-reader'
/** The file the windows are cut from: the asset, or its brotli copy. */
export type MobileWebBundleWindowStream = Pick<MobileWebBundleAssetRead, 'path' | 'byteLength'>
export function windowSlotBytes(
entry: MobileWebBundleAssetRead,
entry: MobileWebBundleWindowStream,
offset: number,
windowBytes: number
): number {
@@ -12,7 +15,7 @@ export function windowSlotBytes(
/** Offsets are planned, so a reply is accepted only if it fills exactly its slot of the grid. */
export function assertWindowFillsItsSlot(
entry: MobileWebBundleAssetRead,
entry: MobileWebBundleWindowStream,
offset: number,
byteLength: number,
eof: boolean,
@@ -1,8 +1,17 @@
import { errorMessage } from '../../../../src/shared/error-message'
import { decodeBase64Bytes } from '../encryption/base64-byte-codec'
import { pairedE2EENativeCodec } from '../encryption/paired-e2ee-native-codec'
import { mobileWebBundleChunkRead, mobileWebBundleRangeRead } from './mobile-web-bundle-operations'
import {
mobileWebBundleBrotliRangeRead,
mobileWebBundleChunkRead,
mobileWebBundleRangeRead
} from './mobile-web-bundle-operations'
import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode'
import type { MobileWebBundleManifestReply } from './mobile-web-bundle-reply-schemas'
import type {
MobileWebBundleAssetRead,
MobileWebBundleManifestReply
} from './mobile-web-bundle-reply-schemas'
import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
import type { RpcClient } from '../rpc/rpc-client-contract'
import { runRpcOperation } from '../rpc/rpc-operation'
import type { SendRequestOptions } from '../rpc/unvalidated-rpc-request-port'
@@ -24,6 +33,8 @@ export type MobileWebBundleWindowReply = {
export type MobileWebBundleWindowReader = {
readonly windowBytes: number
/** What this reader moves for the asset: its brotli copy where it reads one, otherwise the asset itself. */
readonly streamLength: (asset: MobileWebBundleAssetRead) => number
read(
window: {
buildId: string
@@ -32,31 +43,48 @@ export type MobileWebBundleWindowReader = {
},
options?: SendRequestOptions
): Promise<MobileWebBundleWindowReply>
/** The asset's bytes from its reassembled stream, before the caller checks their hash. */
readonly inflate: (
asset: MobileWebBundleAssetRead,
stream: Uint8Array<ArrayBuffer>
) => Promise<Uint8Array<ArrayBuffer>>
}
// Choose ranges only from the manifest’s grid; older hosts still serve chunks.
export function mobileWebBundleWindowReader(
client: RpcClient,
opened: Pick<MobileWebBundleManifestReply, 'chunkBytes' | 'rangeBytes'>
opened: Pick<MobileWebBundleManifestReply, 'chunkBytes' | 'rangeBytes' | 'manifest'>
): MobileWebBundleWindowReader {
const { rangeBytes } = opened
if (rangeBytes === undefined) {
return {
windowBytes: opened.chunkBytes,
read: async (window, options) => {
const { dataBase64, ...header } = await runRpcOperation(
client,
mobileWebBundleChunkRead,
window,
options
)
return { header, bytes: () => decodeBase64(dataBase64) }
}
streamLength: (asset) => asset.byteLength,
read: async (window, options) =>
rawWindowReply(await runRpcOperation(client, mobileWebBundleChunkRead, window, options)),
inflate: async (_asset, stream) => stream
}
}
const native = pairedE2EENativeCodec()
// Hermes has no brotli, so without the native codec every asset stays on gzip ranges.
const brotliLengths = new Map(
native === null
? []
: opened.manifest.assets.flatMap((asset) =>
asset.brotliByteLength !== undefined && asset.brotliByteLength < asset.byteLength
? [[asset.path, asset.brotliByteLength] as const]
: []
)
)
return {
windowBytes: rangeBytes,
streamLength: (asset) => brotliLengths.get(asset.path) ?? asset.byteLength,
read: async (window, options) => {
if (brotliLengths.has(window.path)) {
return rawWindowReply(
await runRpcOperation(client, mobileWebBundleBrotliRangeRead, window, options)
)
}
const { dataBase64, encoding, ...header } = await runRpcOperation(
client,
mobileWebBundleRangeRead,
@@ -72,7 +100,35 @@ export function mobileWebBundleWindowReader(
expected
)
}
}
},
inflate: async (asset, stream) =>
native === null || !brotliLengths.has(asset.path)
? stream
: inflateBrotli(native.brotliDecompress, asset, stream)
}
}
// Chunks and brotli windows carry their bytes as they are.
function rawWindowReply({
dataBase64,
...header
}: MobileWebBundleWindowHeader & { readonly dataBase64: string }): MobileWebBundleWindowReply {
return { header, bytes: () => decodeBase64(dataBase64) }
}
// One byte past the asset bounds a bomb; the caller rejects any length but the asset's.
async function inflateBrotli(
brotliDecompress: (input: Uint8Array, outputLimit: number) => Promise<Uint8Array<ArrayBuffer>>,
asset: MobileWebBundleAssetRead,
stream: Uint8Array
): Promise<Uint8Array<ArrayBuffer>> {
try {
return await brotliDecompress(stream, asset.byteLength + 1)
} catch (error) {
throw new MobileWebBundleFetchError(
'range-undecodable',
`bundle asset ${asset.path} is not valid brotli: ${errorMessage(error)}`
)
}
}
+1 -1
View File
@@ -379,7 +379,7 @@
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"tar": "7.5.22",
"tsrs": "0.18.0-ts7.1.0-dev.20260929",
"tsrs": "0.22.0-ts7.1.0-beta",
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.2",
"typescript-api": "npm:typescript@6.0.3",
+45 -25
View File
@@ -666,8 +666,8 @@ importers:
specifier: 7.5.22
version: 7.5.22
tsrs:
specifier: 0.18.0-ts7.1.0-dev.20260929
version: 0.18.0-ts7.1.0-dev.20260929
specifier: 0.22.0-ts7.1.0-beta
version: 0.22.0-ts7.1.0-beta
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -4186,40 +4186,52 @@ packages:
cpu: [x64]
os: [win32]
'@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929':
resolution: {integrity: sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA==}
'@tsrs/darwin-arm64@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-mY0dMEtFxig4h5At3nByjZe8E4DTDPqqz73kkxQ6gqW5K9ym+u/VHQhtnpTF6ZOTkYyjP3bTaj5T8HcyoGVddA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929':
resolution: {integrity: sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg==}
'@tsrs/darwin-x64@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-dvsSnyU8rpI3OqTIH2xxjt9ThvZC52BsL6/LS69eWg7Ieq2oni1PnLels5gGTFqvHFoRbezi5Y0KY7PQ1OmK5w==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@tsrs/linux-arm64-gnu@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-igg42LxSN/DHFqafvDqZW2JNBMH8WIwc1JE/IY4QUDOXNhZnUfDgEu9xo1QYwbgryeo5llmhRwM4CI/BQUWF1Q==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929':
resolution: {integrity: sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw==}
'@tsrs/linux-arm64-musl@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-f0zCqMkxPof7QVwjdgA8Fb0n6BtX5MOdILH/xfS+He6gRLCoECky0ejbsgLWCpB06GMlsL1gCbDr0/tmc91APg==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929':
resolution: {integrity: sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw==}
'@tsrs/linux-x64-gnu@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-/e511w7xXmxBf14paz8eMLUbmlIc33Sy1E00y1FqmnUK04FTotWl+80mNvIVX5NrlOOJgrh+B125t1CHWu/8vQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929':
resolution: {integrity: sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ==}
'@tsrs/linux-x64-musl@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-B3aDOdS5+h77HcFep+KtOI+XmLm2G+FQPQC3KPSDjS/IG0tY9GPSc14mYnLwgO++dXluukO/oufk3J4Lo0CFjg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tsrs/win32-x64@0.22.0-ts7.1.0-beta':
resolution: {integrity: sha512-cZ69w+wOECbU6YF5UWi15TK73eX1pgoMEHKSmUF4K8PbS7DPIh/nWMsdb3rM8Ee1SUZ9ONCx+S6myh64SRi3Bg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -7940,8 +7952,8 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsrs@0.18.0-ts7.1.0-dev.20260929:
resolution: {integrity: sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA==}
tsrs@0.22.0-ts7.1.0-beta:
resolution: {integrity: sha512-DEnEqQZRM3sHCV7MnQKVeTh61U2SX+0WksVlWp4eVayy0NffQ2oiCOjgH/Eye3sMPYkGwpM3tht3HtVI9NdURg==}
engines: {node: '>=16.20.0'}
hasBin: true
@@ -11174,19 +11186,25 @@ snapshots:
'@transcribe-cpp/win32-x64-cpu-vulkan@0.4.1':
optional: true
'@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929':
'@tsrs/darwin-arm64@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929':
'@tsrs/darwin-x64@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929':
'@tsrs/linux-arm64-gnu@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929':
'@tsrs/linux-arm64-musl@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929':
'@tsrs/linux-x64-gnu@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/linux-x64-musl@0.22.0-ts7.1.0-beta':
optional: true
'@tsrs/win32-x64@0.22.0-ts7.1.0-beta':
optional: true
'@types/aria-query@5.0.4': {}
@@ -15511,13 +15529,15 @@ snapshots:
tslib@2.8.1: {}
tsrs@0.18.0-ts7.1.0-dev.20260929:
tsrs@0.22.0-ts7.1.0-beta:
optionalDependencies:
'@tsrs/darwin-arm64': 0.18.0-ts7.1.0-dev.20260929
'@tsrs/linux-arm64-gnu': 0.18.0-ts7.1.0-dev.20260929
'@tsrs/linux-arm64-musl': 0.18.0-ts7.1.0-dev.20260929
'@tsrs/linux-x64-gnu': 0.18.0-ts7.1.0-dev.20260929
'@tsrs/linux-x64-musl': 0.18.0-ts7.1.0-dev.20260929
'@tsrs/darwin-arm64': 0.22.0-ts7.1.0-beta
'@tsrs/darwin-x64': 0.22.0-ts7.1.0-beta
'@tsrs/linux-arm64-gnu': 0.22.0-ts7.1.0-beta
'@tsrs/linux-arm64-musl': 0.22.0-ts7.1.0-beta
'@tsrs/linux-x64-gnu': 0.22.0-ts7.1.0-beta
'@tsrs/linux-x64-musl': 0.22.0-ts7.1.0-beta
'@tsrs/win32-x64': 0.22.0-ts7.1.0-beta
tsx@4.23.15:
dependencies:
@@ -1,105 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeClientModule from '../../runtime-client'
import { refuseAgentServerRoute, requirePeerMailCarrier } from './agent-server-route'
const { callerShow } = vi.hoisted(() => ({ callerShow: vi.fn() }))
vi.mock('../../runtime-client', async (importOriginal) => {
const actual = await importOriginal<typeof RuntimeClientModule>()
return {
...actual,
RuntimeClient: class {
call = callerShow
}
}
})
function ctx(command: string[], flags: [string, string | boolean][]) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a handler context with only the fields this function reads.
return {
flags: new Map(flags),
client: { isRemote: true },
cwd: '/',
json: false,
rawArgs: [...command, ...flags.flatMap(([name, value]) => [`--${name}`, String(value)])]
} as unknown as Parameters<typeof refuseAgentServerRoute>[1]
}
describe('an agent pointing mail at another server', () => {
beforeEach(() => {
vi.stubEnv('ORCA_AGENT_LAUNCH_TOKEN', 'launch-token')
vi.stubEnv('ORCA_AGENT_SESSION_ID', '')
vi.stubEnv('ORCA_CLI_COMMAND', 'orca')
callerShow.mockResolvedValue({ result: { caller: null, terminal: true } })
})
afterEach(() => {
vi.unstubAllEnvs()
callerShow.mockReset()
})
it('is refused with the --on command to run instead', async () => {
await expect(
refuseAgentServerRoute(
'send',
ctx(
['orchestration', 'send'],
[
['environment', 'devB'],
['to', 'term_b'],
['subject', 'hi']
]
)
)
).rejects.toMatchObject({
code: 'agent_server_route_refused',
message:
'send with --environment devB runs on devB and nothing comes back to you. To message an agent on devB, use: orca orchestration send --on devB --to term_b --subject hi'
})
})
it('refuses a check against another server: the mailbox is local', async () => {
await expect(
refuseAgentServerRoute('check', ctx(['orchestration', 'check'], [['environment', 'devB']]))
).rejects.toMatchObject({
code: 'mailbox_is_local',
message:
'Your mailbox is on this Orca; replies from devB arrive here. Drop --environment devB.'
})
})
it('leaves a person in a shell running the command on that server', async () => {
vi.stubEnv('ORCA_AGENT_LAUNCH_TOKEN', '')
await expect(
refuseAgentServerRoute('send', ctx(['orchestration', 'send'], [['environment', 'devB']]))
).resolves.toBeUndefined()
expect(callerShow).not.toHaveBeenCalled()
})
it('leaves a launch its own Orca does not verify running on that server', async () => {
callerShow.mockResolvedValue({ result: { caller: null, terminal: false } })
await expect(
refuseAgentServerRoute('send', ctx(['orchestration', 'send'], [['environment', 'devB']]))
).resolves.toBeUndefined()
})
})
describe('send --on from an older Orca', () => {
it('is refused before anything is sent', async () => {
const call = vi
.fn()
.mockResolvedValue({ result: { capabilities: ['orchestration.contract.v1'] } })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a handler context with only the fields this function reads.
const context = {
flags: new Map([['on', 'devB']]),
client: { call },
cwd: '/',
json: false
} as unknown as Parameters<typeof requirePeerMailCarrier>[0]
await expect(requirePeerMailCarrier(context)).rejects.toMatchObject({
code: 'server_mail_unsupported'
})
expect(call).toHaveBeenCalledTimes(1)
expect(call).toHaveBeenCalledWith('status.get')
})
})
+1 -1
View File
@@ -43,7 +43,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.',
'--model supports Claude, Codex, Cursor, Antigravity, Grok, and Muse opaque provider model ids; --effort requires --model. OMP also takes its model roles and selectors (such as @worker or provider/model:level), with --effort as its thinking level. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.',
'Finished workers close on their own unless started with --keep-open; a worker started in a new worktree (new-child or new-top-level) is kept open, so release it when no one will use its chat.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'New worktrees use agent-first creation; without --setup, setup runs unless the project turns it off. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'New worktrees without --name get the name Orca gives an unnamed workspace; the receipt names it. Servers older than this change still require --name.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
"How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
@@ -14,6 +14,7 @@ import type {
} from '../../../shared/agent-status/contracts/agent-status-observation'
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status/transport/agent-status-legacy-adapter'
import { mainAgentState } from '../../../shared/agent-status/lifecycle/main-agent-status'
import { previousCodexTurnContext } from '../../../shared/agent-hook-listener/providers/codex-session-start'
import { admitLegacyAgentStatus } from '../../../shared/agent-hook-listener/listener-state'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
@@ -33,7 +34,12 @@ function resolveTurnStartedAt(
payload.toolAgentId === undefined &&
payload.isReplay !== true &&
payload.providerSessionOnly !== true &&
isNewTurnEvent(payload.source, payload.hookEventName)
isNewTurnEvent(payload.source, payload.hookEventName) &&
!(
payload.source === 'codex' &&
payload.hookEventName === 'SessionStart' &&
!payload.hasExplicitPrompt
)
) {
return payload.payload.sessionBoundary === true ? undefined : at
}
@@ -127,9 +133,17 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
now = Date.now(),
observedAt?: number
): EnrichedAgentHookEventPayload {
// Only live submissions open a new turn; cached starts restate historical context.
if (
!payload.isReplay &&
(payload.hookEventName === 'UserPromptSubmit' || payload.hasExplicitPrompt === true)
) {
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
}
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
const previousTurn = previousCodexTurnContext(previous, payload)
const commandCodeNewTurn =
previous !== undefined &&
isCommandCodeNewTurnWhileWorking({
@@ -143,13 +157,13 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
incomingPromptInteractionKey: payload.promptInteractionKey
})
const stateStartedAt =
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
? previous.stateStartedAt
previousTurn && previousTurn.payload.state === payload.payload.state && !commandCodeNewTurn
? previousTurn.stateStartedAt
: (observedAt ?? now)
const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now)
const turnStartedAt = resolveTurnStartedAt(previousTurn, payload, observedAt ?? now)
// Relay rows carry their execution host's turn-end evidence.
const { hostTurn, ...turnEnd } = reduceHostTurnEnd(
previous,
previousTurn,
payload,
observedAt ?? now,
payload.connectionId === null
@@ -174,9 +188,9 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
...turnEnd,
...(hostTurn ? { hostTurn } : {})
},
previous,
previousTurn,
leadReplyOf(payload),
!!previous && this.sameTerminalOwner(previous, payload)
!!previousTurn && this.sameTerminalOwner(previousTurn, payload)
)
}
@@ -6,10 +6,8 @@ import {
needsHookRootEvidence,
readHookProcessVerdict
} from '../../../shared/agent-hook-listener/presence/agent-hook-owner-presence'
import {
reconcileCodexStatusEvent,
markCodexLeadTurnInterrupted
} from '../../../shared/agent-hook-listener/providers/codex-state'
import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state'
import { reconcileCodexStatusEvent } from '../../../shared/agent-hook-listener/providers/codex-session-start'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
@@ -160,10 +158,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
this.commitStatusRowMutation(rowBefore, previous)
return previous
}
// Any submitted prompt (incl. a replayed Codex start) opens a new turn; OSC must not inherit the old stamp.
if (payload.hookEventName === 'UserPromptSubmit' || payload.hasExplicitPrompt === true) {
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
}
if (terminalOwnedPayload.connectionId) {
this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now)
}
@@ -194,9 +188,15 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
const stateReconciledPayload = reconcileCodexStatusEvent(
this.state,
terminalOwnedPayload,
previous?.payload,
previous,
codexRosterAuthority
)
if (!stateReconciledPayload) {
return undefined
}
if (previous && stateReconciledPayload === previous) {
return previous
}
// Relay restart loses root context; child hooks must not erase persisted resume/model identity.
const rootContextPreservingPayload = retainHookRootContext(
stateReconciledPayload,
@@ -73,3 +73,20 @@ it('records where a resumed process really started over the folder its seed name
expect(recordedSession()?.cwd).toBe('/elsewhere')
})
it('names the pane session from a --fork-session start, which Claude 2.1.214+ reports as fork', async () => {
// Captured from Claude 2.1.321 `claude --resume <id> --fork-session` before any message.
await claudeHook({
session_id: 'b47ec7fc-07e4-4012-8b28-1d5daa938f52',
transcript_path:
'/Users/u/.claude/projects/-private-tmp-w39942fork/b47ec7fc-07e4-4012-8b28-1d5daa938f52.jsonl',
cwd: '/private/tmp/w39942fork',
hook_event_name: 'SessionStart',
source: 'fork'
})
expect(recordedSession()).toMatchObject({
id: 'b47ec7fc-07e4-4012-8b28-1d5daa938f52',
cwd: '/private/tmp/w39942fork'
})
})
@@ -0,0 +1,615 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { RelayAgentHookServer } from '../../../../../../wsl-guest/agent-hook-server'
import { buildCachedPaneReplayEnvelope } from '../../../../../../wsl-guest/agent-hook-cached-pane-status'
import type { AgentHookEventPayload } from '../../../../../../shared/agent-hook-listener/listener-event'
import type { AgentHookRelayEnvelope } from '../../../../../../shared/agent-hook-listener/transport/agent-hook-relay'
import { AgentHookServer } from '../../../agent-hook-server'
import { selectFreshExplicitAgentStatus } from '../../../../../runtime/workspaces/process-listing/runtime-hook-agent-row-selection'
import { buildBody, postHookEvent, PANE } from '../../server.test-fixtures'
function previousRoot(children: number): AgentHookEventPayload {
return {
paneKey: PANE,
connectionId: null,
worktreeId: 'old-owner',
source: 'codex',
hookEventName: 'SubagentStart',
toolAgentId: 'old-0',
providerSession: { key: 'session_id', id: 'old-root' },
payload: {
agentType: 'codex',
state: 'working',
model: 'old-model',
prompt: 'old task',
mainAgent: { state: 'done', stateStartedAt: 1000 },
subagents: Array.from({ length: children }, (_, index) => ({
id: `old-${index}`,
state: 'working',
startedAt: 1000
}))
}
}
}
it.each([10, 100])(
'retires %i foreign-session children at a cached startup boundary',
(children) => {
const desktop = new AgentHookServer()
desktop.beginRemoteRelaySession('wsl:Ubuntu')
desktop.ingestRemote(previousRoot(children), 'wsl:Ubuntu')
expect(desktop.getStatusSnapshot()[0].subagents).toHaveLength(children)
if (children === 100) {
desktop.beginRemoteRelaySession('wsl:Ubuntu')
}
const incoming: AgentHookEventPayload = {
...previousRoot(0),
toolAgentId: undefined,
hookEventName: 'SessionStart',
providerSession: { key: 'session_id', id: 'new-root' },
payload: {
agentType: 'codex',
state: 'done',
sessionBoundary: true,
prompt: 'new context',
model: 'new-model',
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2000 }
}
}
desktop.ingestRemote(
buildCachedPaneReplayEnvelope({ event: incoming, meta: { source: 'codex' } }),
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
restoredUnconfirmed: true,
providerSession: { id: 'new-root' },
prompt: 'new context',
model: 'new-model',
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2000 }
})
expect(desktop.getStatusSnapshot()[0].subagents).toBeUndefined()
expect(desktop._getStateForTests().codexSubagentRosterByPaneKey.get(PANE)?.size).toBe(0)
desktop.ingestRemote({ ...incoming, hookEventName: undefined }, 'wsl:Ubuntu')
expect(desktop.getStatusSnapshot()[0].subagents).toBeUndefined()
}
)
it.each([false, true])(
'retains only the incoming owner’s child in a legacy=%s replay',
(legacy) => {
const desktop = new AgentHookServer()
desktop.beginRemoteRelaySession('wsl:Ubuntu')
desktop.ingestRemote(previousRoot(1), 'wsl:Ubuntu')
const incoming: AgentHookEventPayload = {
...previousRoot(0),
worktreeId: 'new-owner',
toolAgentId: undefined,
hookEventName: 'SessionStart',
payload: {
agentType: 'codex',
state: 'working',
prompt: 'new context',
model: 'new-model',
mainAgent: legacy ? undefined : { state: 'done', stateStartedAt: 2000 },
subagents: [{ id: 'own-child', state: 'working', startedAt: 2000 }]
}
}
desktop.ingestRemote(
buildCachedPaneReplayEnvelope({ event: incoming, meta: { source: 'codex' } }),
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
restoredUnconfirmed: true,
worktreeId: 'new-owner',
prompt: 'new context',
model: 'new-model',
mainAgent: { state: 'done' },
subagents: [{ id: 'own-child', state: 'working' }]
})
expect(desktop._getStateForTests().codexSubagentRosterByPaneKey.get(PANE)?.size).toBe(1)
desktop.ingestRemote(
{
...incoming,
hookEventName: undefined,
payload: {
...incoming.payload,
state: 'done',
mainAgent: { state: 'done', stateStartedAt: 2000 },
subagents: undefined
}
},
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
expect(desktop.getStatusSnapshot()[0].subagents).toBeUndefined()
expect(desktop._getStateForTests().codexSubagentRosterByPaneKey.get(PANE)?.size).toBe(0)
}
)
it.each([false, true])(
'does not lend work to a different legacy live session with main=%s',
(main) => {
const desktop = new AgentHookServer()
const previous = previousRoot(1)
desktop.ingestRemote(
{
...previous,
payload: { ...previous.payload, mainAgent: { state: 'working', stateStartedAt: 1000 } }
},
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
mainAgent: { state: 'working', stateStartedAt: 1000 },
subagents: [{ id: 'old-0', state: 'working' }]
})
desktop.ingestRemote(
{
...previous,
toolAgentId: undefined,
hookEventName: 'SessionStart',
providerSession: { key: 'session_id', id: 'new-root' },
payload: {
agentType: 'codex',
state: 'working',
prompt: 'old cached text',
mainAgent: main ? { state: 'working', stateStartedAt: 1000 } : undefined
}
},
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
providerSession: { id: 'new-root' },
state: 'done',
sessionBoundary: true,
mainAgent: { state: 'done' }
})
expect(desktop.getStatusSnapshot()[0].subagents).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].prompt).toBe('')
expect(desktop.getStatusSnapshot()[0].model).toBeUndefined()
}
)
it.each(['previous', 'incoming'] as const)(
'preserves live context when the %s session ID is missing',
(missing) => {
const desktop = new AgentHookServer()
const previous = previousRoot(1)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1_000)
try {
desktop.ingestRemote(
{
...previous,
hookEventName: 'UserPromptSubmit',
toolAgentId: undefined,
hasExplicitPrompt: true,
providerSession: missing === 'previous' ? undefined : previous.providerSession,
payload: { ...previous.payload, mainAgent: { state: 'working', stateStartedAt: 1000 } }
},
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBe(1_000)
vi.setSystemTime(3_000)
desktop.ingestRemote(
{
...previous,
toolAgentId: undefined,
hookEventName: 'SessionStart',
providerSession:
missing === 'incoming' ? undefined : { key: 'session_id', id: 'new-root' },
payload: { agentType: 'codex', state: 'working', prompt: '' }
},
'wsl:Ubuntu'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'old task',
model: 'old-model',
mainAgent: { state: 'working', stateStartedAt: 1000 },
subagents: [{ id: 'old-0', state: 'working' }],
turnStartedAt: 1_000
})
} finally {
desktop.stop()
vi.useRealTimers()
}
}
)
it.each(['live', 'cached', 'old-relay'] as const)(
'drops a completed foreign root’s host facts on %s startup',
(route) => {
vi.useFakeTimers({ toFake: ['Date'] })
const desktop = new AgentHookServer()
const root: AgentHookEventPayload = {
paneKey: PANE,
connectionId: null,
source: 'codex',
hookEventName: 'UserPromptSubmit',
hasExplicitPrompt: true,
providerSession: { key: 'session_id', id: 'root-a' },
payload: {
agentType: 'codex',
state: 'working',
prompt: 'first',
mainAgent: { state: 'working', stateStartedAt: 1_000 }
}
}
try {
vi.setSystemTime(1_000)
desktop.ingestRemote(root, 'ssh-clock')
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBe(1_000)
vi.setSystemTime(2_000)
desktop.ingestRemote(
{
...root,
hookEventName: 'Stop',
hasExplicitPrompt: false,
turnEndedAt: 2_000,
turnEndDisposition: 'identified',
payload: {
agentType: 'codex',
state: 'done',
prompt: 'first',
mainAgent: { state: 'done', stateStartedAt: 2_000 }
}
},
'ssh-clock'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
turnStartedAt: 1_000,
turnEndedAt: 2_000,
stateStartedAt: 2_000
})
vi.setSystemTime(3_000)
const startup: AgentHookEventPayload = {
...root,
source: route === 'old-relay' ? undefined : 'codex',
hookEventName: 'SessionStart',
hasExplicitPrompt: false,
providerSession: { key: 'session_id', id: 'root-b' },
payload: {
agentType: 'codex',
state: 'done',
prompt: '',
sessionBoundary: true,
mainAgent: { state: 'done', stateStartedAt: 2_000 }
}
}
desktop.ingestRemote(
route === 'cached'
? buildCachedPaneReplayEnvelope({ event: startup, meta: { source: 'codex' } })
: startup,
'ssh-clock'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
providerSession: { id: 'root-b' },
stateStartedAt: 3_000
})
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].turnEndedAt).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].turnEndDisposition).toBeUndefined()
} finally {
desktop.stop()
vi.useRealTimers()
}
}
)
it.each(['local', 'relay', 'old-relay', 'old-relay-no-main'] as const)(
'separates Codex session identity from live turns on %s',
async (route) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1_000)
const directory = mkdtempSync(join(tmpdir(), 'codex-session-start-'))
const desktop = new AgentHookServer()
let forwarded: AgentHookRelayEnvelope | undefined
const relay =
route === 'local'
? undefined
: new RelayAgentHookServer({
endpointDir: directory,
forward: (event) => {
forwarded = event
return desktop.ingestRemote(
route.startsWith('old-') && event.hookEventName === 'SessionStart'
? {
...event,
payload: {
...event.payload,
state: 'working',
sessionBoundary: undefined,
mainAgent:
route === 'old-relay-no-main'
? undefined
: { state: 'working', stateStartedAt: 900 }
}
}
: event,
'ssh-connection'
)
}
})
await desktop.start({ env: 'production' })
await relay?.start()
async function post(payload: Record<string, unknown>): Promise<void> {
const body = buildBody({ session_id: 'root-session', ...payload })
const coordinates = relay?.getCoordinates()
const response = coordinates
? await fetch(`http://127.0.0.1:${coordinates.port}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Connection: 'close',
'X-Orca-Agent-Hook-Token': coordinates.token
},
body: JSON.stringify(body)
})
: await postHookEvent(desktop, body, '/hook/codex')
expect(response.status).toBe(204)
}
try {
await post({ hook_event_name: 'SessionStart', source: 'startup' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
sessionBoundary: true,
mainAgent: { state: 'done' }
})
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].turnEndedAt).toBeUndefined()
await post({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'review' })
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBe(1_000)
await post({ hook_event_name: 'SubagentStart', agent_id: 'child-session' })
vi.setSystemTime(2_000)
await post({ hook_event_name: 'SessionStart', source: 'compact' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'review',
turnStartedAt: 1_000,
mainAgent: { state: 'working', stateStartedAt: 1_000 },
subagents: [{ id: 'child-session', state: 'working' }],
providerSession: { id: 'root-session' }
})
await post({ hook_event_name: 'SessionStart', source: 'future-continuation' })
expect(desktop.getStatusSnapshot()[0].mainAgent).toMatchObject({ stateStartedAt: 1_000 })
// A schema-shaped legacy child start must not take over its parent's row.
await post({
hook_event_name: 'SessionStart',
source: 'resume',
session_id: 'child-session',
transcript_path: join(directory, 'child-rollout.jsonl')
})
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
mainAgent: { state: 'working', stateStartedAt: 1_000 },
providerSession: { id: 'root-session' }
})
if (route === 'local') {
vi.setSystemTime(2_500)
await post({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'review' })
expect(desktop.getStatusSnapshot()[0].mainAgent).toMatchObject({ stateStartedAt: 1_000 })
}
vi.setSystemTime(3_000)
await post({
hook_event_name: 'PreToolUse',
tool_name: 'request_user_input',
tool_input: { questions: [] }
})
vi.setSystemTime(4_000)
await post({ hook_event_name: 'SessionStart', source: 'compact' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'waiting',
turnStartedAt: route === 'local' ? 2_500 : 1_000,
prompt: 'review',
toolName: 'request_user_input',
mainAgent: { state: 'waiting', stateStartedAt: 3_000 },
subagents: [{ id: 'child-session', state: 'working' }]
})
if (route === 'local' || route === 'relay') {
vi.setSystemTime(5_000)
await post({ hook_event_name: 'PostToolUse', tool_name: 'Bash' })
vi.setSystemTime(6_000)
await post({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-2', prompt: 'next turn' })
expect(desktop.getStatusSnapshot()[0].mainAgent).toMatchObject({ stateStartedAt: 6_000 })
await post({ hook_event_name: 'Stop' })
await post({ hook_event_name: 'SubagentStart', agent_id: 'child-session' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
mainAgent: { state: 'done', stateStartedAt: 6_000 },
subagents: [{ id: 'child-session', state: 'working' }]
})
vi.setSystemTime(7_000)
await post({ hook_event_name: 'SessionStart', source: 'compact' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
mainAgent: { state: 'done', stateStartedAt: 6_000 },
subagents: [{ id: 'child-session', state: 'working' }]
})
await post({ hook_event_name: 'SubagentStop', agent_id: 'child-session' })
await post({ hook_event_name: 'Interrupt' })
vi.setSystemTime(8_000)
await post({ hook_event_name: 'SessionStart', source: 'compact' })
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
sessionBoundary: true,
turnStartedAt: 6_000,
mainAgent: { state: 'done', stateStartedAt: 6_000, outcome: 'cancellation' }
})
await post({
hook_event_name: 'UserPromptSubmit',
turn_id: 'turn-3',
prompt: 'old session work'
})
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBe(8_000)
vi.setSystemTime(9_000)
await post({ hook_event_name: 'Stop', last_assistant_message: 'old reply' })
expect(desktop.getStatusSnapshot()[0].turnEndedAt).toBe(9_000)
if (route === 'relay') {
expect(forwarded).toMatchObject({ turnEndedAt: 9_000, turnEndDisposition: 'identified' })
}
if (route === 'local') {
expect(desktop.getAcceptedReply(PANE, 9_000)).toBe('old reply')
}
vi.setSystemTime(route === 'local' ? 9_000 : 10_000)
await post({
hook_event_name: 'SessionStart',
source: 'startup',
session_id: 'replacement-session',
transcript_path: join(directory, 'replacement.jsonl')
})
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
sessionBoundary: true,
mainAgent: { state: 'done' },
providerSession: { id: 'replacement-session' }
})
if (route === 'relay') {
expect(forwarded).toMatchObject({
hookEventName: 'SessionStart',
providerSession: { id: 'replacement-session' }
})
expect(forwarded?.turnEndedAt).toBeUndefined()
expect(forwarded?.turnEndDisposition).toBeUndefined()
}
expect(desktop.getStatusSnapshot()[0].turnStartedAt).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].turnEndedAt).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].turnEndDisposition).toBeUndefined()
expect(desktop.getStatusSnapshot()[0].stateStartedAt).toBe(
route === 'local' ? 9_000 : 10_000
)
expect(desktop.getAcceptedReply(PANE, 9_000)).toBeUndefined()
vi.setSystemTime(11_000)
await post({
hook_event_name: 'UserPromptSubmit',
session_id: 'replacement-session',
prompt: 'replacement task'
})
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'replacement task',
turnStartedAt: 11_000
})
}
} finally {
relay?.stop()
desktop.stop()
vi.useRealTimers()
rmSync(directory, { recursive: true, force: true })
}
}
)
it('does not confirm restored work from an older relay startup or redate a submitted replay', async () => {
const directory = mkdtempSync(join(tmpdir(), 'codex-restored-start-'))
const first = new AgentHookServer()
const desktop = new AgentHookServer()
const event = {
paneKey: PANE,
source: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'root-session' },
payload: {
state: 'working' as const,
agentType: 'codex' as const,
prompt: 'old task',
mainAgent: { state: 'working' as const, stateStartedAt: 1_000 }
}
}
try {
await first.start({ env: 'production', userDataPath: directory })
first.ingestRemote({ ...event, hookEventName: 'UserPromptSubmit' }, 'ssh-connection')
first.flushStatusPersistSync()
first.stop()
await desktop.start({ env: 'production', userDataPath: directory })
expect(desktop.getStatusSnapshot()[0].restoredUnconfirmed).toBe(true)
desktop.ingestRemote({ ...event, hookEventName: 'SessionStart' }, 'ssh-connection')
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
restoredUnconfirmed: true,
providerSession: { id: 'root-session' },
mainAgent: { state: 'working', stateStartedAt: 1_000 }
})
desktop.ingestRemote(
{
...event,
hookEventName: 'SessionStart',
hasExplicitPrompt: true,
isReplay: true
},
'ssh-connection'
)
expect(desktop.getStatusSnapshot()[0].restoredUnconfirmed).toBe(true)
desktop.ingestRemote(
{
...event,
hookEventName: 'SubagentStart',
toolAgentId: 'child-1',
payload: {
...event.payload,
subagents: [{ id: 'child-1', state: 'working', startedAt: 1_000 }]
}
},
'ssh-connection'
)
expect(desktop.getStatusSnapshot()[0]).toMatchObject({
restoredUnconfirmed: true,
subagents: [{ id: 'child-1', state: 'working' }]
})
desktop.ingestRemote(
{
...event,
hookEventName: 'UserPromptSubmit',
hasExplicitPrompt: true,
payload: { ...event.payload, mainAgent: { state: 'working', stateStartedAt: 6_000 } }
},
'ssh-connection'
)
expect(desktop.getStatusSnapshot()[0].restoredUnconfirmed).toBeUndefined()
desktop.ingestRemote(
{
...event,
hookEventName: 'Interrupt',
payload: {
...event.payload,
state: 'done',
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 7_000 }
}
},
'ssh-connection'
)
const stopped = desktop.getStatusSnapshot()[0]
expect(stopped.mainAgent).toMatchObject({ state: 'done', outcome: 'cancellation' })
desktop.ingestRemote(
{ ...event, hookEventName: 'SessionStart', hasExplicitPrompt: true, isReplay: true },
'ssh-connection'
)
expect(desktop.getStatusSnapshot()[0]).toEqual(stopped)
expect(
selectFreshExplicitAgentStatus({
handle: 'fixture',
paneKey: PANE,
hookRows: desktop.getStatusSnapshot()
})?.mainAgentWorkingStartedAt
).toBeNull()
vi.spyOn(Date, 'now').mockReturnValue(stopped.receivedAt + 31 * 60_000)
desktop.ingestRemote(
{ ...event, hookEventName: 'SessionStart', hasExplicitPrompt: true, isReplay: true },
'ssh-connection'
)
expect(
selectFreshExplicitAgentStatus({
handle: 'fixture',
paneKey: PANE,
hookRows: desktop.getStatusSnapshot()
})
).toBeNull()
} finally {
vi.restoreAllMocks()
first.stop()
desktop.stop()
rmSync(directory, { recursive: true, force: true })
}
})
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { AgentHookServer } from '../../../agent-hook-server'
import { RelayAgentHookServer } from '../../../../../../wsl-guest/agent-hook-server'
import { buildBody, postHookEvent } from '../../server.test-fixtures'
// Shapes captured from Codex 0.164: a /side fork is its own session with no rollout.
@@ -148,24 +149,79 @@ it.each([
}
})
it('lets a new ephemeral Codex conversation take over a row still working, without its old prompt', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codex-side-thread-'))
dirs.push(dir)
const server = new AgentHookServer()
await server.start({ env: 'production' })
const post = (payload: Record<string, unknown>): Promise<Response> =>
postHookEvent(server, buildBody(payload), '/hook/codex')
try {
const main = { session_id: MAIN, transcript_path: join(dir, 'rollout-main.jsonl') }
await post({ ...main, hook_event_name: 'UserPromptSubmit', prompt: 'main task' })
await post({ ...TITLE_THREAD, hook_event_name: 'SessionStart', source: 'startup' })
await post({ ...TITLE_THREAD, hook_event_name: 'UserPromptSubmit', prompt: 'next task' })
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'next task',
providerSession: { id: SIDE }
})
} finally {
server.stop()
it.each(['local', 'relay', 'relay-done'])(
'lets a submitted Codex conversation take over without its old prompt: %s',
async (route) => {
const dir = mkdtempSync(join(tmpdir(), 'codex-side-thread-'))
dirs.push(dir)
const server = new AgentHookServer()
await server.start({ env: 'production' })
const relay =
route === 'local'
? undefined
: new RelayAgentHookServer({
endpointDir: dir,
forward: (event) => server.ingestRemote(event, 'ssh-connection')
})
await relay?.start()
const post = (payload: Record<string, unknown>): Promise<Response> => {
const coordinates = relay?.getCoordinates()
return coordinates
? fetch(`http://127.0.0.1:${coordinates.port}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Connection: 'close',
'X-Orca-Agent-Hook-Token': coordinates.token
},
body: JSON.stringify(buildBody(payload))
})
: postHookEvent(server, buildBody(payload), '/hook/codex')
}
try {
const main = { session_id: MAIN, transcript_path: join(dir, 'rollout-main.jsonl') }
await post({ ...main, hook_event_name: 'UserPromptSubmit', prompt: 'main task' })
await post({ ...main, hook_event_name: 'SubagentStart', agent_id: 'old-child' })
if (route === 'relay') {
for (const [session_id, prompt] of [
['01a16f91-0000-4000-8000-000000000001', TITLE_PROMPT],
[
'01a16f91-0000-4000-8000-000000000002',
'Write a brief catch-up for a user returning to this task.'
]
]) {
const hidden = { session_id, transcript_path: null }
await post({ ...hidden, hook_event_name: 'SessionStart', source: 'startup' })
expect(server.getStatusSnapshot()[0]).toMatchObject({
prompt: 'main task',
providerSession: { id: MAIN }
})
await post({ ...hidden, hook_event_name: 'UserPromptSubmit', prompt })
await post({ ...hidden, hook_event_name: 'Stop' })
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'main task',
providerSession: { id: MAIN },
subagents: [{ id: 'old-child', state: 'working' }]
})
}
}
if (route === 'relay-done') {
await post({ ...main, hook_event_name: 'Stop' })
}
await post({ ...TITLE_THREAD, hook_event_name: 'SessionStart', source: 'startup' })
await post({ ...TITLE_THREAD, hook_event_name: 'UserPromptSubmit', prompt: 'next task' })
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'next task',
providerSession: { id: SIDE }
})
expect(server.getStatusSnapshot()[0].subagents).toBeUndefined()
await post({ ...TITLE_THREAD, hook_event_name: 'Stop' })
expect(server.getStatusSnapshot()[0].state).toBe('done')
} finally {
relay?.stop()
server.stop()
}
}
})
)
@@ -120,6 +120,30 @@ describe('Codex async questions', () => {
expect(steered?.interactivePrompt).toBeUndefined()
})
it('keeps a compacted question with its session and releases it on replacement', () => {
const pane = codexPane()
pane.report({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Set up a db' })
pane.report(ASK)
expect(pane.report(ACCEPTED).main).toMatchObject(waitingOnQuestion)
expect(pane.report({ hook_event_name: 'SessionStart', source: 'compact' }).main).toMatchObject(
waitingOnQuestion
)
const replacement = pane.report({
hook_event_name: 'SessionStart',
source: 'startup',
session_id: 'replacement'
}).main
expect(replacement).toMatchObject({ state: 'done', providerSession: { id: 'replacement' } })
expect(replacement?.interactivePrompt).toBeUndefined()
const working = pane.report({
hook_event_name: 'PostToolUse',
session_id: 'replacement',
tool_name: 'Bash'
}).main
expect(working).toMatchObject({ state: 'working' })
expect(working?.interactivePrompt).toBeUndefined()
})
it('drops an unanswered question when the turn ends', () => {
const pane = codexPane()
pane.report({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Set up a db' })
@@ -1,6 +1,16 @@
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { normalizeHookPayload } from '../../../../../shared/agent-hook-listener/normalization/normalize-hook-payload'
import { createHookListenerState } from '../../../../../shared/agent-hook-listener/listener-state'
import { pollCodexTranscriptStatus } from '../../../../../shared/agent-hook-listener/providers/codex-transcript-poll'
import { transcriptPollUpdate } from '../../../../../shared/agent-hook-listener/transcript-poll-policy'
import { buildRelayHookEnvelope } from '../../../../../wsl-guest/agent-hook-envelope-build'
import { buildCachedPaneReplayEnvelope } from '../../../../../wsl-guest/agent-hook-cached-pane-status'
import type { AgentHookEventPayload } from '../../../../../shared/agent-hook-listener/listener-event'
import { AgentHookServer, _internals } from '../../agent-hook-server'
import { PANE } from '../server.test-fixtures'
import { PANE, buildBody } from '../server.test-fixtures'
vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../../../telemetry/cohort-classifier', () => ({
@@ -8,19 +18,178 @@ vi.mock('../../../../telemetry/cohort-classifier', () => ({
}))
const CHILD = '01a16839-7f84-7ed3-aebf-ea51e50330cf'
const SECOND_CHILD = '02a16839-7f84-7ed3-aebf-ea51e50330cf'
const line = (type: string): string =>
`${JSON.stringify({ type: 'event_msg', payload: { type } })}\n`
let directory: string
beforeEach(() => {
_internals.resetCachesForTests()
directory = mkdtempSync(join(tmpdir(), 'orca-remote-codex-child-'))
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('remote Codex transcript completion', () => {
it('retires transcript-completed children while keeping surviving child work live', () => {
const parentPath = join(directory, 'rollout-parent.jsonl')
const childPath = join(directory, `rollout-child-${CHILD}.jsonl`)
const secondChildPath = join(directory, `rollout-child-${SECOND_CHILD}.jsonl`)
writeFileSync(parentPath, line('task_complete'))
writeFileSync(childPath, line('task_started'))
writeFileSync(secondChildPath, line('task_started'))
const hostState = createHookListenerState()
const server = new AgentHookServer()
const root = normalizeHookPayload(
hostState,
'codex',
buildBody({
hook_event_name: 'Stop',
session_id: 'root',
transcript_path: parentPath
}),
'production'
)
if (!root) {
throw new Error('Root hook was rejected')
}
server.ingestRemote(buildRelayHookEnvelope(root, 'codex'), 'wsl:Ubuntu')
const childBody = buildBody({
hook_event_name: 'SubagentStart',
agent_id: CHILD,
session_id: CHILD,
transcript_path: childPath
})
const started = normalizeHookPayload(hostState, 'codex', childBody, 'production')
if (!started) {
throw new Error('Child hook was rejected')
}
server.ingestRemote(buildRelayHookEnvelope(started, 'codex'), 'wsl:Ubuntu')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
subagents: [
{
id: CHILD,
state: 'working',
startedAt: expect.any(Number)
}
]
})
])
const secondChildBody = buildBody({
hook_event_name: 'SubagentStart',
agent_id: SECOND_CHILD,
session_id: SECOND_CHILD,
transcript_path: secondChildPath
})
const secondStarted = normalizeHookPayload(hostState, 'codex', secondChildBody, 'production')
if (!secondStarted) {
throw new Error('Second child hook was rejected')
}
server.ingestRemote(buildRelayHookEnvelope(secondStarted, 'codex'), 'wsl:Ubuntu')
expect(
server
.getStatusSnapshot()[0]
.subagents?.map((child) => child.id)
.sort()
).toEqual([CHILD, SECOND_CHILD])
const compact = normalizeHookPayload(
hostState,
'codex',
buildBody({ hook_event_name: 'SessionStart', source: 'compact', session_id: 'root' }),
'production'
)
if (!compact) {
throw new Error('Root compact was rejected')
}
server.ingestRemote(buildRelayHookEnvelope(compact, 'codex'), 'wsl:Ubuntu')
const beforeReplay = server.getStatusSnapshot()[0]
expect(beforeReplay).toMatchObject({
mainAgent: { state: 'done' },
subagents: [{ id: CHILD }, { id: SECOND_CHILD }]
})
server.ingestRemote(
buildCachedPaneReplayEnvelope({ event: compact, meta: { source: 'codex' } }),
'wsl:Ubuntu'
)
expect(server.getStatusSnapshot()[0]).toEqual(beforeReplay)
appendFileSync(childPath, line('task_complete'))
const oneRemaining = pollCodexTranscriptStatus(
hostState,
secondStarted,
secondChildBody,
'production'
)
if (!oneRemaining) {
throw new Error('No first child completion status')
}
const partialUpdate = transcriptPollUpdate('codex', secondStarted, oneRemaining)
if (!partialUpdate) {
throw new Error('Host did not publish first child completion')
}
expect(partialUpdate).toMatchObject({
toolAgentId: SECOND_CHILD,
payload: {
state: 'working',
mainAgent: { state: 'done' },
subagents: [expect.objectContaining({ id: SECOND_CHILD, state: 'working' })]
}
})
expect(partialUpdate.hookEventName).toBeUndefined()
server.ingestRemote(buildRelayHookEnvelope(partialUpdate, 'codex'), 'wsl:Ubuntu')
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
mainAgent: { state: 'done' },
subagents: [expect.objectContaining({ id: SECOND_CHILD, state: 'working' })]
})
appendFileSync(secondChildPath, line('task_complete'))
const polled = pollCodexTranscriptStatus(
hostState,
partialUpdate,
secondChildBody,
'production'
)
if (!polled) {
throw new Error('No completion status')
}
const update = transcriptPollUpdate('codex', partialUpdate, polled)
if (!update) {
throw new Error('Host did not publish transcript completion')
}
expect(update).toMatchObject({ toolAgentId: SECOND_CHILD, payload: { state: 'done' } })
expect(update.hookEventName).toBeUndefined()
expect(update.payload.subagents).toBeUndefined()
server.ingestRemote(buildRelayHookEnvelope(update, 'codex'), 'wsl:Ubuntu')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
mainAgent: expect.objectContaining({ state: 'done' })
})
])
expect(server.getStatusSnapshot()[0].subagents).toBeUndefined()
})
})
describe('remote Codex unlabelled roster removals', () => {
const childStart = (id: string, startedAt: number) => ({
const childStart = (id: string, startedAt: number): AgentHookEventPayload => ({
paneKey: PANE,
connectionId: null,
source: 'codex',
providerSession: { key: 'session_id', id: 'root' },
hookEventName: 'SubagentStart',
toolAgentId: id,
payload: {
@@ -34,6 +203,7 @@ describe('remote Codex unlabelled roster removals', () => {
const emptyPoll = (mainState: 'working' | 'done', toolAgentId?: string) => ({
paneKey: PANE,
source: 'codex',
providerSession: { key: 'session_id', id: 'root' },
toolAgentId,
payload: {
agentType: 'codex',
@@ -48,15 +218,65 @@ describe('remote Codex unlabelled roster removals', () => {
server.beginRemoteRelaySession('wsl:Ubuntu')
server.ingestRemote(childStart(CHILD, 1), 'wsl:Ubuntu')
server.beginRemoteRelaySession('wsl:Ubuntu')
const replay = buildCachedPaneReplayEnvelope({
event: { ...childStart(CHILD, 1), hookEventName: 'SessionStart', toolAgentId: undefined },
meta: { source: 'codex' }
})
server.ingestRemote(replay, 'wsl:Ubuntu')
server.ingestRemote(emptyPoll('done'), 'wsl:Ubuntu')
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
state: 'working',
subagents: [expect.objectContaining({ id: CHILD })]
})
server._getStateForTests().codexSubagentRosterByPaneKey.delete(PANE)
server.ingestRemote(replay, 'wsl:Ubuntu')
server.ingestRemote(emptyPoll('done'), 'wsl:Ubuntu')
expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents?.map((child) => child.id)).toEqual([
CHILD
])
server.ingestRemote(childStart(CHILD, 1), 'wsl:Ubuntu')
server.ingestRemote(emptyPoll('done'), 'wsl:Ubuntu')
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'done' })
expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents).toBeUndefined()
})
it.each([100, 1000])(
'releases %i completed child lifetimes across cached starts',
(lifetimes) => {
const server = new AgentHookServer()
const state = server._getStateForTests()
server.beginRemoteRelaySession('wsl:Ubuntu')
server.ingestRemote(childStart(CHILD, 1), 'wsl:Ubuntu')
for (let index = 0; index < lifetimes; index++) {
const next = childStart(`completed-${index}`, index + 2)
server.ingestRemote(next, 'wsl:Ubuntu')
server.ingestRemote(
buildCachedPaneReplayEnvelope({
event: { ...next, hookEventName: 'SessionStart', toolAgentId: undefined },
meta: { source: 'codex' }
}),
'wsl:Ubuntu'
)
server.ingestRemote(
{
...emptyPoll('done'),
payload: {
...emptyPoll('done').payload,
subagents: [{ id: CHILD, state: 'working', startedAt: 1 }]
}
},
'wsl:Ubuntu'
)
expect(
server.getStatusSnapshotForPane(PANE)[0]?.subagents?.map((child) => child.id)
).toEqual([CHILD])
expect(state.codexSubagentRosterByPaneKey.get(PANE)?.size).toBe(1)
}
server.ingestRemote(emptyPoll('done'), 'wsl:Ubuntu')
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'done' })
expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents).toBeUndefined()
expect(state.codexSubagentRosterByPaneKey.get(PANE)?.size).toBe(0)
}
)
})
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ClaudeUsageTokenTotals } from './types'
import { estimateCostUsd, normalizeModelForPricing } from './claude-model-pricing'
import { estimateCostUsd } from './claude-model-pricing'
const MTOK = 1_000_000
@@ -18,61 +18,10 @@ function estimate(model: string, ...counts: number[]): number | null {
return estimateCostUsd(model, tokens(...counts))
}
describe('model pricing name matching', () => {
it.each([
[' ANTHROPIC/claude-opus-4.1-thinking ', 'claude-opus-4-1'],
['claude-opus-4-20250514', 'claude-opus-4'],
['claude-opus-4.20250514', 'claude-opus-4'],
['claude-opus-4@20250514', 'claude-opus-4'],
['claude-opus-4-5@20251101', 'claude-opus-4-5'],
['us.anthropic.claude-sonnet-4-5-20250929-v1:0', 'claude-sonnet-4-5'],
['claude-sonnet-5-thinking', 'claude-sonnet-5'],
['claude-sonnet-5-5', 'claude-sonnet-5-5'],
['claude-sonnet-5.5[1m]', 'claude-sonnet-5-5'],
['claude-opus-5.5[1m]', 'claude-opus-5-5'],
['claude-opus-5-5-thinking', 'claude-opus-5-5'],
['claude-fable-5-1[1m]', 'claude-fable-5-1'],
['claude-fable-5-1m', 'claude-fable-5'],
['claude-opus-4.8-thinking', 'claude-opus-4-8'],
['claude-3-5-sonnet-20241022', 'claude-sonnet-3-5'],
['claude-3.5-sonnet', 'claude-sonnet-3-5'],
['claude-3-7-sonnet-20250219', 'claude-sonnet-3-7'],
['claude-3-5-haiku-20241022', 'claude-haiku-3-5'],
['claude-3-haiku-20240307', 'claude-haiku-3'],
['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'],
['model_placeholder_m26', 'claude-opus-4-6']
])('reads the family and version out of %s', (model, key) => {
expect(normalizeModelForPricing(model)).toBe(key)
})
it.each([
'claude-opus-5-6',
'claude-sonnet-5-6',
'claude-fable-5-3',
'claude-opus-6',
'claude-opus-4.9',
'claude-opus-5-50',
'claude-sonnet-50',
'claude-3-opus-20240229',
'sonnet',
'gpt-5'
])('leaves %s unpriced', (model) => {
expect(normalizeModelForPricing(model)).toBeNull()
})
})
describe('estimateCostUsd cache-write TTL rates', () => {
it('splits a mixed write bucket without double-billing the 1-hour share', () => {
expect(estimate('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75)
})
it('clamps a 1-hour count that exceeds the reported write total', () => {
expect(estimate('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01)
})
it('still returns null for unknown models', () => {
expect(estimate('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull()
})
})
describe('estimateCostUsd long-context requests', () => {
@@ -89,17 +38,6 @@ describe('estimateCostUsd long-context requests', () => {
})
).toBeCloseTo(0.1 + 0.5)
})
it('ignores a long-context subset for a model with flat rates', () => {
const long = tokens(MTOK)
expect(estimateCostUsd('claude-sonnet-4-6', { ...long, longContext: long })).toBeCloseTo(3)
})
it('clamps a long-context subset larger than its totals', () => {
expect(
estimateCostUsd('claude-haiku-5-5', { ...tokens(MTOK), longContext: tokens(3 * MTOK) })
).toBeCloseTo(0.5)
})
})
// Rate source: platform.claude.com/docs/en/about-claude/pricing.
@@ -28,6 +28,7 @@ export function beginClaudeOptionMutation(session: ClaudeSession, key: string):
if (key === 'model') {
delete session.appliedOptions
delete session.reportedOptions.reportedModel
session.modelMutationDispatch = session.dispatchSequence
} else {
if (session.reportedModelMutation === previous) {
session.reportedModelMutation = sequence
@@ -76,7 +77,12 @@ function observeClaudeResponseModel(
frame: Record<string, unknown>
): boolean {
const response = readClaudeResponseModel(frame)
if (!response) {
// Why: a turn running at a model pick keeps answering on the old model; it must not unseat the pick.
if (
!response ||
(session.modelMutationDispatch === session.dispatchSequence &&
session.reportedModelMutation !== session.optionMutationSequence)
) {
return false
}
const model =
@@ -1,5 +1,10 @@
import { expect, it } from 'vitest'
import { acquired, claudeFrame, fakeClaude } from './claude-structured-session-test-support'
import {
acquired,
claudeFrame,
fakeClaude,
USER_MESSAGE
} from './claude-structured-session-test-support'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
it('publishes the main response model after fallback and restoration, ignoring child, foreign and synthetic frames', async () => {
@@ -74,6 +79,12 @@ it('retains the init context suffix when the response reports the same bare mode
const adapter = await acquired(claude)
const connection = claude.connections[0]!
try {
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'first-response',
body: USER_MESSAGE,
fence: 7
})
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]' })
claudeFrame(connection, {
type: 'assistant',
@@ -84,6 +95,12 @@ it('retains the init context suffix when the response reports the same bare mode
(await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current.reportedModel
).toBe('claude-opus-5-5[1m]')
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'opus' })
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'after-context-pick',
body: USER_MESSAGE,
fence: 7
})
claudeFrame(connection, {
type: 'assistant',
uuid: 'after-context-pick',
@@ -96,3 +113,42 @@ it('retains the init context suffix when the response reports the same bare mode
await adapter.closeAll()
}
})
it('keeps a model picked during a turn over that turn’s later replies until a turn runs it', async () => {
const claude = fakeClaude({ initModel: 'claude-sonnet-5-5' })
const adapter = await acquired(claude)
const connection = claude.connections[0]!
const read = async () => (await adapter.readOptions({ sessionId: 'session-1', fence: 7 })).current
const reply = (uuid: string, model: string) =>
claudeFrame(connection, {
type: 'assistant',
uuid,
message: { role: 'assistant', model, content: [] }
})
try {
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'running',
body: USER_MESSAGE,
fence: 7
})
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-sonnet-5-5' })
reply('before-pick', 'claude-sonnet-5-5')
await adapter.setOption({ sessionId: 'session-1', fence: 7, key: 'model', value: 'haiku' })
reply('same-turn', 'claude-sonnet-5-5')
expect(await read()).toMatchObject({ model: 'haiku' })
expect((await read()).reportedModel).toBeUndefined()
expect(connection.calls).toContainEqual({ subtype: 'set_model', params: { model: 'haiku' } })
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'next',
body: USER_MESSAGE,
fence: 7
})
reply('next-turn', 'claude-haiku-5-5')
expect(await read()).toMatchObject({ model: 'haiku', reportedModel: 'claude-haiku-5-5' })
} finally {
await adapter.closeAll()
}
})
@@ -30,6 +30,7 @@ import { claudeStoppedRequestEndWait } from '../claude-request-end-wait'
import {
finishClaudeCloseAfterExit,
drainClaudeObservedExits,
liveClaudeSession,
observeClaudeSessionExit,
settleClaudeUnexpectedExit,
type ClaudeExitLifecycle
@@ -296,15 +297,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
private session(sessionId: string): ClaudeSession {
const session = this.sessions.get(sessionId)
if (!session) {
// A child that just exited is named by its own diagnostic, not by its absence.
throw (
this.exits.get(sessionId)?.error ??
this.settledExitErrors.get(sessionId) ??
new Error(`no live claude stream-json session for ${sessionId}`)
)
}
return session
return liveClaudeSession(this.exitLifecycle, sessionId)
}
}
@@ -255,3 +255,19 @@ export function finishClaudeCloseAfterExit(input: {
})
)
}
export function liveClaudeSession(
lifecycle: ClaudeExitLifecycle,
sessionId: string
): ClaudeSession {
const session = lifecycle.sessions.get(sessionId)
if (!session) {
// A child that just exited is named by its own diagnostic, not by its absence.
throw (
lifecycle.exits.get(sessionId)?.error ??
lifecycle.settledExitErrors.get(sessionId) ??
new Error(`no live claude stream-json session for ${sessionId}`)
)
}
return session
}
@@ -135,6 +135,8 @@ export type ClaudeSession = {
fastModePerSessionOptIn?: boolean
/** Mutation sequence of the last model report; newer writes outrank it. */
reportedModelMutation: number
/** `dispatchSequence` at the last model write; that turn's replies predate the pick. */
modelMutationDispatch?: number
/** Options whose recorded value the provider reported, not merely accepted. */
confirmedOptions: Set<string>
/** Inputs carried by launch or accepted controls, independent of provider reports. */
@@ -3,11 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import {
claudeChildEnv,
resolveClaudeChildEnvSources,
withOrcaClaudeEntrypoint
} from './claude-structured-child-env'
import { claudeChildEnv, resolveClaudeChildEnvSources } from './claude-structured-child-env'
import { resolveStructuredAgentCommand } from '../../../native-chat/structured-agent-command-resolution'
afterEach(() => {
@@ -36,92 +32,6 @@ it('resolves a custom Claude command against the login shell PATH', async () =>
}
})
it('isolates inherited Claude stamps before applying launch settings', () => {
const sources = {
command: 'claude',
prefixArgs: [],
overlay: undefined,
inheritedEnv: {
CLAUDE_CODE_ENTRYPOINT: 'cli',
CLAUDECODE: '1',
CLAUDE_CODE_MESSAGING_SOCKET: 'parent-socket',
CLAUDE_AGENT_SDK_VERSION: 'parent-sdk',
CLAUDE_EFFORT: 'parent-effort',
CLAUDE_CODE_HTTP_PROXY: 'http://user-proxy'
}
}
const options = { runtimeOnPath: false }
expect(claudeChildEnv(sources, undefined, options)).toEqual({
CLAUDE_CODE_ENTRYPOINT: 'orca',
CLAUDE_CODE_HTTP_PROXY: 'http://user-proxy'
})
const overlay = { CLAUDE_CODE_ENTRYPOINT: 'custom', CLAUDE_EFFORT: 'configured-effort' }
expect(claudeChildEnv({ ...sources, overlay }, undefined, options)).toEqual({
...overlay,
CLAUDE_CODE_HTTP_PROXY: 'http://user-proxy'
})
})
it('keeps a differently cased Windows launch value over the Orca default', () => {
const inherited = {
Claude_Code_Entrypoint: 'cli',
CLAUDE_CODE_MESSAGING_TOKEN: 'parent-token',
Claude_Agent_Sdk_Version: 'parent-sdk',
PATH: 'C:\\bin'
}
expect(
withOrcaClaudeEntrypoint(
inherited,
{ claude_code_entrypoint: 'company-client', claude_agent_sdk_version: 'configured-sdk' },
'win32'
)
).toEqual({
PATH: 'C:\\bin',
CLAUDE_CODE_ENTRYPOINT: 'company-client',
CLAUDE_AGENT_SDK_VERSION: 'configured-sdk'
})
expect(withOrcaClaudeEntrypoint(inherited, undefined, 'win32')).toEqual({
PATH: 'C:\\bin',
CLAUDE_CODE_ENTRYPOINT: 'orca'
})
expect(
withOrcaClaudeEntrypoint(
inherited,
{
CLAUDE_CODE_ENTRYPOINT: 'first',
claude_code_entrypoint: 'last'
},
'win32'
)
).toEqual({ PATH: 'C:\\bin', CLAUDE_CODE_ENTRYPOINT: 'last' })
})
it('uses configured Windows values for both Claude lookup and its final child', async () => {
vi.stubGlobal('process', { ...process, platform: 'win32' })
const sources = await resolveClaudeChildEnvSources({
resolveInheritedEnv: async () => ({
PATH: '/inherited/bin',
TOKEN: 'inherited',
CLAUDE_CONFIG_DIR: '/ambient'
}),
resolveEnv: () => ({
path: '/configured/bin',
tOKEN: 'configured',
claude_config_dir: '/configured/home'
}),
resolveCommand: (options) => {
expect(options?.pathEnv).toBe('/configured/bin')
return { command: '/configured/bin/claude.exe', prefixArgs: [] }
}
})
expect(claudeChildEnv(sources, undefined, { runtimeOnPath: false })).toEqual({
PATH: '/configured/bin',
TOKEN: 'configured',
CLAUDE_CONFIG_DIR: '/configured/home',
CLAUDE_CODE_ENTRYPOINT: 'orca'
})
})
it.skipIf(process.platform !== 'win32')(
'passes configured values through a real Windows child despite inherited casing',
async () => {
@@ -1,6 +1,7 @@
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
import type { ShellReadyState } from './daemon-rpc-contract'
import type { TerminalSnapshot } from './terminal-snapshot'
import type { DaemonLaunchNote } from './daemon-launch-note-protocol'
import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session/agent-session-host-authority'
import type { PtyIncarnationId } from '../../../shared/terminal/pty/lifecycle/pty-incarnation'
@@ -11,6 +12,8 @@ export type DaemonCreateOrAttachResult = {
shellState: ShellReadyState
historySeeded?: boolean
launchAgent?: TuiAgent
/** The launch main armed and has not retired, null for none; older daemons omit it. */
launchNote?: DaemonLaunchNote | null
/** Undefined only when talking to a daemon predating WSL session context. */
wslDistro?: string | null
agentSessionEnsure?: AgentSessionClaimedSpawnResult
@@ -23,15 +26,18 @@ export type DaemonCreateOrAttachResult = {
export function getDaemonSessionResultMetadata(session: {
launchAgent: TuiAgent | null
launchNote: { current: DaemonLaunchNote | null }
historySeeded: boolean | undefined
wslDistro: string | null
}): {
launchAgent?: TuiAgent
launchNote: DaemonLaunchNote | null
historySeeded?: boolean
wslDistro: string | null
} {
return {
...(session.launchAgent ? { launchAgent: session.launchAgent } : {}),
launchNote: session.launchNote.current,
...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}),
// Null identifies native sessions; only older daemons omit the field.
wslDistro: session.wslDistro
@@ -0,0 +1,62 @@
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session/lifecycle/agent-session-resume'
/** The Orca launch running in a session, kept by the daemon so it outlives a main restart. */
export type DaemonLaunchNote = { launchToken: string; launchConfig: SleepingAgentLaunchConfig }
/** Main arms the launch it admits and retires it by token, so a stale retire cannot clear a newer launch. */
export type DaemonLaunchNoteChange = { arm: DaemonLaunchNote } | { retire: string }
export type SetLaunchNoteRequest = {
id: string
type: 'setLaunchNote'
payload: { sessionId: string; change: DaemonLaunchNoteChange }
}
const isString = (value: unknown): value is string => typeof value === 'string'
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
const isStringRecord = (value: unknown): value is Record<string, string> =>
isRecord(value) && Object.values(value).every(isString)
export function parseDaemonLaunchNoteChange(raw: unknown): DaemonLaunchNoteChange | null {
if (!isRecord(raw)) {
return null
}
const { arm, retire } = raw
if (isString(retire)) {
return { retire }
}
const note = parseDaemonLaunchNote(arm)
return note ? { arm: note } : null
}
function parseDaemonLaunchNote(raw: unknown): DaemonLaunchNote | null {
if (!isRecord(raw)) {
return null
}
const { launchToken, launchConfig: config } = raw
if (!isString(launchToken) || launchToken.length === 0 || launchToken.length > 128) {
return null
}
if (!isRecord(config)) {
return null
}
const { agentArgs, agentEnv, agentCommand, ompResumeFilePath } = config
if (
!isString(agentArgs) ||
!isStringRecord(agentEnv) ||
(agentCommand !== undefined && !isString(agentCommand)) ||
(ompResumeFilePath !== undefined && !isString(ompResumeFilePath))
) {
return null
}
return {
launchToken,
launchConfig: {
agentArgs,
agentEnv: { ...agentEnv },
...(agentCommand ? { agentCommand } : {}),
...(ompResumeFilePath ? { ompResumeFilePath } : {})
}
}
}
@@ -25,6 +25,7 @@ import type {
import type * as HistorySeedProtocol from '../history/seed-transfer/terminal-history-seed-transfer-protocol'
import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol'
import type { TerminalSnapshot } from './terminal-snapshot'
import type { SetLaunchNoteRequest } from './daemon-launch-note-protocol'
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
@@ -270,6 +271,7 @@ export type DaemonRequest =
| TakePendingOutputRequest
| PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest
| PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest
| SetLaunchNoteRequest
export type RpcResponseOk<T = unknown> = {
id: string
@@ -1,3 +1,4 @@
import type { DaemonLaunchNoteChange } from '../../protocol/daemon-launch-note-protocol'
import {
PTY_FOREGROUND_OBSERVATION_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
@@ -90,6 +91,11 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
}
}
setLaunchNote(id: string, change: DaemonLaunchNoteChange): void {
// Why fire and forget: an older daemon rejects the request and keeps no note, so restore declines.
void this.client.request('setLaunchNote', { sessionId: id, change }).catch(() => undefined)
}
async confirmShellForeground(id: string): Promise<boolean> {
try {
const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', {
@@ -3,10 +3,11 @@ import type { DaemonCreateOrAttachResult as CreateOrAttachResult } from '../../p
export function daemonSpawnResultIdentity(
result: CreateOrAttachResult
): Pick<PtySpawnResult, 'incarnationId' | 'agentSessionEnsure' | 'launchAgent'> {
): Pick<PtySpawnResult, 'incarnationId' | 'agentSessionEnsure' | 'launchAgent' | 'launchNote'> {
return {
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(result.launchAgent ? { launchAgent: result.launchAgent } : {})
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
...(result.launchNote !== undefined ? { launchNote: result.launchNote } : {})
}
}
@@ -1,3 +1,4 @@
import { parseDaemonLaunchNoteChange } from '../protocol/daemon-launch-note-protocol'
import { handleDaemonTerminalKill } from './daemon-terminal-kill'
import { handleDaemonFencedTermination } from './daemon-fenced-termination'
import { performance } from 'node:perf_hooks'
@@ -146,6 +147,13 @@ export class DaemonRequestRouter {
return {
confirmed: await this.options.host.confirmShellForeground(request.payload.sessionId)
}
case 'setLaunchNote': {
const change = parseDaemonLaunchNoteChange(request.payload.change)
if (change) {
this.options.host.setLaunchNote(request.payload.sessionId, change)
}
return {}
}
case 'clearScrollback':
this.options.host.clearScrollback(request.payload.sessionId)
return {}
@@ -183,6 +183,7 @@ export class DaemonTerminalAdmission {
shellState: result.shellState,
incarnationId: result.incarnationId,
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
...(result.launchNote !== undefined ? { launchNote: result.launchNote } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
@@ -0,0 +1,37 @@
import {
createOsc133CommandFinishedScanner,
type Osc133CommandFinishedScanner
} from '../../../shared/terminal/output/terminal-osc133-command-finished'
import type {
DaemonLaunchNote,
DaemonLaunchNoteChange
} from '../protocol/daemon-launch-note-protocol'
/** Main's live launch here; retired on command-finished too, since it can finish while main is down. */
export class SessionLaunchNote {
current: DaemonLaunchNote | null = null
private retirement: Osc133CommandFinishedScanner | null = null
scan(data: string): void {
this.retirement?.scan(data)
}
change(change: DaemonLaunchNoteChange): void {
const token = this.current?.launchToken
if ('retire' in change) {
if (change.retire === token) {
this.current = null
this.retirement = null
}
return
}
// Re-arming the same launch keeps the scanner, whose carry may hold a split marker.
const note = change.arm
if (note.launchToken !== token) {
this.current = note
this.retirement = createOsc133CommandFinishedScanner(() =>
this.change({ retire: note.launchToken })
)
}
}
}
@@ -1,8 +1,18 @@
import type { ChildProcessVerdictRead, SubprocessHandle } from './session-subprocess-handle'
import type { TerminalForegroundSample } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation'
import { SessionLaunchNote } from './session-launch-note'
export class SessionProcessInspection {
constructor(protected readonly subprocess: SubprocessHandle) {}
/** Main's launch note for this process; Session feeds it the output that can retire it. */
readonly launchNote = new SessionLaunchNote()
readonly processNameIsSpawnFile: boolean
readonly shellIsRootChild: boolean
constructor(protected readonly subprocess: SubprocessHandle) {
this.processNameIsSpawnFile = subprocess.processNameIsSpawnFile === true
this.shellIsRootChild = subprocess.shellIsRootChild === true
}
inspectChildProcesses(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead {
return this.subprocess.inspectChildProcesses?.(fresh, ignoreDetached) ?? 'unverifiable'
+1 -4
View File
@@ -28,8 +28,6 @@ export class Session extends SessionProcessInspection {
readonly terminalHandle: string | null
readonly launchAgent: TuiAgent | null
readonly wslDistro: string | null
readonly processNameIsSpawnFile: boolean
readonly shellIsRootChild: boolean
private _state: SessionState = 'running'
private _exitCode: number | null = null
exitCause: TerminalExitCause | undefined
@@ -51,8 +49,6 @@ export class Session extends SessionProcessInspection {
this.terminalHandle = opts.terminalHandle ?? null
this.launchAgent = opts.launchAgent ?? null
this.wslDistro = opts.wslDistro ?? null
this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true
this.shellIsRootChild = opts.subprocess.shellIsRootChild === true
this.onSessionExit = opts.onExit
const isAlive = () => !this._disposed && this._state !== 'exited'
const pipeline = createSessionOutputPipeline(opts, isAlive)
@@ -80,6 +76,7 @@ export class Session extends SessionProcessInspection {
try {
this.subprocess.onData((data) => {
if (!this._disposed) {
this.launchNote.scan(data)
this.shellReady.ingestSubprocessData(data)
}
})
@@ -1,3 +1,4 @@
import type { DaemonLaunchNote } from '../protocol/daemon-launch-note-protocol'
import type { StartupCommandDelivery } from '../../../shared/agent-launch/startup/codex-startup-delivery'
import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent'
import type { ShellReadyState } from '../protocol/daemon-rpc-contract'
@@ -52,6 +53,7 @@ export type CreateOrAttachResult = {
shellState: ShellReadyState
historySeeded?: boolean
launchAgent?: TuiAgent
launchNote?: DaemonLaunchNote | null
wslDistro: string | null
attachToken: symbol
incarnationId: PtyIncarnationId
@@ -1,3 +1,4 @@
import type { DaemonLaunchNoteChange } from '../protocol/daemon-launch-note-protocol'
import { terminalSessionExitCode } from './terminal-host-returned-process'
import { runTerminalCreation, type PendingTerminalCreation } from './terminal-creation-work'
import { beginTerminalHostFencedTermination } from './terminal-host-fenced-termination'
@@ -261,6 +262,10 @@ export class TerminalHost {
)
}
setLaunchNote(sessionId: string, change: DaemonLaunchNoteChange): void {
this.sessions.get(sessionId)?.launchNote.change(change)
}
clearScrollback(sessionId: string): void {
getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback()
}
+4 -1
View File
@@ -12,7 +12,8 @@ export type AgentStatusRuntimeEnrichment = Pick<
| 'getAgentStatusTerminalHandleForPaneKey'
| 'getAgentStatusOrchestrationContextForPaneKey'
| 'getTerminalProcessIncarnation'
>
> &
Partial<Pick<OrcaRuntimeService, 'getLaunchStatementForPaneKey'>>
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
@@ -94,9 +95,11 @@ export function enrichAgentStatusIpcPayload(
}
const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey)
const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey)
const launchConfig = runtime.getLaunchStatementForPaneKey?.(data.paneKey, data.agentType)
return {
...data,
...(terminalHandle ? { terminalHandle } : {}),
...(launchConfig !== undefined ? { launchConfig } : {}),
...(orchestration ? { orchestration } : {})
}
}
+2 -1
View File
@@ -42,7 +42,8 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
hasStablePaneOwner: ctx.stablePaneOwner !== null,
incarnationId: ctx.result.incarnationId
}),
launchedAgent: freshSpawn ? resolveLaunchedAgent(args.command, args.launchAgent) : null
launchedAgent: freshSpawn ? resolveLaunchedAgent(args.command, args.launchAgent) : null,
freshSpawn
},
preAllocatedHandle: ctx.preAllocatedHandle,
spawnTiming: ctx.spawnTiming,
@@ -20,7 +20,7 @@ import {
seedTerminalRestoreRecordsFromSpawnResult
} from './agent-session-owners'
import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore'
import { admitProviderReattachLaunchIdentity } from './launch-authority'
import { admitProviderReattachLaunchIdentity, reattachLaunchBinding } from './launch-authority'
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-relay/wsl-hook-relay-reattach'
import { wslBridgePtyPresence } from '../../../agent-hooks/wsl-relay/wsl-bridge-pty-presence'
import '../spawn-outcome'
@@ -138,7 +138,8 @@ export async function completePaneAdoption(
leafId: owner.leafId,
...(terminalHandle ? { terminalHandle } : {}),
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
providerReattachLaunchIdentity: admitProviderReattachLaunchIdentity(result) ?? undefined
providerReattachLaunchIdentity: admitProviderReattachLaunchIdentity(result) ?? undefined,
...reattachLaunchBinding({ ...result, isReattach: true })
}
)
const response = provenSpawnResponse(
+18 -2
View File
@@ -1,3 +1,4 @@
import type { DaemonLaunchNote } from '../../../daemon/protocol/daemon-launch-note-protocol'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session/lifecycle/agent-session-resume'
@@ -13,7 +14,7 @@ export function admitRendererAgentLaunchAuthority(args: {
isReattach: boolean
hasStablePaneOwner: boolean
incarnationId: unknown
}): { launchToken: string; launchAgent: TuiAgent } | null {
}): { launchToken: string; launchAgent: TuiAgent; launchConfig: SleepingAgentLaunchConfig } | null {
if (
args.isReattach ||
args.hasStablePaneOwner ||
@@ -27,7 +28,22 @@ export function admitRendererAgentLaunchAuthority(args: {
) {
return null
}
return { launchToken: args.launchToken, launchAgent: args.launchAgent }
return {
launchToken: args.launchToken,
launchAgent: args.launchAgent,
launchConfig: args.launchConfig
}
}
/** A reattach's binding: its host's launch note, none when the host keeps no notes (an older daemon, SSH). */
export function reattachLaunchBinding(result: {
isReattach?: boolean
launchNote?: DaemonLaunchNote | null
}): { reattach?: { launchNote?: DaemonLaunchNote | null } } {
if (!result.isReattach) {
return {}
}
return { reattach: result.launchNote !== undefined ? { launchNote: result.launchNote } : {} }
}
export function admitProviderReattachLaunchIdentity(args: {
+7 -1
View File
@@ -1,6 +1,6 @@
import { adoptMaterializedRuntimePtySpawn } from './spawn-early'
import { createRuntimePtySpawnState } from './spawn-state'
import { getProviderForPty, localProvider } from '../provider/registry'
import { getProviderForPty, hasPtyProviderForInspection, localProvider } from '../provider/registry'
import {
getLocalAutomaticWorkStatus,
localTerminalLimit,
@@ -82,6 +82,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options),
confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId),
confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId),
// Why local only: the daemon that outlives main is this host's.
setLaunchNote: (ptyId, change) => {
if (hasPtyProviderForInspection(ptyId)) {
getProviderForPty(ptyId).setLaunchNote?.(ptyId, change)
}
},
getCwd: (ptyId) => getCwdFromRuntimeController(ptyId),
hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId),
clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId),
+3 -1
View File
@@ -32,7 +32,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
worktreeId: args.worktreeId,
shellOverride: ctx.daemonShellOverride,
rendererLaunchAuthority: null,
launchedAgent: null
launchedAgent: null,
// The runtime's own create records its launch once the spawn commits.
freshSpawn: false
},
preAllocatedHandle: args.preAllocatedHandle,
// Runtime spawns bypass renderer IPC; publish reverse pane lookup for hooks and cleanup.
+13 -4
View File
@@ -19,7 +19,11 @@ import {
} from './host-env/codex-home'
import { commitAttachedPtySize } from './delivery/attached-pty-size'
import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners'
import { admitProviderReattachLaunchIdentity } from './pane/launch-authority'
import {
admitProviderReattachLaunchIdentity,
reattachLaunchBinding,
type admitRendererAgentLaunchAuthority
} from './pane/launch-authority'
import type { ReplacedPaneOwner } from './pane/replaced-pane-owner'
import { admitPtySpawnOwnership, registerPersistedPtySpawn } from './pane/spawn-registration'
import type { PtySpawnOutcome } from './spawn-outcome'
@@ -90,9 +94,11 @@ export type PtySpawnCommitInput = {
store: Store | undefined
worktreeId: string | null | undefined
shellOverride: string | undefined
rendererLaunchAuthority: { launchToken: string; launchAgent: TuiAgent } | null
rendererLaunchAuthority: ReturnType<typeof admitRendererAgentLaunchAuthority>
/** The agent a fresh spawn's launch command starts; null for reattaches, adoptions and shells. */
launchedAgent: TuiAgent | null
/** Neither a reattach nor an adoption of a live pane's process. */
freshSpawn: boolean
}
preAllocatedHandle: string | null | undefined
spawnTiming?: PtySpawnTiming
@@ -162,7 +168,9 @@ export async function commitPtySpawn(input: PtySpawnCommitInput): Promise<PtySpa
? { agentLaunchAuthority: registration.rendererLaunchAuthority }
: {}),
...(registration.launchedAgent ? { launchedAgent: registration.launchedAgent } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}),
...(registration.freshSpawn ? { freshSpawn: true as const } : {}),
...reattachLaunchBinding(result)
}
: undefined,
shouldSkipCodexHomeEnvForWindowsShell(registration.shellOverride, state.cwd)
@@ -223,7 +231,8 @@ async function commitAdoptedAgentSession(
leafId: surface.leafId,
terminalHandle: surface.terminalHandle,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {}),
...reattachLaunchBinding(adoptedResult)
}
)
if (rejectedRegistration) {

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