mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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,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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+93
-118
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,
|
||||
|
||||
+43
-9
@@ -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`
|
||||
|
||||
+22
-2
@@ -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) => {
|
||||
|
||||
+37
-13
@@ -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)
|
||||
|
||||
+39
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
+31
@@ -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)
|
||||
}
|
||||
+5
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+45
-25
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
+17
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
+615
@@ -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 })
|
||||
}
|
||||
})
|
||||
+76
-20
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
+24
@@ -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' })
|
||||
|
||||
+222
-2
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user