mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into deleting-skill
This commit is contained in:
@@ -475,6 +475,44 @@ jobs:
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
|
||||
# would pay for it 32 times to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
|
||||
# shipped in node_modules and never touches node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
|
||||
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
|
||||
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
|
||||
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
|
||||
- name: Resolve Chrome for the browser provider
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
|
||||
if [ -z "$chrome" ]; then
|
||||
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
|
||||
exit 1
|
||||
fi
|
||||
"$chrome" --version
|
||||
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Test external Chromium browser provider
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/orcad/external-chromium-browser-process.integration.test.ts
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
@@ -642,6 +680,7 @@ jobs:
|
||||
- name: Test Windows-specific boundaries
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
|
||||
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
|
||||
src/shared/child-process/windows-command-line.win32.test.ts
|
||||
@@ -658,6 +697,8 @@ jobs:
|
||||
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
|
||||
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
|
||||
src/shared/secure-file-fsync-flags.test.ts
|
||||
src/main/ipc/pty-codex-account-attribution.test.ts
|
||||
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
|
||||
|
||||
# Why the :parallel variant: identical to build:release except the three
|
||||
# electron-vite targets overlap instead of running back to back. The Linux package
|
||||
@@ -744,6 +785,7 @@ jobs:
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- managed_hook_node18
|
||||
- package
|
||||
- package_windows
|
||||
@@ -769,6 +811,7 @@ jobs:
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
|
||||
@@ -788,6 +831,7 @@ jobs:
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
@@ -807,6 +851,7 @@ jobs:
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$ORCAD_BROWSER" \
|
||||
"$MANAGED_HOOK_NODE18" \
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
|
||||
// Why: dev-channel builds must carry the *release* identity — same bundle id,
|
||||
@@ -266,6 +269,13 @@ module.exports = {
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
if (process.platform === 'win32' && canExecuteTargetArch) {
|
||||
verifyPackagedNodePtyJobOwnership(resourcesDir)
|
||||
} else {
|
||||
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
|
||||
}
|
||||
}
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
@@ -14,47 +13,29 @@ inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence/loading-store/store.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
@@ -65,7 +46,6 @@ inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
@@ -78,22 +58,16 @@ inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
@@ -101,26 +75,16 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
@@ -132,9 +96,7 @@ inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
@@ -148,18 +110,12 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
|
||||
@@ -348,6 +348,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
|
||||
return
|
||||
}
|
||||
|
||||
// Why delete only conpty.node: node-pty's loader tries build/Release, then
|
||||
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
|
||||
// between. Only the source build carries Orca's job-object exports, so an ABI
|
||||
// mismatch or an AV quarantine of build/Release/conpty.node would silently
|
||||
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
|
||||
// ancestry, with no error anywhere.
|
||||
//
|
||||
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
|
||||
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
|
||||
// build emits conpty.node and nothing else. conpty_console_list.node,
|
||||
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
|
||||
// silently kills console-membership probing (the forked agent throws at
|
||||
// require, and its caller resolves null with silent: true), and removes the
|
||||
// winpty backend that node-pty still selects below Windows build 18309.
|
||||
//
|
||||
// Why the arch check: a cross-arch package copies the host's build/Release,
|
||||
// so its mere presence does not mean it matches electronArch -- deleting the
|
||||
// target-arch prebuild would then remove the only loadable binary.
|
||||
if (
|
||||
electronPlatformName === 'win32' &&
|
||||
electronArch === process.arch &&
|
||||
existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))
|
||||
) {
|
||||
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
|
||||
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
|
||||
rmSync(join(prebuildDir, staleFallback), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
|
||||
if (allowedPrebuildPrefix) {
|
||||
pruneNodePtyNativeDirectories(
|
||||
|
||||
@@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -5,9 +5,6 @@
|
||||
@@ -1,13 +1,10 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
@@ -12,6 +17,39 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83
|
||||
'msvs_settings': {
|
||||
'VCCLCompilerTool': {
|
||||
'AdditionalOptions': [
|
||||
@@ -42,32 +39,6 @@
|
||||
'-lshlwapi'
|
||||
],
|
||||
},
|
||||
- {
|
||||
- 'target_name': 'conpty_console_list',
|
||||
- 'sources' : [
|
||||
- 'src/win/conpty_console_list.cc'
|
||||
- ],
|
||||
- },
|
||||
- {
|
||||
- 'target_name': 'pty',
|
||||
- 'include_dirs' : [
|
||||
- '<!(node -p "require(\'node-addon-api\').include_dir")',
|
||||
- 'deps/winpty/src/include',
|
||||
- ],
|
||||
- # Disabled due to winpty
|
||||
- 'msvs_disabled_warnings': [ 4506, 4530 ],
|
||||
- 'dependencies' : [
|
||||
- 'deps/winpty/src/winpty.gyp:winpty-agent',
|
||||
- 'deps/winpty/src/winpty.gyp:winpty',
|
||||
- ],
|
||||
- 'sources' : [
|
||||
- 'src/win/winpty.cc',
|
||||
- 'src/win/path_util.cc'
|
||||
- ],
|
||||
- 'libraries': [
|
||||
- '-lshlwapi'
|
||||
- ],
|
||||
- }
|
||||
]
|
||||
}, { # OS!="win"
|
||||
'targets': [
|
||||
@@ -88,6 +85,16 @@
|
||||
'libraries!': [
|
||||
'-lutil'
|
||||
|
||||
@@ -7,14 +7,25 @@
|
||||
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
|
||||
* the host Node floor at 18 instead of 22.5+.
|
||||
*/
|
||||
import { fork, spawnSync } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { arch, platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..', '..')
|
||||
const OUT_DIR = join(ROOT, 'out', 'orcad')
|
||||
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
|
||||
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
|
||||
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
|
||||
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
|
||||
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
|
||||
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
|
||||
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
|
||||
const OUT_FILE = join(OUT_DIR, 'orcad.js')
|
||||
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
|
||||
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
|
||||
|
||||
// Native addons must exist on the host; they cannot be bundled.
|
||||
// `electron` is external so a residual import fails loudly at require() time rather
|
||||
@@ -48,6 +59,25 @@ const externalNativeAddons = {
|
||||
|
||||
rmSync(OUT_DIR, { recursive: true, force: true })
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
|
||||
}
|
||||
|
||||
await build({
|
||||
entryPoints: [WATCHER_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: WATCHER_OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [externalNativeAddons],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'error'
|
||||
})
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [ENTRY],
|
||||
@@ -55,7 +85,7 @@ const result = await build({
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(OUT_DIR, 'orcad.js'),
|
||||
outfile: OUT_FILE,
|
||||
external: EXTERNAL,
|
||||
plugins: [jsoncParserEsm, externalNativeAddons],
|
||||
metafile: true,
|
||||
@@ -78,19 +108,117 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const sqliteImporters = new Set()
|
||||
for (const [file, info] of Object.entries(result.metafile.inputs)) {
|
||||
for (const imported of info.imports ?? []) {
|
||||
const specifier = imported.original ?? imported.path
|
||||
if (specifier === 'node:sqlite') {
|
||||
sqliteImporters.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const graphErrors = []
|
||||
if (electronImporters.size > 0) {
|
||||
console.error(
|
||||
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
|
||||
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
|
||||
graphErrors.push(
|
||||
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
if (sqliteImporters.size > 0) {
|
||||
graphErrors.push(
|
||||
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
|
||||
.map((file) => ` - ${file}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (graphErrors.length > 0) {
|
||||
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
|
||||
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
|
||||
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
|
||||
// install the PTY controller. Once orcad ships, it should become a ratchet entry
|
||||
// point so the two numbers cannot drift.
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
// Why smoke-load and not just read the metafile: the import scan proves no module
|
||||
// *names* electron, but a graph can still fail to resolve under plain Node — a
|
||||
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
|
||||
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
|
||||
// because it is an esbuild artifact rather than a rollup input.
|
||||
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000
|
||||
})
|
||||
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
|
||||
if (
|
||||
smoke.error ||
|
||||
smoke.signal ||
|
||||
!/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput)
|
||||
) {
|
||||
console.error(
|
||||
`[build-orcad] the bundle did not load under plain Node.\n` +
|
||||
`Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` +
|
||||
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
const watcherFailure = await smokeLoadWatcherChild()
|
||||
if (watcherFailure) {
|
||||
console.error(
|
||||
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (process.exitCode !== 1) {
|
||||
console.log(
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
|
||||
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the shipped watcher child and drive one message through it.
|
||||
*
|
||||
* Why a real fork and not existsSync: the file being present says nothing about whether
|
||||
* its graph resolves under plain Node, and this child is only ever reached through
|
||||
* `fork()` at runtime — a broken one degrades silently to in-process watching.
|
||||
* `subscribe-started` is acked before the native module is touched, so this passes on a
|
||||
* build machine with no compiled @parcel/watcher.
|
||||
*/
|
||||
async function smokeLoadWatcherChild() {
|
||||
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
|
||||
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
|
||||
}, 30_000)
|
||||
const settle = (failure) => {
|
||||
clearTimeout(timer)
|
||||
resolve(failure)
|
||||
}
|
||||
child.on('message', (message) => {
|
||||
if (message?.op === 'subscribe-started') {
|
||||
child.disconnect()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => settle(`fork failed: ${error.message}`))
|
||||
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
|
||||
// or a signal here is a load failure rather than a clean teardown.
|
||||
child.on('exit', (code, signal) =>
|
||||
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
|
||||
)
|
||||
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
|
||||
})
|
||||
} finally {
|
||||
rmSync(probeDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,99 @@ function diagnosticLineRange(root, filename, span) {
|
||||
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
|
||||
// Why: a file-splitting refactor makes every line of the new module an "added"
|
||||
// line, so pre-existing lint debt in code that merely MOVED starts failing the
|
||||
// changed-lines gate. The only way to satisfy it is to edit the moved code,
|
||||
// which is exactly what a behavior-preserving refactor must not do. So a
|
||||
// diagnostic is exempt when its highlighted lines already existed, verbatim and
|
||||
// contiguous, somewhere in the base revision of the files this change touches.
|
||||
function normalizeSourceLine(line) {
|
||||
return line.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
||||
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
|
||||
// often deleted or renamed away. Deleted paths never reach the changed-file list
|
||||
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
|
||||
const paths =
|
||||
files ??
|
||||
splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter(
|
||||
(file) => SOURCE_FILE_PATTERN.test(file)
|
||||
)
|
||||
const blocks = []
|
||||
for (const file of paths) {
|
||||
const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
if (result.status !== 0 || typeof result.stdout !== 'string') {
|
||||
continue
|
||||
}
|
||||
blocks.push(
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeSourceLine)
|
||||
.filter((line) => line !== '')
|
||||
)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
export function isMovedCode(highlightedLines, baseBlocks) {
|
||||
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
if (needle.length === 0) {
|
||||
return false
|
||||
}
|
||||
// Why a near-match rather than an exact contiguous one: a split moves a block
|
||||
// verbatim but a diagnostic's span often reaches past it — most commonly to a
|
||||
// hook dependency array, which legitimately grows when closure variables become
|
||||
// props. Requiring every line to match would report the moved body as new. So:
|
||||
// the block must still start at the same line in the base and appear IN ORDER,
|
||||
// and nearly all of it must be present. Genuinely new code shares neither the
|
||||
// anchor nor the ordering, so it stays reported.
|
||||
const MIN_COVERAGE = 0.9
|
||||
return baseBlocks.some((rawHaystack) => {
|
||||
const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
|
||||
for (let start = 0; start < haystack.length; start += 1) {
|
||||
if (haystack[start] !== needle[0]) {
|
||||
continue
|
||||
}
|
||||
let matched = 1
|
||||
let cursor = start + 1
|
||||
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
|
||||
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
|
||||
cursor += 1
|
||||
}
|
||||
if (cursor < haystack.length) {
|
||||
matched += 1
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
if (matched / needle.length >= MIN_COVERAGE) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function diagnosticHighlightedLines(root, filename, span) {
|
||||
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
||||
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
|
||||
const range = diagnosticLineRange(root, filename, span)
|
||||
if (range === null) {
|
||||
return []
|
||||
}
|
||||
return source.slice(range.start - 1, range.end)
|
||||
}
|
||||
|
||||
export function diagnosticTouchesAddedLines(
|
||||
diagnostic,
|
||||
rangesByFile,
|
||||
root = process.cwd(),
|
||||
baseBlocks = []
|
||||
) {
|
||||
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
||||
const ranges = rangesByFile.get(file)
|
||||
if (!ranges) {
|
||||
@@ -147,7 +239,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
|
||||
}
|
||||
return (diagnostic.labels ?? []).some((label) => {
|
||||
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
||||
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
|
||||
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
||||
return false
|
||||
}
|
||||
return !isMovedCode(
|
||||
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
|
||||
baseBlocks
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,10 +291,12 @@ export function main(
|
||||
return 0
|
||||
}
|
||||
|
||||
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OXLINT_SCANS,
|
||||
diagnosticTouchesAddedLines,
|
||||
isMovedCode,
|
||||
overlapsAddedLines,
|
||||
parseAddedLineRanges
|
||||
} from './check-changed-code-quality.mjs'
|
||||
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved-code exemption', () => {
|
||||
it('treats a verbatim contiguous block from the base as moved', () => {
|
||||
const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores indentation and whitespace changes from the move', () => {
|
||||
const base = [[' items.map((item, index) => (', ' key={index}']]
|
||||
expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt a genuinely new violation', () => {
|
||||
const base = [['const a = 1', 'const b = 2']]
|
||||
expect(isMovedCode(['rows.map((row, i) => <td key={i} />)'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt a block that is only partly present in the base', () => {
|
||||
const base = [['doThing()', 'unrelated()']]
|
||||
expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a few lines appended inside the moved block', () => {
|
||||
// A split commonly grows a hook dependency array when closure variables
|
||||
// become props; the moved body around it is still moved.
|
||||
const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
|
||||
const base = [body]
|
||||
const moved = [...body.slice(0, 19), 'newDep,', body[19]]
|
||||
expect(isMovedCode(moved, base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not exempt when the anchor line is absent from the base', () => {
|
||||
const base = [['doThing()', 'filler()', 'other()']]
|
||||
expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not exempt when most of the block is absent from the base', () => {
|
||||
const base = [['keep0()', 'keep1()', 'unrelated()']]
|
||||
const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
|
||||
expect(isMovedCode(mostlyNew, base)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores blank lines when matching', () => {
|
||||
const base = [['a()', 'b()']]
|
||||
expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never exempts an empty highlight', () => {
|
||||
expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
|
||||
// itself and the RPC server that fronts it.
|
||||
const ENTRY_POINTS = [
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
|
||||
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
|
||||
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
|
||||
// graph is strictly larger than the two runtime entries. Measuring only those let the
|
||||
// two numbers drift — the gate would read zero while the shipped artifact regressed.
|
||||
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
|
||||
]
|
||||
|
||||
// Native addons and electron cannot be bundled; externalising them is what the
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const NODE_PTY_PATCH = readFileSync(
|
||||
new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const PATCHED = {
|
||||
dir: 'build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
const PREBUILD = {
|
||||
dir: 'prebuilds/win32-x64/',
|
||||
module: {
|
||||
startProcess: () => {},
|
||||
connect: () => {},
|
||||
resize: () => {},
|
||||
clear: () => {},
|
||||
kill: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('assertNodePtyJobOwnership', () => {
|
||||
it('keeps node-addon-api project paths absolute during Windows source builds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the unchanged Windows helper and fallback on their upstream prebuilds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'")
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
|
||||
})
|
||||
|
||||
it('rejects the prebuild that shipped without the job exports', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('names where the bad native came from, so the fix is obvious', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/prebuilds\/win32-x64/)
|
||||
})
|
||||
|
||||
it('accepts a source build carrying the patch', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
|
||||
['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
|
||||
])('stays out of the way on %s', (_case, spec) => {
|
||||
expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { release } from 'node:os'
|
||||
import { basename, resolve } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const runtime = readRuntimeArg()
|
||||
@@ -277,6 +278,7 @@ function loadNodePtyNativeModule() {
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
|
||||
@@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('ensure-native-runtime', () => {
|
||||
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
|
||||
@@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => {
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
return
|
||||
}
|
||||
const exported = native?.module ?? native
|
||||
const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`node-pty's conpty native is missing ${missing.join(', ')}.`,
|
||||
`Resolved from: ${native?.dir ?? 'unknown'}`,
|
||||
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
|
||||
'and pane teardown falls back to guessing by PID ancestry.',
|
||||
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { assertNodePtyJobOwnership }
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs')
|
||||
|
||||
/**
|
||||
* node-pty's loader tries build/Release, then build/Debug, then
|
||||
* prebuilds/<platform>-<arch>, swallowing failures in between. Only the source
|
||||
* build carries Orca's job-object exports, so leaving the prebuilt conpty.node
|
||||
* beside it means an ABI mismatch or an AV quarantine silently downgrades the
|
||||
* app to a binary that cannot own a PTY tree.
|
||||
*
|
||||
* The siblings must survive: Orca's patch deletes the `conpty_console_list` and
|
||||
* winpty `pty` gyp targets, so those binaries exist nowhere but here.
|
||||
*/
|
||||
describe('prunePackagedNodePty: the Windows conpty fallback', () => {
|
||||
let resources
|
||||
const HOST_ARCH = process.arch
|
||||
|
||||
const nodePty = () => join(resources, 'node_modules', 'node-pty')
|
||||
const write = (relative) => {
|
||||
const target = join(nodePty(), relative)
|
||||
mkdirSync(join(target, '..'), { recursive: true })
|
||||
writeFileSync(target, 'x')
|
||||
}
|
||||
const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name)
|
||||
|
||||
/** What a real packaged win32 prebuilds/<arch> directory holds. */
|
||||
const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe']
|
||||
|
||||
const seedWindowsTree = (arch = HOST_ARCH) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe'))
|
||||
write(join('prebuilds', `win32-${arch}`, 'conpty.node'))
|
||||
for (const sibling of SIBLINGS) {
|
||||
write(join('prebuilds', `win32-${arch}`, sibling))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resources = mkdtempSync(join(tmpdir(), 'orca-prune-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(resources, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes the stale conpty fallback when the source build is present', () => {
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true)
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => {
|
||||
// Orca's patch removes the conpty_console_list and winpty gyp targets, so a
|
||||
// Windows source build never produces these. Deleting them kills console
|
||||
// membership silently and breaks PTY spawn below Windows build 18309.
|
||||
seedWindowsTree()
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt(sibling))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback when there is no source build to prefer', () => {
|
||||
write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll'))
|
||||
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe'))
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => {
|
||||
// electron-builder --win --arm64 on an x64 host copies an x64
|
||||
// build/Release; deleting the arm64 prebuild would remove the only binary
|
||||
// the shipped app could load.
|
||||
const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64'
|
||||
seedWindowsTree(target)
|
||||
|
||||
prunePackagedNodePty(resources, 'win32', target)
|
||||
|
||||
expect(existsSync(prebuilt('conpty.node', target))).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', 'arm64'],
|
||||
['linux', 'x64']
|
||||
])('leaves %s prebuilds alone', (platform, arch) => {
|
||||
write(join('build', 'Release', 'conpty.node'))
|
||||
write(join('prebuilds', `${platform}-${arch}`, 'pty.node'))
|
||||
|
||||
prunePackagedNodePty(resources, platform, arch)
|
||||
|
||||
expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -345,6 +345,7 @@ describe('PR workflow parallelism', () => {
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'orcad_browser',
|
||||
'managed_hook_node18',
|
||||
'package',
|
||||
'package_windows'
|
||||
@@ -354,5 +355,9 @@ describe('PR workflow parallelism', () => {
|
||||
)
|
||||
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
|
||||
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
|
||||
// Why assert this one too: the browser provider test skips itself without
|
||||
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
|
||||
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
|
||||
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -475,9 +475,14 @@ function loadNativeModule(moduleName) {
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
projectRequire('node-pty')
|
||||
const { assertNodePtyJobOwnership } = projectRequire(
|
||||
'./config/scripts/node-pty-job-ownership.cjs'
|
||||
)
|
||||
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
|
||||
const native = loadNativeModule(getNodePtyNativeModuleName())
|
||||
const nativeName = getNodePtyNativeModuleName()
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
'node-pty resolved to ' +
|
||||
|
||||
@@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo
|
||||
const sourceInstallScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
new URL('./node-pty-job-ownership.cjs', import.meta.url)
|
||||
)
|
||||
|
||||
describe('rebuild-native-deps Electron install fallback', () => {
|
||||
it('continues non-strict postinstall when Electron retry download fails', () => {
|
||||
@@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
@@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'rebuilds a loadable ConPTY native that lacks Orca job ownership',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
|
||||
writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
|
||||
npm_config_platform: 'win32',
|
||||
npm_config_arch: process.arch
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('Rebuilding failed native modules: node-pty')
|
||||
expect(result.stdout).toContain('missing listJobProcessIds')
|
||||
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
|
||||
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
|
||||
() => {
|
||||
@@ -305,6 +340,10 @@ function mkTempProject() {
|
||||
sourceInstallScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
@@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
|
||||
}
|
||||
|
||||
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
|
||||
function writeFakeLoadableNodePty(
|
||||
projectDir,
|
||||
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
|
||||
) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
@@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
`
|
||||
exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
|
||||
return {
|
||||
dir: ${JSON.stringify(nativeDir)},
|
||||
module: {
|
||||
nativeName,
|
||||
...(${JSON.stringify(ownsPtyJob)}
|
||||
? {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) {
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeWindowsProcessTree(projectDir) {
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
}
|
||||
|
||||
function writeNodePtyPatchFile(projectDir) {
|
||||
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
* - a paired client can list worktrees and create a terminal,
|
||||
* - a command run in that terminal produces its output,
|
||||
* - the server exits when asked.
|
||||
* Optional `--browser` assertions:
|
||||
* - create, navigate, evaluate, and screenshot through the selected host provider.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
|
||||
@@ -274,6 +277,50 @@ async function main() {
|
||||
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
|
||||
}
|
||||
log(`server resolves ${shown.id}`)
|
||||
if (process.argv.includes('--browser')) {
|
||||
const status = orca(pairingCode, ['status'])
|
||||
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
|
||||
throw new Error(
|
||||
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
|
||||
)
|
||||
}
|
||||
const fixturePath = join(userDataDir, 'browser-smoke.html')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
|
||||
)
|
||||
const browserPageId = orca(pairingCode, [
|
||||
'tab',
|
||||
'create',
|
||||
'--worktree',
|
||||
created.id,
|
||||
'--url',
|
||||
'about:blank'
|
||||
])?.browserPageId
|
||||
if (!browserPageId) {
|
||||
throw new Error('browser.tabCreate returned no browser page id')
|
||||
}
|
||||
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
|
||||
const targetUrl = pathToFileURL(fixturePath).href
|
||||
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
|
||||
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
|
||||
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
|
||||
}
|
||||
const evaluated = orca(pairingCode, [
|
||||
'eval',
|
||||
...targetFlags,
|
||||
'--expression',
|
||||
'document.querySelector("main")?.textContent'
|
||||
])
|
||||
if (evaluated?.result !== 'browser-ready') {
|
||||
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
|
||||
}
|
||||
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
|
||||
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
|
||||
throw new Error('browser.screenshot returned no PNG data')
|
||||
}
|
||||
log('browser navigate/evaluate/screenshot round trip OK')
|
||||
}
|
||||
|
||||
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
|
||||
if (!terminal?.handle) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { createRequire } = require('node:module')
|
||||
const { join } = require('node:path')
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
|
||||
function loadPackagedConpty(resourcesDir) {
|
||||
const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
|
||||
const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
|
||||
return loadNativeModule('conpty')
|
||||
}
|
||||
|
||||
function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
|
||||
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
|
||||
if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
|
||||
throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
|
||||
}
|
||||
console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedNodePtyJobOwnership }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
} = require('./verify-packaged-node-pty-job-ownership.cjs')
|
||||
|
||||
const PATCHED = {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
}
|
||||
|
||||
describe('verifyPackagedNodePtyJobOwnership', () => {
|
||||
it('accepts the packaged patched ConPTY binding', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => PATCHED
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a packaged upstream prebuild', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
|
||||
})
|
||||
).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
it('requires the patched source-build directory', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
|
||||
})
|
||||
).toThrow(/expected patched build\/Release/)
|
||||
})
|
||||
|
||||
it('does not load Windows natives for other targets', () => {
|
||||
const loadNative = vi.fn()
|
||||
verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative })
|
||||
expect(loadNative).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,7 @@
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
"../src/main/codex/codex-process-exit-deadline.ts",
|
||||
"../src/main/codex/codex-state-db.ts",
|
||||
"../src/main/codex/codex-trust-identity.ts",
|
||||
"../src/main/codex/codex-trust-config-rollback.ts",
|
||||
"../src/main/codex/codex-trust-grant-telemetry.ts",
|
||||
"../src/main/codex/codex-trust-grant-host.ts",
|
||||
@@ -58,10 +59,16 @@
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-settings-promotion-write-target.ts",
|
||||
"../src/main/codex/config-sync-stall.ts",
|
||||
"../src/main/codex/config-toml-atomic-write.ts",
|
||||
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-blocks.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-edit.ts",
|
||||
"../src/main/codex/config-toml-hook-trust-read.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-project-trust.ts",
|
||||
"../src/main/codex/config-toml-runtime-owned-sections.ts",
|
||||
"../src/main/codex/config-toml-syntax.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
|
||||
+2
-1361
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,11 @@ import {
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
const accountCardsSource = readFileSync(
|
||||
new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
@@ -83,14 +87,8 @@ describe('mobile accounts route', () => {
|
||||
})
|
||||
|
||||
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
|
||||
|
||||
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
|
||||
// missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const accountsSection = homeSource.slice(start)
|
||||
expect(accountsSection).toContain('openMobileAccounts(host.id)')
|
||||
expect(accountsSection).not.toContain('/accounts`')
|
||||
expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}')
|
||||
expect(accountCardsSource).toContain('props.onOpen(host.id)')
|
||||
expect(accountCardsSource).not.toContain('/accounts`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
|
||||
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
|
||||
@@ -17,11 +19,18 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
|
||||
inactive: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
|
||||
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
export function AgentSpinner({
|
||||
status,
|
||||
workingMode
|
||||
}: {
|
||||
status: WorktreeStatus
|
||||
workingMode?: AgentWorkingMode
|
||||
}) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
const monitoring = status === 'working' && workingMode === 'monitoring'
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'working') {
|
||||
if (status === 'working' && !monitoring) {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
@@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
}, [status, spinValue])
|
||||
}, [monitoring, status, spinValue])
|
||||
|
||||
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
|
||||
|
||||
if (monitoring) {
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={12} color={STATUS_COLORS.working} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'working') {
|
||||
const rotate = spinValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Radio } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
|
||||
@@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
done: '#10b981',
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
idle: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
@@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'monitoring') {
|
||||
return (
|
||||
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
|
||||
<Radio size={10} color={WORKING_COLOR} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
|
||||
@@ -59,7 +69,7 @@ const styles = StyleSheet.create({
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#eab308',
|
||||
borderColor: WORKING_COLOR,
|
||||
borderTopColor: 'transparent'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Pressable, Switch, Text, View } from 'react-native'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { SetupRunPolicy } from './new-worktree-modal-types'
|
||||
|
||||
export function NewWorkspaceSetupScriptField({
|
||||
command,
|
||||
source,
|
||||
runPolicy,
|
||||
decision,
|
||||
runSetup,
|
||||
onDecisionChange,
|
||||
onRunSetupChange
|
||||
}: {
|
||||
command: string
|
||||
source: string | null
|
||||
runPolicy: SetupRunPolicy
|
||||
decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
onDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
onRunSetupChange: (run: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<View style={styles.setupHeader}>
|
||||
<Text style={styles.label}>Setup script</Text>
|
||||
{source ? (
|
||||
<View style={styles.sourceBadge}>
|
||||
<Text style={styles.sourceBadgeText}>
|
||||
{source === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.setupBox}>
|
||||
{runPolicy === 'ask' ? (
|
||||
<View style={styles.setupChoiceRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.setupChoiceButton,
|
||||
decision === 'run' && styles.setupChoiceButtonSelected
|
||||
]}
|
||||
onPress={() => onDecisionChange('run')}
|
||||
>
|
||||
<Text style={styles.setupChoiceText}>Run</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.setupChoiceButton,
|
||||
decision === 'skip' && styles.setupChoiceButtonSelected
|
||||
]}
|
||||
onPress={() => onDecisionChange('skip')}
|
||||
>
|
||||
<Text style={styles.setupChoiceText}>Skip</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.setupToggleRow}>
|
||||
<Text style={styles.setupToggleLabel}>Run setup command</Text>
|
||||
<Switch
|
||||
value={runSetup}
|
||||
onValueChange={onRunSetupChange}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
style={styles.setupSwitch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.setupCommandBlock}>
|
||||
<Text style={styles.setupCommand}>{command}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import { workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
|
||||
export function NewWorkspaceSshConnectionField({
|
||||
repoName,
|
||||
sshGate,
|
||||
onConnect
|
||||
}: {
|
||||
repoName: string
|
||||
sshGate: WorkspaceSshGate
|
||||
onConnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>SSH Connection</Text>
|
||||
<View style={styles.sshBox}>
|
||||
<View style={styles.sshRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.sshDot,
|
||||
sshGate.status === 'connected'
|
||||
? styles.sshDotConnected
|
||||
: sshGate.connectInProgress
|
||||
? styles.sshDotProgress
|
||||
: styles.sshDotDisconnected
|
||||
]}
|
||||
/>
|
||||
<View style={styles.sshCopy}>
|
||||
<Text style={styles.sshTitle} numberOfLines={1}>
|
||||
{repoName}
|
||||
</Text>
|
||||
<Text style={styles.sshSubtitle}>{workspaceSshStatusLabel(sshGate.status)}</Text>
|
||||
</View>
|
||||
{sshGate.status === 'connected' ? null : (
|
||||
<Pressable
|
||||
style={[styles.sshConnectButton, sshGate.connectInProgress && styles.disabled]}
|
||||
disabled={sshGate.connectInProgress}
|
||||
onPress={onConnect}
|
||||
>
|
||||
<Text style={styles.sshConnectText}>
|
||||
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{sshGate.error ? <Text style={styles.errorInline}>{sshGate.error}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { SetupRunPolicy } from './new-worktree-modal-types'
|
||||
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
|
||||
import { NewWorkspaceSetupScriptField } from './NewWorkspaceSetupScriptField'
|
||||
import { NewWorkspaceSshConnectionField } from './NewWorkspaceSshConnectionField'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
type Selection = { label: string; detail?: string }
|
||||
|
||||
export function NewWorktreeFormSheet(props: {
|
||||
visible: boolean
|
||||
interactive: boolean
|
||||
loading: boolean
|
||||
hasRepos: boolean
|
||||
project: Selection | null
|
||||
runTarget: Selection | null
|
||||
projectBadgeColor: string | null
|
||||
selectedRepoIsGit: boolean
|
||||
selectedRepoConnectionId: string | null
|
||||
selectedRepoName: string
|
||||
sshGate: WorkspaceSshGate
|
||||
composer: Composer
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
showAdvanced: boolean
|
||||
note: string
|
||||
setupCommand: string | null
|
||||
setupSource: string | null
|
||||
setupRunPolicy: SetupRunPolicy
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
error: string
|
||||
creating: boolean
|
||||
canCreate: boolean
|
||||
onClose: () => void
|
||||
onOpenProject: () => void
|
||||
onOpenRunTarget: () => void
|
||||
onOpenSource: () => void
|
||||
onClearError: () => void
|
||||
onConnect: () => void
|
||||
onOpenAgent: () => void
|
||||
onShowAdvancedChange: (show: boolean) => void
|
||||
onNoteChange: (note: string) => void
|
||||
onSetupDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
onRunSetupChange: (run: boolean) => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
return (
|
||||
<BottomDrawer visible={props.visible} interactive={props.interactive} onClose={props.onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create worktree</Text>
|
||||
</View>
|
||||
|
||||
{props.loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : !props.hasRepos ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.emptyText}>No projects found</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<NewWorktreeProjectTargetFields
|
||||
project={props.project}
|
||||
runTarget={props.runTarget}
|
||||
projectBadgeColor={props.projectBadgeColor}
|
||||
onOpenProject={props.onOpenProject}
|
||||
onOpenRunTarget={props.onOpenRunTarget}
|
||||
/>
|
||||
|
||||
<SmartWorkspaceSourceField
|
||||
composer={props.composer}
|
||||
label={props.selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
|
||||
disabled={props.sshGate.requiresConnection}
|
||||
interactive={props.interactive}
|
||||
onBeforeOpen={props.onClearError}
|
||||
onOpenDrawer={props.onOpenSource}
|
||||
/>
|
||||
|
||||
{props.composer.forkPushWarning ? (
|
||||
<Text style={styles.sourceWarning}>{props.composer.forkPushWarning}</Text>
|
||||
) : null}
|
||||
|
||||
{props.selectedRepoConnectionId ? (
|
||||
<NewWorkspaceSshConnectionField
|
||||
repoName={props.selectedRepoName}
|
||||
sshGate={props.sshGate}
|
||||
onConnect={props.onConnect}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Agent</Text>
|
||||
<Pressable
|
||||
style={[styles.fieldButton, props.sshGate.requiresConnection && styles.disabled]}
|
||||
disabled={props.sshGate.requiresConnection}
|
||||
onPress={props.onOpenAgent}
|
||||
>
|
||||
<MobileAgentIcon agentId={props.selectedAgent.id} size={16} />
|
||||
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
||||
{props.sshGate.requiresConnection
|
||||
? 'Connect target first'
|
||||
: props.selectedAgent.label}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.advancedToggle}
|
||||
onPress={() => props.onShowAdvancedChange(!props.showAdvanced)}
|
||||
>
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
{props.showAdvanced ? (
|
||||
<ChevronUp size={14} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronDown size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{props.showAdvanced ? (
|
||||
<>
|
||||
<SmartWorkspaceAdvancedFields
|
||||
composer={props.composer}
|
||||
selectedRepoIsGit={props.selectedRepoIsGit}
|
||||
/>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Note</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={props.note}
|
||||
onChangeText={props.onNoteChange}
|
||||
placeholder="Write a note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{props.setupCommand ? (
|
||||
<NewWorkspaceSetupScriptField
|
||||
command={props.setupCommand}
|
||||
source={props.setupSource}
|
||||
runPolicy={props.setupRunPolicy}
|
||||
decision={props.setupDecisionChoice}
|
||||
runSetup={props.runSetup}
|
||||
onDecisionChange={props.onSetupDecisionChange}
|
||||
onRunSetupChange={props.onRunSetupChange}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{props.error ? <Text style={styles.error}>{props.error}</Text> : null}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.createButton, !props.canCreate && styles.createButtonDisabled]}
|
||||
disabled={!props.canCreate}
|
||||
onPress={props.onCreate}
|
||||
>
|
||||
{props.creating ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.createText}>
|
||||
{props.sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
@@ -68,6 +68,21 @@ function pickerItems(
|
||||
return picker?.props.items ?? []
|
||||
}
|
||||
|
||||
function sourceInputs(renderer: ReactTestRenderer) {
|
||||
return renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
|
||||
)
|
||||
}
|
||||
|
||||
async function flushUpdates(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal project targets', () => {
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
@@ -164,4 +179,224 @@ describe('NewWorktreeModal project targets', () => {
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('loads SSH state before detecting agents on the selected remote target', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'repo-remote',
|
||||
displayName: 'orca',
|
||||
path: '/home/dev/orca',
|
||||
connectionId: 'build-server',
|
||||
kind: 'git',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
}
|
||||
setCachedRepos('host-ssh', [remoteRepo])
|
||||
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
|
||||
}
|
||||
if (method === 'ssh.getState') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
result: {
|
||||
state: {
|
||||
targetId: 'build-server',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (method === 'preflight.detectRemoteAgents') {
|
||||
return Promise.resolve({ ok: true, result: ['codex'] })
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
||||
}
|
||||
if (method === 'ui.get') {
|
||||
return Promise.resolve({ ok: true, result: { ui: {} } })
|
||||
}
|
||||
if (method === 'preflight.check') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'linear.status') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
|
||||
}
|
||||
if (method === 'repo.hooks') {
|
||||
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-ssh',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
|
||||
connectionId: 'build-server'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps folder workspaces on the local execution and text-only source path', async () => {
|
||||
const folderRepo = {
|
||||
id: 'folder-1',
|
||||
displayName: 'notes',
|
||||
path: '/src/notes',
|
||||
kind: 'folder'
|
||||
}
|
||||
setCachedRepos('host-folder', [folderRepo])
|
||||
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
|
||||
}
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return Promise.resolve({ ok: true, result: ['codex'] })
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
|
||||
}
|
||||
if (method === 'ui.get') {
|
||||
return Promise.resolve({ ok: true, result: { ui: {} } })
|
||||
}
|
||||
if (method === 'preflight.check') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'linear.status') {
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
if (method === 'repo.hooks') {
|
||||
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
|
||||
}
|
||||
return Promise.resolve({ ok: true, result: {} })
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-folder',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
await flushUpdates()
|
||||
|
||||
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
|
||||
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
|
||||
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
|
||||
})
|
||||
|
||||
it('ignores a stale repo list after the client changes', async () => {
|
||||
let resolveOldList: ((value: unknown) => void) | undefined
|
||||
const oldList = new Promise((resolve) => {
|
||||
resolveOldList = resolve
|
||||
})
|
||||
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
|
||||
const oldClient = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return oldList
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const freshClient = {
|
||||
sendRequest: vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const modalProps = {
|
||||
visible: true,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
|
||||
})
|
||||
const sourceInput = sourceInputs(renderer)[0]!
|
||||
act(() => sourceInput.props.onChangeText('stale-client-name'))
|
||||
expect(sourceInput.props.value).toBe('stale-client-name')
|
||||
await act(async () => {
|
||||
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
||||
resolveOldList?.({ ok: true, result: { repos } })
|
||||
await flushUpdates()
|
||||
|
||||
expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
|
||||
})
|
||||
|
||||
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
|
||||
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
const modalProps = {
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
||||
})
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
||||
|
||||
// One repo.list per opening. A third means the previous session's instance
|
||||
// re-ran its visible-gated effects before the remount key caught up.
|
||||
const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
|
||||
expect(repoListCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('starts with fresh form state after closing and reopening', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
const modalProps = {
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
|
||||
})
|
||||
const sourceInput = sourceInputs(renderer)[0]!
|
||||
act(() => sourceInput.props.onChangeText('previous-workspace'))
|
||||
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
|
||||
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
|
||||
|
||||
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
import { View } from 'react-native'
|
||||
import { Monitor } from 'lucide-react-native'
|
||||
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
||||
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
|
||||
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
|
||||
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
|
||||
import type {
|
||||
NewWorkspaceProjectOption,
|
||||
NewWorkspaceRunTargetOption
|
||||
} from './new-workspace-project-targets'
|
||||
import { getMobileWorkspaceRepoBadgeColor } from './new-worktree-modal-types'
|
||||
import { PickerListDrawer } from './PickerListDrawer'
|
||||
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
|
||||
export function NewWorktreeModalDrawers(props: {
|
||||
visible: boolean
|
||||
drawerView: NewWorktreeDrawerView
|
||||
client: Parameters<typeof SmartWorkspaceSourceDrawer>[0]['client']
|
||||
composer: Composer
|
||||
sourceAvailability: SmartModeAvailabilityInput
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
repos: MobileWorkspaceRepo[]
|
||||
pasteRepos: PasteRepoCandidate[]
|
||||
sshReady: boolean
|
||||
projectPickerItems: NewWorkspaceProjectOption<MobileWorkspaceRepo>[]
|
||||
selectedProjectId: string | null
|
||||
runTargetPickerItems: NewWorkspaceRunTargetOption<MobileWorkspaceRepo>[]
|
||||
pickerAgentOptions: NewWorktreeAgentOption[]
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setupTrustPrompt: SetupTrustPrompt | null
|
||||
creating: boolean
|
||||
onSourceRepoChange: (repo: MobileWorkspaceRepo) => void
|
||||
onRepoChange: (repo: MobileWorkspaceRepo) => void
|
||||
onAgentChange: (agent: NewWorktreeAgentOption) => void
|
||||
onTransitionToForm: () => void
|
||||
onApproveSetupTrust: (alwaysTrust: boolean) => void
|
||||
onSkipSetupTrust: () => void
|
||||
onCloseSetupTrust: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SmartWorkspaceSourceDrawer
|
||||
visible={props.visible && props.drawerView === 'source'}
|
||||
client={props.client}
|
||||
composer={props.composer}
|
||||
availability={props.sourceAvailability}
|
||||
repoId={props.selectedRepo?.id ?? null}
|
||||
repos={props.pasteRepos}
|
||||
sshReady={props.sshReady}
|
||||
onRepoChange={(repoId) => {
|
||||
const nextRepo = props.repos.find((repo) => repo.id === repoId)
|
||||
if (nextRepo) {
|
||||
props.onSourceRepoChange(nextRepo)
|
||||
}
|
||||
}}
|
||||
onClose={props.onTransitionToForm}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'project'}
|
||||
title="Project"
|
||||
items={props.projectPickerItems}
|
||||
selectedId={props.selectedProjectId ?? ''}
|
||||
onSelect={(item) => props.onRepoChange(item.repo)}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={(item) => (
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: getMobileWorkspaceRepoBadgeColor(item.repo) }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'runTarget'}
|
||||
title="Run on"
|
||||
items={props.runTargetPickerItems}
|
||||
selectedId={props.selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => props.onRepoChange(item.repo)}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={props.visible && props.drawerView === 'agent'}
|
||||
title="Agent"
|
||||
items={props.pickerAgentOptions}
|
||||
selectedId={props.selectedAgent.id}
|
||||
onSelect={props.onAgentChange}
|
||||
onClose={props.onTransitionToForm}
|
||||
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
|
||||
<SetupHookTrustDrawer
|
||||
visible={props.visible && props.drawerView === 'trust' && props.setupTrustPrompt != null}
|
||||
prompt={props.setupTrustPrompt}
|
||||
busy={props.creating}
|
||||
onRunOnce={() => props.onApproveSetupTrust(false)}
|
||||
onAlwaysTrust={() => props.onApproveSetupTrust(true)}
|
||||
onDontRun={props.onSkipSetupTrust}
|
||||
onClose={props.onCloseSetupTrust}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ describe('memoized worktree rows', () => {
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: agent({ state: 'done', updatedAt: 2_000 }),
|
||||
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
@@ -207,5 +207,22 @@ describe('memoized worktree rows', () => {
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
|
||||
})
|
||||
|
||||
it('passes workspace monitoring mode to the status indicator', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(ListRowHarness, {
|
||||
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
|
||||
now: 2_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
|
||||
status: 'working',
|
||||
workingMode: 'monitoring'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
||||
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { RepoIcon } from '../../../src/shared/repo-icon'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
@@ -39,6 +40,7 @@ export type WorktreeListRowItem = {
|
||||
lineageChildCount?: number
|
||||
lineageCollapsed?: boolean
|
||||
agents?: RuntimeWorktreeAgentRow[]
|
||||
workingMode?: AgentWorkingMode
|
||||
}
|
||||
|
||||
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
@@ -97,7 +99,7 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.indicatorCol}>
|
||||
<AgentSpinner status={status} />
|
||||
<AgentSpinner status={status} workingMode={item.workingMode} />
|
||||
{item.unread && (
|
||||
<Bell
|
||||
size={10}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSpinner } from './AgentSpinner'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
|
||||
const DESKTOP_WORKING_COLOR = '#eab308'
|
||||
|
||||
type MonitoringTestRenderer = {
|
||||
readonly root: {
|
||||
findByType(type: string): { props: Record<string, unknown> }
|
||||
}
|
||||
unmount(): void
|
||||
}
|
||||
|
||||
const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
|
||||
animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
|
||||
animationTiming: vi.fn(() => ({})),
|
||||
setValue: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
|
||||
vi.mock('react-native', () => ({
|
||||
Animated: {
|
||||
Value: function Value() {
|
||||
return { interpolate: vi.fn(() => 'rotation'), setValue }
|
||||
},
|
||||
View: 'AnimatedView',
|
||||
loop: animationLoop,
|
||||
timing: animationTiming
|
||||
},
|
||||
Easing: { linear: 'linear' },
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
describe('mobile monitoring indicators', () => {
|
||||
let renderer: MonitoringTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
animationLoop.mockClear()
|
||||
animationTiming.mockClear()
|
||||
setValue.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('renders a static Radio for a monitoring agent', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 10
|
||||
})
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a static Radio for an all-monitoring workspace', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer?.root.findByType('Radio').props).toMatchObject({
|
||||
color: DESKTOP_WORKING_COLOR,
|
||||
size: 12
|
||||
})
|
||||
expect(animationTiming).not.toHaveBeenCalled()
|
||||
expect(animationLoop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the spinner fallback when workingMode is absent', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AgentSpinner, { status: 'working' }))
|
||||
})
|
||||
|
||||
expect(animationTiming).toHaveBeenCalledOnce()
|
||||
expect(animationLoop).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Platform, StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const newWorktreeFormStyles = StyleSheet.create({
|
||||
header: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: spacing.xl,
|
||||
alignItems: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
field: {
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
labelHint: {
|
||||
fontWeight: '400',
|
||||
color: colors.textMuted
|
||||
},
|
||||
fieldButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
fieldButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
fieldButtonPlaceholder: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
repoDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.55
|
||||
},
|
||||
sshBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs
|
||||
},
|
||||
sshRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
sshDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999
|
||||
},
|
||||
sshDotConnected: {
|
||||
backgroundColor: colors.statusGreen
|
||||
},
|
||||
sshDotProgress: {
|
||||
backgroundColor: colors.statusAmber
|
||||
},
|
||||
sshDotDisconnected: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
sshCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
sshTitle: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '600'
|
||||
},
|
||||
sshSubtitle: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1
|
||||
},
|
||||
sshConnectButton: {
|
||||
borderRadius: radii.button,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
sshConnectText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600'
|
||||
},
|
||||
errorInline: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 12
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
||||
fontSize: typography.bodySize,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
error: {
|
||||
color: colors.statusRed,
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
sourceWarning: {
|
||||
marginTop: -spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
fontSize: 12,
|
||||
color: colors.statusAmber
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
advancedText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
sourceBadge: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: spacing.xs + 2,
|
||||
paddingVertical: 2
|
||||
},
|
||||
sourceBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
setupBox: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
padding: spacing.md
|
||||
},
|
||||
setupToggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupToggleLabel: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
setupChoiceRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
setupChoiceButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupChoiceButtonSelected: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderColor: colors.textSecondary
|
||||
},
|
||||
setupChoiceText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
setupSwitch: {
|
||||
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
|
||||
},
|
||||
setupCommandBlock: {
|
||||
backgroundColor: colors.bgBase,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
setupCommand: {
|
||||
fontSize: 13,
|
||||
fontFamily: typography.monoFamily,
|
||||
color: colors.textPrimary
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 160,
|
||||
alignItems: 'center'
|
||||
},
|
||||
createButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
createText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
|
||||
export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
Pick<
|
||||
SharedRepo,
|
||||
| 'badgeColor'
|
||||
| 'connectionId'
|
||||
| 'executionHostId'
|
||||
| 'kind'
|
||||
| 'upstream'
|
||||
| 'repoIcon'
|
||||
| 'gitRemoteIdentity'
|
||||
>
|
||||
>
|
||||
|
||||
export type NewWorktreeModalProps = {
|
||||
visible: boolean
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
||||
|
||||
export type RepoHooksResponse = {
|
||||
hooks: { scripts?: { setup?: string } } | null
|
||||
source: string | null
|
||||
setupRunPolicy?: SetupRunPolicy
|
||||
setupTrust?: SetupHookTrust
|
||||
}
|
||||
|
||||
export type SetupHookDetails = {
|
||||
repoId: string
|
||||
command: string | null
|
||||
source: string | null
|
||||
trust: SetupHookTrust | null
|
||||
runPolicy: SetupRunPolicy
|
||||
}
|
||||
|
||||
export function getMobileWorkspaceRepoBadgeColor(repo: MobileWorkspaceRepo | null): string {
|
||||
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import {
|
||||
NEW_WORKTREE_AGENT_OPTIONS,
|
||||
NEW_WORKTREE_BLANK_AGENT,
|
||||
resolveNewWorktreeAgentSelection,
|
||||
type NewWorktreeAgentOption,
|
||||
type NewWorktreeRuntimeSettings
|
||||
} from './new-worktree-agent-selection'
|
||||
|
||||
export function useNewWorkspaceAgentSelection(args: {
|
||||
visible: boolean
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
detectedAgentIds: Set<string> | null
|
||||
}): {
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
|
||||
setAgentOverridden: (overridden: boolean) => void
|
||||
pickerAgentOptions: NewWorktreeAgentOption[]
|
||||
} {
|
||||
const [selectedAgentState, setSelectedAgent] = useState<NewWorktreeAgentOption>(
|
||||
NEW_WORKTREE_AGENT_OPTIONS[0]!
|
||||
)
|
||||
const [agentOverriddenState, setAgentOverridden] = useState(false)
|
||||
const resolution = resolveNewWorktreeAgentSelection({
|
||||
visible: args.visible,
|
||||
selectedAgent: selectedAgentState,
|
||||
agentOverridden: agentOverriddenState,
|
||||
runtimeSettings: args.runtimeSettings,
|
||||
detectedAgentIds: args.detectedAgentIds
|
||||
})
|
||||
if (
|
||||
selectedAgentState.id !== resolution.selectedAgent.id ||
|
||||
agentOverriddenState !== resolution.agentOverridden
|
||||
) {
|
||||
setSelectedAgent(resolution.selectedAgent)
|
||||
setAgentOverridden(resolution.agentOverridden)
|
||||
}
|
||||
const visibleAgentOptions = NEW_WORKTREE_AGENT_OPTIONS.filter(
|
||||
(agent) =>
|
||||
agent.id !== '__blank__' &&
|
||||
(args.detectedAgentIds === null || args.detectedAgentIds.has(agent.id)) &&
|
||||
isMobileTuiAgentEnabled(agent.id, args.runtimeSettings?.disabledTuiAgents)
|
||||
)
|
||||
return {
|
||||
selectedAgent: resolution.selectedAgent,
|
||||
setSelectedAgent,
|
||||
setAgentOverridden,
|
||||
pickerAgentOptions: [...visibleAgentOptions, NEW_WORKTREE_BLANK_AGENT]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
|
||||
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
|
||||
import {
|
||||
isSetupHookTrusted,
|
||||
persistSetupHookTrustApproval,
|
||||
wasSetupHookPreviouslyApproved,
|
||||
type SetupHookTrust
|
||||
} from '../tasks/setup-hook-trust'
|
||||
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
|
||||
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import {
|
||||
pickPreferredNewWorktreeAgent,
|
||||
type NewWorktreeAgentOption,
|
||||
type NewWorktreeRuntimeSettings
|
||||
} from './new-worktree-agent-selection'
|
||||
import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-types'
|
||||
import type { SetupTrustPrompt } from './SetupHookTrustDrawer'
|
||||
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
|
||||
type CreateOptions = {
|
||||
setupOverride?: Exclude<WorkspaceCreateSetupDecision, 'inherit'>
|
||||
approvedSetupContentHash?: string
|
||||
}
|
||||
|
||||
type Composer = ReturnType<typeof useMobileComposerSource>
|
||||
|
||||
export function useNewWorkspaceCreateSubmit(args: {
|
||||
client: RpcClient | null
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
selectedAgent: NewWorktreeAgentOption
|
||||
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
|
||||
setAgentOverridden: (overridden: boolean) => void
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
|
||||
detectedAgentIds: Set<string> | null
|
||||
sshGate: WorkspaceSshGate
|
||||
composer: Composer
|
||||
note: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
retiredWorktreeNames: RetiredNameRegistry
|
||||
setupCommand: string | null
|
||||
setupTrust: SetupHookTrust | null
|
||||
setupRunPolicy: SetupRunPolicy
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
runSetup: boolean
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
getWorktreeCreateCutoverSupport: () => Promise<boolean>
|
||||
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
|
||||
setError: Dispatch<SetStateAction<string>>
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}): {
|
||||
creating: boolean
|
||||
setupTrustPrompt: SetupTrustPrompt | null
|
||||
create: () => Promise<void>
|
||||
approveSetupTrust: (alwaysTrust: boolean) => Promise<void>
|
||||
closeSetupTrust: () => void
|
||||
skipSetupTrust: () => void
|
||||
} {
|
||||
const createInFlightRef = useRef(false)
|
||||
const setupTrustActionInFlightRef = useRef(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [setupTrustPrompt, setSetupTrustPrompt] = useState<SetupTrustPrompt | null>(null)
|
||||
|
||||
async function create(options: CreateOptions = {}): Promise<void> {
|
||||
const { client, selectedRepo } = args
|
||||
if (!client || !selectedRepo || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
createInFlightRef.current = true
|
||||
setCreating(true)
|
||||
args.setError('')
|
||||
try {
|
||||
if (args.sshGate.requiresConnection) {
|
||||
args.setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
|
||||
return
|
||||
}
|
||||
let latestRuntimeSettings = args.runtimeSettings
|
||||
try {
|
||||
const settingsResponse = await client.sendRequest('settings.get')
|
||||
if (settingsResponse.ok) {
|
||||
const result = (settingsResponse as RpcSuccess).result as {
|
||||
settings: NewWorktreeRuntimeSettings
|
||||
}
|
||||
latestRuntimeSettings = result.settings
|
||||
args.setRuntimeSettings(result.settings)
|
||||
}
|
||||
} catch {
|
||||
// The runtime validates the same setting before spawning.
|
||||
}
|
||||
if (
|
||||
args.selectedAgent.id !== '__blank__' &&
|
||||
!isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
|
||||
) {
|
||||
args.setSelectedAgent(
|
||||
pickPreferredNewWorktreeAgent(latestRuntimeSettings, args.detectedAgentIds)
|
||||
)
|
||||
args.setAgentOverridden(false)
|
||||
args.setError('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedName = args.composer.name.trim()
|
||||
const baseName =
|
||||
trimmedName ||
|
||||
getSuggestedCreatureName(
|
||||
args.existingWorktreePaths ?? [],
|
||||
undefined,
|
||||
args.retiredWorktreeNames
|
||||
)
|
||||
let setupDecision: WorkspaceCreateSetupDecision = 'inherit'
|
||||
if (args.setupCommand) {
|
||||
if (options.setupOverride) {
|
||||
setupDecision = options.setupOverride
|
||||
} else if (args.setupRunPolicy === 'ask') {
|
||||
if (!args.setupDecisionChoice) {
|
||||
args.setError('Choose whether to run the setup script.')
|
||||
return
|
||||
}
|
||||
setupDecision = args.setupDecisionChoice
|
||||
} else {
|
||||
setupDecision = args.runSetup ? 'run' : 'skip'
|
||||
}
|
||||
}
|
||||
if (
|
||||
setupDecision === 'run' &&
|
||||
args.setupTrust &&
|
||||
args.setupTrust.contentHash !== options.approvedSetupContentHash &&
|
||||
!isSetupHookTrusted(args.trustedOrcaHooks, selectedRepo.id, args.setupTrust.contentHash)
|
||||
) {
|
||||
setSetupTrustPrompt({
|
||||
repoId: selectedRepo.id,
|
||||
repoName: selectedRepo.displayName,
|
||||
scriptContent: args.setupTrust.scriptContent,
|
||||
contentHash: args.setupTrust.contentHash,
|
||||
previouslyApproved: wasSetupHookPreviouslyApproved(args.trustedOrcaHooks, selectedRepo.id)
|
||||
})
|
||||
args.transitionDrawer('trust')
|
||||
return
|
||||
}
|
||||
|
||||
const createdWithAgentId =
|
||||
args.selectedAgent.id !== '__blank__' ? args.selectedAgent.id : undefined
|
||||
const trimmedNote = args.note.trim() || undefined
|
||||
const selection = args.composer.createSelection
|
||||
const result = selection
|
||||
? await createWorkspaceFromComposerSource({
|
||||
client,
|
||||
selection,
|
||||
targetRepoId: selectedRepo.id,
|
||||
setupDecision,
|
||||
agent: { choice: normalizeWorkspaceAgent(args.selectedAgent.id) ?? 'blank' },
|
||||
workspaceName: trimmedName || undefined,
|
||||
note: trimmedNote,
|
||||
nameIsAutoManaged: args.composer.isNameAutoManaged,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
: await createBlankWorkspace({
|
||||
client,
|
||||
repoId: selectedRepo.id,
|
||||
baseName,
|
||||
nameWasGenerated: !trimmedName,
|
||||
createdWithAgentId,
|
||||
comment: trimmedNote,
|
||||
setupDecision,
|
||||
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
|
||||
})
|
||||
if ('error' in result) {
|
||||
args.setError(result.error)
|
||||
return
|
||||
}
|
||||
args.onClose()
|
||||
args.onCreated(result.worktreeId, result.name)
|
||||
} catch (error) {
|
||||
args.setError(error instanceof Error ? error.message : 'Failed to create workspace')
|
||||
} finally {
|
||||
createInFlightRef.current = false
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
|
||||
if (
|
||||
!args.client ||
|
||||
!setupTrustPrompt ||
|
||||
setupTrustActionInFlightRef.current ||
|
||||
createInFlightRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
setupTrustActionInFlightRef.current = true
|
||||
setCreating(true)
|
||||
try {
|
||||
const nextTrust = await persistSetupHookTrustApproval({
|
||||
client: args.client,
|
||||
trust: args.trustedOrcaHooks,
|
||||
repoId: setupTrustPrompt.repoId,
|
||||
contentHash: setupTrustPrompt.contentHash,
|
||||
alwaysTrust
|
||||
})
|
||||
args.setTrustedOrcaHooks(nextTrust)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
args.transitionDrawer('form')
|
||||
await create({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
|
||||
} catch (error) {
|
||||
args.setError(error instanceof Error ? error.message : 'Failed to trust setup script.')
|
||||
} finally {
|
||||
setupTrustActionInFlightRef.current = false
|
||||
if (!createInFlightRef.current) {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
setSetupTrustPrompt(null)
|
||||
args.transitionDrawer('form')
|
||||
}
|
||||
|
||||
function skipSetupTrust(): void {
|
||||
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
closeSetupTrust()
|
||||
void create({ setupOverride: 'skip' })
|
||||
}
|
||||
|
||||
return { creating, setupTrustPrompt, create, approveSetupTrust, closeSetupTrust, skipSetupTrust }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { deriveWorkspaceSshGate, type WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
|
||||
|
||||
type DetectedAgentIdsState = {
|
||||
connectionId: string | null
|
||||
ids: Set<string>
|
||||
}
|
||||
|
||||
function fallbackSshState(
|
||||
targetId: string,
|
||||
status: SshConnectionState['status'],
|
||||
error: string | null
|
||||
): SshConnectionState {
|
||||
return { targetId, status, error, reconnectAttempt: 0 }
|
||||
}
|
||||
|
||||
export function useNewWorkspaceExecutionTarget(args: {
|
||||
client: RpcClient | null
|
||||
connectionId: string | null
|
||||
visible: boolean
|
||||
}): {
|
||||
sshGate: WorkspaceSshGate
|
||||
detectedAgentIds: Set<string> | null
|
||||
connect: () => Promise<void>
|
||||
} {
|
||||
const { client, connectionId, visible } = args
|
||||
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
|
||||
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
null
|
||||
)
|
||||
const sshGate = deriveWorkspaceSshGate({
|
||||
connectionId,
|
||||
state: sshState,
|
||||
connecting: connectingTargetId === connectionId
|
||||
})
|
||||
const detectedAgentIds =
|
||||
detectedAgentIdsState?.connectionId === connectionId &&
|
||||
(connectionId === null || sshGate.status === 'connected')
|
||||
? detectedAgentIdsState.ids
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || !connectionId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('ssh.getState', { targetId: connectionId })
|
||||
.then((response) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(state.state ?? fallbackSshState(connectionId, 'disconnected', null))
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!stale) {
|
||||
setSshState(
|
||||
fallbackSshState(
|
||||
connectionId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to read SSH connection state.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client || (connectionId && sshGate.status !== 'connected')) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = connectionId
|
||||
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
|
||||
: await client.sendRequest('preflight.detectAgents')
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({
|
||||
connectionId,
|
||||
ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (!stale) {
|
||||
setDetectedAgentIdsState({ connectionId, ids: new Set() })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, connectionId, sshGate.status, visible])
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (!client || !connectionId) {
|
||||
return
|
||||
}
|
||||
setConnectingTargetId(connectionId)
|
||||
setSshState(fallbackSshState(connectionId, 'connecting', null))
|
||||
try {
|
||||
const response = await client.sendRequest(
|
||||
'ssh.connect',
|
||||
{ targetId: connectionId },
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
||||
setSshState(result.state ?? fallbackSshState(connectionId, 'connected', null))
|
||||
} catch (error) {
|
||||
setSshState(
|
||||
fallbackSshState(
|
||||
connectionId,
|
||||
'error',
|
||||
error instanceof Error ? error.message : 'Failed to connect to SSH repository.'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setConnectingTargetId((current) => (current === connectionId ? null : current))
|
||||
}
|
||||
}
|
||||
|
||||
return { sshGate, detectedAgentIds, connect }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
|
||||
import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo'
|
||||
import {
|
||||
getMobileNewWorkspaceDialogEligibleRepos,
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo,
|
||||
resolveMobileNewWorkspaceDialogRepoId
|
||||
} from '../worktree/new-workspace-dialog-repo-selection'
|
||||
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
|
||||
|
||||
export function useNewWorkspaceRepositories(args: {
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
visible: boolean
|
||||
}): {
|
||||
repos: MobileWorkspaceRepo[]
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void
|
||||
loading: boolean
|
||||
} {
|
||||
const { client, hostId, visible } = args
|
||||
const [initialRepos] = useState(() =>
|
||||
hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null
|
||||
)
|
||||
const [repos, setRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
|
||||
return
|
||||
}
|
||||
const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos)
|
||||
const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({
|
||||
eligibleRepos,
|
||||
activeRepoId: lastVisitedRepo.repoId
|
||||
})
|
||||
const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null
|
||||
if (preferredRepo) {
|
||||
setSelectedRepo(preferredRepo)
|
||||
}
|
||||
}, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
setLoading(true)
|
||||
void client
|
||||
.sendRequest('repo.list')
|
||||
.then((response) => {
|
||||
if (stale || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as { repos: MobileWorkspaceRepo[] }
|
||||
setRepos(result.repos)
|
||||
if (hostId) {
|
||||
setCachedRepos(hostId, result.repos)
|
||||
}
|
||||
setSelectedRepo((current) =>
|
||||
refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current)
|
||||
)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
type TaskProvider
|
||||
} from '../tasks/mobile-task-providers'
|
||||
import type { NewWorktreeRuntimeSettings } from './new-worktree-agent-selection'
|
||||
|
||||
function settledSuccess(entry: PromiseSettledResult<RpcResponse>): RpcSuccess | null {
|
||||
return entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
|
||||
}
|
||||
|
||||
export function useNewWorkspaceRuntimeContext(
|
||||
client: RpcClient | null,
|
||||
visible: boolean,
|
||||
hostId?: string
|
||||
): {
|
||||
runtimeSettings: NewWorktreeRuntimeSettings | null
|
||||
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
|
||||
trustedOrcaHooks: PersistedTrustedOrcaHooks
|
||||
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
|
||||
availableProviders: TaskProvider[]
|
||||
} {
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<NewWorktreeRuntimeSettings | null>(null)
|
||||
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
||||
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
const probes = Promise.allSettled([
|
||||
client.sendRequest('preflight.check'),
|
||||
client.sendRequest('linear.status')
|
||||
])
|
||||
const [settingsRes, uiRes] = await Promise.allSettled([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
|
||||
const settingsResult = settledSuccess(settingsRes)
|
||||
const settingsValue = settingsResult
|
||||
? (
|
||||
settingsResult.result as {
|
||||
settings: NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown }
|
||||
}
|
||||
).settings
|
||||
: null
|
||||
if (settingsValue) {
|
||||
setRuntimeSettings(settingsValue)
|
||||
}
|
||||
const uiResult = settledSuccess(uiRes)
|
||||
if (uiResult) {
|
||||
const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
|
||||
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
|
||||
}
|
||||
|
||||
const [preflightRes, linearRes] = await probes
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const glabInstalled =
|
||||
(settledSuccess(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)
|
||||
?.glab?.installed === true
|
||||
const linearConnected =
|
||||
(settledSuccess(linearRes)?.result as { connected?: boolean } | undefined)?.connected ===
|
||||
true
|
||||
const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
|
||||
setAvailableProviders(
|
||||
filterAvailableTaskProviders(visibleProviders, {
|
||||
gitlabInstalled: glabInstalled,
|
||||
linearConnected
|
||||
}).filter((provider) => visibleProviders.includes(provider))
|
||||
)
|
||||
})()
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [visible, client, hostId])
|
||||
|
||||
return {
|
||||
runtimeSettings,
|
||||
setRuntimeSettings,
|
||||
trustedOrcaHooks,
|
||||
setTrustedOrcaHooks,
|
||||
availableProviders
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
|
||||
import type {
|
||||
MobileWorkspaceRepo,
|
||||
RepoHooksResponse,
|
||||
SetupHookDetails
|
||||
} from './new-worktree-modal-types'
|
||||
|
||||
export function useNewWorkspaceSetupScript(args: {
|
||||
client: RpcClient | null
|
||||
selectedRepo: MobileWorkspaceRepo | null
|
||||
}): {
|
||||
setupCommand: string | null
|
||||
setupSource: string | null
|
||||
setupTrust: SetupHookDetails['trust']
|
||||
setupRunPolicy: SetupHookDetails['runPolicy']
|
||||
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
|
||||
setSetupDecisionChoice: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
|
||||
runSetup: boolean
|
||||
setRunSetup: (run: boolean) => void
|
||||
showAdvanced: boolean
|
||||
setShowAdvanced: (show: boolean) => void
|
||||
} {
|
||||
const { client, selectedRepo } = args
|
||||
const [details, setDetails] = useState<SetupHookDetails | null>(null)
|
||||
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
|
||||
WorkspaceCreateSetupDecision,
|
||||
'inherit'
|
||||
> | null>(null)
|
||||
const [runSetup, setRunSetup] = useState(true)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const activeDetails = selectedRepo && details?.repoId === selectedRepo.id ? details : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !selectedRepo) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void client
|
||||
.sendRequest('repo.hooks', { repo: `id:${selectedRepo.id}` })
|
||||
.then((response) => {
|
||||
if (stale || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as RepoHooksResponse
|
||||
const command = result.hooks?.scripts?.setup?.trim() || null
|
||||
const runPolicy = result.setupRunPolicy ?? 'run-by-default'
|
||||
setDetails({
|
||||
repoId: selectedRepo.id,
|
||||
command,
|
||||
source: result.source,
|
||||
trust: normalizeSetupHookTrust(result.setupTrust),
|
||||
runPolicy
|
||||
})
|
||||
setSetupDecisionChoice(null)
|
||||
setRunSetup(runPolicy !== 'skip-by-default')
|
||||
if (command && runPolicy === 'ask') {
|
||||
setShowAdvanced(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setDetails({
|
||||
repoId: selectedRepo.id,
|
||||
command: null,
|
||||
source: null,
|
||||
trust: null,
|
||||
runPolicy: 'run-by-default'
|
||||
})
|
||||
setSetupDecisionChoice(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [client, selectedRepo])
|
||||
|
||||
return {
|
||||
setupCommand: activeDetails?.command ?? null,
|
||||
setupSource: activeDetails?.source ?? null,
|
||||
setupTrust: activeDetails?.trust ?? null,
|
||||
setupRunPolicy: activeDetails?.runPolicy ?? 'run-by-default',
|
||||
setupDecisionChoice,
|
||||
setSetupDecisionChoice,
|
||||
runSetup,
|
||||
setRunSetup,
|
||||
showAdvanced,
|
||||
setShowAdvanced
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
|
||||
describe('Home host edit navigation wiring', () => {
|
||||
it('uses the cold-navigator-safe edit transition', () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons'
|
||||
import {
|
||||
getActiveProviderRateLimits,
|
||||
getUsageBarState,
|
||||
hasActiveProviderUsage,
|
||||
UsageBar,
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey
|
||||
} from '../components/AccountUsage'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
|
||||
export function MobileHomeAccountUsageCards(props: {
|
||||
items: { host: HostProfile; snapshot: AccountsSnapshot }[]
|
||||
onOpen: (hostId: string) => void
|
||||
}) {
|
||||
if (props.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.sectionHeading}>Account usage</Text>
|
||||
{props.items.map(({ host, snapshot }) => {
|
||||
const claudeActive =
|
||||
snapshot.claude.accounts.find(
|
||||
(account) => account.id === snapshot.claude.activeAccountId
|
||||
) ?? null
|
||||
const codexActive =
|
||||
snapshot.codex.accounts.find(
|
||||
(account) => account.id === snapshot.codex.activeAccountId
|
||||
) ?? null
|
||||
return (
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={() => props.onOpen(host.id)}
|
||||
>
|
||||
{props.items.length > 1 ? (
|
||||
<Text style={styles.hostLabel} numberOfLines={1}>
|
||||
{host.name}
|
||||
</Text>
|
||||
) : null}
|
||||
{(['claude', 'codex'] as ProviderKey[]).map((provider) => {
|
||||
const active = provider === 'claude' ? claudeActive : codexActive
|
||||
const accounts =
|
||||
provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
|
||||
const limits = getActiveProviderRateLimits(snapshot, provider)
|
||||
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
|
||||
return null
|
||||
}
|
||||
const sessionBar = getUsageBarState(limits, 'session')
|
||||
const weeklyBar = getUsageBarState(limits, 'weekly')
|
||||
return (
|
||||
<View key={provider} style={styles.row}>
|
||||
<View style={styles.icon}>
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeIcon size={18} />
|
||||
) : (
|
||||
<OpenAIIcon size={18} color={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.email} numberOfLines={1}>
|
||||
{active?.email ?? 'System default'}
|
||||
</Text>
|
||||
<View style={styles.bars}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
usedPercent={sessionBar.usedPercent}
|
||||
unavailable={sessionBar.unavailable}
|
||||
loading={sessionBar.loading}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weeklyBar.usedPercent}
|
||||
unavailable={weeklyBar.unavailable}
|
||||
loading={weeklyBar.loading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
hostLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
fontWeight: '500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2 },
|
||||
icon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
info: { flex: 1, minWidth: 0, gap: 2 },
|
||||
email: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
bars: { flexDirection: 'row', gap: spacing.md, marginTop: 4 }
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { QrCode } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const ONBOARDING_STEPS = [
|
||||
{
|
||||
title: 'Open Orca desktop',
|
||||
desc: 'Go to Settings → Mobile and generate a pairing QR code.'
|
||||
},
|
||||
{
|
||||
title: 'Scan the code',
|
||||
desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.'
|
||||
},
|
||||
{
|
||||
title: "You're connected",
|
||||
desc: 'Your desktop will appear here. Everything is encrypted end-to-end.'
|
||||
}
|
||||
]
|
||||
|
||||
export function MobileHomeEmptyState(props: {
|
||||
bottomInset: number
|
||||
contentMaxWidth: number
|
||||
isWideLayout: boolean
|
||||
onPairDesktop: () => void
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.emptyContainer,
|
||||
{ paddingBottom: props.bottomInset },
|
||||
props.isWideLayout && {
|
||||
maxWidth: props.contentMaxWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.emptyHero}>
|
||||
<Text style={styles.emptyTitle}>Connect your desktop</Text>
|
||||
<Text style={styles.emptyBody}>
|
||||
Pair with Orca on your computer to check on your agents, jump into any terminal, and drive
|
||||
work from your phone.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={props.onPairDesktop}>
|
||||
<QrCode size={17} color={colors.bgBase} />
|
||||
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.stepsSection}>
|
||||
<Text style={styles.sectionHeading}>How it works</Text>
|
||||
{ONBOARDING_STEPS.map((step, index) => (
|
||||
<View key={step.title} style={[styles.stepRow, index > 0 && styles.stepRowBorder]}>
|
||||
<View style={styles.stepNum}>
|
||||
<Text style={styles.stepNumText}>{index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepText}>
|
||||
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||
<Text style={styles.stepDesc}>{step.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
emptyContainer: { flex: 1 },
|
||||
emptyHero: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 40
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginBottom: 10
|
||||
},
|
||||
emptyBody: {
|
||||
fontSize: 15,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 32
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
borderRadius: radii.card
|
||||
},
|
||||
primaryButtonText: { color: colors.bgBase, fontSize: 15, fontWeight: '700' },
|
||||
stepsSection: { paddingHorizontal: spacing.xl },
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
paddingVertical: spacing.lg
|
||||
},
|
||||
stepRowBorder: { borderTopWidth: 1, borderTopColor: colors.borderSubtle },
|
||||
stepNum: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 1
|
||||
},
|
||||
stepNumText: { fontSize: 12, fontWeight: '700', color: colors.textSecondary },
|
||||
stepText: { flex: 1 },
|
||||
stepTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 3
|
||||
},
|
||||
stepDesc: { fontSize: 12, color: colors.textMuted, lineHeight: 17 }
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { memo, useCallback, type ReactElement } from 'react'
|
||||
import { FlatList, StyleSheet, View } from 'react-native'
|
||||
import type { ListRenderItemInfo } from 'react-native'
|
||||
import { MobileHostCard } from '../components/MobileHostCard'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import { spacing } from '../theme/mobile-theme'
|
||||
import { classifyConnection } from '../transport/connection-health'
|
||||
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
|
||||
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import { MobileHomeListHeader } from './MobileHomeListHeader'
|
||||
|
||||
type MobileHomeHostListProps = {
|
||||
autoConnectHostIds: string[]
|
||||
bottomInset: number
|
||||
contentMaxWidth: number
|
||||
footer: ReactElement
|
||||
hostAttempts: Record<string, number>
|
||||
hostLastConnected: Record<string, number | null>
|
||||
hostPairingRejected: Record<string, boolean>
|
||||
hostPaths: Record<string, MobileConnectionPath>
|
||||
hostPendingPaths: Record<string, MobileConnectionPath | null>
|
||||
hosts: HostCatalogEntry[]
|
||||
hostStates: Record<string, ConnectionState>
|
||||
isWideLayout: boolean
|
||||
stats: HomeStatsSummary | null
|
||||
worktreeInfo: Record<string, HostWorktreeInfo>
|
||||
onOpen: (host: HostCatalogEntry) => void
|
||||
onLongPress: (host: HostCatalogEntry) => void
|
||||
onOpenActions: (host: HostCatalogEntry) => void
|
||||
}
|
||||
|
||||
export function MobileHomeHostList(props: MobileHomeHostListProps) {
|
||||
const renderHost = useCallback(
|
||||
({ item }: ListRenderItemInfo<HostCatalogEntry>) => (
|
||||
<MobileHomeHostRow
|
||||
item={item}
|
||||
autoConnectHostIds={props.autoConnectHostIds}
|
||||
hostAttempts={props.hostAttempts}
|
||||
hostLastConnected={props.hostLastConnected}
|
||||
hostPairingRejected={props.hostPairingRejected}
|
||||
hostPaths={props.hostPaths}
|
||||
hostPendingPaths={props.hostPendingPaths}
|
||||
hostStates={props.hostStates}
|
||||
worktreeInfo={props.worktreeInfo}
|
||||
onOpen={props.onOpen}
|
||||
onLongPress={props.onLongPress}
|
||||
onOpenActions={props.onOpenActions}
|
||||
/>
|
||||
),
|
||||
[
|
||||
props.autoConnectHostIds,
|
||||
props.hostAttempts,
|
||||
props.hostLastConnected,
|
||||
props.hostPairingRejected,
|
||||
props.hostPaths,
|
||||
props.hostPendingPaths,
|
||||
props.hostStates,
|
||||
props.onLongPress,
|
||||
props.onOpen,
|
||||
props.onOpenActions,
|
||||
props.worktreeInfo
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={props.hosts}
|
||||
keyExtractor={(host) => host.id}
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
{ paddingBottom: spacing.xl + props.bottomInset },
|
||||
props.isWideLayout && {
|
||||
maxWidth: props.contentMaxWidth,
|
||||
width: '100%',
|
||||
alignSelf: 'center'
|
||||
}
|
||||
]}
|
||||
ListHeaderComponent={<MobileHomeListHeader stats={props.stats} />}
|
||||
ItemSeparatorComponent={CardGap}
|
||||
renderItem={renderHost}
|
||||
ListFooterComponent={props.footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type MobileHomeHostRowProps = Pick<
|
||||
MobileHomeHostListProps,
|
||||
| 'autoConnectHostIds'
|
||||
| 'hostAttempts'
|
||||
| 'hostLastConnected'
|
||||
| 'hostPairingRejected'
|
||||
| 'hostPaths'
|
||||
| 'hostPendingPaths'
|
||||
| 'hostStates'
|
||||
| 'worktreeInfo'
|
||||
| 'onOpen'
|
||||
| 'onLongPress'
|
||||
| 'onOpenActions'
|
||||
> & { item: HostCatalogEntry }
|
||||
|
||||
const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostRowProps) {
|
||||
const { item, onLongPress, onOpen, onOpenActions } = props
|
||||
const state = resolveHomeHostConnectionState(
|
||||
item.id,
|
||||
props.hostStates[item.id],
|
||||
props.autoConnectHostIds
|
||||
)
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: props.hostAttempts[item.id] ?? 0,
|
||||
lastConnectedAt: props.hostLastConnected[item.id] ?? null,
|
||||
endpoint: item.endpoint,
|
||||
pendingPath: props.hostPendingPaths[item.id] ?? null,
|
||||
pairingRejected: props.hostPairingRejected[item.id] ?? false
|
||||
})
|
||||
const open = useCallback(() => onOpen(item), [item, onOpen])
|
||||
const longPress = useCallback(() => onLongPress(item), [item, onLongPress])
|
||||
const openActions = useCallback(() => onOpenActions(item), [item, onOpenActions])
|
||||
|
||||
return (
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
credentialStatus={item.credentialStatus}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={props.hostPaths[item.id] ?? 'lan'}
|
||||
worktreeInfo={props.worktreeInfo[item.id]}
|
||||
onPress={open}
|
||||
onLongPress={longPress}
|
||||
onOpenActions={openActions}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
function CardGap() {
|
||||
return <View style={styles.cardGap} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl },
|
||||
cardGap: { height: spacing.sm }
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
import { MobileHomeQuickActions } from '../components/MobileHomeQuickActions'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import type { HostProfile } from '../transport/types'
|
||||
import type { HomeResumeCard } from '../worktree/home-resume-card'
|
||||
import { MobileHomeAccountUsageCards } from './MobileHomeAccountUsageCards'
|
||||
import { MobileHomeResumeCard } from './MobileHomeResumeCard'
|
||||
import { MobileHomeTasksCard } from './MobileHomeTasksCard'
|
||||
|
||||
export function MobileHomeListFooter(props: {
|
||||
accountsHosts: { host: HostProfile; snapshot: AccountsSnapshot }[]
|
||||
connectedHosts: HostProfile[]
|
||||
primaryHost: HostProfile | null
|
||||
primaryTaskProviders: TaskProvider[]
|
||||
resumeCard: HomeResumeCard | null
|
||||
onCreateWorkspace: (hostId: string) => void
|
||||
onOpenAccounts: (hostId: string) => void
|
||||
onOpenResume: (card: HomeResumeCard) => void
|
||||
onOpenTasks: (provider?: TaskProvider) => void
|
||||
onPairDesktop: () => void
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{props.resumeCard ? (
|
||||
<>
|
||||
<Text style={styles.sectionHeading}>Resume</Text>
|
||||
<MobileHomeResumeCard card={props.resumeCard} onOpen={props.onOpenResume} />
|
||||
</>
|
||||
) : null}
|
||||
<Text style={styles.sectionHeading}>Tasks</Text>
|
||||
<MobileHomeTasksCard
|
||||
enabled={props.primaryHost != null}
|
||||
providers={props.primaryTaskProviders}
|
||||
onOpen={props.onOpenTasks}
|
||||
/>
|
||||
<MobileHomeQuickActions
|
||||
connectedHosts={props.connectedHosts}
|
||||
onPairDesktop={props.onPairDesktop}
|
||||
onCreateWorkspace={props.onCreateWorkspace}
|
||||
/>
|
||||
<MobileHomeAccountUsageCards items={props.accountsHosts} onOpen={props.onOpenAccounts} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.floor(ms / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
const minutes = totalMinutes % 60
|
||||
return totalHours > 0 ? `${totalHours}h ${minutes}m` : `${totalMinutes}m`
|
||||
}
|
||||
|
||||
export function MobileHomeListHeader({ stats }: { stats: HomeStatsSummary | null }) {
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.heroTitle}>Welcome back</Text>
|
||||
</View>
|
||||
{stats ? (
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalAgentsSpawned.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>Agents spawned</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
|
||||
<Text style={styles.statLabel}>Agent time</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>PRs created</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.sectionHeading}>Desktops</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
hero: { paddingTop: spacing.xs, paddingBottom: spacing.md },
|
||||
heroTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
statsRow: { flexDirection: 'row', gap: 10, marginBottom: spacing.lg },
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(26,26,26,0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
statValue: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3
|
||||
},
|
||||
statLabel: { color: colors.textMuted, fontSize: 11, fontWeight: '500', marginTop: 2 },
|
||||
sectionHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ChevronRight, Terminal } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import type { HomeResumeCard } from '../worktree/home-resume-card'
|
||||
|
||||
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
|
||||
|
||||
function homeResumeRepoColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(index)) | 0
|
||||
}
|
||||
return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length]
|
||||
}
|
||||
|
||||
export function MobileHomeResumeCard(props: {
|
||||
card: HomeResumeCard
|
||||
onOpen: (card: HomeResumeCard) => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={!props.card.actionable}
|
||||
style={({ pressed }) => [
|
||||
styles.resumeCard,
|
||||
!props.card.actionable && styles.cardDisabled,
|
||||
pressed && styles.cardPressed
|
||||
]}
|
||||
onPress={() => props.onOpen(props.card)}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.resumeMain}>
|
||||
<Text style={styles.resumeTitle} numberOfLines={1}>
|
||||
{props.card.worktree.displayName}
|
||||
</Text>
|
||||
<View style={styles.resumeSub}>
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: homeResumeRepoColor(props.card.worktree.repo) }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.resumeSubText} numberOfLines={1}>
|
||||
{props.card.worktree.repo}
|
||||
{' · '}
|
||||
{props.card.worktree.branch}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
resumeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12
|
||||
},
|
||||
cardDisabled: { opacity: 0.45 },
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
resumeIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14
|
||||
},
|
||||
resumeMain: { flex: 1, minWidth: 0 },
|
||||
resumeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
resumeSub: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
|
||||
repoDot: { width: 7, height: 7, borderRadius: 3.5 },
|
||||
resumeSubText: { fontSize: 12, color: colors.textSecondary, flex: 1 }
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Alert, StyleSheet } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useOpenMobileAccounts } from '../accounts/use-open-mobile-accounts'
|
||||
import { getProvenCachedWorktrees } from '../cache/worktree-cache'
|
||||
import { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { getHostListActionSheetActions } from '../host-list-action-sheet-actions'
|
||||
import { hostNewWorktreeRoute } from '../host-route-action-state'
|
||||
import { hostRouteWithNotice } from '../host-route-notice'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { triggerMediumImpact } from '../platform/haptics'
|
||||
import { useOpenMobileSession } from '../session/use-open-mobile-session'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { useOpenMobileTasks } from '../tasks/use-open-mobile-tasks'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
useDisconnectHostClient,
|
||||
useForceReconnect,
|
||||
useForgetHostClient
|
||||
} from '../transport/client-context'
|
||||
import { hostEndpointLabel } from '../transport/host-endpoint-label'
|
||||
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
|
||||
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { useOpenMobileHostEdit } from '../transport/use-open-mobile-host-edit'
|
||||
import type { HomeWorktreeSummary } from '../worktree/home-worktree-info'
|
||||
import { isResumeTargetConfirmedMissing, type HomeResumeCard } from '../worktree/home-resume-card'
|
||||
import { MobileHomeEmptyState } from './MobileHomeEmptyState'
|
||||
import { MobileHomeHostList } from './MobileHomeHostList'
|
||||
import { MobileHomeListFooter } from './MobileHomeListFooter'
|
||||
import { MobileHomeTopBar } from './MobileHomeTopBar'
|
||||
import { useMobileHomeData } from './use-mobile-home-data'
|
||||
|
||||
export function MobileHomeScreen() {
|
||||
const data = useMobileHomeData()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const openMobileHostEdit = useOpenMobileHostEdit()
|
||||
const openMobileTasks = useOpenMobileTasks()
|
||||
const openMobileSession = useOpenMobileSession()
|
||||
const openMobileAccounts = useOpenMobileAccounts()
|
||||
const disconnectHostClient = useDisconnectHostClient()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const openResume = useCallback(
|
||||
(card: HomeResumeCard) => {
|
||||
if (
|
||||
isResumeTargetConfirmedMissing(
|
||||
card,
|
||||
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
|
||||
)
|
||||
) {
|
||||
data.router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
|
||||
return
|
||||
}
|
||||
openMobileSession({
|
||||
hostId: card.hostId,
|
||||
worktreeId: card.worktree.worktreeId,
|
||||
name: card.worktree.displayName || card.worktree.repo
|
||||
})
|
||||
},
|
||||
[data.router, openMobileSession]
|
||||
)
|
||||
|
||||
const openTasks = useCallback(
|
||||
(provider?: TaskProvider) => {
|
||||
if (data.primaryHost) {
|
||||
openMobileTasks(data.primaryHost.id, provider)
|
||||
}
|
||||
},
|
||||
[data.primaryHost, openMobileTasks]
|
||||
)
|
||||
|
||||
function openHost(host: HostCatalogEntry): void {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
data.router.push('/pair-scan')
|
||||
} else if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(data.setHostCatalog)
|
||||
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
|
||||
} else {
|
||||
data.router.push(`/h/${host.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function openHostActions(host: HostCatalogEntry): void {
|
||||
if (host.profile) {
|
||||
setActionTarget(host.profile)
|
||||
} else {
|
||||
setConfirmRemove(host)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(): Promise<void> {
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
}
|
||||
const host = confirmRemove
|
||||
try {
|
||||
await removeHostAndCloseClient(host.id, forgetHostClient)
|
||||
setConfirmRemove(null)
|
||||
data.setHostCatalog(await loadHostCatalog())
|
||||
} catch {
|
||||
setConfirmRemove(host)
|
||||
Alert.alert('Could not remove host', 'Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<MobileHomeTopBar onOpenSettings={() => data.router.push('/settings')} />
|
||||
{data.hostCatalog.length === 0 ? (
|
||||
<MobileHomeEmptyState
|
||||
bottomInset={insets.bottom}
|
||||
contentMaxWidth={contentMaxWidth}
|
||||
isWideLayout={isWideLayout}
|
||||
onPairDesktop={() => data.router.push('/pair-scan')}
|
||||
/>
|
||||
) : (
|
||||
<MobileHomeHostList
|
||||
autoConnectHostIds={data.autoConnectHostIds}
|
||||
bottomInset={insets.bottom}
|
||||
contentMaxWidth={contentMaxWidth}
|
||||
footer={
|
||||
<MobileHomeListFooter
|
||||
accountsHosts={data.accountsHosts}
|
||||
connectedHosts={data.connectedHosts}
|
||||
primaryHost={data.primaryHost}
|
||||
primaryTaskProviders={data.primaryTaskProviders}
|
||||
resumeCard={data.resumeCard}
|
||||
onCreateWorkspace={(hostId) => data.router.push(hostNewWorktreeRoute(hostId))}
|
||||
onOpenAccounts={openMobileAccounts}
|
||||
onOpenResume={openResume}
|
||||
onOpenTasks={openTasks}
|
||||
onPairDesktop={() => data.router.push('/pair-scan')}
|
||||
/>
|
||||
}
|
||||
hostAttempts={data.hostAttempts}
|
||||
hostLastConnected={data.hostLastConnected}
|
||||
hostPairingRejected={data.hostPairingRejected}
|
||||
hostPaths={data.hostPaths}
|
||||
hostPendingPaths={data.hostPendingPaths}
|
||||
hosts={data.sortedHostCatalog}
|
||||
hostStates={data.hostStates}
|
||||
isWideLayout={isWideLayout}
|
||||
stats={data.stats}
|
||||
worktreeInfo={data.worktreeInfo}
|
||||
onOpen={openHost}
|
||||
onLongPress={(host) => {
|
||||
triggerMediumImpact()
|
||||
openHostActions(host)
|
||||
}}
|
||||
onOpenActions={openHostActions}
|
||||
/>
|
||||
)}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null}
|
||||
title={actionTarget?.name}
|
||||
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
|
||||
actions={getHostListActionSheetActions({
|
||||
host: actionTarget,
|
||||
state: actionTarget
|
||||
? resolveHomeHostConnectionState(
|
||||
actionTarget.id,
|
||||
data.hostStates[actionTarget.id],
|
||||
data.autoConnectHostIds
|
||||
)
|
||||
: 'disconnected',
|
||||
hasEverConnected: actionTarget
|
||||
? (data.hostLastConnected[actionTarget.id] ?? null) != null
|
||||
: false,
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={confirmRemove != null}
|
||||
title="Remove Host"
|
||||
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void handleRemove()}
|
||||
onCancel={() => setConfirmRemove(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bgBase }
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ChevronRight, ListTodo } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { TaskProviderLogo } from '../components/TaskProviderLogo'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
|
||||
const TASK_PROVIDER_LABELS: Record<TaskProvider, string> = {
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
linear: 'Linear'
|
||||
}
|
||||
|
||||
export function MobileHomeTasksCard(props: {
|
||||
enabled: boolean
|
||||
providers: TaskProvider[]
|
||||
onOpen: (provider?: TaskProvider) => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={!props.enabled}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
!props.enabled && styles.cardDisabled,
|
||||
pressed && styles.cardPressed
|
||||
]}
|
||||
onPress={() => props.onOpen()}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
<ListTodo size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.main}>
|
||||
<Text style={styles.title}>Tasks</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{props.providers.length > 0
|
||||
? props.providers.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
|
||||
: 'No task sources connected'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.trailing}>
|
||||
<View
|
||||
style={styles.providerRow}
|
||||
accessibilityLabel={props.providers
|
||||
.map((provider) => TASK_PROVIDER_LABELS[provider])
|
||||
.join(', ')}
|
||||
>
|
||||
{props.providers.map((provider) => (
|
||||
<Pressable
|
||||
key={provider}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${TASK_PROVIDER_LABELS[provider]} tasks`}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [
|
||||
styles.providerButton,
|
||||
pressed && styles.providerButtonPressed
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onOpen(provider)
|
||||
}}
|
||||
>
|
||||
<TaskProviderLogo provider={provider} size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
minHeight: 72,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12
|
||||
},
|
||||
cardDisabled: { opacity: 0.45 },
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
icon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14
|
||||
},
|
||||
main: { flex: 1, minWidth: 0 },
|
||||
title: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
|
||||
subtitle: { fontSize: 12, color: colors.textSecondary, marginTop: 3 },
|
||||
trailing: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
marginLeft: spacing.sm
|
||||
},
|
||||
providerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 2
|
||||
},
|
||||
providerButton: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
providerButtonPressed: { backgroundColor: colors.bgRaised }
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Settings } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { OrcaLogo } from '../components/OrcaLogo'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
export function MobileHomeTopBar({ onOpenSettings }: { onOpenSettings: () => void }) {
|
||||
return (
|
||||
<View style={styles.topBar}>
|
||||
<View style={styles.brandLockup}>
|
||||
<View style={styles.logoMark}>
|
||||
<OrcaLogo size={18} />
|
||||
</View>
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={onOpenSettings}
|
||||
>
|
||||
<Settings size={18} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
brandLockup: { flexDirection: 'row', alignItems: 'center', minWidth: 0 },
|
||||
logoMark: { marginRight: spacing.sm },
|
||||
brandName: { color: colors.textPrimary, fontSize: 17, fontWeight: '700' },
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconButtonPressed: { backgroundColor: colors.bgRaised }
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import {
|
||||
reconcileMobileHomeHostStates,
|
||||
type MobileHomeClientEntry
|
||||
} from './mobile-home-connection-state'
|
||||
|
||||
function catalogHost(
|
||||
id: string,
|
||||
credentialStatus: HostCatalogEntry['credentialStatus']
|
||||
): HostCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
endpoint: `ws://${id}`,
|
||||
publicKeyB64: `key-${id}`,
|
||||
lastConnected: 0,
|
||||
credentialStatus,
|
||||
profile: null
|
||||
}
|
||||
}
|
||||
|
||||
function liveClient(hostId: string, state: MobileHomeClientEntry['state']): MobileHomeClientEntry {
|
||||
return { hostId, state, client: {} as RpcClient }
|
||||
}
|
||||
|
||||
describe('mobile home connection state', () => {
|
||||
it('keeps an unacquired ready host absent on the initial frame', () => {
|
||||
const previous = {}
|
||||
const next = reconcileMobileHomeHostStates(previous, [], [catalogHost('host-1', 'ready')])
|
||||
|
||||
expect(next).toBe(previous)
|
||||
})
|
||||
|
||||
it('uses credential status when a host has no live client', () => {
|
||||
const next = reconcileMobileHomeHostStates(
|
||||
{ ready: 'connected' },
|
||||
[],
|
||||
[
|
||||
catalogHost('missing', 'missing'),
|
||||
catalogHost('locked', 'temporarily-unavailable'),
|
||||
catalogHost('ready', 'ready')
|
||||
]
|
||||
)
|
||||
|
||||
expect(next).toEqual({ missing: 'auth-failed', locked: 'disconnected', ready: 'disconnected' })
|
||||
})
|
||||
|
||||
it('prefers the live client and drops hosts removed from the catalog', () => {
|
||||
const next = reconcileMobileHomeHostStates(
|
||||
{ removed: 'connected', current: 'reconnecting' },
|
||||
[liveClient('current', 'connected')],
|
||||
[catalogHost('current', 'missing')]
|
||||
)
|
||||
|
||||
expect(next).toEqual({ current: 'connected' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
|
||||
|
||||
export type MobileHomeClientEntry = {
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
}
|
||||
|
||||
export function readMobileHomeReconnectAttempts(
|
||||
previous: Record<string, number>,
|
||||
clients: MobileHomeClientEntry[]
|
||||
): Record<string, number> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
for (const entry of clients) {
|
||||
const attempts = entry.client.getReconnectAttempt()
|
||||
if (next[entry.hostId] !== attempts) {
|
||||
next[entry.hostId] = attempts
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
|
||||
export function readMobileHomeLastConnected(
|
||||
previous: Record<string, number | null>,
|
||||
clients: MobileHomeClientEntry[]
|
||||
): Record<string, number | null> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
for (const entry of clients) {
|
||||
const connectedAt = entry.client.getLastConnectedAt()
|
||||
if (next[entry.hostId] !== connectedAt) {
|
||||
next[entry.hostId] = connectedAt
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
|
||||
export function reconcileMobileHomeHostStates(
|
||||
previous: Record<string, ConnectionState>,
|
||||
clients: MobileHomeClientEntry[],
|
||||
hostCatalog: HostCatalogEntry[]
|
||||
): Record<string, ConnectionState> {
|
||||
const next = { ...previous }
|
||||
let changed = false
|
||||
const liveIds = new Set(clients.map((entry) => entry.hostId))
|
||||
for (const entry of clients) {
|
||||
if (next[entry.hostId] !== entry.state) {
|
||||
next[entry.hostId] = entry.state
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const host of hostCatalog) {
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
}
|
||||
const fallback = host.credentialStatus === 'missing' ? 'auth-failed' : 'disconnected'
|
||||
const previousState = next[host.id]
|
||||
const shouldTrack =
|
||||
host.credentialStatus !== 'ready' ||
|
||||
(previousState != null && previousState !== 'disconnected' && previousState !== 'auth-failed')
|
||||
if (shouldTrack && previousState !== fallback) {
|
||||
next[host.id] = fallback
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(next)) {
|
||||
if (!liveIds.has(id) && !hostCatalog.some((host) => host.id === id)) {
|
||||
delete next[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : previous
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage'
|
||||
import type { HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import {
|
||||
filterAvailableTaskProviders,
|
||||
normalizeVisibleTaskProviders,
|
||||
type TaskProvider
|
||||
} from '../tasks/mobile-task-providers'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../transport/request-single-flight'
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
}
|
||||
|
||||
type HomePreflightStatus = {
|
||||
glab?: { installed?: boolean }
|
||||
}
|
||||
|
||||
type HomeLinearStatus = {
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
export type HomeStatsSetter = (
|
||||
updater: (previous: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
|
||||
) => void
|
||||
|
||||
export type HomeAccountsSetter = (
|
||||
updater: (previous: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
|
||||
) => void
|
||||
|
||||
export type HomeTaskProvidersSetter = (
|
||||
updater: (previous: Record<string, TaskProvider[]>) => Record<string, TaskProvider[]>
|
||||
) => void
|
||||
|
||||
export function fetchMobileHomeStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: HomeStatsSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
.then((response) => {
|
||||
if (!disposed() && response.ok) {
|
||||
setStats((previous) => ({
|
||||
...previous,
|
||||
[hostId]: response.result as HomeStatsSummary
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function fetchMobileHomeAccounts(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setSnapshots: HomeAccountsSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
sendSingleFlightRequest(client, hostId, 'accounts.list')
|
||||
.then((response) => {
|
||||
if (!disposed() && response.ok) {
|
||||
const snapshot = decodeAccountsSnapshot(response.result)
|
||||
setSnapshots((previous) => ({ ...previous, [hostId]: snapshot }))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export function fetchMobileHomeTaskProviders(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setProviders: HomeTaskProvidersSetter,
|
||||
disposed: () => boolean
|
||||
): void {
|
||||
Promise.all([
|
||||
sendSingleFlightRequest(client, hostId, 'settings.get'),
|
||||
sendSingleFlightRequest(client, hostId, 'preflight.check'),
|
||||
sendSingleFlightRequest(client, hostId, 'linear.status')
|
||||
])
|
||||
.then(([settingsResponse, preflightResponse, linearResponse]) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
const settings = settingsResponse.ok
|
||||
? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
|
||||
{}) as HomeTaskSettings)
|
||||
: {}
|
||||
const preflight = preflightResponse.ok
|
||||
? (preflightResponse.result as HomePreflightStatus)
|
||||
: null
|
||||
const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null
|
||||
const providers = filterAvailableTaskProviders(
|
||||
normalizeVisibleTaskProviders(settings.visibleTaskProviders),
|
||||
{
|
||||
gitlabInstalled: preflight?.glab?.installed === true,
|
||||
linearConnected: linear?.connected === true
|
||||
}
|
||||
)
|
||||
setProviders((previous) => ({ ...previous, [hostId]: providers }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) {
|
||||
setProviders((previous) =>
|
||||
previous[hostId] ? previous : { ...previous, [hostId]: ['github'] }
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
import { hasRenderableUsage } from '../components/AccountUsage'
|
||||
import { loadHomeSnapshot, saveHomeSnapshot } from '../cache/home-snapshot-cache'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../cache/worktree-cache'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../onboarding/mobile-onboarding-plan'
|
||||
import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total'
|
||||
import type { TaskProvider } from '../tasks/mobile-task-providers'
|
||||
import { selectConnectableHostProfiles } from '../transport/host-catalog-selection'
|
||||
import { loadHostCatalog } from '../transport/host-store'
|
||||
import type { HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
readLastVisitedWorktreeRecord
|
||||
} from '../worktree/last-visited-worktree-repo'
|
||||
import { selectHomeResumeCard } from '../worktree/home-resume-card'
|
||||
import {
|
||||
fetchMobileHomeAccounts,
|
||||
fetchMobileHomeStats,
|
||||
fetchMobileHomeTaskProviders
|
||||
} from './mobile-home-host-requests'
|
||||
import { useMobileHomeHostConnections } from './use-mobile-home-host-connections'
|
||||
|
||||
export function useMobileHomeData() {
|
||||
const router = useRouter()
|
||||
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
|
||||
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const onboardingCheckedRef = useRef(false)
|
||||
const hydratedRef = useRef(false)
|
||||
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
|
||||
const connections = useMobileHomeHostConnections(hosts, hostCatalog, {
|
||||
setStats: setStatsByHost,
|
||||
setWorktreeInfo,
|
||||
setAccounts: setAccountsByHost,
|
||||
setTaskProviders: setTaskProvidersByHost
|
||||
})
|
||||
const allClientsRef = useRef(connections.allClients)
|
||||
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) {
|
||||
return
|
||||
}
|
||||
hydratedRef.current = true
|
||||
let cancelled = false
|
||||
void loadHomeSnapshot().then((snapshot) => {
|
||||
if (cancelled || !snapshot) {
|
||||
return
|
||||
}
|
||||
setWorktreeInfo((previous) =>
|
||||
Object.keys(previous).length > 0 ? previous : snapshot.worktreeInfo
|
||||
)
|
||||
setAccountsByHost((previous) =>
|
||||
Object.keys(previous).length > 0 ? previous : snapshot.accountsByHost
|
||||
)
|
||||
for (const [hostId, info] of Object.entries(snapshot.worktreeInfo)) {
|
||||
if (info.lastActiveWorktree) {
|
||||
setCachedWorktrees(hostId, [info.lastActiveWorktree])
|
||||
}
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(worktreeInfo).length > 0 || Object.keys(accountsByHost).length > 0) {
|
||||
saveHomeSnapshot({ worktreeInfo, accountsByHost, savedAt: Date.now() })
|
||||
}
|
||||
}, [worktreeInfo, accountsByHost])
|
||||
|
||||
useEffect(() => {
|
||||
allClientsRef.current = connections.allClients
|
||||
}, [connections.allClients])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHostCatalog().then(async (catalog) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHostCatalog(catalog)
|
||||
if (catalog.length === 0 || onboardingCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
onboardingCheckedRef.current = true
|
||||
const steps = await loadMobileOnboardingSteps()
|
||||
if (!stale && steps.length > 0) {
|
||||
router.replace(mobileOnboardingDestination(steps))
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
|
||||
if (!stale) {
|
||||
setLastVisited(readLastVisitedWorktreeRecord(raw))
|
||||
}
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchMobileHomeStats(entry.client, entry.hostId, setStatsByHost, () => stale)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchMobileHomeAccounts(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchMobileHomeTaskProviders(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setTaskProvidersByHost,
|
||||
() => stale
|
||||
)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [router])
|
||||
)
|
||||
|
||||
const sortedHosts = useMemo(
|
||||
() => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected),
|
||||
[hosts]
|
||||
)
|
||||
const sortedHostCatalog = useMemo(
|
||||
() => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected),
|
||||
[hostCatalog]
|
||||
)
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
|
||||
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
|
||||
const resumeCard = useMemo(
|
||||
() =>
|
||||
selectHomeResumeCard({
|
||||
hosts: sortedHosts,
|
||||
hostStates: connections.hostStates,
|
||||
worktreeInfo,
|
||||
lastVisited,
|
||||
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
|
||||
}),
|
||||
[sortedHosts, connections.hostStates, worktreeInfo, lastVisited]
|
||||
)
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: { host: HostProfile; snapshot: AccountsSnapshot }[] = []
|
||||
for (const host of sortedHosts) {
|
||||
const snapshot = accountsByHost[host.id]
|
||||
if (
|
||||
connections.hostStates[host.id] === 'connected' &&
|
||||
snapshot &&
|
||||
(hasRenderableUsage(snapshot, 'claude') || hasRenderableUsage(snapshot, 'codex'))
|
||||
) {
|
||||
items.push({ host, snapshot })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [sortedHosts, connections.hostStates, accountsByHost])
|
||||
const connectedHosts = useMemo(
|
||||
() => sortedHosts.filter((host) => connections.hostStates[host.id] === 'connected'),
|
||||
[sortedHosts, connections.hostStates]
|
||||
)
|
||||
const primaryHost = connectedHosts[0] ?? null
|
||||
const primaryTaskProviders = primaryHost
|
||||
? (taskProvidersByHost[primaryHost.id] ?? ['github'])
|
||||
: []
|
||||
const hostPaths = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, path }) => [hostId, path])
|
||||
)
|
||||
const hostPendingPaths = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])
|
||||
)
|
||||
const hostPairingRejected = Object.fromEntries(
|
||||
connections.allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
|
||||
)
|
||||
|
||||
return {
|
||||
...connections,
|
||||
accountsHosts,
|
||||
connectedHosts,
|
||||
hostCatalog,
|
||||
hostPairingRejected,
|
||||
hostPaths,
|
||||
hostPendingPaths,
|
||||
primaryHost,
|
||||
primaryTaskProviders,
|
||||
resumeCard,
|
||||
router,
|
||||
setHostCatalog,
|
||||
sortedHostCatalog,
|
||||
stats,
|
||||
worktreeInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { decodeAccountsSnapshot } from '../components/AccountUsage'
|
||||
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
|
||||
import { usePrimeHosts } from '../transport/client-context'
|
||||
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
|
||||
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { useAllHostClients } from '../transport/use-all-host-clients'
|
||||
import {
|
||||
fetchHomeHostWorktreeInfo,
|
||||
type HostWorktreeInfoSetter
|
||||
} from '../worktree/home-host-worktree-fetch'
|
||||
import {
|
||||
readMobileHomeLastConnected,
|
||||
readMobileHomeReconnectAttempts,
|
||||
reconcileMobileHomeHostStates
|
||||
} from './mobile-home-connection-state'
|
||||
import {
|
||||
fetchMobileHomeStats,
|
||||
fetchMobileHomeTaskProviders,
|
||||
type HomeAccountsSetter,
|
||||
type HomeStatsSetter,
|
||||
type HomeTaskProvidersSetter
|
||||
} from './mobile-home-host-requests'
|
||||
|
||||
type Setters = {
|
||||
setStats: HomeStatsSetter
|
||||
setWorktreeInfo: HostWorktreeInfoSetter
|
||||
setAccounts: HomeAccountsSetter
|
||||
setTaskProviders: HomeTaskProvidersSetter
|
||||
}
|
||||
|
||||
function wireMobileHomeHostSubscriptions(
|
||||
entry: { hostId: string; client: RpcClient; state: ConnectionState },
|
||||
setters: Setters
|
||||
): () => void {
|
||||
let unsubscribeNotifications: (() => void) | null = null
|
||||
let unsubscribeAccounts: (() => void) | null = null
|
||||
const refetchGate = createHostConnectRefetchGate()
|
||||
const wireState = (state: ConnectionState): void => {
|
||||
const reconnected = refetchGate.observe(state)
|
||||
if (state === 'connected') {
|
||||
unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as { type?: string; snapshot?: unknown }
|
||||
if (event.type !== 'ready' && event.type !== 'snapshot') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(event.snapshot)
|
||||
setters.setAccounts((previous) => ({ ...previous, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot when a mixed-version host publishes malformed data.
|
||||
}
|
||||
})
|
||||
if (reconnected) {
|
||||
fetchMobileHomeStats(entry.client, entry.hostId, setters.setStats, () => false)
|
||||
void fetchHomeHostWorktreeInfo(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setters.setWorktreeInfo,
|
||||
() => false
|
||||
)
|
||||
fetchMobileHomeTaskProviders(
|
||||
entry.client,
|
||||
entry.hostId,
|
||||
setters.setTaskProviders,
|
||||
() => false
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
unsubscribeNotifications?.()
|
||||
unsubscribeNotifications = null
|
||||
unsubscribeAccounts?.()
|
||||
unsubscribeAccounts = null
|
||||
}
|
||||
wireState(entry.state)
|
||||
const unsubscribeState = entry.client.onStateChange(wireState)
|
||||
return () => {
|
||||
unsubscribeState()
|
||||
unsubscribeNotifications?.()
|
||||
unsubscribeAccounts?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function useMobileHomeHostConnections(
|
||||
hosts: HostProfile[],
|
||||
hostCatalog: HostCatalogEntry[],
|
||||
setters: Setters
|
||||
) {
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
|
||||
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
|
||||
const allClients = useAllHostClients(hostIds, {
|
||||
autoConnectHostIds,
|
||||
closeUnusedOnRelease: true
|
||||
})
|
||||
const primeHosts = usePrimeHosts()
|
||||
const subscriptionsRef = useRef(new Map<string, { client: RpcClient; cleanup: () => void }>())
|
||||
const { setAccounts, setStats, setTaskProviders, setWorktreeInfo } = setters
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length > 0) {
|
||||
primeHosts(hosts)
|
||||
}
|
||||
}, [hosts, primeHosts])
|
||||
|
||||
useEffect(() => {
|
||||
setHostAttempts((previous) => readMobileHomeReconnectAttempts(previous, allClients))
|
||||
setHostLastConnected((previous) => readMobileHomeLastConnected(previous, allClients))
|
||||
setHostStates((previous) => reconcileMobileHomeHostStates(previous, allClients, hostCatalog))
|
||||
}, [allClients, hostCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const clientsByHostId = new Map(allClients.map((entry) => [entry.hostId, entry.client]))
|
||||
for (const [hostId, subscription] of subscriptionsRef.current) {
|
||||
if (clientsByHostId.get(hostId) !== subscription.client) {
|
||||
subscription.cleanup()
|
||||
subscriptionsRef.current.delete(hostId)
|
||||
}
|
||||
}
|
||||
const activeSetters = { setAccounts, setStats, setTaskProviders, setWorktreeInfo }
|
||||
for (const entry of allClients) {
|
||||
if (!subscriptionsRef.current.has(entry.hostId)) {
|
||||
subscriptionsRef.current.set(entry.hostId, {
|
||||
client: entry.client,
|
||||
cleanup: wireMobileHomeHostSubscriptions(entry, activeSetters)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [allClients, setAccounts, setStats, setTaskProviders, setWorktreeInfo])
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = subscriptionsRef.current
|
||||
return () => {
|
||||
for (const subscription of subscriptions.values()) {
|
||||
subscription.cleanup()
|
||||
}
|
||||
subscriptions.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
allClients,
|
||||
autoConnectHostIds,
|
||||
hostAttempts,
|
||||
hostLastConnected,
|
||||
hostStates
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
@@ -97,28 +97,18 @@ describe('mobile session route', () => {
|
||||
})
|
||||
|
||||
it('routes the home Resume card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
|
||||
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
|
||||
|
||||
// Assert the markers first: a renamed banner would otherwise slice garbage and
|
||||
// report a missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
expect(resumeCard).toContain('openResume(')
|
||||
expect(resumeCard).not.toContain('router.push(')
|
||||
expect(homeSource).toContain('onOpenResume={openResume}')
|
||||
|
||||
// The tap handler itself must go through the coordinated transition; its only
|
||||
// direct push is the shallow noticed host-index route for a proven-missing target.
|
||||
const handlerStart = homeSource.indexOf('const openResume = useCallback(')
|
||||
const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart)
|
||||
const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart)
|
||||
expect(handlerStart).toBeGreaterThanOrEqual(0)
|
||||
expect(handlerEnd).toBeGreaterThan(handlerStart)
|
||||
|
||||
const openResume = homeSource.slice(handlerStart, handlerEnd)
|
||||
expect(openResume).toContain('openMobileSession({')
|
||||
expect(openResume.match(/router\.push\(/g)).toHaveLength(1)
|
||||
expect(openResume).toContain('router.push(hostRouteWithNotice(')
|
||||
expect(openResume).toContain('data.router.push(hostRouteWithNotice(')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
launchAgent: 'claude',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
workingMode: undefined as 'monitoring' | undefined,
|
||||
agentType: 'claude',
|
||||
lastAssistantMessage: 'Partial reply',
|
||||
providerSession: { id: 'session-1' }
|
||||
},
|
||||
isActive: true
|
||||
@@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
viewMode.isTabChatView = () => true
|
||||
workingTab.agentStatus.workingMode = undefined
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
@@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
|
||||
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
|
||||
expect(controller?.showNativeChat).toBe(true)
|
||||
expect(controller?.nativeChatAgentWorking).toBe(true)
|
||||
const scopeKey = controller?.nativeChatStreamScopeKey
|
||||
expect(scopeKey).toContain('session-1')
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
@@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => {
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
})
|
||||
|
||||
it('treats passive monitoring as outside the foreground turn', () => {
|
||||
workingTab.agentStatus.workingMode = 'monitoring'
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
expect(controller?.nativeChatAgentWorking).toBe(false)
|
||||
expect(controller?.nativeChatStreamLive).toBe(false)
|
||||
expect(controller?.nativeChatStreamingText).toBeUndefined()
|
||||
expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
|
||||
const before = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(before).toBe(controller?.nativeChatStreamScopeKey)
|
||||
|
||||
@@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: {
|
||||
transcriptSettled: nativeChatSession.status === 'ready'
|
||||
})
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
|
||||
const activeTabStatus = activeSessionTab?.agentStatus
|
||||
const activeTabAgentWorking =
|
||||
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
|
||||
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
|
||||
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
|
||||
// Deliberately not gated on the chat view being visible: the streaming gate
|
||||
// has to tell "hidden mid-turn" from "the turn ended".
|
||||
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
|
||||
const nativeChatStreamLive = activeTabAgentWorking
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import {
|
||||
RpcClientReconnectSchedule,
|
||||
RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
} from './rpc-client-reconnect-schedule'
|
||||
import { RpcClientRequestTracker } from './rpc-client-request-tracker'
|
||||
import { RpcClientSocketCloseController } from './rpc-client-socket-close-controller'
|
||||
import { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import {
|
||||
RpcClientStreamRegistry,
|
||||
type RpcStreamingListener,
|
||||
type RpcStreamSubscribeOptions
|
||||
} from './rpc-client-stream-registry'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type {
|
||||
ConnectionLogLevel,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
export class DirectRpcClient implements RpcClient {
|
||||
private socketSession: RpcClientSocketSession | null = null
|
||||
private readonly connectionState: RpcClientConnectionState
|
||||
private readonly reconnect: RpcClientReconnectSchedule
|
||||
private readonly requests: RpcClientRequestTracker
|
||||
private readonly streams: RpcClientStreamRegistry
|
||||
private readonly liveness: RpcSessionLivenessWatchdog
|
||||
private readonly socketFactory: RpcClientSocketFactory
|
||||
private readonly authenticationRetry: RpcClientAuthenticationRetry
|
||||
private readonly socketClose: RpcClientSocketCloseController
|
||||
private requestCounter = 0
|
||||
private logCounter = 0
|
||||
private intentionallyClosed = false
|
||||
private authenticationGeneration = 0
|
||||
private livenessSession: RpcClientSocketSession | null = null
|
||||
|
||||
constructor(
|
||||
private readonly endpoint: string,
|
||||
private readonly deviceToken: string,
|
||||
serverPublicKeyB64: string,
|
||||
private readonly options: ConnectOptions
|
||||
) {
|
||||
this.reconnect = new RpcClientReconnectSchedule({
|
||||
openConnection: () => this.openConnection(),
|
||||
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
|
||||
emitLog: (message, detail) => this.emitLog('info', message, detail)
|
||||
})
|
||||
this.connectionState = new RpcClientConnectionState({
|
||||
endpoint,
|
||||
initialListener: options.onStateChange,
|
||||
getReconnectAttempt: () => this.reconnect.getAttempt(),
|
||||
isClosed: () => this.intentionallyClosed
|
||||
})
|
||||
this.streams = new RpcClientStreamRegistry({
|
||||
nextId: () => this.nextId(),
|
||||
deviceToken,
|
||||
getState: () => this.connectionState.get(),
|
||||
sendEncrypted: (request) => this.sendEncrypted(request)
|
||||
})
|
||||
this.requests = new RpcClientRequestTracker({
|
||||
nextId: () => this.nextId(),
|
||||
deviceToken,
|
||||
getState: () => this.connectionState.get(),
|
||||
waitForConnected: (timeoutMs) => this.waitForConnected(timeoutMs),
|
||||
sendEncrypted: (request) => this.sendEncrypted(request)
|
||||
})
|
||||
this.liveness = new RpcSessionLivenessWatchdog({
|
||||
transport: 'direct',
|
||||
sendProbe: (identity) => this.sendLivenessProbe(identity),
|
||||
terminate: (identity) => {
|
||||
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
|
||||
this.socketClose.forceClose(this.livenessSession)
|
||||
}
|
||||
}
|
||||
})
|
||||
this.socketFactory = new RpcClientSocketFactory({
|
||||
endpoint,
|
||||
deviceToken,
|
||||
serverPublicKeyB64,
|
||||
getCurrentSocket: () => this.socketSession?.socket ?? null,
|
||||
getState: () => this.getState(),
|
||||
getReconnectAttempt: () => this.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => this.getLastConnectedAt(),
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
|
||||
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
|
||||
onAuthenticated: (session) => this.handleAuthenticated(session),
|
||||
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
|
||||
onRpcResponse: (response) => this.handleRpcResponse(response),
|
||||
onBinary: (bytes) => this.streams.handleBinary(bytes),
|
||||
onAuthenticatedInbound: (session) => this.liveness.noteAuthenticatedInbound(session),
|
||||
onClosed: (session, closeCode) => this.socketClose.handle(session, closeCode),
|
||||
onForcedClose: (session) => this.socketClose.forceClose(session)
|
||||
})
|
||||
this.authenticationRetry = new RpcClientAuthenticationRetry({
|
||||
endpoint,
|
||||
stopLiveness: () => this.stopLiveness(),
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
|
||||
retry: (reason) => this.retryAuthentication(reason),
|
||||
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
|
||||
})
|
||||
this.socketClose = new RpcClientSocketCloseController({
|
||||
connectionState: this.connectionState,
|
||||
reconnect: this.reconnect,
|
||||
requests: this.requests,
|
||||
streams: this.streams,
|
||||
socketFactory: this.socketFactory,
|
||||
authenticationRetry: this.authenticationRetry,
|
||||
getCurrentSession: () => this.socketSession,
|
||||
clearCurrentSession: () => (this.socketSession = null),
|
||||
getAuthenticationGeneration: () => this.authenticationGeneration,
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
stopLiveness: (session) => {
|
||||
if (this.livenessSession === session) {
|
||||
this.stopLiveness()
|
||||
}
|
||||
},
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
|
||||
})
|
||||
this.openConnection()
|
||||
}
|
||||
|
||||
sendRequest(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: SendRequestOptions
|
||||
): Promise<RpcResponse> {
|
||||
return this.requests.sendRequest(method, params, options)
|
||||
}
|
||||
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
onData: RpcStreamingListener,
|
||||
options?: RpcStreamSubscribeOptions
|
||||
): () => void {
|
||||
return this.streams.subscribe(method, params, onData, options)
|
||||
}
|
||||
|
||||
updateTerminalSubscriptionViewport(
|
||||
terminal: string,
|
||||
viewport: { cols: number; rows: number }
|
||||
): void {
|
||||
this.streams.updateTerminalViewport(terminal, viewport)
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.connectionState.get()
|
||||
}
|
||||
|
||||
getReconnectAttempt(): number {
|
||||
return this.reconnect.getAttempt()
|
||||
}
|
||||
|
||||
getLastConnectedAt(): number | null {
|
||||
return this.connectionState.getLastConnectedAt()
|
||||
}
|
||||
|
||||
getLastInboundAt = (): number | null => this.liveness.getLastInboundAt() || null
|
||||
|
||||
onStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
return this.connectionState.addListener(listener)
|
||||
}
|
||||
|
||||
notifyForeground(_reason?: ForegroundNudgeReason): void {
|
||||
if (this.intentionallyClosed) {
|
||||
return
|
||||
}
|
||||
if (this.getState() === 'connected') {
|
||||
console.log('[net] foreground — probing live connection')
|
||||
if (this.livenessSession) {
|
||||
this.liveness.probeNow(this.livenessSession)
|
||||
}
|
||||
return
|
||||
}
|
||||
const dialing = this.socketSession
|
||||
const dialAgeMs = Date.now() - this.socketFactory.getDialStartedAt()
|
||||
let abandoned = false
|
||||
if (dialing && isStaleForegroundDial(this.getState(), dialAgeMs)) {
|
||||
console.log('[net] foreground — abandoning stale dial', {
|
||||
state: this.getState(),
|
||||
dialAgeMs
|
||||
})
|
||||
this.socketClose.forceClose(dialing)
|
||||
abandoned = true
|
||||
}
|
||||
if (this.getState() === 'reconnecting') {
|
||||
console.log('[net] foreground — restarting reconnect loop', {
|
||||
attempt: this.getReconnectAttempt(),
|
||||
hadTimer: this.reconnect.hasTimer()
|
||||
})
|
||||
this.reconnect.redialNow(!abandoned)
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.intentionallyClosed = true
|
||||
this.reconnect.cancel()
|
||||
const session = this.socketSession
|
||||
session?.clearTimers()
|
||||
if (this.livenessSession) {
|
||||
this.liveness.stop(this.livenessSession)
|
||||
this.livenessSession = null
|
||||
}
|
||||
session?.close()
|
||||
this.socketSession = null
|
||||
session?.clearKey()
|
||||
this.connectionState.publish('disconnected')
|
||||
this.requests.rejectAll('Client closed', { deliveryUnknown: true })
|
||||
}
|
||||
|
||||
private openConnection(): void {
|
||||
if (this.intentionallyClosed) {
|
||||
return
|
||||
}
|
||||
this.connectionState.publish('connecting')
|
||||
this.socketSession = this.socketFactory.open()
|
||||
}
|
||||
|
||||
private handleAuthenticated(session: RpcClientSocketSession): void {
|
||||
console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() })
|
||||
this.livenessSession = session
|
||||
this.liveness.start(session)
|
||||
this.authenticationGeneration++
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
private handleRpcResponse(response: RpcResponse): void {
|
||||
if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) {
|
||||
return
|
||||
}
|
||||
if (!response.ok && response.error.code === 'unauthorized') {
|
||||
this.authenticationRetry.reject('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
if (!this.streams.handleResponse(response)) {
|
||||
this.requests.resolve(response)
|
||||
}
|
||||
}
|
||||
|
||||
private stopLiveness(): void {
|
||||
if (this.livenessSession) {
|
||||
this.liveness.stop(this.livenessSession)
|
||||
this.livenessSession = null
|
||||
}
|
||||
}
|
||||
|
||||
private retryAuthentication(reason: string): void {
|
||||
const closing = this.socketSession
|
||||
this.socketSession = null
|
||||
closing?.clearKey()
|
||||
this.streams.markForReplay()
|
||||
this.requests.rejectAll(reason)
|
||||
closing?.close()
|
||||
this.connectionState.publish('reconnecting')
|
||||
this.reconnect.schedule()
|
||||
}
|
||||
|
||||
private latchAuthenticationFailure(reason: string): void {
|
||||
this.intentionallyClosed = true
|
||||
this.socketSession?.close()
|
||||
this.socketSession = null
|
||||
this.connectionState.publish('auth-failed')
|
||||
this.requests.rejectAll(reason)
|
||||
}
|
||||
|
||||
private sendEncrypted(request: unknown): boolean {
|
||||
if (this.socketSession) {
|
||||
return this.socketSession.sendEncrypted(request)
|
||||
}
|
||||
console.log('[net] sendEncrypted FAILED — channel not ready', {
|
||||
hasWs: false,
|
||||
hasKey: false,
|
||||
state: this.getState()
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
private sendLivenessProbe(identity: object): boolean {
|
||||
if (identity !== this.livenessSession || this.getState() !== 'connected') {
|
||||
return false
|
||||
}
|
||||
return this.sendEncrypted({
|
||||
id: `${LIVENESS_REQUEST_ID_PREFIX}${this.nextId()}`,
|
||||
deviceToken: this.deviceToken,
|
||||
method: 'status.get'
|
||||
})
|
||||
}
|
||||
|
||||
private waitForConnected(timeoutMs?: number): Promise<void> {
|
||||
if (
|
||||
this.getState() === 'reconnecting' &&
|
||||
this.getReconnectAttempt() >= RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
) {
|
||||
return Promise.reject(new Error('Connection retry limit reached'))
|
||||
}
|
||||
return this.connectionState.waitForConnected(timeoutMs)
|
||||
}
|
||||
|
||||
private nextId(): string {
|
||||
return `rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
|
||||
this.options.onLog?.({
|
||||
id: `log-${++this.logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => {
|
||||
)
|
||||
expect(socket.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a typed close code when transport error precedes close', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' }))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', () => {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
})
|
||||
|
||||
it('bounds an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
|
||||
link.close()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the missing-close timer when explicitly closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new ThrowingSocket()
|
||||
const onError = vi.fn()
|
||||
const link = new MobileRelayE2eeLink({
|
||||
endpoint: {
|
||||
cellUrl: 'https://relay-c1.onorca.dev',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9'
|
||||
},
|
||||
credential: 'credential',
|
||||
expectedCredentialKind: 'resume',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'desktop-key',
|
||||
onAuthenticated: vi.fn(),
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onError,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onerror?.()
|
||||
link.close()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
// Native WebSockets normally emit close immediately after error; bound the
|
||||
// missing-close case so a dead socket cannot leave recovery pending forever.
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
export class RelayOuterError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`relay_outer_${code}`)
|
||||
@@ -32,6 +36,7 @@ export class MobileRelayE2eeLink {
|
||||
private readonly channel: MobileE2EEV2PhysicalChannel
|
||||
private outerReady = false
|
||||
private closed = false
|
||||
private transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private inboundChain: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(options: MobileRelayE2eeLinkOptions) {
|
||||
@@ -70,6 +75,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.socket.close()
|
||||
}
|
||||
@@ -103,8 +112,21 @@ export class MobileRelayE2eeLink {
|
||||
})
|
||||
.catch((error: unknown) => this.fail(asError(error)))
|
||||
}
|
||||
this.socket.onerror = () => this.fail(new Error('relay transport error'))
|
||||
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
|
||||
// `error` is often delivered just before `close`; wait for close so a
|
||||
// typed relay code is not replaced by a generic transport error.
|
||||
this.socket.onerror = () => {
|
||||
this.transportErrorTimer ??= setTimeout(() => {
|
||||
this.transportErrorTimer = null
|
||||
this.fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
this.socket.onclose = (event) => {
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
}
|
||||
|
||||
private acceptHello(raw: unknown): void {
|
||||
@@ -137,6 +159,10 @@ export class MobileRelayE2eeLink {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
if (this.transportErrorTimer) {
|
||||
clearTimeout(this.transportErrorTimer)
|
||||
this.transportErrorTimer = null
|
||||
}
|
||||
this.channel.dispose()
|
||||
this.options.onError(error)
|
||||
this.socket.close()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
|
||||
import {
|
||||
RelayDirectorMoveNotNewerError,
|
||||
resolvePairingInviteThroughDirector
|
||||
} from './mobile-relay-invite-director'
|
||||
|
||||
class FakeSocket {
|
||||
sent: string[] = []
|
||||
@@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects same/older epochs and untrusted extra fields', async () => {
|
||||
it('rejects malformed moves with untrusted extra fields', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
@@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
await expect(resolving).rejects.toThrow(/not strictly newer/)
|
||||
await expect(resolving).rejects.toThrow(/invalid relay director move/)
|
||||
})
|
||||
|
||||
it('describes a same-epoch move without accepting it', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const resolving = resolvePairingInviteThroughDirector({
|
||||
relay,
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'relay-moved',
|
||||
v: 1,
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
|
||||
const error = await resolving.catch((value: unknown) => value)
|
||||
expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError)
|
||||
expect(error).toMatchObject({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
|
||||
|
||||
export class RelayDirectorMoveNotNewerError extends Error {
|
||||
readonly cellUrl: string
|
||||
readonly assignmentEpoch: number
|
||||
readonly currentCellUrl: string
|
||||
readonly currentAssignmentEpoch: number
|
||||
|
||||
constructor(args: {
|
||||
cellUrl: string
|
||||
assignmentEpoch: number
|
||||
currentCellUrl: string
|
||||
currentAssignmentEpoch: number
|
||||
}) {
|
||||
super('relay director move was not strictly newer')
|
||||
this.name = 'RelayDirectorMoveNotNewerError'
|
||||
this.cellUrl = args.cellUrl
|
||||
this.assignmentEpoch = args.assignmentEpoch
|
||||
this.currentCellUrl = args.currentCellUrl
|
||||
this.currentAssignmentEpoch = args.currentAssignmentEpoch
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePairingInviteThroughDirector(args: {
|
||||
relay: PairingRelay
|
||||
timeoutMs?: number
|
||||
@@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: {
|
||||
return
|
||||
}
|
||||
const moved = RelayMovedSchema.safeParse(value)
|
||||
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(new Error('relay director move was not strictly newer'))
|
||||
if (!moved.success) {
|
||||
finish(new Error('invalid relay director move'))
|
||||
return
|
||||
}
|
||||
if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
|
||||
finish(
|
||||
new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: moved.data.cellUrl,
|
||||
assignmentEpoch: moved.data.assignmentEpoch,
|
||||
currentCellUrl: args.relay.cellUrl,
|
||||
currentAssignmentEpoch: args.relay.assignmentEpoch
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
|
||||
@@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => {
|
||||
expect(fakes.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the typed close code when transport error precedes close', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 4409 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(4409))
|
||||
})
|
||||
|
||||
it('classifies an opaque close after transport error as 1006', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
socket.onerror?.()
|
||||
socket.onclose?.({ code: 0 })
|
||||
|
||||
await expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
})
|
||||
|
||||
it('settles after an error when the platform never emits close', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const socket = new FakeSocket()
|
||||
const client = connectMobileRelayForPairing({
|
||||
relay,
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
createSocket: () => socket as unknown as WebSocket
|
||||
})
|
||||
const status = client.sendRequest('status.get')
|
||||
const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006))
|
||||
socket.onerror?.()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
|
||||
const socket = new FakeSocket()
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
|
||||
@@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
export { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
|
||||
const RELAY_ERROR_CLOSE_GRACE_MS = 250
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
@@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: {
|
||||
let requestCounter = 0
|
||||
let closed = false
|
||||
let intentionallyClosed = false
|
||||
let transportErrorTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let outerReady = false
|
||||
let authenticated = false
|
||||
let resolveAuthenticated!: () => void
|
||||
@@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: {
|
||||
})
|
||||
.catch((error: unknown) => fail(asError(error)))
|
||||
}
|
||||
socket.onerror = () => fail(new Error('relay transport error'))
|
||||
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
|
||||
// WebSocket implementations commonly emit `error` immediately before
|
||||
// `close`; the bounded fallback represents an opaque 1006 close.
|
||||
socket.onerror = () => {
|
||||
transportErrorTimer ??= setTimeout(() => {
|
||||
transportErrorTimer = null
|
||||
fail(new RelayOuterError(1006))
|
||||
}, RELAY_ERROR_CLOSE_GRACE_MS)
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
fail(new RelayOuterError(event.code || 1006))
|
||||
}
|
||||
|
||||
function acceptRelayHello(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
@@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
if (transportErrorTimer) {
|
||||
clearTimeout(transportErrorTimer)
|
||||
transportErrorTimer = null
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
log('info', 'Relay: pairing socket closed', cellHost)
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayOuterError } from './mobile-relay-physical-client'
|
||||
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
@@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => {
|
||||
expect(stale.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries the authoritative cell when the director confirms the same assignment', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const persistMove = vi.fn()
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: (relay) => {
|
||||
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
|
||||
expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch)
|
||||
return connects++ === 0 ? stale : target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove,
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(2)
|
||||
expect(persistMove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps recovery bounded when the authoritative-cell constructor throws', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const target = client(Promise.resolve(success()))
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
if (connects === 1) {
|
||||
return stale
|
||||
}
|
||||
if (connects === 2) {
|
||||
throw new Error('relay constructor failed')
|
||||
}
|
||||
return target
|
||||
},
|
||||
resolveDirector: async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: relay.cellUrl,
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
},
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
onLog: (entry) => entries.push(entry)
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
|
||||
expect(connects).toBe(3)
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'warn',
|
||||
message: 'Relay: recovery attempt 1 failed',
|
||||
detail: 'Error: relay constructor failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry a same-epoch move to a different cell', async () => {
|
||||
const stale = client(Promise.reject(new RelayOuterError(1006)))
|
||||
const resolveDirector = vi.fn(async (relay) => {
|
||||
throw new RelayDirectorMoveNotNewerError({
|
||||
cellUrl: 'https://relay-c2.onorca.dev',
|
||||
assignmentEpoch: relay.assignmentEpoch,
|
||||
currentCellUrl: relay.cellUrl,
|
||||
currentAssignmentEpoch: relay.assignmentEpoch
|
||||
})
|
||||
})
|
||||
let connects = 0
|
||||
const candidate = createRecoveringPairingRelayCandidate({
|
||||
journal,
|
||||
connect: () => {
|
||||
connects++
|
||||
return stale
|
||||
},
|
||||
resolveDirector,
|
||||
persistMove: vi.fn(),
|
||||
now: () => 1,
|
||||
random: () => 0,
|
||||
sleep: async () => {}
|
||||
})
|
||||
|
||||
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
|
||||
expect(resolveDirector).toHaveBeenCalledTimes(3)
|
||||
expect(connects).toBe(1)
|
||||
})
|
||||
|
||||
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
|
||||
const offline = client(Promise.reject(new RelayOuterError(4404)))
|
||||
const resolveDirector = vi.fn()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
|
||||
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
|
||||
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
|
||||
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
|
||||
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
@@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (isCurrentAssignmentMove(error, relay)) {
|
||||
log(
|
||||
'info',
|
||||
'Relay: director kept current assignment',
|
||||
redactSocketEndpoint(relay.cellUrl)
|
||||
)
|
||||
client.close()
|
||||
await backOff(attempt)
|
||||
if (closed) {
|
||||
throw new Error('relay pairing client closed')
|
||||
}
|
||||
try {
|
||||
client = args.connect(relay, args.onLog)
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (retryError) {
|
||||
lastError = retryError
|
||||
log(
|
||||
'warn',
|
||||
`Relay: recovery attempt ${attempt + 1} failed`,
|
||||
pairingRelayErrorDetail(retryError)
|
||||
)
|
||||
if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
throw retryError
|
||||
}
|
||||
await backOff(attempt)
|
||||
continue
|
||||
}
|
||||
}
|
||||
log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))
|
||||
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
|
||||
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
|
||||
@@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
|
||||
return (
|
||||
error instanceof RelayDirectorMoveNotNewerError &&
|
||||
error.assignmentEpoch === relay.assignmentEpoch &&
|
||||
error.cellUrl === relay.cellUrl &&
|
||||
error.currentAssignmentEpoch === relay.assignmentEpoch &&
|
||||
error.currentCellUrl === relay.cellUrl
|
||||
)
|
||||
}
|
||||
|
||||
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
|
||||
return {
|
||||
...journal.metadata.relay,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
|
||||
type AuthenticationRetryOptions = {
|
||||
endpoint: string
|
||||
stopLiveness: () => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
retry: (reason: string) => void
|
||||
latchFailure: (reason: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientAuthenticationRetry {
|
||||
private rejectionCount = 0
|
||||
|
||||
constructor(private readonly options: AuthenticationRetryOptions) {}
|
||||
|
||||
accepted(): void {
|
||||
this.rejectionCount = 0
|
||||
}
|
||||
|
||||
reject(reason: string, preserveRecovery = false): void {
|
||||
this.options.stopLiveness()
|
||||
this.rejectionCount++
|
||||
if (this.rejectionCount < AUTH_RETRY_BUDGET) {
|
||||
console.log('[net] auth rejected — retrying handshake', {
|
||||
attempt: this.rejectionCount,
|
||||
budget: AUTH_RETRY_BUDGET,
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
this.options.emitWarning(
|
||||
'Authentication rejected',
|
||||
`Retrying (${this.rejectionCount}/${AUTH_RETRY_BUDGET})`
|
||||
)
|
||||
if (!preserveRecovery) {
|
||||
this.options.retry(reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
|
||||
attempt: this.rejectionCount,
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
this.options.latchFailure(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
type ConnectWaiter = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
type ConnectionStateOptions = {
|
||||
endpoint: string
|
||||
initialListener?: (state: ConnectionState) => void
|
||||
getReconnectAttempt: () => number
|
||||
isClosed: () => boolean
|
||||
}
|
||||
|
||||
export class RpcClientConnectionState {
|
||||
private state: ConnectionState = 'disconnected'
|
||||
private lastConnectedAt: number | null = null
|
||||
private stateEnteredAt = Date.now()
|
||||
private readonly listeners = new Set<(state: ConnectionState) => void>()
|
||||
private readonly waiters: ConnectWaiter[] = []
|
||||
|
||||
constructor(private readonly options: ConnectionStateOptions) {
|
||||
if (options.initialListener) {
|
||||
this.listeners.add(options.initialListener)
|
||||
}
|
||||
}
|
||||
|
||||
get(): ConnectionState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
getLastConnectedAt(): number | null {
|
||||
return this.lastConnectedAt
|
||||
}
|
||||
|
||||
publish(next: ConnectionState): void {
|
||||
if (this.state === next) {
|
||||
return
|
||||
}
|
||||
const previous = this.state
|
||||
const dweltMs = Date.now() - this.stateEnteredAt
|
||||
this.state = next
|
||||
this.stateEnteredAt = Date.now()
|
||||
console.log('[net] state', {
|
||||
from: previous,
|
||||
to: next,
|
||||
dweltMs,
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint)
|
||||
})
|
||||
if (next === 'connected') {
|
||||
this.lastConnectedAt = Date.now()
|
||||
this.resolveWaiters()
|
||||
} else if (next === 'disconnected' || next === 'auth-failed') {
|
||||
this.rejectWaiters(
|
||||
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
|
||||
)
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
|
||||
waitForConnected(timeoutMs?: number): Promise<void> {
|
||||
if (this.state === 'connected') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (this.options.isClosed()) {
|
||||
return Promise.reject(new Error('Client closed'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
|
||||
if (timeoutMs !== undefined) {
|
||||
waiter.timeout = setTimeout(
|
||||
() => {
|
||||
const index = this.waiters.indexOf(waiter)
|
||||
if (index !== -1) {
|
||||
this.waiters.splice(index, 1)
|
||||
}
|
||||
reject(new Error('Timed out while connecting to the remote Orca runtime.'))
|
||||
},
|
||||
Math.max(0, timeoutMs)
|
||||
)
|
||||
}
|
||||
this.waiters.push(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
rejectWaiters(reason: string): void {
|
||||
const error = new Error(reason)
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: (state: ConnectionState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private resolveWaiters(): void {
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
waiter.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
|
||||
export const RPC_RECONNECT_ATTEMPT_LIMIT = 12
|
||||
const TRICKLE_RECONNECT_DELAY_MS = 90_000
|
||||
|
||||
type ReconnectScheduleOptions = {
|
||||
openConnection: () => void
|
||||
rejectConnectWaiters: (reason: string) => void
|
||||
emitLog: (message: string, detail: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientReconnectSchedule {
|
||||
private attempt = 0
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private readonly options: ReconnectScheduleOptions) {}
|
||||
|
||||
getAttempt(): number {
|
||||
return this.attempt
|
||||
}
|
||||
|
||||
authenticated(): void {
|
||||
this.attempt = 0
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
const trickle = this.attempt >= RPC_RECONNECT_ATTEMPT_LIMIT
|
||||
let delayMs: number
|
||||
if (trickle) {
|
||||
delayMs = TRICKLE_RECONNECT_DELAY_MS
|
||||
this.options.rejectConnectWaiters('Connection retry limit reached')
|
||||
} else {
|
||||
delayMs = RECONNECT_DELAYS[Math.min(this.attempt, RECONNECT_DELAYS.length - 1)]!
|
||||
this.attempt++
|
||||
}
|
||||
console.log('[net] scheduleReconnect', {
|
||||
delayMs,
|
||||
attempt: this.attempt,
|
||||
trickle
|
||||
})
|
||||
this.options.emitLog(
|
||||
`Reconnect scheduled in ${delayMs}ms`,
|
||||
trickle ? `Attempt ${this.attempt} (slow retry)` : `Attempt ${this.attempt}`
|
||||
)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.options.openConnection()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
redialNow(resetAttempts: boolean): void {
|
||||
this.cancel()
|
||||
if (resetAttempts) {
|
||||
this.attempt = 0
|
||||
}
|
||||
this.options.openConnection()
|
||||
}
|
||||
|
||||
hasTimer(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
|
||||
import type { SendRequestOptions } from './rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
type RequestTrackerOptions = {
|
||||
nextId: () => string
|
||||
getState: () => ConnectionState
|
||||
waitForConnected: (timeoutMs?: number) => Promise<void>
|
||||
sendEncrypted: (request: unknown) => boolean
|
||||
deviceToken: string
|
||||
}
|
||||
|
||||
export class RpcClientRequestTracker {
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
|
||||
constructor(private readonly options: RequestTrackerOptions) {}
|
||||
|
||||
async sendRequest(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
requestOptions?: SendRequestOptions
|
||||
): Promise<RpcResponse> {
|
||||
const budget = openRpcRequestBudget(requestOptions)
|
||||
const waitStart = budget.startedAt
|
||||
const wasConnected = this.options.getState() === 'connected'
|
||||
if (requestOptions?.failWhenDisconnected && !wasConnected) {
|
||||
throw new Error(`Not connected: ${method}`)
|
||||
}
|
||||
await this.options.waitForConnected(requestOptions?.timeoutMs)
|
||||
if (!wasConnected) {
|
||||
console.log('[net] sendRequest waited for connect', {
|
||||
method,
|
||||
waitedMs: Date.now() - waitStart
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.options.nextId()
|
||||
const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS)
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
console.log('[net] sendRequest TIMEOUT', {
|
||||
method,
|
||||
timeoutMs,
|
||||
state: this.options.getState()
|
||||
})
|
||||
reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`)))
|
||||
}, timeoutMs)
|
||||
this.pending.set(id, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(response)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
if (
|
||||
!this.options.sendEncrypted({
|
||||
id,
|
||||
deviceToken: this.options.deviceToken,
|
||||
method,
|
||||
params
|
||||
})
|
||||
) {
|
||||
this.pending.delete(id)
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('Connection interrupted'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resolve(response: RpcResponse): boolean {
|
||||
const request = this.pending.get(response.id)
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
this.pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(reason: string, options?: { deliveryUnknown?: boolean }): void {
|
||||
const error = options?.deliveryUnknown
|
||||
? markRpcDeliveryUnknown(new Error(reason))
|
||||
: new Error(reason)
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id)
|
||||
queueMicrotask(() => request.reject(error))
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.pending.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function buildReadyStreamUnsubscribe(
|
||||
method: string,
|
||||
subscriptionId: string
|
||||
): { method: string; params: { subscriptionId: string } } | null {
|
||||
if (method === 'browser.screencast') {
|
||||
return { method: 'browser.screencast.unsubscribe', params: { subscriptionId } }
|
||||
}
|
||||
if (method === 'runtime.clientEvents.subscribe') {
|
||||
return { method: 'runtime.clientEvents.unsubscribe', params: { subscriptionId } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import type { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import type { RpcClientReconnectSchedule } from './rpc-client-reconnect-schedule'
|
||||
import type { RpcClientRequestTracker } from './rpc-client-request-tracker'
|
||||
import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
|
||||
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
|
||||
type SocketCloseControllerOptions = {
|
||||
connectionState: RpcClientConnectionState
|
||||
reconnect: RpcClientReconnectSchedule
|
||||
requests: RpcClientRequestTracker
|
||||
streams: RpcClientStreamRegistry
|
||||
socketFactory: RpcClientSocketFactory
|
||||
authenticationRetry: RpcClientAuthenticationRetry
|
||||
getCurrentSession: () => RpcClientSocketSession | null
|
||||
clearCurrentSession: () => void
|
||||
getAuthenticationGeneration: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
stopLiveness: (session: RpcClientSocketSession) => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketCloseController {
|
||||
private readonly synthesizedCloses = new RpcSynthesizedCloseIndex()
|
||||
|
||||
constructor(private readonly options: SocketCloseControllerOptions) {}
|
||||
|
||||
forceClose(session: RpcClientSocketSession): void {
|
||||
session.close()
|
||||
if (this.options.getCurrentSession() === session) {
|
||||
this.synthesizedCloses.remember(session.socket, this.options.getAuthenticationGeneration())
|
||||
this.handle(session)
|
||||
}
|
||||
}
|
||||
|
||||
handle(session: RpcClientSocketSession, closeCode?: number): void {
|
||||
if (this.options.getCurrentSession() !== session) {
|
||||
if (
|
||||
this.synthesizedCloses.takeUnauthorized(
|
||||
session.socket,
|
||||
closeCode,
|
||||
this.options.getAuthenticationGeneration(),
|
||||
UNAUTHORIZED_CLOSE_CODE
|
||||
)
|
||||
) {
|
||||
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked', true)
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
|
||||
state: this.options.connectionState.get(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
return
|
||||
}
|
||||
this.options.socketFactory.noteClosed()
|
||||
session.clearTimers()
|
||||
session.clearKey()
|
||||
this.options.clearCurrentSession()
|
||||
this.options.streams.markForReplay()
|
||||
this.options.stopLiveness(session)
|
||||
if (this.options.isIntentionallyClosed()) {
|
||||
console.log('[net] handleSocketClosed — intentional close')
|
||||
this.options.connectionState.publish('disconnected')
|
||||
this.options.requests.rejectAll('Connection closed', { deliveryUnknown: true })
|
||||
return
|
||||
}
|
||||
if (closeCode === UNAUTHORIZED_CLOSE_CODE) {
|
||||
console.log('[net] handleSocketClosed — unauthorized close code', {
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed → reconnect', {
|
||||
pendingCount: this.options.requests.size(),
|
||||
streamCount: this.options.streams.size(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
|
||||
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
|
||||
this.options.connectionState.publish('reconnecting')
|
||||
this.options.reconnect.schedule()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { publicKeyFromBase64 } from './e2ee'
|
||||
import { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SocketFactoryOptions = {
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
serverPublicKeyB64: string
|
||||
getCurrentSocket: () => WebSocket | null
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
getLastConnectedAt: () => number | null
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
onRpcResponse: (response: RpcResponse) => void
|
||||
onBinary: (bytes: Uint8Array) => void
|
||||
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
|
||||
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
|
||||
onForcedClose: (session: RpcClientSocketSession) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketFactory {
|
||||
private readonly serverPublicKey: Uint8Array
|
||||
private lastInboundAt: number | null = null
|
||||
private lastSocketClosedAt: number | null = null
|
||||
private constructionCount = 0
|
||||
private dialStartedAt = 0
|
||||
|
||||
constructor(private readonly options: SocketFactoryOptions) {
|
||||
this.serverPublicKey = publicKeyFromBase64(options.serverPublicKeyB64)
|
||||
}
|
||||
|
||||
open(): RpcClientSocketSession {
|
||||
const now = Date.now()
|
||||
const lastConnectedAt = this.options.getLastConnectedAt()
|
||||
this.constructionCount++
|
||||
console.log('[net] openConnection', {
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint),
|
||||
wsCount: this.constructionCount,
|
||||
msSinceLastConnected: lastConnectedAt !== null ? now - lastConnectedAt : null,
|
||||
msSinceLastClose: this.lastSocketClosedAt !== null ? now - this.lastSocketClosedAt : null,
|
||||
msSinceLastInbound: this.lastInboundAt !== null ? now - this.lastInboundAt : null
|
||||
})
|
||||
this.dialStartedAt = now
|
||||
this.options.emitLog(
|
||||
'info',
|
||||
this.options.getReconnectAttempt() > 0
|
||||
? `Reconnecting (attempt ${this.options.getReconnectAttempt() + 1})`
|
||||
: 'Opening WebSocket',
|
||||
redactSocketEndpoint(this.options.endpoint)
|
||||
)
|
||||
return new RpcClientSocketSession({
|
||||
endpoint: this.options.endpoint,
|
||||
deviceToken: this.options.deviceToken,
|
||||
serverPublicKey: this.serverPublicKey,
|
||||
getCurrentSocket: this.options.getCurrentSocket,
|
||||
getState: this.options.getState,
|
||||
getReconnectAttempt: this.options.getReconnectAttempt,
|
||||
isIntentionallyClosed: this.options.isIntentionallyClosed,
|
||||
emitLog: this.options.emitLog,
|
||||
onHandshakeStarted: this.options.onHandshakeStarted,
|
||||
onAuthenticated: this.options.onAuthenticated,
|
||||
onAuthRejected: this.options.onAuthRejected,
|
||||
onRpcResponse: this.options.onRpcResponse,
|
||||
onBinary: this.options.onBinary,
|
||||
onAnyInbound: (receivedAt) => (this.lastInboundAt = receivedAt),
|
||||
onAuthenticatedInbound: this.options.onAuthenticatedInbound,
|
||||
onClosed: this.options.onClosed,
|
||||
onForcedClose: this.options.onForcedClose
|
||||
})
|
||||
}
|
||||
|
||||
getDialStartedAt(): number {
|
||||
return this.dialStartedAt
|
||||
}
|
||||
|
||||
noteClosed(): void {
|
||||
this.lastSocketClosedAt = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
decrypt,
|
||||
decryptBytes,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
generateKeyPair,
|
||||
publicKeyToBase64
|
||||
} from './e2ee'
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
|
||||
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
const WEBSOCKET_CONNECTING_STATE = 0
|
||||
|
||||
type SocketSessionOptions = {
|
||||
endpoint: string
|
||||
deviceToken: string
|
||||
serverPublicKey: Uint8Array
|
||||
getCurrentSocket: () => WebSocket | null
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
onRpcResponse: (response: RpcResponse) => void
|
||||
onBinary: (bytes: Uint8Array) => void
|
||||
onAnyInbound: (receivedAt: number) => void
|
||||
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
|
||||
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
|
||||
onForcedClose: (session: RpcClientSocketSession) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketSession {
|
||||
readonly socket: WebSocket
|
||||
readonly constructedAt = Date.now()
|
||||
private sharedKey: Uint8Array | null = null
|
||||
private authenticated = false
|
||||
private lastInboundAt: number | null = null
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private readonly options: SocketSessionOptions) {
|
||||
this.socket = new WebSocket(options.endpoint)
|
||||
this.attachHandlers()
|
||||
this.armConnectTimeout()
|
||||
}
|
||||
|
||||
sendEncrypted(request: unknown): boolean {
|
||||
if (this.socket.readyState === WebSocket.OPEN && this.sharedKey) {
|
||||
try {
|
||||
this.socket.send(encrypt(JSON.stringify(request), this.sharedKey))
|
||||
return true
|
||||
} catch {
|
||||
if (this.options.getCurrentSocket() === this.socket) {
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
console.log('[net] sendEncrypted FAILED — channel not ready', {
|
||||
hasWs: this.options.getCurrentSocket() !== null,
|
||||
readyState: this.socket.readyState,
|
||||
hasKey: this.sharedKey !== null,
|
||||
state: this.options.getState()
|
||||
})
|
||||
if (
|
||||
this.options.getState() === 'connected' &&
|
||||
this.options.getCurrentSocket() === this.socket &&
|
||||
this.socket.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
|
||||
readyState: this.socket.readyState
|
||||
})
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
clearTimers(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
clearKey(): void {
|
||||
this.sharedKey = null
|
||||
}
|
||||
|
||||
private attachHandlers(): void {
|
||||
this.socket.onopen = () => {
|
||||
if (this.isStale('open')) {
|
||||
return
|
||||
}
|
||||
console.log('[net] ws.onopen', { attempt: this.options.getReconnectAttempt() })
|
||||
this.clearConnectTimer()
|
||||
this.options.onHandshakeStarted()
|
||||
this.options.emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
|
||||
const ephemeral = generateKeyPair()
|
||||
const hello = JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
|
||||
})
|
||||
try {
|
||||
this.socket.send(hello)
|
||||
} catch {
|
||||
this.options.onForcedClose(this)
|
||||
return
|
||||
}
|
||||
this.options.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready')
|
||||
this.sharedKey = deriveSharedKey(ephemeral.secretKey, this.options.serverPublicKey)
|
||||
this.armHandshakeTimeout()
|
||||
}
|
||||
this.socket.onmessage = (event) => {
|
||||
if (!this.isStale('message')) {
|
||||
void this.handleMessage(event.data)
|
||||
}
|
||||
}
|
||||
this.socket.onclose = (event) => {
|
||||
const closeCode = logRpcSocketClose({
|
||||
event,
|
||||
state: this.options.getState(),
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
intentionallyClosed: this.options.isIntentionallyClosed(),
|
||||
endpoint: redactSocketEndpoint(this.options.endpoint),
|
||||
constructedAt: this.constructedAt,
|
||||
authenticated: this.authenticated,
|
||||
lastInboundAt: this.lastInboundAt
|
||||
})
|
||||
this.options.onClosed(this, closeCode)
|
||||
}
|
||||
this.socket.onerror = (event) => {
|
||||
if (this.isStale('error')) {
|
||||
return
|
||||
}
|
||||
const error = event as { message?: string } | undefined
|
||||
const description = describeSocketEvent(event)
|
||||
console.log('[net] ws.onerror', {
|
||||
message: error?.message,
|
||||
state: this.options.getState(),
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
eventKeys: description.keys,
|
||||
eventStr: description.json
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(rawData: unknown): Promise<void> {
|
||||
const receivedAt = Date.now()
|
||||
this.lastInboundAt = receivedAt
|
||||
this.options.onAnyInbound(receivedAt)
|
||||
const raw = typeof rawData === 'string' ? rawData : null
|
||||
if (!this.authenticated) {
|
||||
this.handleHandshakeMessage(raw)
|
||||
return
|
||||
}
|
||||
if (!this.sharedKey || this.sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
if (raw === null) {
|
||||
const bytes = await websocketPayloadToUint8(rawData)
|
||||
if (this.options.getCurrentSocket() !== this.socket || !bytes) {
|
||||
return
|
||||
}
|
||||
const plaintext = decryptBytes(bytes, this.sharedKey)
|
||||
if (!plaintext) {
|
||||
return
|
||||
}
|
||||
this.options.onAuthenticatedInbound(this)
|
||||
this.options.onBinary(plaintext)
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw, this.sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
this.options.onAuthenticatedInbound(this)
|
||||
let response: unknown
|
||||
try {
|
||||
response = JSON.parse(plaintext)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (isRpcResponse(response)) {
|
||||
this.options.onRpcResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private handleHandshakeMessage(raw: string | null): void {
|
||||
if (raw === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(raw) as { type?: unknown }
|
||||
if (message.type === 'e2ee_ready') {
|
||||
this.options.emitLog('success', 'Received e2ee_ready', 'Sending device token')
|
||||
this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.options.deviceToken })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// The authenticated handshake messages are encrypted.
|
||||
}
|
||||
if (!this.sharedKey || this.sharedKey.length !== 32) {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw, this.sharedKey)
|
||||
if (plaintext === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const message = JSON.parse(plaintext) as {
|
||||
type?: unknown
|
||||
ok?: unknown
|
||||
error?: { code?: unknown }
|
||||
}
|
||||
if (message.type === 'e2ee_authenticated') {
|
||||
this.clearHandshakeTimer()
|
||||
this.authenticated = true
|
||||
this.options.onAuthenticated(this)
|
||||
} else if (
|
||||
message.type === 'e2ee_error' ||
|
||||
(message.ok === false && message.error?.code === 'unauthorized')
|
||||
) {
|
||||
console.log('[net] e2ee auth FAILED', {
|
||||
msgType: message.type,
|
||||
error: message.error
|
||||
})
|
||||
this.clearHandshakeTimer()
|
||||
this.options.onAuthRejected('Unauthorized — pairing may be revoked')
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed handshake payloads.
|
||||
}
|
||||
}
|
||||
|
||||
private armConnectTimeout(): void {
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null
|
||||
if (
|
||||
this.options.getCurrentSocket() === this.socket &&
|
||||
this.socket.readyState === WEBSOCKET_CONNECTING_STATE
|
||||
) {
|
||||
console.log('[net] connect-timeout fired (onopen never arrived)', {
|
||||
attempt: this.options.getReconnectAttempt(),
|
||||
timeoutMs: CONNECT_TIMEOUT_MS
|
||||
})
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'WebSocket connect timeout',
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private armHandshakeTimeout(): void {
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (this.options.getCurrentSocket() !== this.socket || this.authenticated) {
|
||||
return
|
||||
}
|
||||
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS
|
||||
})
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'Handshake timeout',
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private clearConnectTimer(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer)
|
||||
this.connectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private isStale(eventName: string): boolean {
|
||||
return isStaleRpcSocketEvent(
|
||||
this.options.getCurrentSocket(),
|
||||
this.socket,
|
||||
eventName,
|
||||
this.options.getState(),
|
||||
this.options.getReconnectAttempt()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SentRequest = {
|
||||
id: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
function createRegistry(initialState: ConnectionState = 'connected') {
|
||||
const sent: SentRequest[] = []
|
||||
let state = initialState
|
||||
let id = 0
|
||||
const registry = new RpcClientStreamRegistry({
|
||||
nextId: () => `rpc-${++id}`,
|
||||
deviceToken: 'device-token',
|
||||
getState: () => state,
|
||||
sendEncrypted: (request) => {
|
||||
sent.push(request as SentRequest)
|
||||
return true
|
||||
}
|
||||
})
|
||||
return {
|
||||
registry,
|
||||
sent,
|
||||
setState(next: ConnectionState) {
|
||||
state = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamingResponse(id: string, result: unknown): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
|
||||
function terminalOutput(streamId: number, chunk: string): Uint8Array {
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: new TextEncoder().encode(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
describe('RpcClientStreamRegistry', () => {
|
||||
it('replays the latest terminal viewport without retaining stale stream routing', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const events: unknown[] = []
|
||||
registry.subscribe(
|
||||
'terminal.subscribe',
|
||||
{ terminal: 'term-1', viewport: { cols: 45, rows: 20 } },
|
||||
(event) => events.push(event)
|
||||
)
|
||||
const first = sent[0]!
|
||||
registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 7 }))
|
||||
registry.handleBinary(terminalOutput(7, 'before'))
|
||||
|
||||
registry.updateTerminalViewport('term-1', { cols: 60, rows: 24 })
|
||||
registry.markForReplay()
|
||||
registry.replayAfterAuthentication()
|
||||
registry.handleBinary(terminalOutput(7, 'stale'))
|
||||
|
||||
expect(sent[1]).toMatchObject({
|
||||
id: first.id,
|
||||
method: 'terminal.subscribe',
|
||||
params: { terminal: 'term-1', viewport: { cols: 60, rows: 24 } }
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'subscribed', streamId: 7 },
|
||||
{ type: 'data', streamId: 7, chunk: 'before' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a disposed browser tombstone until ready can be unsubscribed', () => {
|
||||
const { registry, sent } = createRegistry()
|
||||
const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {})
|
||||
const request = sent[0]!
|
||||
|
||||
dispose()
|
||||
expect(sent).toHaveLength(1)
|
||||
|
||||
registry.handleResponse(
|
||||
streamingResponse(request.id, {
|
||||
type: 'ready',
|
||||
subscriptionId: 'browser-screencast:page-1:test'
|
||||
})
|
||||
)
|
||||
expect(sent[1]).toMatchObject({
|
||||
method: 'browser.screencast.unsubscribe',
|
||||
params: { subscriptionId: 'browser-screencast:page-1:test' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
decodeBrowserScreencastFrame,
|
||||
type BrowserScreencastFrame
|
||||
} from './browser-screencast-protocol'
|
||||
import {
|
||||
buildStreamUnsubscribe,
|
||||
buildTerminalUnsubscribeParams,
|
||||
updateTerminalSubscriptionViewport
|
||||
} from './rpc-client-terminal-subscription'
|
||||
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
|
||||
import {
|
||||
isStreamingSubscriptionReadyResult,
|
||||
isTerminalSubscribedResult
|
||||
} from './rpc-subscription-result-shapes'
|
||||
import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router'
|
||||
import type { ConnectionState, RpcResponse, RpcSuccess } from './types'
|
||||
|
||||
export type RpcStreamingListener = (result: unknown) => void
|
||||
|
||||
export type RpcStreamSubscribeOptions = {
|
||||
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
|
||||
}
|
||||
|
||||
type StreamRequest = {
|
||||
method: string
|
||||
params: unknown
|
||||
listener: RpcStreamingListener
|
||||
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
|
||||
subscriptionId?: string
|
||||
cancelled?: boolean
|
||||
sent?: boolean
|
||||
}
|
||||
|
||||
type StreamRegistryOptions = {
|
||||
nextId: () => string
|
||||
deviceToken: string
|
||||
getState: () => ConnectionState
|
||||
sendEncrypted: (request: unknown) => boolean
|
||||
}
|
||||
|
||||
export class RpcClientStreamRegistry {
|
||||
private readonly streams = new Map<string, StreamRequest>()
|
||||
private readonly terminalRouter = new RpcClientTerminalStreamRouter()
|
||||
private activeBrowserRequestId: string | null = null
|
||||
private pendingBrowserRequestId: string | null = null
|
||||
|
||||
constructor(private readonly options: StreamRegistryOptions) {}
|
||||
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
listener: RpcStreamingListener,
|
||||
subscribeOptions?: RpcStreamSubscribeOptions
|
||||
): () => void {
|
||||
const id = this.options.nextId()
|
||||
const stream: StreamRequest = {
|
||||
method,
|
||||
params,
|
||||
listener,
|
||||
onBinaryFrame: subscribeOptions?.onBinaryFrame
|
||||
}
|
||||
this.streams.set(id, stream)
|
||||
if (method === 'browser.screencast') {
|
||||
this.replaceBrowserStream(id)
|
||||
}
|
||||
if (this.options.getState() === 'connected') {
|
||||
if (this.send(id, stream)) {
|
||||
stream.sent = true
|
||||
} else {
|
||||
this.emitError(stream, 'Connection interrupted')
|
||||
this.remove(id)
|
||||
}
|
||||
} else {
|
||||
console.log('[net] subscribe queued — waiting for connected', {
|
||||
method,
|
||||
state: this.options.getState()
|
||||
})
|
||||
}
|
||||
return () => this.dispose(id)
|
||||
}
|
||||
|
||||
replayAfterAuthentication(): void {
|
||||
for (const [id, stream] of this.streams) {
|
||||
if (stream.cancelled) {
|
||||
this.remove(id)
|
||||
continue
|
||||
}
|
||||
if (stream.sent) {
|
||||
continue
|
||||
}
|
||||
if (stream.method === 'browser.screencast') {
|
||||
this.pendingBrowserRequestId = id
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
this.resetTerminalRouting(id)
|
||||
if (this.send(id, stream)) {
|
||||
stream.sent = true
|
||||
} else {
|
||||
this.markForReplay()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markForReplay(): void {
|
||||
this.activeBrowserRequestId = null
|
||||
this.pendingBrowserRequestId = null
|
||||
for (const [id, stream] of this.streams) {
|
||||
stream.sent = false
|
||||
this.resetTerminalRouting(id)
|
||||
}
|
||||
}
|
||||
|
||||
handleResponse(response: RpcResponse): boolean {
|
||||
if (response.ok && response.streaming === true) {
|
||||
this.handleStreamingResponse(response)
|
||||
return true
|
||||
}
|
||||
const stream = this.streams.get(response.id)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as Record<string, unknown> | null
|
||||
if (stream && result?.type === 'end') {
|
||||
if (!stream.cancelled) {
|
||||
stream.listener(result)
|
||||
}
|
||||
this.remove(response.id)
|
||||
return true
|
||||
}
|
||||
if (stream && result?.type === 'scrollback') {
|
||||
stream.listener(result)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!stream) {
|
||||
return false
|
||||
}
|
||||
this.emitError(
|
||||
stream,
|
||||
response.ok ? 'Streaming request ended before it was ready.' : response.error.message,
|
||||
response.ok ? undefined : response.error
|
||||
)
|
||||
this.remove(response.id)
|
||||
return true
|
||||
}
|
||||
|
||||
handleBinary(bytes: Uint8Array): void {
|
||||
const browserFrame = decodeBrowserScreencastFrame(bytes)
|
||||
if (browserFrame) {
|
||||
this.handleBrowserFrame(browserFrame)
|
||||
return
|
||||
}
|
||||
this.terminalRouter.handle(bytes)
|
||||
}
|
||||
|
||||
updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void {
|
||||
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.streams.size
|
||||
}
|
||||
|
||||
private handleStreamingResponse(response: RpcSuccess): void {
|
||||
const stream = this.streams.get(response.id)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
const result = response.result
|
||||
if (isStreamingSubscriptionReadyResult(result)) {
|
||||
stream.subscriptionId = result.subscriptionId
|
||||
if (stream.cancelled) {
|
||||
this.sendServerSubscriptionUnsubscribe(stream)
|
||||
this.remove(response.id)
|
||||
return
|
||||
}
|
||||
if (stream.method === 'browser.screencast') {
|
||||
if (
|
||||
this.pendingBrowserRequestId !== response.id &&
|
||||
this.activeBrowserRequestId !== response.id
|
||||
) {
|
||||
this.sendBrowserUnsubscribe(result.subscriptionId)
|
||||
this.remove(response.id)
|
||||
return
|
||||
}
|
||||
this.pendingBrowserRequestId = null
|
||||
this.activeBrowserRequestId = response.id
|
||||
}
|
||||
}
|
||||
if (isTerminalSubscribedResult(result)) {
|
||||
this.terminalRouter.register(response.id, result.streamId, stream.listener)
|
||||
}
|
||||
if (!stream.cancelled) {
|
||||
stream.listener(result)
|
||||
}
|
||||
}
|
||||
|
||||
private dispose(id: string): void {
|
||||
const stream = this.streams.get(id)
|
||||
if (stream?.method === 'browser.screencast') {
|
||||
stream.cancelled = true
|
||||
this.clearBrowserRequest(id)
|
||||
this.disposeServerSubscription(id, stream)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'runtime.clientEvents.subscribe') {
|
||||
this.disposeServerSubscription(id, stream)
|
||||
return
|
||||
}
|
||||
if (stream?.method === 'terminal.subscribe') {
|
||||
const params = buildTerminalUnsubscribeParams(stream.params)
|
||||
if (params) {
|
||||
this.sendRpc('terminal.unsubscribe', params)
|
||||
}
|
||||
} else {
|
||||
const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params)
|
||||
if (unsubscribe) {
|
||||
this.sendRpc(unsubscribe.method, unsubscribe.params)
|
||||
}
|
||||
}
|
||||
this.remove(id)
|
||||
}
|
||||
|
||||
private replaceBrowserStream(id: string): void {
|
||||
if (this.activeBrowserRequestId && this.activeBrowserRequestId !== id) {
|
||||
this.dispose(this.activeBrowserRequestId)
|
||||
}
|
||||
if (this.pendingBrowserRequestId && this.pendingBrowserRequestId !== id) {
|
||||
this.dispose(this.pendingBrowserRequestId)
|
||||
}
|
||||
this.pendingBrowserRequestId = id
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
|
||||
private disposeServerSubscription(id: string, stream: StreamRequest): void {
|
||||
stream.cancelled = true
|
||||
if (stream.subscriptionId) {
|
||||
this.sendServerSubscriptionUnsubscribe(stream)
|
||||
this.remove(id)
|
||||
} else if (!stream.sent) {
|
||||
this.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private sendServerSubscriptionUnsubscribe(stream: StreamRequest): void {
|
||||
if (!stream.subscriptionId) {
|
||||
return
|
||||
}
|
||||
const unsubscribe = buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId)
|
||||
if (unsubscribe) {
|
||||
this.sendRpc(unsubscribe.method, unsubscribe.params)
|
||||
}
|
||||
}
|
||||
|
||||
private sendBrowserUnsubscribe(subscriptionId: string): void {
|
||||
this.sendRpc('browser.screencast.unsubscribe', { subscriptionId })
|
||||
}
|
||||
|
||||
private sendRpc(method: string, params: unknown): boolean {
|
||||
return this.options.sendEncrypted({
|
||||
id: this.options.nextId(),
|
||||
deviceToken: this.options.deviceToken,
|
||||
method,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
private send(id: string, stream: StreamRequest): boolean {
|
||||
return this.options.sendEncrypted({
|
||||
id,
|
||||
deviceToken: this.options.deviceToken,
|
||||
method: stream.method,
|
||||
params: stream.params
|
||||
})
|
||||
}
|
||||
|
||||
private remove(id: string): void {
|
||||
const stream = this.streams.get(id)
|
||||
this.streams.delete(id)
|
||||
this.clearBrowserRequest(id)
|
||||
this.resetTerminalRouting(id)
|
||||
if (stream?.method === 'browser.screencast') {
|
||||
stream.cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
private clearBrowserRequest(id: string): void {
|
||||
if (this.activeBrowserRequestId === id) {
|
||||
this.activeBrowserRequestId = null
|
||||
}
|
||||
if (this.pendingBrowserRequestId === id) {
|
||||
this.pendingBrowserRequestId = null
|
||||
}
|
||||
}
|
||||
|
||||
private resetTerminalRouting(id: string): void {
|
||||
this.terminalRouter.reset(id)
|
||||
}
|
||||
|
||||
private handleBrowserFrame(frame: BrowserScreencastFrame): void {
|
||||
if (!this.activeBrowserRequestId) {
|
||||
return
|
||||
}
|
||||
const stream = this.streams.get(this.activeBrowserRequestId)
|
||||
if (!stream || stream.cancelled || stream.method !== 'browser.screencast') {
|
||||
return
|
||||
}
|
||||
stream.onBinaryFrame?.(frame)
|
||||
}
|
||||
|
||||
private emitError(stream: StreamRequest, message: string, error?: unknown): void {
|
||||
if (!stream.cancelled) {
|
||||
stream.listener({ type: 'error', message, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
handleTerminalBinaryFrame,
|
||||
type TerminalSnapshotState
|
||||
} from './rpc-client-terminal-binary-frame'
|
||||
|
||||
type TerminalListener = (result: unknown) => void
|
||||
|
||||
export class RpcClientTerminalStreamRouter {
|
||||
private readonly listeners = new Map<number, TerminalListener>()
|
||||
private readonly idsByRequest = new Map<string, Set<number>>()
|
||||
private readonly snapshots = new Map<number, TerminalSnapshotState>()
|
||||
|
||||
register(requestId: string, streamId: number, listener: TerminalListener): void {
|
||||
const ids = this.idsByRequest.get(requestId) ?? new Set<number>()
|
||||
this.idsByRequest.set(requestId, ids)
|
||||
ids.add(streamId)
|
||||
this.listeners.set(streamId, listener)
|
||||
}
|
||||
|
||||
reset(requestId: string): void {
|
||||
const streamIds = this.idsByRequest.get(requestId)
|
||||
if (!streamIds) {
|
||||
return
|
||||
}
|
||||
for (const streamId of streamIds) {
|
||||
this.listeners.delete(streamId)
|
||||
this.snapshots.delete(streamId)
|
||||
}
|
||||
this.idsByRequest.delete(requestId)
|
||||
}
|
||||
|
||||
handle(bytes: Uint8Array): void {
|
||||
handleTerminalBinaryFrame(bytes, {
|
||||
terminalSnapshots: this.snapshots,
|
||||
getListener: (streamId) => this.listeners.get(streamId)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeA
|
||||
describe('agentDotState', () => {
|
||||
it('maps known states through and unknown to idle', () => {
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0)
|
||||
).toBe('monitoring')
|
||||
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
|
||||
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
|
||||
@@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => {
|
||||
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
|
||||
'Working'
|
||||
)
|
||||
expect(
|
||||
agentDisplayLabel(
|
||||
row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }),
|
||||
0
|
||||
)
|
||||
).toBe('Monitoring background tasks')
|
||||
})
|
||||
|
||||
it('falls back to the decayed state label when stale', () => {
|
||||
|
||||
@@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
|
||||
// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
|
||||
// status state; 'blocked'/'waiting' read as attention states, 'done' as
|
||||
// complete, everything else idle.
|
||||
export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted'
|
||||
export type AgentDotState =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'blocked'
|
||||
| 'waiting'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'interrupted'
|
||||
|
||||
export function agentDotState(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'updatedAt'>,
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
|
||||
now: number
|
||||
): AgentDotState {
|
||||
if (row.interrupted) {
|
||||
return 'interrupted'
|
||||
}
|
||||
switch (row.state) {
|
||||
case 'working':
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
// Why: an agent that exits without a final report would otherwise read as
|
||||
// active forever. Decay a stale active state to idle, matching desktop's
|
||||
// renderer-side staleness decay (worktree-agent-rows.ts).
|
||||
return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
|
||||
case 'working':
|
||||
if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
return 'idle'
|
||||
}
|
||||
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
}
|
||||
@@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return 'Working'
|
||||
case 'monitoring':
|
||||
return 'Monitoring background tasks'
|
||||
case 'blocked':
|
||||
return 'Blocked'
|
||||
case 'waiting':
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from './home-resume-card'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
const resumeCardSource = readFileSync(
|
||||
new URL('../home/MobileHomeResumeCard.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function worktree(worktreeId: string): HomeWorktreeSummary {
|
||||
return {
|
||||
@@ -120,17 +123,8 @@ describe('home resume card', () => {
|
||||
})
|
||||
|
||||
it('renders the home Resume card inert until its host connects', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
|
||||
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
|
||||
|
||||
// Assert the markers first: a renamed banner would otherwise slice garbage and report a
|
||||
// missing prop instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
expect(resumeCard).toContain('disabled={!resumeCard.actionable}')
|
||||
expect(resumeCard).toContain('!resumeCard.actionable && styles.cardDisabled')
|
||||
expect(resumeCardSource).toContain('disabled={!props.card.actionable}')
|
||||
expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
|
||||
export type Worktree = {
|
||||
@@ -45,6 +46,7 @@ export type Worktree = {
|
||||
linkedGitLabIssue?: number | null
|
||||
comment?: string
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
workingMode?: AgentWorkingMode
|
||||
agents?: RuntimeWorktreeAgentRow[]
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => {
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects monitoring mode changes within working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects workspace monitoring mode changes within working', () => {
|
||||
const first = [worktree({ status: 'working' })]
|
||||
const second = [worktree({ status: 'working', workingMode: 'monitoring' })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats missing and empty agent arrays as equivalent for rendering', () => {
|
||||
const first = [worktree({ agents: undefined })]
|
||||
const second = [worktree({ agents: [] })]
|
||||
|
||||
@@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
|
||||
(left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) &&
|
||||
(left.comment ?? '') === (right.comment ?? '') &&
|
||||
(left.status ?? null) === (right.status ?? null) &&
|
||||
(left.workingMode ?? null) === (right.workingMode ?? null) &&
|
||||
arePullRequestsEqual(left.linkedPR, right.linkedPR) &&
|
||||
areAgentRowsEqual(left.agents ?? [], right.agents ?? [])
|
||||
)
|
||||
@@ -102,6 +103,7 @@ function areAgentRowsEqual(
|
||||
a.paneKey !== b.paneKey ||
|
||||
a.parentPaneKey !== b.parentPaneKey ||
|
||||
a.state !== b.state ||
|
||||
a.workingMode !== b.workingMode ||
|
||||
a.agentType !== b.agentType ||
|
||||
a.prompt !== b.prompt ||
|
||||
a.lastAssistantMessage !== b.lastAssistantMessage ||
|
||||
|
||||
Generated
+3
-3
@@ -27,7 +27,7 @@ patchedDependencies:
|
||||
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
|
||||
path: config/patches/lint-staged@16.4.0.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5
|
||||
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -69,7 +69,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5)
|
||||
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -12046,7 +12046,7 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5):
|
||||
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
resolveCliCommandMock,
|
||||
rmSyncMock,
|
||||
spawnMock,
|
||||
stdioForWindowsInteractiveChildMock,
|
||||
writeKeychainMock
|
||||
} = vi.hoisted(() => ({
|
||||
deleteKeychainMock: vi.fn(),
|
||||
@@ -20,6 +22,7 @@ const {
|
||||
resolveCliCommandMock: vi.fn(),
|
||||
rmSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
stdioForWindowsInteractiveChildMock: vi.fn(),
|
||||
writeKeychainMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -41,10 +44,16 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
|
||||
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
|
||||
writeActiveClaudeKeychainCredentials: writeKeychainMock
|
||||
}))
|
||||
vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
// Why importOriginal: withCliRuntimeOnPath is a pure filesystem-probing helper,
|
||||
// and the PATH assertions below are only meaningful against the real one.
|
||||
vi.mock('../../shared/node-cli-command-resolution', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof NodeCliCommandResolutionModule>()),
|
||||
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
|
||||
resolveCliCommand: resolveCliCommandMock
|
||||
}))
|
||||
vi.mock('../../shared/windows-console-input', () => ({
|
||||
stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock
|
||||
}))
|
||||
|
||||
import { ACCOUNT_HANDLERS } from './account'
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
@@ -105,6 +114,10 @@ describe('account CLI handlers', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
spawnMock.mockReset().mockImplementation(() => successfulChild())
|
||||
stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({
|
||||
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
resolveCliCommandMock.mockReset().mockImplementation((command: string) => command)
|
||||
getVersionManagerBinPathsMock.mockReset().mockReturnValue([])
|
||||
readKeychainMock.mockReset().mockResolvedValue(null)
|
||||
@@ -159,6 +172,56 @@ describe('account CLI handlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const dispose = vi.fn()
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, 'inherit', 'inherit'],
|
||||
dispose
|
||||
})
|
||||
|
||||
await ACCOUNT_HANDLERS['account add'](context('codex'))
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['login', '--device-auth'],
|
||||
expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] })
|
||||
)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disposes the Windows console input fd when spawn throws synchronously', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const dispose = vi.fn()
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, 'inherit', 'inherit'],
|
||||
dispose
|
||||
})
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error('invalid stdio')
|
||||
})
|
||||
|
||||
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio')
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
stdioForWindowsInteractiveChildMock.mockReturnValue({
|
||||
stdio: [11, process.stderr, 'inherit'],
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
await ACCOUNT_HANDLERS['account add'](context('codex', true))
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['login', '--device-auth'],
|
||||
expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes Windows package-manager shims through the safe cmd launcher', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd')
|
||||
|
||||
@@ -14,13 +14,15 @@ import {
|
||||
} from '../../main/claude-accounts/keychain'
|
||||
import {
|
||||
getVersionManagerBinPaths,
|
||||
resolveCliCommand
|
||||
resolveCliCommand,
|
||||
withCliRuntimeOnPath
|
||||
} from '../../shared/node-cli-command-resolution'
|
||||
import {
|
||||
getSpawnArgsForWindows,
|
||||
UnsafeWindowsBatchArgumentsError,
|
||||
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
|
||||
} from '../../shared/windows-batch-spawn'
|
||||
import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input'
|
||||
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type {
|
||||
@@ -112,13 +114,25 @@ async function runAgentLoginInTerminal(
|
||||
)
|
||||
return
|
||||
}
|
||||
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
// Why: JSON mode reserves stdout for the response envelope while keeping
|
||||
// the interactive login attached to the user's terminal via stderr.
|
||||
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
|
||||
env
|
||||
})
|
||||
// Why paired after the seed: addAgentNodePaths prepends the *newest* version
|
||||
// manager bin, which is not necessarily where this CLI lives. Pairing last puts
|
||||
// the CLI's own node in front of that seed (stablyai/orca#10932).
|
||||
const env = withCliRuntimeOnPath(
|
||||
resolvedCommand,
|
||||
addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
|
||||
)
|
||||
const consoleStdio = stdioForWindowsInteractiveChild(json)
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
// Why: JSON mode reserves stdout for the response envelope while keeping
|
||||
// the interactive login attached to the user's terminal via stderr.
|
||||
stdio: consoleStdio.stdio,
|
||||
env
|
||||
})
|
||||
} finally {
|
||||
consoleStdio.dispose()
|
||||
}
|
||||
session.child = child
|
||||
child.once('error', (error) =>
|
||||
rejectPromise(
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
|
||||
import { printResult } from '../format'
|
||||
import { HANDLER_GROUPS } from '../handler-group-manifest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import {
|
||||
ORCHESTRATION_DISPATCH_HANDLER,
|
||||
ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS
|
||||
} from './orchestration/dispatch-handlers'
|
||||
import { getOptionalStructuredMessagePayload } from './orchestration/message-payload'
|
||||
import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags'
|
||||
import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output'
|
||||
|
||||
describe('extracted orchestration flag parsing', () => {
|
||||
it('serializes phase and omits empty CSV entries', () => {
|
||||
const payload = getOptionalStructuredMessagePayload(
|
||||
new Map([
|
||||
['phase', 'verification'],
|
||||
['files-modified', ' src/a.ts, ,src/b.ts, ']
|
||||
])
|
||||
)
|
||||
|
||||
expect(JSON.parse(payload ?? '')).toEqual({
|
||||
filesModified: ['src/a.ts', 'src/b.ts'],
|
||||
phase: 'verification'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsafe positive integers before dispatch', () => {
|
||||
expect(() =>
|
||||
getOptionalPositiveIntegerValueFlag(
|
||||
new Map([['timeout-ms', '9007199254740992']]),
|
||||
'timeout-ms'
|
||||
)
|
||||
).toThrow('Invalid positive safe integer for --timeout-ms')
|
||||
})
|
||||
})
|
||||
|
||||
it('composes handlers in the canonical command order', () => {
|
||||
const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys
|
||||
expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared)
|
||||
})
|
||||
|
||||
describe('extracted orchestration worker formatting', () => {
|
||||
it('renders terminal tails without changing line boundaries', () => {
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
terminal: { tail: ['first', '', 'third'] }
|
||||
} as never)
|
||||
).toBe('first\n\nthird')
|
||||
})
|
||||
|
||||
it('renders transcript blocks and survives unserializable tool input', () => {
|
||||
const circular: { self?: unknown } = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
source: 'transcript',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
id: 'message_1',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'working' },
|
||||
{ type: 'tool-call', name: 'inspect', input: circular },
|
||||
{ type: 'tool-result', output: 'failed', isError: true },
|
||||
{ type: 'image-ref', url: 'https://example.test/proof.png' }
|
||||
],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never)
|
||||
).toBe(
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders release evidence in receipt order', () => {
|
||||
expect(
|
||||
formatWorkerRelease({
|
||||
dispatchId: 'dispatch_1',
|
||||
state: 'release_unknown',
|
||||
reason: 'host_unreachable',
|
||||
processAction: 'unverifiable',
|
||||
archive: { source: 'transcript', status: 'saved' },
|
||||
lastError: 'transport closed',
|
||||
recovery: 'Reconnect and retry.'
|
||||
})
|
||||
).toBe(
|
||||
'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' +
|
||||
'archive transcript [saved]\ntransport closed\nReconnect and retry.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extracted orchestration dispatch handlers', () => {
|
||||
const call = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
call.mockReset()
|
||||
vi.mocked(printResult).mockReset()
|
||||
})
|
||||
|
||||
it('allows a dry run without a recipient and returns only its preamble', async () => {
|
||||
call.mockResolvedValue({
|
||||
result: { dispatch: null, dryRun: true, preamble: 'preview preamble' }
|
||||
})
|
||||
|
||||
await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['from', 'terminal_coordinator'],
|
||||
['dry-run', true],
|
||||
['return-preamble', true]
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.dispatch', {
|
||||
task: 'task_1',
|
||||
run: undefined,
|
||||
to: undefined,
|
||||
from: 'terminal_coordinator',
|
||||
inject: undefined,
|
||||
dryRun: true,
|
||||
returnPreamble: true,
|
||||
devMode: false
|
||||
})
|
||||
expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe(
|
||||
'preview preamble'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => {
|
||||
call.mockResolvedValue({ result: { dispatch: null } })
|
||||
|
||||
await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({
|
||||
flags: new Map([['task', 'task_1']]),
|
||||
client: { call },
|
||||
cwd: '/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', {
|
||||
task: 'task_1',
|
||||
preamble: undefined,
|
||||
from: undefined,
|
||||
devMode: false
|
||||
})
|
||||
expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.')
|
||||
})
|
||||
})
|
||||
|
||||
function renderedValue(value: unknown): string {
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string
|
||||
return formatter(value)
|
||||
}
|
||||
+27
-1322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
|
||||
// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default.
|
||||
function resolveKeepaliveIntervalMs(): number {
|
||||
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
|
||||
if (!raw) {
|
||||
return DEFAULT_KEEPALIVE_INTERVAL_MS
|
||||
}
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_KEEPALIVE_INTERVAL_MS
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function startCheckKeepalive(deadlineMs: number | undefined): () => void {
|
||||
const startedAt = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
const payload = {
|
||||
_keepalive: true,
|
||||
// Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive.
|
||||
_heartbeat: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
deadlineMs: deadlineMs ?? null
|
||||
}
|
||||
process.stderr.write(`${JSON.stringify(payload)}\n`)
|
||||
}, resolveKeepaliveIntervalMs())
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { isDevCliInvocation } from './runtime-compatibility'
|
||||
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
|
||||
|
||||
export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
|
||||
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const dryRun = flags.has('dry-run') ? true : undefined
|
||||
const returnPreamble = flags.has('return-preamble') ? true : undefined
|
||||
// Why: --to is only required for non-dry-run; the RPC handler re-enforces.
|
||||
const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to')
|
||||
const result = await callOrchestrationMutation<{
|
||||
dispatch: { id: string; task_id: string; status: string } | null
|
||||
injected?: boolean
|
||||
dryRun?: boolean
|
||||
preamble?: string
|
||||
}>(client, flags, 'orchestration.dispatch', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
to,
|
||||
from,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
dryRun,
|
||||
returnPreamble,
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.dryRun) {
|
||||
return value.preamble ?? ''
|
||||
}
|
||||
const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]`
|
||||
return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
|
||||
const showPreamble = flags.has('preamble') ? true : undefined
|
||||
// Why: a preview must embed the same real coordinator handle as an actual dispatch.
|
||||
const from = showPreamble
|
||||
? await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
: undefined
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string } | null
|
||||
preamble?: string
|
||||
}>('orchestration.dispatchShow', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
preamble: showPreamble,
|
||||
from,
|
||||
devMode: isDevCliInvocation()
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.preamble && showPreamble) {
|
||||
return value.preamble
|
||||
}
|
||||
if (!value.dispatch) {
|
||||
return 'No dispatch context found.'
|
||||
}
|
||||
return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]`
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration coordinator-start': async () => {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
'The legacy automatic coordinator command is retired. No effects were applied.',
|
||||
orchestrationMigrationData('command_retired')
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration coordinator-stop': async () => {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
'The legacy automatic coordinator command is retired. No effects were applied.',
|
||||
orchestrationMigrationData('command_retired')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
|
||||
|
||||
export const ORCHESTRATION_GATE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration gate-create': async ({ flags, client, cwd, json }) => {
|
||||
const result = await callOrchestrationMutation<{
|
||||
gate: { id: string; task_id: string; status: string }
|
||||
}>(client, flags, 'orchestration.gateCreate', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
question: getRequiredStringFlag(flags, 'question'),
|
||||
options: getOptionalStringFlag(flags, 'options'),
|
||||
// Why: gates are Run-scoped, so the coordinator handle is the authorized caller identity.
|
||||
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Gate ${value.gate.id} created for task ${value.gate.task_id} [${value.gate.status}]`
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration gate-resolve': async ({ flags, client, cwd, json }) => {
|
||||
const result = await callOrchestrationMutation<{
|
||||
gate: { id: string; task_id: string; status: string; resolution: string }
|
||||
}>(client, flags, 'orchestration.gateResolve', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
resolution: getRequiredStringFlag(flags, 'resolution'),
|
||||
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
printResult(result, json, (value) => `Gate ${value.gate.id} resolved: ${value.gate.resolution}`)
|
||||
},
|
||||
|
||||
'orchestration gate-list': async ({ flags, client, cwd, json }) => {
|
||||
const run = getOptionalStringFlag(flags, 'run')
|
||||
// Why: named runs remain inspectable without a pane; only implicit runs resolve identity.
|
||||
const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client)
|
||||
const result = await client.call<{
|
||||
gates: { id: string; task_id: string; question: string; status: string }[]
|
||||
count: number
|
||||
runId?: string
|
||||
}>('orchestration.gateList', {
|
||||
task: getOptionalStringFlag(flags, 'task'),
|
||||
status: getOptionalStringFlag(flags, 'status'),
|
||||
run,
|
||||
from
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.gates.length === 0) {
|
||||
return 'No gates found.'
|
||||
}
|
||||
return value.gates
|
||||
.map((gate) => `${gate.id} task=${gate.task_id} [${gate.status}] "${gate.question}"`)
|
||||
.join('\n')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import type { RuntimeRpcSuccess } from '../../runtime-client'
|
||||
import {
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type LegacyCompatibilityResult,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../../shared/orchestration-check-output'
|
||||
import { startCheckKeepalive } from './check-keepalive'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { getOptionalPositiveIntegerValueFlag } from './numeric-flags'
|
||||
import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility'
|
||||
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
|
||||
|
||||
type CheckResult = {
|
||||
messages: MessageSummary[]
|
||||
count: number
|
||||
formatted?: string
|
||||
deliveryId?: string | null
|
||||
runId?: string
|
||||
timedOut?: boolean
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_CHECK_HANDLER: Record<string, CommandHandler> = {
|
||||
'orchestration check': async ({ flags, client, cwd, json }) => {
|
||||
const wait = flags.has('wait')
|
||||
const peek = flags.has('peek')
|
||||
// Why: older runtimes strip unknown peek and run --unread --peek as destructive mark-read.
|
||||
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose at most one message read mode: --unread, --peek, or --all.'
|
||||
)
|
||||
}
|
||||
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
|
||||
const explicitTerminal = getOptionalStringFlag(flags, 'terminal')
|
||||
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
|
||||
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
|
||||
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
|
||||
try {
|
||||
result = await callOrchestrationMutation<CheckResult>(client, flags, 'orchestration.check', {
|
||||
terminal,
|
||||
terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined,
|
||||
// Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read.
|
||||
unread: flags.has('unread') ? true : peek ? false : undefined,
|
||||
peek: peek ? true : undefined,
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
format: flags.has('format') ? true : undefined,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
ack: getOptionalStringFlag(flags, 'ack'),
|
||||
wait: wait ? true : undefined,
|
||||
timeoutMs
|
||||
})
|
||||
} finally {
|
||||
stopKeepalive?.()
|
||||
}
|
||||
if (peek) {
|
||||
result = filterLegacyPeekResult(result, wait)
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
|
||||
}
|
||||
printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal))
|
||||
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
|
||||
if (compatibilityAck && compatibilityAck.length > 0) {
|
||||
await flushOrchestrationStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: compatibilityAck,
|
||||
types: getOptionalStringFlag(flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterLegacyPeekResult(
|
||||
result: RuntimeRpcSuccess<CheckResult>,
|
||||
wait: boolean
|
||||
): RuntimeRpcSuccess<CheckResult> {
|
||||
const rawRowCount = result.result.messages.length
|
||||
const unreadOnly = result.result.messages.filter((message) => message.read !== 1)
|
||||
const removedReadRows = unreadOnly.length !== rawRowCount
|
||||
// Why: read rows prove a pre-peek runtime cannot honor wait, so fail instead of returning early.
|
||||
if (wait && removedReadRows && unreadOnly.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'peek_wait_unsupported',
|
||||
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
|
||||
)
|
||||
}
|
||||
if (removedReadRows && rawRowCount >= 100) {
|
||||
console.error(
|
||||
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
// Why: a pre-peek runtime formats all rows, which no longer matches the filtered set.
|
||||
...(removedReadRows ? { formatted: undefined } : {}),
|
||||
messages: unreadOnly,
|
||||
count: unreadOnly.length
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../../flags'
|
||||
import {
|
||||
formatMessageReadOnlyTag,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../../shared/orchestration-check-output'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
|
||||
|
||||
export const ORCHESTRATION_INBOX_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration reply': async ({ flags, client, cwd, json }) => {
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await callOrchestrationMutation<{ message: { id: string } }>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
body: getRequiredStringFlag(flags, 'body'),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
from
|
||||
}
|
||||
)
|
||||
printResult(result, json, (value) => `Replied ${value.message.id}`)
|
||||
},
|
||||
|
||||
'orchestration inbox': async ({ flags, client, json }) => {
|
||||
const full = flags.has('full')
|
||||
const result = await client.call<{
|
||||
messages: MessageSummary[]
|
||||
count: number
|
||||
}>('orchestration.inbox', {
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
terminal: getOptionalStringFlag(flags, 'terminal')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.count === 0) {
|
||||
return 'No messages.'
|
||||
}
|
||||
// Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing.
|
||||
return value.messages
|
||||
.map((message) => {
|
||||
const head = `${message.id}${formatMessageReadOnlyTag(message)} ${message.from_handle} -> ${message.to_handle ?? '?'}: "${message.subject}"`
|
||||
if (!full) {
|
||||
return head
|
||||
}
|
||||
const parts = [head]
|
||||
if (message.body && message.body.length > 0) {
|
||||
parts.push(message.body)
|
||||
}
|
||||
if (message.payload) {
|
||||
parts.push(`[payload] ${message.payload}`)
|
||||
}
|
||||
return parts.join('\n')
|
||||
})
|
||||
.join(full ? '\n\n' : '\n')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
|
||||
export function getOptionalStructuredMessagePayload(
|
||||
flags: Map<string, string | boolean>
|
||||
): string | undefined {
|
||||
const rawPayload = getOptionalStringFlag(flags, 'payload')
|
||||
const taskId = getOptionalStringFlag(flags, 'task-id')
|
||||
const dispatchId = getOptionalStringFlag(flags, 'dispatch-id')
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
const filesModified = getOptionalStringFlag(flags, 'files-modified')
|
||||
const reportPath = getOptionalStringFlag(flags, 'report-path')
|
||||
const phase = getOptionalStringFlag(flags, 'phase')
|
||||
const hasStructuredPayload =
|
||||
taskId !== undefined ||
|
||||
dispatchId !== undefined ||
|
||||
outcome !== undefined ||
|
||||
filesModified !== undefined ||
|
||||
reportPath !== undefined ||
|
||||
phase !== undefined
|
||||
if (!hasStructuredPayload) {
|
||||
return rawPayload
|
||||
}
|
||||
if (rawPayload !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --payload or structured payload flags, not both.'
|
||||
)
|
||||
}
|
||||
// Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting.
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (taskId) {
|
||||
payload.taskId = taskId
|
||||
}
|
||||
if (dispatchId) {
|
||||
payload.dispatchId = dispatchId
|
||||
}
|
||||
if (outcome) {
|
||||
if (outcome !== 'succeeded' && outcome !== 'failed') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid --outcome. Expected succeeded or failed.'
|
||||
)
|
||||
}
|
||||
payload.outcome = outcome
|
||||
}
|
||||
if (filesModified) {
|
||||
payload.filesModified = filesModified
|
||||
.split(',')
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (reportPath) {
|
||||
payload.reportPath = reportPath
|
||||
}
|
||||
if (phase) {
|
||||
payload.phase = phase
|
||||
}
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement'
|
||||
import { getOptionalStructuredMessagePayload } from './message-payload'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { isDevCliInvocation } from './runtime-compatibility'
|
||||
import {
|
||||
resolveOrchestrationTerminalHandle,
|
||||
throwNoActiveSenderTerminal
|
||||
} from './terminal-identity'
|
||||
|
||||
type LifecycleSendResult =
|
||||
| {
|
||||
action: 'completed' | 'failed'
|
||||
authority?: 'run_home' | 'worker_server_legacy'
|
||||
}
|
||||
| { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean }
|
||||
| { action: 'rejected'; code: string; reason: string }
|
||||
|
||||
type SendRecipientWarning = {
|
||||
code: string
|
||||
recipient: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type OrchestrationSendResult =
|
||||
| {
|
||||
message: { id: string; run_id?: string }
|
||||
lifecycle?: LifecycleSendResult
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
| {
|
||||
messages: { id: string }[]
|
||||
recipients: number
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
| {
|
||||
relay: {
|
||||
messageId: string
|
||||
sequence: number
|
||||
dispatchId: string
|
||||
destination?: 'run_home' | 'worker'
|
||||
accepted: true
|
||||
}
|
||||
lifecycle?: LifecycleSendResult
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
|
||||
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages belong to one exact Dispatch and cannot target a group address.`
|
||||
}
|
||||
|
||||
function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void {
|
||||
if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) {
|
||||
throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type))
|
||||
}
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_SEND_HANDLER: Record<string, CommandHandler> = {
|
||||
'orchestration send': async ({ flags, client, cwd, json }) => {
|
||||
const to = getOptionalStringFlag(flags, 'to')
|
||||
const type = getOptionalStringFlag(flags, 'type')
|
||||
if (to) {
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
}
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
if (type !== 'worker_done' && outcome !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--outcome is only valid with --type worker_done.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
(type === 'worker_done' || type === 'heartbeat') &&
|
||||
!getOptionalStringFlag(flags, 'from') &&
|
||||
!process.env.ORCA_TERMINAL_HANDLE
|
||||
) {
|
||||
// Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker.
|
||||
throwNoActiveSenderTerminal()
|
||||
}
|
||||
|
||||
// Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes.
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const sendParams = {
|
||||
from,
|
||||
to,
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
subject: getRequiredStringFlag(flags, 'subject'),
|
||||
body: getOptionalStringFlag(flags, 'body'),
|
||||
type,
|
||||
priority: getOptionalStringFlag(flags, 'priority'),
|
||||
threadId: getOptionalStringFlag(flags, 'thread-id'),
|
||||
payload: getOptionalStructuredMessagePayload(flags),
|
||||
// Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it.
|
||||
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
|
||||
waitForLifecycleSettlement: type === 'worker_done' ? true : undefined,
|
||||
devMode: isDevCliInvocation()
|
||||
}
|
||||
const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
const result = await callOrchestrationMutation<OrchestrationSendResult>(
|
||||
client,
|
||||
flags,
|
||||
'orchestration.send',
|
||||
sendParams,
|
||||
dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined
|
||||
)
|
||||
await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result)
|
||||
if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') {
|
||||
throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason)
|
||||
}
|
||||
printResult(result, json, (value) => {
|
||||
const warnings = 'warnings' in value ? (value.warnings ?? []) : []
|
||||
const withWarnings = (line: string): string =>
|
||||
warnings.length > 0
|
||||
? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n')
|
||||
: line
|
||||
if ('message' in value) {
|
||||
return withWarnings(`Sent ${value.message.id}`)
|
||||
}
|
||||
if ('relay' in value) {
|
||||
if (value.relay.destination === 'worker') {
|
||||
return withWarnings(
|
||||
`Queued ${value.relay.messageId} for worker Dispatch ${value.relay.dispatchId}`
|
||||
)
|
||||
}
|
||||
return withWarnings(
|
||||
`Queued ${value.relay.messageId} for Run home (Dispatch ${value.relay.dispatchId})`
|
||||
)
|
||||
}
|
||||
return withWarnings(
|
||||
`Sent ${value.messages.length} messages to ${value.recipients} recipients`
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { RuntimeClient } from '../../runtime-client'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery'
|
||||
|
||||
export function callOrchestrationMutation<TResult>(
|
||||
client: RuntimeClient,
|
||||
flags: Map<string, string | boolean>,
|
||||
method: string,
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number; orchestrationCapability?: string }
|
||||
) {
|
||||
const requestId = getOptionalStringFlag(flags, 'retry-request')
|
||||
const result = requestId
|
||||
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
|
||||
: options
|
||||
? client.call<TResult>(method, params, options)
|
||||
: client.call<TResult>(method, params)
|
||||
return result.catch((error) => {
|
||||
throw orchestrationMutationRecoveryError(error)
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user