mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 00:02:33 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into auto-e2e-tests-autofix-scheduled-ci-1h-run-28-20260831T0700
This commit is contained in:
@@ -131,6 +131,9 @@ jobs:
|
||||
- name: Enforce max-lines ratchet
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
- name: Enforce ts-nocheck ratchet
|
||||
run: pnpm run check:ts-nocheck-ratchet
|
||||
|
||||
- name: Enforce runtime Electron-import ratchet
|
||||
run: pnpm run check:runtime-electron-ratchet
|
||||
|
||||
|
||||
@@ -1344,6 +1344,7 @@ jobs:
|
||||
# otherwise undecodable. The main bundle is platform-independent, so one
|
||||
# leg publishes the maps for the whole release.
|
||||
- name: Bundle main-process source maps
|
||||
id: bundle-main-sourcemaps
|
||||
if: matrix.platform == 'linux-x64'
|
||||
shell: bash
|
||||
env:
|
||||
@@ -1351,9 +1352,17 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
|
||||
echo "::error::No main-process source maps in out/main. Did build.sourcemap regress in electron.vite.config.ts?"
|
||||
exit 1
|
||||
# Older cut tags predate the hidden-source-map build setting. They
|
||||
# are valid legacy releases, but have no map bundle to publish.
|
||||
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
|
||||
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
|
||||
exit 1
|
||||
fi
|
||||
echo "has_maps=false" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
|
||||
exit 0
|
||||
fi
|
||||
echo "has_maps=true" >>"$GITHUB_OUTPUT"
|
||||
# Why: every entry in electron-builder's `files` is a negation, so
|
||||
# app-builder prepends `**/*` and packs anything left in the workspace
|
||||
# root into app.asar. Stage the bundle outside the checkout instead.
|
||||
@@ -1361,7 +1370,7 @@ jobs:
|
||||
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
|
||||
|
||||
- name: Publish main-process source maps
|
||||
if: matrix.platform == 'linux-x64'
|
||||
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
|
||||
@@ -11,10 +11,6 @@ inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.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/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Ratchet gate for the `@ts-nocheck` directive.
|
||||
//
|
||||
// TypeScript only honours `@ts-nocheck` in a comment before the first statement, and
|
||||
// once present it disables type checking for the ENTIRE file. PR #17605 split a single
|
||||
// 43,928-line class into ~172 modules whose linear mixin-inheritance chain cannot yet
|
||||
// express forward references, so each carries a grandfathered `@ts-nocheck` header. This
|
||||
// check freezes that set (the baseline) and fails CI when a NEW file adds the directive —
|
||||
// the existing files are grandfathered; new ones must fix their types instead. The
|
||||
// baseline may only shrink.
|
||||
|
||||
const BASELINE_PATH = 'config/ts-nocheck-baseline.txt'
|
||||
// These two files legitimately contain the directive text as data (regex, fixtures),
|
||||
// so scanning them would self-flag. The ratchet does not police itself.
|
||||
const SELF_FILES = new Set([
|
||||
'config/scripts/check-ts-nocheck-ratchet.mjs',
|
||||
'config/scripts/check-ts-nocheck-ratchet.test.mjs'
|
||||
])
|
||||
|
||||
// True if `@ts-nocheck` appears in a comment before the first statement, matching the
|
||||
// TypeScript rule. Limitation: only the leading run of blank lines / line comments /
|
||||
// block comments at the top of the file is scanned, so a directive-looking string deeper
|
||||
// in a block comment that itself starts at the top is still checked — but anything after
|
||||
// real code (or inside a string literal, which never opens the leading comment run) is not.
|
||||
export function hasTsNoCheck(sourceText) {
|
||||
let i = 0
|
||||
const n = sourceText.length
|
||||
while (i < n) {
|
||||
const rest = sourceText.slice(i)
|
||||
const blank = /^[ \t]*\r?\n/.exec(rest)
|
||||
if (blank) {
|
||||
i += blank[0].length
|
||||
continue
|
||||
}
|
||||
if (rest.startsWith('//')) {
|
||||
const end = sourceText.indexOf('\n', i)
|
||||
const line = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end)
|
||||
if (/^\/\/\s*@ts-nocheck\b/.test(line)) {
|
||||
return true
|
||||
}
|
||||
i = end === -1 ? n : end + 1
|
||||
continue
|
||||
}
|
||||
if (rest.startsWith('/*')) {
|
||||
const end = sourceText.indexOf('*/', i + 2)
|
||||
const block = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end + 2)
|
||||
if (/^\/\*\s*@ts-nocheck\b/.test(block)) {
|
||||
return true
|
||||
}
|
||||
i = end === -1 ? n : end + 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function parseBaseline(text) {
|
||||
return new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
)
|
||||
}
|
||||
|
||||
export function diffBaseline(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = baseline instanceof Set ? baseline : new Set(baseline)
|
||||
const added = [...cur].filter((e) => !base.has(e)).sort()
|
||||
const stale = [...base].filter((e) => !cur.has(e)).sort()
|
||||
return { added, stale }
|
||||
}
|
||||
|
||||
// Collect every currently tracked file that carries a `@ts-nocheck` header.
|
||||
export function collectCurrentTsNoCheckFiles(root = process.cwd()) {
|
||||
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !SELF_FILES.has(f))
|
||||
|
||||
const entries = []
|
||||
for (const rel of tracked) {
|
||||
let src
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, rel), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (hasTsNoCheck(src)) {
|
||||
entries.push(rel)
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort()
|
||||
}
|
||||
|
||||
function printAddedFailure(added) {
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New @ts-nocheck not allowed: ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`)
|
||||
console.error('')
|
||||
for (const entry of added) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.')
|
||||
console.error(
|
||||
' The grandfathered entries exist only because the split runtime mixin chain cannot'
|
||||
)
|
||||
console.error(' express forward references yet — that is not a general license to suppress.')
|
||||
console.error('')
|
||||
console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.')
|
||||
console.error('')
|
||||
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
|
||||
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
function printStaleFailure(stale) {
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`)
|
||||
console.error(
|
||||
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
|
||||
)
|
||||
console.error('')
|
||||
for (const entry of stale) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
export function main(root = process.cwd()) {
|
||||
const baselineFile = path.join(root, BASELINE_PATH)
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(
|
||||
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = collectCurrentTsNoCheckFiles(root)
|
||||
const { added, stale } = diffBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
printAddedFailure(added)
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
|
||||
)
|
||||
printStaleFailure(stale)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
printStaleFailure(stale)
|
||||
return 1
|
||||
}
|
||||
console.log(
|
||||
`ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeBaseline(root, entries) {
|
||||
const header = [
|
||||
'# Files currently allowed to carry a `@ts-nocheck` header.',
|
||||
'# This is a RATCHET: the list may only SHRINK. These exist only because the split',
|
||||
'# runtime mixin chain cannot express forward references yet — do NOT add entries to',
|
||||
'# get CI green; fix the types instead.',
|
||||
'# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)',
|
||||
''
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const root = process.cwd()
|
||||
const arg = process.argv[2]
|
||||
if (arg === '--init') {
|
||||
// One-time bootstrap: capture the current @ts-nocheck set as the baseline.
|
||||
const entries = collectCurrentTsNoCheckFiles(root)
|
||||
writeBaseline(root, entries)
|
||||
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--prune') {
|
||||
// Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds).
|
||||
const current = new Set(collectCurrentTsNoCheckFiles(root))
|
||||
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
|
||||
const kept = [...baseline].filter((e) => current.has(e)).sort()
|
||||
const newlyAdded = [...current].filter((e) => !baseline.has(e))
|
||||
writeBaseline(root, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
|
||||
)
|
||||
if (newlyAdded.length > 0) {
|
||||
console.error(
|
||||
`::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.exit(main(root))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs'
|
||||
|
||||
describe('hasTsNoCheck', () => {
|
||||
it('detects a line-comment form', () => {
|
||||
expect(hasTsNoCheck('// @ts-nocheck\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a block-comment form', () => {
|
||||
expect(hasTsNoCheck('/* @ts-nocheck */\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the no-space form', () => {
|
||||
expect(hasTsNoCheck('//@ts-nocheck\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a directive with a -- Why reason', () => {
|
||||
expect(
|
||||
hasTsNoCheck(
|
||||
'// @ts-nocheck -- Why: mechanically split, covered by AST tests.\nimport x from "y"\n'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows blank lines and other leading comments before the directive', () => {
|
||||
const src =
|
||||
'\n// Copyright notice.\n\n/* another leading comment */\n// @ts-nocheck\nexport const a = 1\n'
|
||||
expect(hasTsNoCheck(src)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match once a statement has started', () => {
|
||||
const src = 'export const a = 1\n// @ts-nocheck\n'
|
||||
expect(hasTsNoCheck(src)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match inside a string literal', () => {
|
||||
const src = 'export const a = "// @ts-nocheck"\n'
|
||||
expect(hasTsNoCheck(src)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for ordinary source', () => {
|
||||
expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('drops comments and blank lines', () => {
|
||||
const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n')
|
||||
expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBaseline', () => {
|
||||
it('reports added and stale entries', () => {
|
||||
const { added, stale } = diffBaseline(
|
||||
['src/b.ts', 'src/c.ts'],
|
||||
new Set(['src/a.ts', 'src/b.ts'])
|
||||
)
|
||||
expect(added).toEqual(['src/c.ts']) // new suppression
|
||||
expect(stale).toEqual(['src/a.ts']) // suppression removed
|
||||
})
|
||||
|
||||
it('is clean when current matches baseline', () => {
|
||||
const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts']))
|
||||
expect(added).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Where the Electron executable sits inside a `dist` tree, relative to it. */
|
||||
export function getElectronPlatformPath(targetPlatform) {
|
||||
switch (targetPlatform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron'
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron'
|
||||
case 'win32':
|
||||
return 'electron.exe'
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
/** Matches the fake package version and the platform/arch runInstallScript installs for. */
|
||||
export const sharedEntryName = '41.5.0-linux-x64'
|
||||
export const sharedEntryNameFor = (version) => `${version}-linux-x64`
|
||||
|
||||
export function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
return projectDir
|
||||
}
|
||||
|
||||
export function runInstallScript(projectDir, extraEnv = {}) {
|
||||
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_CACHE: undefined,
|
||||
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
|
||||
npm_config_platform: 'linux',
|
||||
npm_config_arch: 'x64',
|
||||
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
|
||||
...extraEnv
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function writeFakeElectronPackage(
|
||||
projectDir,
|
||||
{ lazyRequireMarker = null, version = '41.5.0' } = {}
|
||||
) {
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
mkdirSync(electronDir, { recursive: true })
|
||||
writeFileSync(join(electronDir, 'package.json'), JSON.stringify({ name: 'electron', version }))
|
||||
writeFileSync(join(electronDir, 'checksums.json'), '{}')
|
||||
writeFileSync(
|
||||
join(electronDir, 'index.js'),
|
||||
`
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
|
||||
const pathFile = path.join(__dirname, 'path.txt')
|
||||
if (!fs.existsSync(pathFile)) {
|
||||
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
|
||||
}
|
||||
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
export function writeFakeElectronDist(
|
||||
projectDir,
|
||||
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
|
||||
) {
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
mkdirSync(join(electronDir, 'dist'), { recursive: true })
|
||||
writeFileSync(join(electronDir, 'dist/version'), version)
|
||||
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
|
||||
if (pathContents !== undefined) {
|
||||
writeFileSync(join(electronDir, 'path.txt'), pathContents)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{
|
||||
downloadNeverSettles = false,
|
||||
downloadFailures = 0,
|
||||
downloadErrorCode = 'ECONNRESET',
|
||||
downloadHttpStatus = null
|
||||
} = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(getDir, 'index.js'),
|
||||
`
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
let downloadAttempt = 0
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
downloadAttempt += 1
|
||||
appendFileSync(
|
||||
'electron-get.log',
|
||||
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
|
||||
)
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
if (${JSON.stringify(downloadHttpStatus)} != null) {
|
||||
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
|
||||
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
|
||||
throw error
|
||||
}
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
throw Object.assign(new TypeError('fetch failed'), { cause })
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
return artifactPath
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
export function writeFakeExtractor(projectDir, { createExecutable, version = '41.5.0' }) {
|
||||
writeFileSync(
|
||||
join(projectDir, 'fake-extractor.cjs'),
|
||||
`
|
||||
const { appendFileSync, mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const extractDir = process.argv[3]
|
||||
appendFileSync(join(__dirname, 'fake-extractor.log'), extractDir + '\\n')
|
||||
mkdirSync(join(extractDir, 'locales'), { recursive: true })
|
||||
if (${JSON.stringify(createExecutable)}) {
|
||||
writeFileSync(join(extractDir, 'electron'), '')
|
||||
writeFileSync(join(extractDir, 'electron.exe'), '')
|
||||
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
|
||||
writeFileSync(join(extractDir, 'version'), ${JSON.stringify(`v${version}`)})
|
||||
if (process.platform !== 'win32') {
|
||||
symlinkSync('version', join(extractDir, 'version-link'))
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
export function writeTypeDefPublishFailurePreload(projectDir) {
|
||||
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`
|
||||
const fs = require('node:fs')
|
||||
const { syncBuiltinESMExports } = require('node:module')
|
||||
const { basename, dirname } = require('node:path')
|
||||
const renameSync = fs.renameSync
|
||||
fs.renameSync = (source, target) => {
|
||||
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
|
||||
const error = new Error('injected Electron type definition publish failure')
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return renameSync(source, target)
|
||||
}
|
||||
syncBuiltinESMExports()
|
||||
`
|
||||
)
|
||||
return preloadPath
|
||||
}
|
||||
|
||||
export function initGitRepo(projectDir) {
|
||||
runGit(projectDir, ['init', '--quiet', '--initial-branch=main'])
|
||||
runGit(projectDir, ['config', 'user.email', 'orca-test@example.com'])
|
||||
runGit(projectDir, ['config', 'user.name', 'Orca Test'])
|
||||
runGit(projectDir, ['commit', '--quiet', '--allow-empty', '-m', 'init'])
|
||||
}
|
||||
|
||||
export function addSiblingWorktree(projectDir, siblingDir) {
|
||||
runGit(projectDir, ['worktree', 'add', '--quiet', '-b', 'sibling', siblingDir])
|
||||
copyScriptWithLocalModules(sourceScriptPath, join(siblingDir, 'config', 'scripts'))
|
||||
return siblingDir
|
||||
}
|
||||
|
||||
function runGit(projectDir, args) {
|
||||
execFileSync('git', ['-C', projectDir, ...args], { stdio: 'ignore' })
|
||||
}
|
||||
|
||||
export function sharedCacheRoot(repoDir) {
|
||||
return join(repoDir, '.git', 'orca-cache', 'electron')
|
||||
}
|
||||
|
||||
export function readSharedDistMarker(projectDir) {
|
||||
try {
|
||||
return readFileSync(join(projectDir, 'node_modules/electron/.orca-shared-dist'), 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readExtractorCallCount(projectDir) {
|
||||
try {
|
||||
return readFileSync(join(projectDir, 'fake-extractor.log'), 'utf8').trim().split('\n').length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function writeNonDarwinPlatformPreload(projectDir) {
|
||||
const preloadPath = join(projectDir, 'non-darwin-platform.cjs')
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
|
||||
`
|
||||
)
|
||||
return preloadPath
|
||||
}
|
||||
@@ -15,6 +15,14 @@ import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { getElectronPlatformPath } from './electron-platform-path.mjs'
|
||||
import {
|
||||
shareElectronDistFromCache,
|
||||
hasAdoptedSharedElectronDist,
|
||||
publishSharedElectronDist,
|
||||
recordAdoptedSharedElectronDist,
|
||||
resolveSharedElectronDistEntry
|
||||
} from './shared-electron-dist-cache.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
|
||||
@@ -54,7 +62,18 @@ try {
|
||||
|
||||
async function main() {
|
||||
repairElectronPathFile()
|
||||
const sharedEntry = resolveSharedElectronDistEntry({
|
||||
repoRoot: projectDir,
|
||||
electronPackageDir,
|
||||
version: electronVersion,
|
||||
targetPlatform,
|
||||
targetArch
|
||||
})
|
||||
|
||||
if (electronPackageIsUsable()) {
|
||||
if (sharedEntry !== null && !hasAdoptedSharedElectronDist(sharedEntry)) {
|
||||
shareExistingElectronDist(sharedEntry)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +81,7 @@ async function main() {
|
||||
// Node. Install only Electron's npm package binary here; do not run the full
|
||||
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
|
||||
console.log('[electron-package] Electron package binary is missing; running Electron install.')
|
||||
await installElectronPackageBinary()
|
||||
await installElectronPackageBinary(sharedEntry)
|
||||
|
||||
repairElectronPathFile()
|
||||
|
||||
@@ -122,8 +141,11 @@ function repairElectronPathFile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function installElectronPackageBinary() {
|
||||
async function installElectronPackageBinary(sharedEntry) {
|
||||
const electronDistDir = resolve(electronPackageDir, 'dist')
|
||||
if (sharedEntry !== null && adoptSharedElectronDist(sharedEntry, electronDistDir)) {
|
||||
return
|
||||
}
|
||||
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
|
||||
const persistentCacheRoot =
|
||||
process.env.ORCA_ELECTRON_PACKAGE_CACHE_ROOT || process.env.ELECTRON_CACHE || null
|
||||
@@ -158,11 +180,73 @@ async function installElectronPackageBinary() {
|
||||
}
|
||||
|
||||
moveExtractedElectronDist(extractDir, electronDistDir)
|
||||
if (sharedEntry !== null) {
|
||||
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point this worktree's dist at the copy its siblings already share, so the ~295MB tree costs one
|
||||
* allocation per repository instead of one per worktree.
|
||||
*
|
||||
* Staged inside node_modules/electron on purpose: clonefile only shares blocks within a volume, and
|
||||
* staging elsewhere would silently downgrade the publish rename to a cross-device byte copy.
|
||||
*/
|
||||
function adoptSharedElectronDist(sharedEntry, electronDistDir) {
|
||||
const stageRoot = mkdtempSync(resolve(electronPackageDir, '.dist-clone-'))
|
||||
try {
|
||||
const stagePath = join(stageRoot, 'dist')
|
||||
if (
|
||||
!shareElectronDistFromCache(sharedEntry, stagePath, {
|
||||
version: electronVersion,
|
||||
platformPath
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
moveExtractedElectronDist(stagePath, electronDistDir)
|
||||
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
|
||||
console.log(
|
||||
`[electron-package] Shared Electron ${electronVersion} from ${sharedEntry.entryPath}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
// The download path below is always a correct fallback, so sharing never fails an install.
|
||||
console.warn(`[electron-package] Shared Electron dist unavailable: ${formatShareError(error)}`)
|
||||
return false
|
||||
} finally {
|
||||
rmSync(stageRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** An already-installed dist joins the cache: clone from it if it exists, seed it otherwise. */
|
||||
function shareExistingElectronDist(sharedEntry) {
|
||||
const electronDistDir = resolve(electronPackageDir, 'dist')
|
||||
if (!adoptSharedElectronDist(sharedEntry, electronDistDir)) {
|
||||
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
|
||||
}
|
||||
}
|
||||
|
||||
function publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir) {
|
||||
const published = publishSharedElectronDist(electronDistDir, sharedEntry, {
|
||||
version: electronVersion,
|
||||
platformPath
|
||||
})
|
||||
if (published) {
|
||||
console.log(
|
||||
`[electron-package] Published Electron ${electronVersion} to ${sharedEntry.entryPath}`
|
||||
)
|
||||
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
|
||||
}
|
||||
}
|
||||
|
||||
function formatShareError(error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
|
||||
const retryDelays = getDownloadRetryDelays()
|
||||
|
||||
@@ -438,19 +522,3 @@ function getElectronTargetPlatform() {
|
||||
function getElectronTargetArch() {
|
||||
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
|
||||
}
|
||||
|
||||
function getElectronPlatformPath(targetPlatform) {
|
||||
switch (targetPlatform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron'
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron'
|
||||
case 'win32':
|
||||
return 'electron.exe'
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(
|
||||
new URL('./install-electron-package-binary.mjs', import.meta.url)
|
||||
)
|
||||
import {
|
||||
addSiblingWorktree,
|
||||
initGitRepo,
|
||||
mkTempProject,
|
||||
readExtractorCallCount,
|
||||
readSharedDistMarker,
|
||||
runInstallScript,
|
||||
sharedCacheRoot,
|
||||
sharedEntryName,
|
||||
sharedEntryNameFor,
|
||||
writeFakeElectronDist,
|
||||
writeFakeElectronGet,
|
||||
writeFakeElectronPackage,
|
||||
writeFakeExtractor,
|
||||
writeNonDarwinPlatformPreload,
|
||||
writeTypeDefPublishFailurePreload
|
||||
} from './install-electron-package-binary-test-fixtures.mjs'
|
||||
|
||||
describe('install-electron-package-binary', () => {
|
||||
it('installs Electron from an isolated cache and repairs path.txt', () => {
|
||||
@@ -403,6 +413,199 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// The shared cache is macOS-only: it exists to avoid a second copy via APFS clonefile.
|
||||
it('publishes a shared Electron dist entry after a fresh download', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(projectDir, { CI: '' })
|
||||
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(lstatSync(entryPath).isDirectory()).toBe(true)
|
||||
expect(lstatSync(entryPath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(existsSync(join(entryPath, 'electron'))).toBe(true)
|
||||
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
|
||||
expect(result.stdout).toMatch(/Published Electron 41\.5\.0 to .*41\.5\.0-linux-x64$/m)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('shares the Electron dist into a sibling worktree without downloading', () => {
|
||||
const projectDir = mkTempProject()
|
||||
const siblingDir = `${projectDir}-sibling`
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
|
||||
|
||||
addSiblingWorktree(projectDir, siblingDir)
|
||||
writeFakeElectronPackage(siblingDir)
|
||||
writeFakeElectronGet(siblingDir)
|
||||
writeFakeExtractor(siblingDir, { createExecutable: true })
|
||||
|
||||
const result = runInstallScript(siblingDir, { CI: '' })
|
||||
const siblingDistDir = join(siblingDir, 'node_modules/electron/dist')
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readExtractorCallCount(siblingDir)).toBe(0)
|
||||
expect(existsSync(join(siblingDir, 'electron-get.log'))).toBe(false)
|
||||
expect(lstatSync(siblingDistDir).isDirectory()).toBe(true)
|
||||
expect(lstatSync(siblingDistDir).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(join(siblingDistDir, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(existsSync(join(siblingDistDir, 'electron'))).toBe(true)
|
||||
expect(readFileSync(join(siblingDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
|
||||
'electron'
|
||||
)
|
||||
expect(readSharedDistMarker(siblingDir)).toBe(sharedEntryName)
|
||||
expect(result.stdout).toContain('Shared Electron 41.5.0 from')
|
||||
} finally {
|
||||
rmSync(siblingDir, { recursive: true, force: true })
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes an already installed Electron dist that predates the shared cache', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
writeFakeElectronDist(projectDir, {
|
||||
executableContents: 'existing executable',
|
||||
pathContents: 'electron'
|
||||
})
|
||||
|
||||
const result = runInstallScript(projectDir, { CI: '' })
|
||||
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
|
||||
const distDir = join(projectDir, 'node_modules/electron/dist')
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readExtractorCallCount(projectDir)).toBe(0)
|
||||
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
|
||||
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(readFileSync(join(entryPath, 'electron'), 'utf8')).toBe('existing executable')
|
||||
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
|
||||
expect(readFileSync(join(distDir, 'electron'), 'utf8')).toBe('existing executable')
|
||||
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a corrupt shared Electron dist entry instead of re-downloading forever', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
|
||||
mkdirSync(entryPath, { recursive: true })
|
||||
writeFileSync(join(entryPath, 'version'), 'v40.0.0')
|
||||
writeFileSync(join(entryPath, 'electron'), 'stale executable')
|
||||
|
||||
const result = runInstallScript(projectDir, { CI: '' })
|
||||
const distDir = join(projectDir, 'node_modules/electron/dist')
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('Failed to install Electron package binary')
|
||||
expect(readExtractorCallCount(projectDir)).toBe(1)
|
||||
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
|
||||
'electron'
|
||||
)
|
||||
// Why not just fall back: an entry left corrupt makes every sibling worktree download again.
|
||||
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
|
||||
expect(readdirSync(sharedCacheRoot(projectDir))).toEqual([sharedEntryName])
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('hardlinks the shared Electron dist on a host without copy-on-write', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
const preloadPath = writeNonDarwinPlatformPreload(projectDir)
|
||||
const nonDarwinEnv = {
|
||||
CI: '',
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath}`]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const result = runInstallScript(projectDir, nonDarwinEnv)
|
||||
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
|
||||
const distDir = join(projectDir, 'node_modules/electron/dist')
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
|
||||
// Why read-only: these are the same inodes, so an extract over dist would otherwise rewrite
|
||||
// the cache and every sibling worktree at once.
|
||||
expect(statSync(join(entryPath, 'electron')).mode & 0o222).toBe(0)
|
||||
expect(statSync(join(entryPath, 'electron')).ino).toBe(
|
||||
statSync(join(distDir, 'electron')).ino
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('gives an Electron upgrade its own cache entry and leaves the old one for other branches', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
initGitRepo(projectDir)
|
||||
writeFakeElectronPackage(projectDir)
|
||||
writeFakeElectronGet(projectDir)
|
||||
writeFakeExtractor(projectDir, { createExecutable: true })
|
||||
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
|
||||
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('41.5.0'))
|
||||
|
||||
// Upgrade the pinned Electron, exactly as a branch bumping the dependency would.
|
||||
writeFakeElectronPackage(projectDir, { version: '42.0.0' })
|
||||
writeFakeExtractor(projectDir, { createExecutable: true, version: '42.0.0' })
|
||||
const upgraded = runInstallScript(projectDir, { CI: '' })
|
||||
const cacheRoot = sharedCacheRoot(projectDir)
|
||||
|
||||
expect(upgraded.status, upgraded.stderr).toBe(0)
|
||||
expect(readFileSync(join(projectDir, 'node_modules/electron/dist/version'), 'utf8')).toBe(
|
||||
'v42.0.0'
|
||||
)
|
||||
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('42.0.0'))
|
||||
// Why the old entry stays: sibling worktrees on the previous branch still share it.
|
||||
expect(readdirSync(cacheRoot).sort()).toEqual([
|
||||
sharedEntryNameFor('41.5.0'),
|
||||
sharedEntryNameFor('42.0.0')
|
||||
])
|
||||
expect(readFileSync(join(cacheRoot, sharedEntryNameFor('41.5.0'), 'version'), 'utf8')).toBe(
|
||||
'v41.5.0'
|
||||
)
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not exit successfully when Electron download never settles', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
@@ -421,155 +624,3 @@ describe('install-electron-package-binary', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(
|
||||
sourceScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
return projectDir
|
||||
}
|
||||
|
||||
function runInstallScript(projectDir, extraEnv = {}) {
|
||||
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_CACHE: undefined,
|
||||
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
|
||||
npm_config_platform: 'linux',
|
||||
npm_config_arch: 'x64',
|
||||
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
|
||||
...extraEnv
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
mkdirSync(electronDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(electronDir, 'package.json'),
|
||||
JSON.stringify({ name: 'electron', version: '41.5.0' })
|
||||
)
|
||||
writeFileSync(join(electronDir, 'checksums.json'), '{}')
|
||||
writeFileSync(
|
||||
join(electronDir, 'index.js'),
|
||||
`
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
|
||||
const pathFile = path.join(__dirname, 'path.txt')
|
||||
if (!fs.existsSync(pathFile)) {
|
||||
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
|
||||
}
|
||||
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeElectronDist(
|
||||
projectDir,
|
||||
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
|
||||
) {
|
||||
const electronDir = join(projectDir, 'node_modules', 'electron')
|
||||
mkdirSync(join(electronDir, 'dist'), { recursive: true })
|
||||
writeFileSync(join(electronDir, 'dist/version'), version)
|
||||
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
|
||||
if (pathContents !== undefined) {
|
||||
writeFileSync(join(electronDir, 'path.txt'), pathContents)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFakeElectronGet(
|
||||
projectDir,
|
||||
{
|
||||
downloadNeverSettles = false,
|
||||
downloadFailures = 0,
|
||||
downloadErrorCode = 'ECONNRESET',
|
||||
downloadHttpStatus = null
|
||||
} = {}
|
||||
) {
|
||||
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
|
||||
mkdirSync(getDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(getDir, 'index.js'),
|
||||
`
|
||||
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
let downloadAttempt = 0
|
||||
exports.downloadArtifact = async function downloadArtifact(details) {
|
||||
downloadAttempt += 1
|
||||
appendFileSync(
|
||||
'electron-get.log',
|
||||
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
|
||||
)
|
||||
if (${JSON.stringify(downloadNeverSettles)}) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
|
||||
if (${JSON.stringify(downloadHttpStatus)} != null) {
|
||||
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
|
||||
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
|
||||
throw error
|
||||
}
|
||||
const cause = Object.assign(new Error('download failed'), {
|
||||
code: ${JSON.stringify(downloadErrorCode)}
|
||||
})
|
||||
throw Object.assign(new TypeError('fetch failed'), { cause })
|
||||
}
|
||||
mkdirSync(details.cacheRoot, { recursive: true })
|
||||
const artifactPath = join(details.cacheRoot, 'electron.zip')
|
||||
writeFileSync(artifactPath, 'fake zip')
|
||||
return artifactPath
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeFakeExtractor(projectDir, { createExecutable }) {
|
||||
writeFileSync(
|
||||
join(projectDir, 'fake-extractor.cjs'),
|
||||
`
|
||||
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const extractDir = process.argv[3]
|
||||
mkdirSync(join(extractDir, 'locales'), { recursive: true })
|
||||
if (${JSON.stringify(createExecutable)}) {
|
||||
writeFileSync(join(extractDir, 'electron'), '')
|
||||
writeFileSync(join(extractDir, 'electron.exe'), '')
|
||||
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
|
||||
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
|
||||
if (process.platform !== 'win32') {
|
||||
symlinkSync('version', join(extractDir, 'version-link'))
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
function writeTypeDefPublishFailurePreload(projectDir) {
|
||||
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
|
||||
writeFileSync(
|
||||
preloadPath,
|
||||
`
|
||||
const fs = require('node:fs')
|
||||
const { syncBuiltinESMExports } = require('node:module')
|
||||
const { basename, dirname } = require('node:path')
|
||||
const renameSync = fs.renameSync
|
||||
fs.renameSync = (source, target) => {
|
||||
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
|
||||
const error = new Error('injected Electron type definition publish failure')
|
||||
error.code = 'EACCES'
|
||||
throw error
|
||||
}
|
||||
return renameSync(source, target)
|
||||
}
|
||||
syncBuiltinESMExports()
|
||||
`
|
||||
)
|
||||
return preloadPath
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from '
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
|
||||
const sourceInstallScriptPath = fileURLToPath(
|
||||
@@ -19,10 +20,7 @@ export function mkTempProject() {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-'))
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs'))
|
||||
copyFileSync(
|
||||
sourceInstallScriptPath,
|
||||
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
|
||||
)
|
||||
copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Converts worktrees that already have their own Electron dist over to the shared cache.
|
||||
// A normal install only shares when Electron is (re)installed, and an existing healthy worktree
|
||||
// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade.
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
hasAdoptedSharedElectronDist,
|
||||
isUsableElectronDist,
|
||||
publishSharedElectronDist,
|
||||
recordAdoptedSharedElectronDist,
|
||||
resolveSharedElectronDistEntry,
|
||||
shareElectronDistFromCache
|
||||
} from './shared-electron-dist-cache.mjs'
|
||||
import { getElectronPlatformPath } from './electron-platform-path.mjs'
|
||||
|
||||
const apply = process.argv.includes('--apply')
|
||||
const repoRoot = process.argv.includes('--repo')
|
||||
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
|
||||
: process.cwd()
|
||||
|
||||
function listWorktrees(root) {
|
||||
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('worktree '))
|
||||
.map((line) => line.slice('worktree '.length).trim())
|
||||
}
|
||||
|
||||
function measure(distPath) {
|
||||
try {
|
||||
return (
|
||||
Number(execFileSync('du', ['-sk', distPath], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024
|
||||
)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Swap in a shared copy behind a rename, so an interrupted run never leaves a partial dist. */
|
||||
function adoptInto(distPath, entry, identity) {
|
||||
const stagePath = `${distPath}.reclaim-${process.pid}-${randomUUID()}`
|
||||
if (!shareElectronDistFromCache(entry, stagePath, identity)) {
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
return false
|
||||
}
|
||||
const previousPath = `${distPath}.previous-${process.pid}-${randomUUID()}`
|
||||
renameSync(distPath, previousPath)
|
||||
try {
|
||||
renameSync(stagePath, distPath)
|
||||
} catch (error) {
|
||||
renameSync(previousPath, distPath)
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
rmSync(previousPath, { recursive: true, force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
let reclaimed = 0
|
||||
let converted = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const worktree of listWorktrees(repoRoot)) {
|
||||
const electronPackageDir = path.join(worktree, 'node_modules', 'electron')
|
||||
const distPath = path.join(electronPackageDir, 'dist')
|
||||
if (!existsSync(path.join(electronPackageDir, 'package.json')) || !existsSync(distPath)) {
|
||||
continue
|
||||
}
|
||||
if (statSync(distPath, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
||||
continue
|
||||
}
|
||||
|
||||
let version
|
||||
try {
|
||||
version = JSON.parse(
|
||||
readFileSync(path.join(electronPackageDir, 'package.json'), 'utf8')
|
||||
).version
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const targetPlatform = process.platform
|
||||
const targetArch = process.arch
|
||||
let platformPath
|
||||
try {
|
||||
platformPath = getElectronPlatformPath(targetPlatform)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!isUsableElectronDist(distPath, version, platformPath)) {
|
||||
console.log(`skip ${worktree} (dist is not a complete Electron ${version})`)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = resolveSharedElectronDistEntry({
|
||||
repoRoot: worktree,
|
||||
electronPackageDir,
|
||||
version,
|
||||
targetPlatform,
|
||||
targetArch
|
||||
})
|
||||
if (entry === null) {
|
||||
continue
|
||||
}
|
||||
if (hasAdoptedSharedElectronDist(entry)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const size = measure(distPath)
|
||||
if (!apply) {
|
||||
console.log(`would share ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB (${version})`)
|
||||
reclaimed += size
|
||||
converted += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(entry.entryPath)) {
|
||||
if (publishSharedElectronDist(distPath, entry, { version, platformPath })) {
|
||||
recordAdoptedSharedElectronDist(entry, writeFileSync)
|
||||
console.log(`seeded ${worktree} -> ${entry.entryPath}`)
|
||||
converted += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (adoptInto(distPath, entry, { version, platformPath })) {
|
||||
recordAdoptedSharedElectronDist(entry, writeFileSync)
|
||||
reclaimed += size
|
||||
converted += 1
|
||||
console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`)
|
||||
}
|
||||
} catch (error) {
|
||||
// A worktree that fails is left exactly as it was; it still has its own working dist.
|
||||
console.warn(`skip ${worktree} (${error instanceof Error ? error.message : String(error)})`)
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` +
|
||||
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
|
||||
`${skipped > 0 ? `; skipped ${skipped}` : ''}` +
|
||||
`${apply ? '' : '\nRe-run with --apply to do it.'}`
|
||||
)
|
||||
@@ -41,14 +41,24 @@ describe('release-cut source map publication', () => {
|
||||
expect(publish.with.command).toContain('runner.temp')
|
||||
})
|
||||
|
||||
it('fails the release when no source maps were emitted', () => {
|
||||
// Why: a silent regression of build.sourcemap would ship an undecodable
|
||||
// release rather than an obviously broken one.
|
||||
it('fails only when a map-enabled cut emits no source maps', () => {
|
||||
// Why: legacy tags predate source-map publication, but a newer tag that
|
||||
// enables hidden maps must still fail loudly if the build regresses.
|
||||
const bundle = buildSteps[stepIndex('Bundle main-process source maps')]
|
||||
expect(bundle.id).toBe('bundle-main-sourcemaps')
|
||||
expect(bundle.run).toContain('grep -Eq')
|
||||
expect(bundle.run).toContain('sourcemap:[[:space:]]*')
|
||||
expect(bundle.run).toContain('has_maps=false')
|
||||
expect(bundle.run).toContain('has_maps=true')
|
||||
expect(bundle.run).toContain('::error::')
|
||||
expect(bundle.run).toContain('exit 1')
|
||||
})
|
||||
|
||||
it('skips publication for legacy cut refs without hidden source maps', () => {
|
||||
const publish = buildSteps[stepIndex('Publish main-process source maps')]
|
||||
expect(publish.if).toContain("steps.bundle-main-sourcemaps.outputs.has_maps == 'true'")
|
||||
})
|
||||
|
||||
it('bundles maps after the build and before packaging strips them', () => {
|
||||
const bundle = stepIndex('Bundle main-process source maps')
|
||||
expect(bundle).toBeGreaterThan(stepIndex('Build app'))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
@@ -16,8 +15,10 @@ import {
|
||||
import net from 'node:net'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
|
||||
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
|
||||
import { copyPrivateTree } from './space-sharing-copy.mjs'
|
||||
import {
|
||||
DEV_BUNDLE_ID,
|
||||
getDevBundlePlistPatches,
|
||||
@@ -298,9 +299,9 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
rmSync(distDir, { recursive: true, force: true })
|
||||
mkdirSync(distDir, { recursive: true })
|
||||
// Why: Electron.framework uses relative symlinks for its bundle resources;
|
||||
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
|
||||
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
|
||||
// Why clone-first: this ~280MB copy is made per branch title x Electron version, and only the
|
||||
// plist/helper/codesign bytes patched below ever diverge from the source.
|
||||
copyPrivateTree(sourceAppPath, appPath)
|
||||
restoreElectronFrameworkSymlinks(appPath)
|
||||
|
||||
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Copy a script and every co-located module it imports into a fixture's `config/scripts`.
|
||||
*
|
||||
* Walked rather than listed: a module the script needs but the fixture never copied fails every
|
||||
* test in the suite with a module-resolution error that looks nothing like the defect it hides.
|
||||
*/
|
||||
export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) {
|
||||
mkdirSync(destinationScriptsDir, { recursive: true })
|
||||
for (const modulePath of collectScriptModules(sourceScriptPath)) {
|
||||
copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath)))
|
||||
}
|
||||
}
|
||||
|
||||
function collectScriptModules(scriptPath, seen = new Set()) {
|
||||
if (seen.has(scriptPath)) {
|
||||
return seen
|
||||
}
|
||||
seen.add(scriptPath)
|
||||
for (const [, specifier] of readFileSync(scriptPath, 'utf8').matchAll(/from '(\.\/[^']+)'/g)) {
|
||||
collectScriptModules(join(dirname(scriptPath), specifier), seen)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { makeTreeReadOnly, shareTree } from './space-sharing-copy.mjs'
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
||||
// Sibling of path.txt, never inside dist: an install replaces dist wholesale.
|
||||
const MARKER_FILENAME = '.orca-shared-dist'
|
||||
|
||||
/**
|
||||
* Where sibling worktrees of one repository keep their shared extracted Electron.
|
||||
*
|
||||
* Null means "install normally" -- every caller treats a missing entry as "do what you did before".
|
||||
*/
|
||||
export function resolveSharedElectronDistEntry(options) {
|
||||
const { repoRoot, version, targetPlatform, targetArch } = options
|
||||
const env = options.env ?? process.env
|
||||
// Packaging jobs get a fresh checkout per run, so a cache only adds a failure mode.
|
||||
if (env.CI === '1' || env.CI === 'true') {
|
||||
return null
|
||||
}
|
||||
if (![version, targetPlatform, targetArch].every((part) => IDENTITY_PATTERN.test(part ?? ''))) {
|
||||
return null
|
||||
}
|
||||
let gitCommonDir
|
||||
try {
|
||||
gitCommonDir = resolveGitCommonDir(repoRoot, options.execFile ?? execFileSync)
|
||||
} catch {
|
||||
return null // Folder workspace, or no Git on PATH.
|
||||
}
|
||||
const cacheRoot = path.join(gitCommonDir, 'orca-cache', 'electron')
|
||||
return {
|
||||
cacheRoot,
|
||||
entryPath: path.join(cacheRoot, `${version}-${targetPlatform}-${targetArch}`),
|
||||
markerPath: path.join(options.electronPackageDir, MARKER_FILENAME)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitCommonDir(repoRoot, execFile = execFileSync) {
|
||||
const rawPath = execFile('git', ['-C', repoRoot, 'rev-parse', '--git-common-dir'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
if (!rawPath) {
|
||||
throw new Error('Git returned an empty common directory')
|
||||
}
|
||||
return path.resolve(repoRoot, rawPath)
|
||||
}
|
||||
|
||||
/** True once this worktree's dist is already a clone of the current cache entry. */
|
||||
export function hasAdoptedSharedElectronDist(entry) {
|
||||
try {
|
||||
return readFileSync(entry.markerPath, 'utf8') === path.basename(entry.entryPath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function recordAdoptedSharedElectronDist(entry, write) {
|
||||
try {
|
||||
write(entry.markerPath, path.basename(entry.entryPath))
|
||||
} catch {
|
||||
// The marker is only an optimization: a missing one costs one extra clone.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a validated cache entry into `stagePath`. Deliberately never falls back to a byte copy --
|
||||
* with no storage to share the caller is better off with its normal install, which this must not
|
||||
* slow down.
|
||||
*/
|
||||
export function shareElectronDistFromCache(entry, stagePath, options) {
|
||||
const { version, platformPath } = options
|
||||
if (!isUsableElectronDist(entry.entryPath, version, platformPath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
;(options.share ?? shareTree)(entry.entryPath, stagePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return isUsableElectronDist(stagePath, version, platformPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish this worktree's dist as the shared entry, best effort.
|
||||
*
|
||||
* No lock: staging names are unique and `rename` onto a populated directory fails with ENOTEMPTY,
|
||||
* so a concurrent publisher either wins the rename or cleans up its own staging tree. Neither can
|
||||
* observe a half-written entry, and a usable entry already in place is never overwritten.
|
||||
*/
|
||||
export function publishSharedElectronDist(distPath, entry, options = {}) {
|
||||
const { version, platformPath } = options
|
||||
const uuid = options.uuid ?? randomUUID
|
||||
const canValidate = Boolean(version) && Boolean(platformPath)
|
||||
// Without an identity to check against, "unusable" is unknowable -- never discard on a guess.
|
||||
if (existsSync(entry.entryPath) && (!canValidate || isUsable(entry, version, platformPath))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const stagePath = `${entry.entryPath}.staging-${process.pid}-${uuid()}`
|
||||
try {
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
;(options.share ?? shareTree)(distPath, stagePath)
|
||||
// Before publishing, not after: an entry is visible the instant the rename lands, and under
|
||||
// hardlink sharing this is the only thing standing between a stray write and every worktree.
|
||||
;(options.protect ?? makeTreeReadOnly)(stagePath)
|
||||
} catch {
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
return false
|
||||
}
|
||||
|
||||
return swapInElectronDistEntry(entry, stagePath, {
|
||||
canValidate,
|
||||
version,
|
||||
platformPath,
|
||||
uuid,
|
||||
rename: options.rename ?? renameSync
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entry with a staged tree, re-checking first.
|
||||
*
|
||||
* Sharing the tree above takes seconds, and a sibling worktree can publish a perfectly good entry
|
||||
* in that time. Re-validating here, immediately before the destructive rename, keeps us from
|
||||
* discarding that entry -- and restoring the quarantine on a failed swap keeps a losing publisher
|
||||
* from leaving the cache empty.
|
||||
*/
|
||||
function swapInElectronDistEntry(entry, stagePath, options) {
|
||||
const { canValidate, version, platformPath, uuid, rename } = options
|
||||
let quarantinePath = null
|
||||
if (existsSync(entry.entryPath)) {
|
||||
// Same rule as before staging: an entry we cannot judge, or one that is good, is never
|
||||
// displaced. Both mean another worktree got there first, so keep theirs.
|
||||
if (!canValidate || isUsable(entry, version, platformPath)) {
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
return false
|
||||
}
|
||||
quarantinePath = `${entry.entryPath}.unusable-${process.pid}-${uuid()}`
|
||||
try {
|
||||
renameSync(entry.entryPath, quarantinePath)
|
||||
} catch {
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
return false // Another worktree is already replacing it.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rename(stagePath, entry.entryPath)
|
||||
} catch {
|
||||
rmSync(stagePath, { recursive: true, force: true })
|
||||
if (quarantinePath !== null) {
|
||||
// Put it back rather than leave no entry at all; a bad entry still beats an empty cache,
|
||||
// because the next publisher re-validates and replaces it.
|
||||
try {
|
||||
renameSync(quarantinePath, entry.entryPath)
|
||||
return false
|
||||
} catch {
|
||||
rmSync(quarantinePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (quarantinePath !== null) {
|
||||
rmSync(quarantinePath, { recursive: true, force: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isUsable(entry, version, platformPath) {
|
||||
return isUsableElectronDist(entry.entryPath, version, platformPath)
|
||||
}
|
||||
|
||||
export function isUsableElectronDist(distPath, version, platformPath) {
|
||||
try {
|
||||
if (!lstatSync(distPath).isDirectory()) {
|
||||
return false
|
||||
}
|
||||
const installedVersion = readFileSync(path.join(distPath, 'version'), 'utf8')
|
||||
.trim()
|
||||
.replace(/^v/, '')
|
||||
return installedVersion === version && existsSync(path.join(distPath, platformPath))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
shareElectronDistFromCache,
|
||||
hasAdoptedSharedElectronDist,
|
||||
isUsableElectronDist,
|
||||
publishSharedElectronDist,
|
||||
recordAdoptedSharedElectronDist,
|
||||
resolveSharedElectronDistEntry
|
||||
} from './shared-electron-dist-cache.mjs'
|
||||
import { makeTreeReadOnly } from './space-sharing-copy.mjs'
|
||||
|
||||
const VERSION = '43.4.1'
|
||||
const PLATFORM_PATH = path.join('Electron.app', 'Contents', 'MacOS', 'Electron')
|
||||
const identity = { version: VERSION, platformPath: PLATFORM_PATH }
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) {
|
||||
rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function makeRoot(): string {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-shared-electron-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writeDist(distPath: string, version = VERSION): string {
|
||||
mkdirSync(path.join(distPath, path.dirname(PLATFORM_PATH)), { recursive: true })
|
||||
writeFileSync(path.join(distPath, 'version'), `v${version}\n`)
|
||||
writeFileSync(path.join(distPath, PLATFORM_PATH), 'electron')
|
||||
return distPath
|
||||
}
|
||||
|
||||
function makeEntry(root: string, entryName = `${VERSION}-darwin-arm64`) {
|
||||
const cacheRoot = path.join(root, 'cache')
|
||||
return {
|
||||
cacheRoot,
|
||||
entryPath: path.join(cacheRoot, entryName),
|
||||
markerPath: path.join(root, '.orca-shared-dist')
|
||||
}
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
repoRoot: '/repo',
|
||||
electronPackageDir: '/repo/node_modules/electron',
|
||||
version: VERSION,
|
||||
targetPlatform: 'darwin',
|
||||
targetArch: 'arm64',
|
||||
hostPlatform: 'darwin' as const,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
execFile: (() => '/repo/.git\n') as unknown as typeof execFileSync
|
||||
}
|
||||
|
||||
describe('resolveSharedElectronDistEntry', () => {
|
||||
it('keys the entry by version, platform, and arch under the git common dir', () => {
|
||||
const entry = resolveSharedElectronDistEntry(baseOptions)
|
||||
expect(entry?.cacheRoot).toBe(path.join('/repo/.git', 'orca-cache', 'electron'))
|
||||
expect(entry?.entryPath).toBe(
|
||||
path.join('/repo/.git', 'orca-cache', 'electron', '43.4.1-darwin-arm64')
|
||||
)
|
||||
expect(entry?.markerPath).toBe(path.join('/repo/node_modules/electron', '.orca-shared-dist'))
|
||||
})
|
||||
|
||||
it('offers an entry on every platform a worktree is developed on', () => {
|
||||
for (const hostPlatform of ['darwin', 'linux', 'win32']) {
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, hostPlatform })).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('declines on CI, where every job gets a fresh checkout', () => {
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: '1' } })).toBeNull()
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'true' } })).toBeNull()
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'false' } })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('declines outside a Git worktree so folder workspaces install normally', () => {
|
||||
const execFile = (() => {
|
||||
throw new Error('not a git repository')
|
||||
}) as unknown as typeof execFileSync
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, execFile })).toBeNull()
|
||||
})
|
||||
|
||||
it('declines an identity that would not be a single safe path segment', () => {
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetArch: '../escape' })).toBeNull()
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetPlatform: 'dar/win' })).toBeNull()
|
||||
expect(resolveSharedElectronDistEntry({ ...baseOptions, version: '' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUsableElectronDist', () => {
|
||||
it('accepts a complete dist and tolerates the leading v in the version file', () => {
|
||||
const root = makeRoot()
|
||||
expect(isUsableElectronDist(writeDist(path.join(root, 'dist')), VERSION, PLATFORM_PATH)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a version mismatch, a missing executable, and a missing directory', () => {
|
||||
const root = makeRoot()
|
||||
expect(
|
||||
isUsableElectronDist(writeDist(path.join(root, 'a'), '40.0.0'), VERSION, PLATFORM_PATH)
|
||||
).toBe(false)
|
||||
const partial = path.join(root, 'b')
|
||||
mkdirSync(partial, { recursive: true })
|
||||
writeFileSync(path.join(partial, 'version'), `v${VERSION}`)
|
||||
expect(isUsableElectronDist(partial, VERSION, PLATFORM_PATH)).toBe(false)
|
||||
expect(isUsableElectronDist(path.join(root, 'missing'), VERSION, PLATFORM_PATH)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a symlink so a redirected entry is never treated as cache content', () => {
|
||||
const root = makeRoot()
|
||||
writeDist(path.join(root, 'real'))
|
||||
symlinkSync(path.join(root, 'real'), path.join(root, 'link'), 'dir')
|
||||
expect(isUsableElectronDist(path.join(root, 'link'), VERSION, PLATFORM_PATH)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishSharedElectronDist', () => {
|
||||
it('publishes through a staging directory and an atomic rename', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
const dist = writeDist(path.join(root, 'dist'))
|
||||
const share = vi.fn((source: string, destination: string) => {
|
||||
expect(path.basename(destination)).toMatch(/^43\.4\.1-darwin-arm64\.staging-/)
|
||||
writeDist(destination)
|
||||
expect(source).toBe(dist)
|
||||
})
|
||||
expect(publishSharedElectronDist(dist, entry, { share, ...identity })).toBe(true)
|
||||
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
|
||||
})
|
||||
|
||||
it('publishes the entry read-only, before it is reachable under its final name', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
const dist = writeDist(path.join(root, 'dist'))
|
||||
const protectedPaths: string[] = []
|
||||
const share = (source: string, destination: string) => {
|
||||
writeDist(destination)
|
||||
expect(source).toBe(dist)
|
||||
}
|
||||
const protect = (target: string) => {
|
||||
// Why order matters: a reader can clone the entry the instant the rename lands.
|
||||
expect(existsSync(entry.entryPath)).toBe(false)
|
||||
protectedPaths.push(target)
|
||||
makeTreeReadOnly(target)
|
||||
}
|
||||
expect(publishSharedElectronDist(dist, entry, { share, protect, ...identity })).toBe(true)
|
||||
expect(protectedPaths).toHaveLength(1)
|
||||
expect(statSync(path.join(entry.entryPath, 'version')).mode & 0o222).toBe(0)
|
||||
// Entry directories stay removable, which is what the install transaction actually needs.
|
||||
expect(() => rmSync(entry.entryPath, { recursive: true })).not.toThrow()
|
||||
})
|
||||
|
||||
it('never overwrites an entry another worktree already published', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath)
|
||||
writeFileSync(path.join(entry.entryPath, 'marker'), 'first-writer')
|
||||
const share = vi.fn()
|
||||
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
|
||||
false
|
||||
)
|
||||
expect(share).not.toHaveBeenCalled()
|
||||
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
|
||||
})
|
||||
|
||||
it('loses a publish race without clobbering the winner or leaking staging', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
const share = (_source: string, destination: string) => {
|
||||
writeDist(destination)
|
||||
// The winner lands between our existence check and our rename.
|
||||
writeDist(entry.entryPath)
|
||||
writeFileSync(path.join(entry.entryPath, 'marker'), 'winner')
|
||||
}
|
||||
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
|
||||
})
|
||||
|
||||
it('keeps a good entry a sibling published while this one was still sharing', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath, '40.0.0') // Unusable, so this worktree intends to replace it.
|
||||
const share = (_source: string, destination: string) => {
|
||||
writeDist(destination)
|
||||
// A sibling replaces the bad entry with a good one while this share is still running.
|
||||
rmSync(entry.entryPath, { recursive: true, force: true })
|
||||
writeDist(entry.entryPath)
|
||||
writeFileSync(path.join(entry.entryPath, 'marker'), 'sibling')
|
||||
}
|
||||
expect(
|
||||
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
|
||||
).toBe(false)
|
||||
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
|
||||
})
|
||||
|
||||
it('restores the quarantined entry rather than leaving the cache empty', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath, '40.0.0')
|
||||
writeFileSync(path.join(entry.entryPath, 'marker'), 'stale')
|
||||
const share = (_source: string, destination: string) => writeDist(destination)
|
||||
// The swap itself fails; a bad entry still beats no entry, since the next publisher replaces it.
|
||||
const failingRename = () => {
|
||||
throw new Error('rename failed')
|
||||
}
|
||||
expect(
|
||||
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, {
|
||||
share,
|
||||
rename: failingRename,
|
||||
...identity
|
||||
})
|
||||
).toBe(false)
|
||||
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
|
||||
})
|
||||
|
||||
it('replaces an entry that fails validation instead of stranding every worktree', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath, '40.0.0')
|
||||
const share = (_source: string, destination: string) => writeDist(destination)
|
||||
expect(
|
||||
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
|
||||
).toBe(true)
|
||||
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
|
||||
})
|
||||
|
||||
it('never discards an entry it was given no identity to check', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath, '40.0.0')
|
||||
const share = vi.fn()
|
||||
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
|
||||
false
|
||||
)
|
||||
expect(share).not.toHaveBeenCalled()
|
||||
expect(existsSync(path.join(entry.entryPath, 'version'))).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves no entry and no staging tree when sharing fails', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
const share = (_source: string, destination: string) => {
|
||||
writeDist(destination)
|
||||
throw new Error('no shareable storage')
|
||||
}
|
||||
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
|
||||
false
|
||||
)
|
||||
expect(existsSync(entry.entryPath)).toBe(false)
|
||||
expect(readdirSync(entry.cacheRoot)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shareElectronDistFromCache', () => {
|
||||
it('shares a validated entry with real filesystem semantics', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath)
|
||||
const stagePath = path.join(root, 'stage')
|
||||
expect(
|
||||
shareElectronDistFromCache(entry, stagePath, {
|
||||
version: VERSION,
|
||||
platformPath: PLATFORM_PATH
|
||||
})
|
||||
).toBe(true)
|
||||
expect(isUsableElectronDist(stagePath, VERSION, PLATFORM_PATH)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an entry that fails validation instead of installing it', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath, '40.0.0')
|
||||
const stagePath = path.join(root, 'stage')
|
||||
expect(
|
||||
shareElectronDistFromCache(entry, stagePath, {
|
||||
version: VERSION,
|
||||
platformPath: PLATFORM_PATH
|
||||
})
|
||||
).toBe(false)
|
||||
expect(existsSync(stagePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports failure rather than falling back to a full copy', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
mkdirSync(entry.cacheRoot, { recursive: true })
|
||||
writeDist(entry.entryPath)
|
||||
// Injected rather than provoked: what counts as an unshareable destination differs per
|
||||
// mechanism, and a byte-copy fallback here would defeat the point of the cache.
|
||||
const stagePath = path.join(root, 'stage')
|
||||
const share = () => {
|
||||
throw new Error('no shareable storage')
|
||||
}
|
||||
expect(
|
||||
shareElectronDistFromCache(entry, stagePath, {
|
||||
version: VERSION,
|
||||
platformPath: PLATFORM_PATH,
|
||||
share
|
||||
})
|
||||
).toBe(false)
|
||||
expect(existsSync(stagePath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared dist marker', () => {
|
||||
it('reports adoption only for the entry the marker names', () => {
|
||||
const root = makeRoot()
|
||||
const entry = makeEntry(root)
|
||||
expect(hasAdoptedSharedElectronDist(entry)).toBe(false)
|
||||
recordAdoptedSharedElectronDist(entry, writeFileSync)
|
||||
expect(hasAdoptedSharedElectronDist(entry)).toBe(true)
|
||||
expect(
|
||||
hasAdoptedSharedElectronDist({
|
||||
...entry,
|
||||
entryPath: path.join(entry.cacheRoot, '44.0.0-darwin-arm64')
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a marker write failure, which only costs one extra share', () => {
|
||||
const entry = makeEntry(makeRoot())
|
||||
expect(() =>
|
||||
recordAdoptedSharedElectronDist(entry, () => {
|
||||
throw new Error('read-only node_modules')
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// -c asks for clonefile(2). -P keeps Electron.framework's relative symlinks as symlinks; resolving
|
||||
// them breaks Chromium's bundle lookup.
|
||||
export const MACOS_CLONE_ARGS = Object.freeze(['-c', '-R', '-P'])
|
||||
// -a implies -d (no symlink following) and preserves mode. --reflink=always fails loudly on a
|
||||
// filesystem without reflinks rather than silently writing a second full copy.
|
||||
export const LINUX_REFLINK_ARGS = Object.freeze(['--reflink=always', '-a'])
|
||||
|
||||
/**
|
||||
* Copy a directory tree so the destination costs no new storage.
|
||||
*
|
||||
* Three mechanisms, strongest isolation first. Clone and reflink are copy-on-write, so the
|
||||
* destination is genuinely private. Hardlinks are not: the two trees share inodes, and a write
|
||||
* through either mutates both. That is only sound for a tree nothing writes to, which is why
|
||||
* `makeTreeReadOnly` exists and why the caller must apply it.
|
||||
*
|
||||
* Throws when no mechanism is available, so a caller can fall back to installing normally rather
|
||||
* than silently paying for a second full copy.
|
||||
*/
|
||||
export function shareTree(sourcePath, destinationPath, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
const errors = []
|
||||
for (const mechanism of getShareMechanisms(platform)) {
|
||||
try {
|
||||
;(options[mechanism] ?? shareMechanisms[mechanism])(sourcePath, destinationPath)
|
||||
return mechanism
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
// A mechanism can fail part-way through a tree; the next one needs a clean destination.
|
||||
rmSync(destinationPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
throw new AggregateError(errors, `Could not share storage for ${destinationPath}`)
|
||||
}
|
||||
|
||||
function getShareMechanisms(platform) {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
// APFS only. HFS+ has no clonefile, and hardlinking a 585-entry bundle buys little.
|
||||
return ['clone']
|
||||
case 'linux':
|
||||
// reflink covers btrfs/XFS/bcachefs/ZFS; ext4 has none, which is most developers.
|
||||
return ['reflink', 'hardlink']
|
||||
case 'win32':
|
||||
// Block cloning is ReFS-only, so NTFS gets hardlinks or nothing.
|
||||
return ['hardlink']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const shareMechanisms = {
|
||||
clone: (sourcePath, destinationPath) =>
|
||||
execFileSync('/bin/cp', [...MACOS_CLONE_ARGS, sourcePath, destinationPath], {
|
||||
stdio: 'ignore'
|
||||
}),
|
||||
reflink: (sourcePath, destinationPath) =>
|
||||
execFileSync('cp', [...LINUX_REFLINK_ARGS, sourcePath, destinationPath], { stdio: 'ignore' }),
|
||||
hardlink: hardlinkTree
|
||||
}
|
||||
|
||||
export function hardlinkTree(sourcePath, destinationPath) {
|
||||
mkdirSync(destinationPath, { recursive: true })
|
||||
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
|
||||
const from = join(sourcePath, entry.name)
|
||||
const to = join(destinationPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
hardlinkTree(from, to)
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
symlinkSync(readlinkSync(from), to)
|
||||
} else {
|
||||
linkSync(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop write permission across a tree.
|
||||
*
|
||||
* This is what makes hardlink sharing safe: Electron's own install.js extracts over an existing
|
||||
* dist with O_TRUNC, which through a hardlink would rewrite every sibling worktree and the cache at
|
||||
* once. Read-only turns that into EPERM. Directories stay writable because unlink needs a writable
|
||||
* parent, not a writable file, so the install transaction's renames still work.
|
||||
*/
|
||||
export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
|
||||
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
|
||||
const entryPath = join(targetPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
makeTreeReadOnly(entryPath, chmod)
|
||||
} else if (!entry.isSymbolicLink()) {
|
||||
chmod(entryPath, 0o555)
|
||||
}
|
||||
}
|
||||
chmod(targetPath, 0o755)
|
||||
}
|
||||
|
||||
/**
|
||||
* Share storage when possible, otherwise copy the bytes.
|
||||
*
|
||||
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
|
||||
* through into the source.
|
||||
*/
|
||||
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
|
||||
const platform = options.platform ?? process.platform
|
||||
const copy = options.copy ?? copyTreeVerbatim
|
||||
const privateMechanisms = new Set(['clone', 'reflink'])
|
||||
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
|
||||
try {
|
||||
const mechanism = shareTree(sourcePath, destinationPath, {
|
||||
...options,
|
||||
hardlink: () => {
|
||||
throw new Error('hardlinks would not be private')
|
||||
}
|
||||
})
|
||||
return { mechanism, copyError: null }
|
||||
} catch (copyError) {
|
||||
copy(sourcePath, destinationPath)
|
||||
return { mechanism: null, copyError }
|
||||
}
|
||||
}
|
||||
copy(sourcePath, destinationPath)
|
||||
return { mechanism: null, copyError: null }
|
||||
}
|
||||
|
||||
function copyTreeVerbatim(sourcePath, destinationPath) {
|
||||
cpSync(sourcePath, destinationPath, {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
verbatimSymlinks: true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LINUX_REFLINK_ARGS,
|
||||
MACOS_CLONE_ARGS,
|
||||
copyPrivateTree,
|
||||
hardlinkTree,
|
||||
makeTreeReadOnly,
|
||||
shareTree
|
||||
} from './space-sharing-copy.mjs'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length > 0) {
|
||||
rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function makeTree(): { root: string; source: string } {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-share-'))
|
||||
roots.push(root)
|
||||
const source = path.join(root, 'source')
|
||||
mkdirSync(path.join(source, 'nested'), { recursive: true })
|
||||
writeFileSync(path.join(source, 'nested', 'file'), 'contents')
|
||||
symlinkSync(path.join('nested', 'file'), path.join(source, 'relative-link'))
|
||||
return { root, source }
|
||||
}
|
||||
|
||||
describe('shareTree', () => {
|
||||
// Mechanism selection is asserted with stubs, because the real mechanisms only exist on the host
|
||||
// that owns them: /bin/cp -c is macOS-only and `cp --reflink` is GNU-only.
|
||||
it('prefers the strongest isolation each platform offers', () => {
|
||||
const stub = () =>
|
||||
vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
|
||||
const stubs = { clone: stub(), reflink: stub(), hardlink: stub() }
|
||||
const { root, source } = makeTree()
|
||||
expect(shareTree(source, path.join(root, 'a'), { platform: 'darwin', ...stubs })).toBe('clone')
|
||||
expect(shareTree(source, path.join(root, 'b'), { platform: 'linux', ...stubs })).toBe('reflink')
|
||||
expect(shareTree(source, path.join(root, 'c'), { platform: 'win32', ...stubs })).toBe(
|
||||
'hardlink'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps relative symlinks unresolved on whatever this host supports', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'shared')
|
||||
expect(shareTree(source, destination)).toBeTruthy()
|
||||
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
|
||||
})
|
||||
|
||||
it('falls from reflink to hardlink on Linux, where ext4 has no reflinks', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'shared')
|
||||
const reflink = vi.fn(() => {
|
||||
throw new Error('failed to clone: Invalid cross-device link')
|
||||
})
|
||||
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
|
||||
expect(reflink).toHaveBeenCalledOnce()
|
||||
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
|
||||
statSync(path.join(source, 'nested', 'file')).ino
|
||||
)
|
||||
})
|
||||
|
||||
it('hardlinks on Windows, the only mechanism NTFS offers', () => {
|
||||
const { root, source } = makeTree()
|
||||
expect(shareTree(source, path.join(root, 'shared'), { platform: 'win32' })).toBe('hardlink')
|
||||
})
|
||||
|
||||
it('clears a part-way tree before trying the next mechanism', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'shared')
|
||||
const reflink = (_source: string, target: string) => {
|
||||
mkdirSync(target, { recursive: true })
|
||||
writeFileSync(path.join(target, 'half-written'), 'partial')
|
||||
throw new Error('reflink failed midway')
|
||||
}
|
||||
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
|
||||
expect(existsSync(path.join(destination, 'half-written'))).toBe(false)
|
||||
})
|
||||
|
||||
it('throws rather than silently paying for a second full copy', () => {
|
||||
const { root, source } = makeTree()
|
||||
expect(() => shareTree(source, path.join(root, 'shared'), { platform: 'freebsd' })).toThrow(
|
||||
/Could not share storage/
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loudly instead of degrading, on both copy-out mechanisms', () => {
|
||||
expect(MACOS_CLONE_ARGS).toContain('-P')
|
||||
expect(LINUX_REFLINK_ARGS).toContain('--reflink=always')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hardlinkTree', () => {
|
||||
it('shares inodes for files but recreates symlinks as their own entries', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'linked')
|
||||
hardlinkTree(source, destination)
|
||||
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
|
||||
statSync(path.join(source, 'nested', 'file')).ino
|
||||
)
|
||||
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
|
||||
})
|
||||
|
||||
it('propagates a write through the shared inode, which is why callers must protect it', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'linked')
|
||||
hardlinkTree(source, destination)
|
||||
writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')
|
||||
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('mutated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeTreeReadOnly', () => {
|
||||
it('drops write permission on files while leaving directories traversable and unlinkable', () => {
|
||||
const { source } = makeTree()
|
||||
makeTreeReadOnly(source)
|
||||
expect(statSync(path.join(source, 'nested', 'file')).mode & 0o222).toBe(0)
|
||||
// Asserted as behavior, not mode bits: Windows maps chmod onto the read-only attribute alone,
|
||||
// so a directory there never reports 0o755. What has to hold everywhere is that the install
|
||||
// transaction can still rename dist aside and remove it.
|
||||
expect(() => rmSync(path.join(source, 'nested'), { recursive: true })).not.toThrow()
|
||||
})
|
||||
|
||||
it('turns an extract-over-dist write into an error instead of silent shared corruption', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'linked')
|
||||
hardlinkTree(source, destination)
|
||||
makeTreeReadOnly(destination)
|
||||
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')).toThrow()
|
||||
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'keeps the executable bit, which Electron needs to launch',
|
||||
() => {
|
||||
const { source } = makeTree()
|
||||
const executable = path.join(source, 'electron')
|
||||
writeFileSync(executable, 'binary', { mode: 0o755 })
|
||||
makeTreeReadOnly(source)
|
||||
// Verified on real ext4: 0o555. Windows has no execute bit -- the read-only attribute does
|
||||
// not gate execution there, confirmed by running a read-only hardlinked .exe on NTFS.
|
||||
expect(statSync(executable).mode & 0o111).toBe(0o111)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('copyPrivateTree', () => {
|
||||
it('never hardlinks, because the caller patches what it gets back', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'private')
|
||||
const hardlink = vi.fn()
|
||||
const result = copyPrivateTree(source, destination, { platform: 'linux', hardlink })
|
||||
expect(hardlink).not.toHaveBeenCalled()
|
||||
expect(statSync(path.join(destination, 'nested', 'file')).ino).not.toBe(
|
||||
statSync(path.join(source, 'nested', 'file')).ino
|
||||
)
|
||||
expect(result.mechanism === 'reflink' || result.mechanism === null).toBe(true)
|
||||
})
|
||||
|
||||
it('copies bytes on a platform with no private mechanism at all', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'private')
|
||||
const hardlink = vi.fn()
|
||||
expect(copyPrivateTree(source, destination, { platform: 'win32', hardlink })).toEqual({
|
||||
mechanism: null,
|
||||
copyError: null
|
||||
})
|
||||
expect(hardlink).not.toHaveBeenCalled()
|
||||
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
|
||||
})
|
||||
|
||||
it('reports the private mechanism it used', () => {
|
||||
const { root, source } = makeTree()
|
||||
const clone = vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
|
||||
expect(
|
||||
copyPrivateTree(source, path.join(root, 'private'), { platform: 'darwin', clone })
|
||||
).toEqual({
|
||||
mechanism: 'clone',
|
||||
copyError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a byte copy when the private mechanism fails', () => {
|
||||
const { root, source } = makeTree()
|
||||
const destination = path.join(root, 'private')
|
||||
const clone = () => {
|
||||
throw new Error('clonefile unsupported')
|
||||
}
|
||||
const result = copyPrivateTree(source, destination, { platform: 'darwin', clone })
|
||||
expect(result.mechanism).toBeNull()
|
||||
expect(result.copyError).toBeInstanceOf(Error)
|
||||
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
# Files currently allowed to carry a `@ts-nocheck` header.
|
||||
# This is a RATCHET: the list may only SHRINK. These exist only because the split
|
||||
# runtime mixin chain cannot express forward references yet — do NOT add entries to
|
||||
# get CI green; fix the types instead.
|
||||
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
|
||||
src/main/runtime/orca-runtime-activate-managed-worktree.ts
|
||||
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
|
||||
src/main/runtime/orca-runtime-agent-teams-launch-plan.ts
|
||||
src/main/runtime/orca-runtime-apply-layout.ts
|
||||
src/main/runtime/orca-runtime-apply-mobile-display-mode.ts
|
||||
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
|
||||
src/main/runtime/orca-runtime-apply-tracked-pty-title.ts
|
||||
src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts
|
||||
src/main/runtime/orca-runtime-attach-window.ts
|
||||
src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts
|
||||
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
|
||||
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
|
||||
src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts
|
||||
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
|
||||
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
||||
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
|
||||
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
|
||||
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
|
||||
src/main/runtime/orca-runtime-core.ts
|
||||
src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts
|
||||
src/main/runtime/orca-runtime-create-agent-session.ts
|
||||
src/main/runtime/orca-runtime-create-managed-remote-worktree.ts
|
||||
src/main/runtime/orca-runtime-create-managed-worktree.ts
|
||||
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts
|
||||
src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-create-terminal-desktop.ts
|
||||
src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts
|
||||
src/main/runtime/orca-runtime-create-terminal.ts
|
||||
src/main/runtime/orca-runtime-deliver-pending-messages.ts
|
||||
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
|
||||
src/main/runtime/orca-runtime-fence-automation-owner.ts
|
||||
src/main/runtime/orca-runtime-file-commands.ts
|
||||
src/main/runtime/orca-runtime-fit-override-listeners.ts
|
||||
src/main/runtime/orca-runtime-focus-terminal.ts
|
||||
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
|
||||
src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts
|
||||
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
|
||||
src/main/runtime/orca-runtime-get-runtime-id.ts
|
||||
src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts
|
||||
src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
|
||||
src/main/runtime/orca-runtime-get-worktree-ps.ts
|
||||
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
|
||||
src/main/runtime/orca-runtime-handle-mobile-subscribe-internal.ts
|
||||
src/main/runtime/orca-runtime-handle-mobile-subscribe.ts
|
||||
src/main/runtime/orca-runtime-handle-mobile-unsubscribe.ts
|
||||
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
|
||||
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
|
||||
src/main/runtime/orca-runtime-has-recent-terminal-output-path.ts
|
||||
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
|
||||
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
|
||||
src/main/runtime/orca-runtime-linear-commands.ts
|
||||
src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts
|
||||
src/main/runtime/orca-runtime-list-managed-worktrees.ts
|
||||
src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts
|
||||
src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts
|
||||
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
|
||||
src/main/runtime/orca-runtime-mobile-took-floor.ts
|
||||
src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
|
||||
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
|
||||
src/main/runtime/orca-runtime-on-client-disconnected.ts
|
||||
src/main/runtime/orca-runtime-on-pty-data.ts
|
||||
src/main/runtime/orca-runtime-on-pty-exit.ts
|
||||
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
|
||||
src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts
|
||||
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
|
||||
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
|
||||
src/main/runtime/orca-runtime-pick-most-recent-actor.ts
|
||||
src/main/runtime/orca-runtime-postlude.ts
|
||||
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
|
||||
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
|
||||
src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts
|
||||
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
|
||||
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
|
||||
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-reclaim-terminal-for-desktop.ts
|
||||
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
|
||||
src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts
|
||||
src/main/runtime/orca-runtime-record-pty-worktree.ts
|
||||
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
|
||||
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
|
||||
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
|
||||
src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts
|
||||
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
|
||||
src/main/runtime/orca-runtime-register-pty.ts
|
||||
src/main/runtime/orca-runtime-remove-managed-worktree.ts
|
||||
src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts
|
||||
src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts
|
||||
src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-resolve-exit-waiters.ts
|
||||
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
|
||||
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts
|
||||
src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts
|
||||
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
|
||||
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
|
||||
src/main/runtime/orca-runtime-resolve-waiter.ts
|
||||
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts
|
||||
src/main/runtime/orca-runtime-resolve-worktree-selector.ts
|
||||
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
|
||||
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
|
||||
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
|
||||
src/main/runtime/orca-runtime-runtime-id.ts
|
||||
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
|
||||
src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts
|
||||
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts
|
||||
src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts
|
||||
src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts
|
||||
src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts
|
||||
src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts
|
||||
src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
|
||||
src/main/runtime/orca-runtime-split-terminal.ts
|
||||
src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts
|
||||
src/main/runtime/orca-runtime-state-fields.ts
|
||||
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
|
||||
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
|
||||
src/main/runtime/orca-runtime-stop-structured-session-process.ts
|
||||
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
|
||||
src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts
|
||||
src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts
|
||||
src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts
|
||||
src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts
|
||||
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
|
||||
src/main/runtime/orca-runtime-sync-window-graph.ts
|
||||
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
|
||||
src/main/runtime/orca-runtime-terminal-drivers.ts
|
||||
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
|
||||
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
|
||||
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
|
||||
src/main/runtime/orca-runtime-visible-snapshot-preview.ts
|
||||
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
|
||||
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
|
||||
src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts
|
||||
src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts
|
||||
src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
|
||||
src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-clear.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-click.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-network-log.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-screencast.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-set-headers.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-tab-close.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-tab-create.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-tab-list.ts
|
||||
src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
|
||||
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
|
||||
src/main/runtime/runtime-browser-commands-state.ts
|
||||
src/main/runtime/runtime-file-command-host.ts
|
||||
src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts
|
||||
src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts
|
||||
src/main/runtime/runtime-file-commands-constructor.ts
|
||||
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
|
||||
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
|
||||
src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts
|
||||
src/main/runtime/runtime-file-commands-read-mobile-file.ts
|
||||
src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts
|
||||
src/main/runtime/runtime-file-commands-resolve-terminal-path.ts
|
||||
src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts
|
||||
src/main/runtime/runtime-file-commands-search-local-runtime-files.ts
|
||||
src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts
|
||||
src/main/runtime/runtime-file-commands-search-runtime-files.ts
|
||||
src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts
|
||||
src/main/runtime/runtime-file-commands-terminal-artifact-access.ts
|
||||
src/main/runtime/runtime-file-commands-terminal-file-paths.ts
|
||||
src/main/runtime/runtime-file-commands-write-file-explorer-file.ts
|
||||
src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts
|
||||
src/main/runtime/runtime-file-watcher-leases.ts
|
||||
+3
-1
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
@@ -29,6 +29,7 @@
|
||||
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
|
||||
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
|
||||
"build:orcad": "node config/scripts/build-orcad.mjs",
|
||||
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
|
||||
@@ -85,6 +86,7 @@
|
||||
"build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
|
||||
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
|
||||
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
|
||||
"reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs",
|
||||
"rebuild:node": "pnpm rebuild node-pty",
|
||||
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
|
||||
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
|
||||
|
||||
@@ -174,6 +174,52 @@ describe('browserManager', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses devtools for an offscreen guest instead of opening it on the host display', async () => {
|
||||
const guest = {
|
||||
id: 138,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'window'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
expect(
|
||||
browserManager.registerOffscreenGuest({
|
||||
browserPageId: 'offscreen-devtools',
|
||||
webContentsId: guest.id
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
await expect(browserManager.openDevTools('offscreen-devtools')).resolves.toBe(false)
|
||||
expect(guestOpenDevToolsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps devtools available for a desktop webview guest', async () => {
|
||||
const guest = {
|
||||
id: 139,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'desktop-devtools',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
await expect(browserManager.openDevTools('desktop-devtools')).resolves.toBe(true)
|
||||
expect(guestOpenDevToolsMock).toHaveBeenCalledWith({ mode: 'detach' })
|
||||
})
|
||||
|
||||
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
|
||||
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
|
||||
it('refuses unregisterGuest for a page the document registry holds', () => {
|
||||
|
||||
@@ -1926,6 +1926,11 @@ export class BrowserManager {
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
// Offscreen guests have no visible window on this desktop; detaching DevTools would open it
|
||||
// on the host display with no route back to the remote client.
|
||||
if (this.offscreenGuestIds.has(webContentsId)) {
|
||||
return false
|
||||
}
|
||||
guest.openDevTools({ mode: 'detach' })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
hasCommitObjectViaGitExec,
|
||||
hasLocalCommitObject,
|
||||
isFullGitObjectId
|
||||
} from './commit-object-ref'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { hasCommitObjectViaGitExec, isFullGitObjectId } from './commit-object-ref'
|
||||
|
||||
describe('commit object refs', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('recognizes only complete git object IDs', () => {
|
||||
expect(isFullGitObjectId('a'.repeat(40))).toBe(true)
|
||||
expect(isFullGitObjectId('A'.repeat(40))).toBe(true)
|
||||
@@ -49,15 +34,4 @@ describe('commit object refs', () => {
|
||||
|
||||
expect(gitExec).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks local commit objects in the target repo path', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'a'.repeat(40), stderr: '' })
|
||||
|
||||
await expect(hasLocalCommitObject('/repo', 'a'.repeat(40))).resolves.toBe(true)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', `${'a'.repeat(40)}^{commit}`],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
type GitExec = (args: string[]) => Promise<unknown>
|
||||
|
||||
const FULL_GIT_OBJECT_ID_PATTERN = /^[0-9a-f]{40}$/i
|
||||
@@ -20,7 +18,3 @@ export async function hasCommitObjectViaGitExec(gitExec: GitExec, ref: string):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function hasLocalCommitObject(repoPath: string, ref: string): Promise<boolean> {
|
||||
return hasCommitObjectViaGitExec((args) => gitExecFileAsync(args, { cwd: repoPath }), ref)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path'
|
||||
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { toWindowsWslPath } from '../wsl'
|
||||
import { resolveGitMetadataPath } from '../../shared/git-metadata-path'
|
||||
|
||||
export type GitMarkerScanResult =
|
||||
| { status: 'valid'; rootPath: string }
|
||||
@@ -136,18 +135,6 @@ function parseGitdirFile(basePath: string, content: string): string | null {
|
||||
return resolveGitMetadataPath(basePath, match[1])
|
||||
}
|
||||
|
||||
function resolveGitMetadataPath(basePath: string, rawPath: string): string | null {
|
||||
const value = rawPath.trim()
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const baseWsl = parseWslUncPath(basePath)
|
||||
if (baseWsl && value.startsWith('/')) {
|
||||
return toWindowsWslPath(value, baseWsl.distro)
|
||||
}
|
||||
return isAbsolute(value) ? value : resolve(basePath, value)
|
||||
}
|
||||
|
||||
function hasValidGitDirectorySync(gitDir: string): boolean {
|
||||
return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
import {
|
||||
getLocalBaseRefUpdateSuggestionForWorktreeCreate,
|
||||
refreshLocalBaseRefForWorktreeCreate
|
||||
@@ -200,11 +201,17 @@ async function performAddWorktree(
|
||||
args.push(effectiveBase)
|
||||
}
|
||||
}
|
||||
await gitExecFileAsync(args, {
|
||||
...gitExecOptions(repoPath, options),
|
||||
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
|
||||
timeout: resolveWorktreeAddTimeoutMs()
|
||||
})
|
||||
try {
|
||||
await gitExecFileAsync(args, {
|
||||
...gitExecOptions(repoPath, options),
|
||||
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
|
||||
timeout: resolveWorktreeAddTimeoutMs()
|
||||
})
|
||||
} finally {
|
||||
// Git may have written the target's `.git` marker even when it reports a late
|
||||
// failure, so drop any pre-create route before the follow-up commands route.
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
}
|
||||
|
||||
if (options.checkoutExistingBranch) {
|
||||
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const gitExecFileAsync = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./runner', () => ({ gitExecFileAsync }))
|
||||
|
||||
import { hasLocalWorktreeBaseRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
|
||||
|
||||
describe('probeWorktreeBaseRefPresence', () => {
|
||||
it('uses an exact show-ref probe and reports a present ref', async () => {
|
||||
@@ -52,3 +57,47 @@ describe('probeWorktreeBaseRefPresence', () => {
|
||||
expect(runGit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLocalWorktreeBaseRef', () => {
|
||||
const repoPath = String.raw`C:\workspace\repo`
|
||||
|
||||
function resolveOnly(present: string[]): void {
|
||||
gitExecFileAsync.mockImplementation(async (args: string[]) => ({
|
||||
stdout: present.includes(args.at(-1)?.replace('^{commit}', '') ?? '') ? 'f'.repeat(40) : '',
|
||||
stderr: ''
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecFileAsync.mockReset()
|
||||
})
|
||||
|
||||
it('prefers the remote namespace for a slashed short name', async () => {
|
||||
resolveOnly(['refs/remotes/origin/main'])
|
||||
|
||||
await expect(hasLocalWorktreeBaseRef(repoPath, 'origin/main')).resolves.toBe(true)
|
||||
expect(gitExecFileAsync).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
})
|
||||
|
||||
it('probes a bare commit id as an object, not as a ref', async () => {
|
||||
const sha = 'a'.repeat(40)
|
||||
resolveOnly([sha])
|
||||
|
||||
await expect(hasLocalWorktreeBaseRef(repoPath, sha, { wslDistro: 'Ubuntu' })).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(gitExecFileAsync).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', `${sha}^{commit}`],
|
||||
{ cwd: repoPath, wslDistro: 'Ubuntu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a base no namespace resolves as absent', async () => {
|
||||
resolveOnly([])
|
||||
|
||||
await expect(hasLocalWorktreeBaseRef(repoPath, 'feature/topic')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { isShowRefNoMatchError } from './exact-ref-probe'
|
||||
import { hasCommitObjectViaGitExec } from './commit-object-ref'
|
||||
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
||||
|
||||
type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
@@ -40,6 +42,34 @@ export async function hasWorktreeBaseCommitRef(
|
||||
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
|
||||
* full commit id — already resolves in this repo's own object/ref store.
|
||||
*
|
||||
* Single copy on purpose: the create path, the speculative create prefetch and
|
||||
* the remote-repo create path must agree on what counts as a local base, or the
|
||||
* warm-up prepares a checkout create then rejects.
|
||||
*/
|
||||
export async function hasLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const refExists = (qualifiedRef: string) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasCommitObjectViaGitExec(
|
||||
(gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }),
|
||||
baseRef
|
||||
)
|
||||
}
|
||||
|
||||
export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown'
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
@@ -50,10 +51,14 @@ async function performDiscardPreparedWorktree(
|
||||
} catch {
|
||||
// It may be unlocked already or only partially registered.
|
||||
}
|
||||
await gitExecFileAsync(
|
||||
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
|
||||
cleanupGitOptions
|
||||
)
|
||||
try {
|
||||
await gitExecFileAsync(
|
||||
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
|
||||
cleanupGitOptions
|
||||
)
|
||||
} finally {
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareWorktreeCreateCheckout(
|
||||
@@ -81,6 +86,8 @@ export async function prepareWorktreeCreateCheckout(
|
||||
],
|
||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||
)
|
||||
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
// Why: reset materializes files without running user post-checkout hooks before submit.
|
||||
await gitExecFileAsync(
|
||||
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
|
||||
@@ -213,20 +220,26 @@ export async function finalizePreparedWorktree(
|
||||
|
||||
let moved = false
|
||||
try {
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'move',
|
||||
'-f',
|
||||
'-f',
|
||||
preparedPath,
|
||||
worktreePath
|
||||
],
|
||||
gitExecOptions(repoPath, finalizeGitOptions)
|
||||
)
|
||||
moved = true
|
||||
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
|
||||
try {
|
||||
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(repoPath),
|
||||
'worktree',
|
||||
'move',
|
||||
'-f',
|
||||
'-f',
|
||||
preparedPath,
|
||||
worktreePath
|
||||
],
|
||||
gitExecOptions(repoPath, finalizeGitOptions)
|
||||
)
|
||||
moved = true
|
||||
} finally {
|
||||
// The move rewrites both `.git` markers, and a failure can have rewritten one.
|
||||
invalidateWslLinkedWorktreeGitRouting(preparedPath)
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
}
|
||||
await gitExecFileAsync(
|
||||
[
|
||||
...windowsLongPathGitArgs(worktreePath),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
import { bumpWorktreeScanGeneration } from './worktree-scan-cache'
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,9 @@ export async function moveWorktree(
|
||||
gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
|
||||
)
|
||||
} finally {
|
||||
// A failed move can still have rewritten one `.git` marker, so re-probe both paths.
|
||||
invalidateWslLinkedWorktreeGitRouting(oldPath)
|
||||
invalidateWslLinkedWorktreeGitRouting(newPath)
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeModule from './worktree'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
gitExecFileSyncMock,
|
||||
translateWslOutputPathsMock,
|
||||
listWorktreesMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output),
|
||||
listWorktreesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitExecFileSync: gitExecFileSyncMock,
|
||||
translateWslOutputPaths: translateWslOutputPathsMock
|
||||
}))
|
||||
|
||||
vi.mock('./status', () => ({
|
||||
resolveGitDir: vi.fn(),
|
||||
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
|
||||
}))
|
||||
|
||||
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
|
||||
vi.mock('../worktree-trash', () => ({
|
||||
moveWorktreeDirectoryToTrash: vi.fn().mockResolvedValue(undefined),
|
||||
restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true),
|
||||
scheduleWorktreeTrashDeletion: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./worktree', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WorktreeModule>()),
|
||||
resolveWorktreeAddBaseContext: vi.fn(async () => ({ effectiveBase: 'origin/main' })),
|
||||
persistWorktreeCreationBase: vi.fn(),
|
||||
configurePushAutoSetupRemote: vi.fn(),
|
||||
notifyPreparedWorktreeMutation: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-scan-cache', () => ({
|
||||
bumpWorktreeScanGeneration: vi.fn(),
|
||||
listWorktrees: listWorktreesMock
|
||||
}))
|
||||
|
||||
import { addWorktree } from './worktree-add'
|
||||
import {
|
||||
discardPreparedWorktree,
|
||||
finalizePreparedWorktree,
|
||||
prepareWorktreeCreateCheckout
|
||||
} from './worktree-create-preparation'
|
||||
import { moveWorktree } from './worktree-move'
|
||||
import { removeWorktree } from './worktree-removal'
|
||||
import {
|
||||
resetWslLinkedWorktreeGitRoutingForTests,
|
||||
seedWslLinkedWorktreeGitRoutingForTests,
|
||||
usesHostGitForWslLinkedWorktree
|
||||
} from './wsl-linked-worktree-git-routing'
|
||||
|
||||
const REPO = String.raw`C:\repo`
|
||||
const LINKED = String.raw`C:\ws\linked`
|
||||
const MOVED = String.raw`C:\ws\moved`
|
||||
const PREPARED = String.raw`C:\ws\.orca-preparing\wt`
|
||||
|
||||
function hasCachedHostRoute(path: string): boolean {
|
||||
return usesHostGitForWslLinkedWorktree(path, 'Ubuntu', 'win32')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
listWorktreesMock.mockReset()
|
||||
listWorktreesMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => resetWslLinkedWorktreeGitRoutingForTests())
|
||||
|
||||
describe('worktree mutations invalidate the WSL linked-worktree Git route', () => {
|
||||
it('drops the target route after `git worktree add`', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
|
||||
await addWorktree(REPO, LINKED, 'feature')
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the target route even when `git worktree add` fails late', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: could not create leading dirs'))
|
||||
|
||||
await expect(addWorktree(REPO, LINKED, 'feature')).rejects.toThrow('leading dirs')
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops both routes after `git worktree move`', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
|
||||
|
||||
await moveWorktree(REPO, LINKED, MOVED)
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
expect(hasCachedHostRoute(MOVED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops both routes when `git worktree move` fails', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: destination exists'))
|
||||
|
||||
await expect(moveWorktree(REPO, LINKED, MOVED)).rejects.toThrow('destination exists')
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
expect(hasCachedHostRoute(MOVED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the route after `git worktree remove`', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
|
||||
await removeWorktree(REPO, LINKED, true)
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the route after a prepared worktree is discarded', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
|
||||
await discardPreparedWorktree(REPO, LINKED)
|
||||
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the target route after the prepared checkout is added', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
|
||||
|
||||
await prepareWorktreeCreateCheckout(REPO, PREPARED, 'origin/main', 'orca preparation')
|
||||
|
||||
expect(hasCachedHostRoute(PREPARED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops both routes after the prepared checkout is moved into place', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
|
||||
await finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
|
||||
|
||||
expect(hasCachedHostRoute(PREPARED)).toBe(false)
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops both routes when the finalize move fails', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
|
||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) =>
|
||||
args.includes('move')
|
||||
? Promise.reject(new Error('fatal: destination exists'))
|
||||
: { stdout: '', stderr: '' }
|
||||
)
|
||||
|
||||
await expect(
|
||||
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
|
||||
).rejects.toThrow('destination exists')
|
||||
|
||||
expect(hasCachedHostRoute(PREPARED)).toBe(false)
|
||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an unrelated worktree route cached', async () => {
|
||||
seedWslLinkedWorktreeGitRoutingForTests(MOVED)
|
||||
|
||||
await removeWorktree(REPO, LINKED, true)
|
||||
|
||||
expect(hasCachedHostRoute(MOVED)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { gitExecFileAsync } from './runner'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-removal'
|
||||
import { listWorktreesStrict } from './worktree-listing'
|
||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||
import type { RemoveWorktreeOptions } from './worktree-operation-options'
|
||||
import {
|
||||
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS,
|
||||
@@ -38,6 +39,7 @@ export async function removeWorktree(
|
||||
performRemoveWorktree(repoPath, worktreePath, force, options)
|
||||
)
|
||||
} finally {
|
||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||
bumpWorktreeScanGeneration(repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findExistingWorktreeSymlinkPaths, getSafeRelativePath } from './worktree-symlink-detection'
|
||||
|
||||
describe('getSafeRelativePath', () => {
|
||||
// The only case in this file that binds the production change: every one of
|
||||
// these is admitted by at least one host's `path.isAbsolute` — the
|
||||
// drive-relative spellings are admitted by *every* host's, Windows included.
|
||||
it('rejects Windows drive-qualified entries, including drive-relative, on any host', () => {
|
||||
expect(getSafeRelativePath('C:\\Windows\\Temp')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('C:/Windows/Temp')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath(' d:\\payload ')).toEqual({ safe: false })
|
||||
// Drive-RELATIVE: `win32.resolve('D:\\wt', 'C:payload')` discards the
|
||||
// worktree root and lands under C:'s current directory.
|
||||
expect(getSafeRelativePath('C:payload')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('c:')).toEqual({ safe: false })
|
||||
})
|
||||
|
||||
it('keeps colon-bearing paths that are not drive-qualified', () => {
|
||||
expect(getSafeRelativePath('build:out')).toEqual({ safe: true, rel: 'build:out' })
|
||||
expect(getSafeRelativePath('logs/2026-08-31T10:00.txt')).toEqual({
|
||||
safe: true,
|
||||
rel: 'logs/2026-08-31T10:00.txt'
|
||||
})
|
||||
})
|
||||
|
||||
// These pin the rest of the guard expression, which this commit rewrote:
|
||||
// both `isAbsolute` calls were deleted as provably subsumed by the strip
|
||||
// above plus the drive check, so their inputs must still be judged the same.
|
||||
it('still strips leading separators of either flavour and keeps the remainder relative', () => {
|
||||
expect(getSafeRelativePath('node_modules')).toEqual({ safe: true, rel: 'node_modules' })
|
||||
expect(getSafeRelativePath(' .env ')).toEqual({ safe: true, rel: '.env' })
|
||||
expect(getSafeRelativePath('/.env')).toEqual({ safe: true, rel: '.env' })
|
||||
expect(getSafeRelativePath('\\.env')).toEqual({ safe: true, rel: '.env' })
|
||||
expect(getSafeRelativePath('//srv/share/x')).toEqual({ safe: true, rel: 'srv/share/x' })
|
||||
})
|
||||
|
||||
it('still rejects empty, whitespace-only, and parent-directory entries', () => {
|
||||
expect(getSafeRelativePath('')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath(' ')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('../secrets')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('safe/../../escape')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('..\\escape')).toEqual({ safe: false })
|
||||
expect(getSafeRelativePath('foo\\..\\..\\escape')).toEqual({ safe: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('findExistingWorktreeSymlinkPaths', () => {
|
||||
let root: string
|
||||
let primary: string
|
||||
let worktree: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-symlink-detection-'))
|
||||
primary = join(root, 'primary')
|
||||
worktree = join(root, 'worktree')
|
||||
mkdirSync(primary)
|
||||
mkdirSync(worktree)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why skipped on Windows rather than made platform-independent: the fixture
|
||||
// needs a real on-disk directory named `C:`, which only POSIX allows. The
|
||||
// Windows half of this guard is bound by the unit tests above, which run
|
||||
// everywhere because they never touch the filesystem.
|
||||
const posixIt = process.platform === 'win32' ? it.skip : it
|
||||
|
||||
posixIt('does not report a drive-qualified entry even when the literal path exists', async () => {
|
||||
mkdirSync(join(worktree, 'C:'))
|
||||
writeFileSync(join(primary, 'payload'), 'X=1\n')
|
||||
symlinkSync(join(primary, 'payload'), join(worktree, 'C:', 'payload'))
|
||||
symlinkSync(join(primary, 'payload'), join(worktree, 'C:payload'))
|
||||
|
||||
await expect(
|
||||
findExistingWorktreeSymlinkPaths(worktree, ['C:/payload', 'C:\\payload', 'C:payload'])
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// Why this is a leaf module rather than part of ipc/worktree-symlinks: status
|
||||
// and review-creation need only the read-only "is this a symlink" question, and
|
||||
@@ -8,16 +8,25 @@ import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
export type SafeRelativePathResult = { safe: true; rel: string } | { safe: false }
|
||||
|
||||
// Why a regex rather than `path.isAbsolute`: the strip below already removed
|
||||
// every leading `/` and `\`, so the only rooted spelling that can still reach
|
||||
// the guard is a Windows drive designator — and `win32.isAbsolute` misses the
|
||||
// drive-RELATIVE form (`C:foo`), which `win32.resolve` still resolves against
|
||||
// that drive's current directory instead of the worktree root.
|
||||
const WINDOWS_DRIVE_DESIGNATOR = /^[a-zA-Z]:/
|
||||
|
||||
export function getSafeRelativePath(rawPath: string): SafeRelativePathResult {
|
||||
// Why: strip leading separators (both `/` and `\`) before the guard so
|
||||
// Windows-style input like `\foo` is normalized the same way POSIX `/foo`
|
||||
// is, and the traversal check below sees the already-relative form.
|
||||
const rel = rawPath.trim().replace(/^[\\/]+/, '')
|
||||
// Why: split on both separators so a Windows-authored `..\escape` is
|
||||
// rejected the same way POSIX `../escape` is. `path.isAbsolute` catches
|
||||
// drive-letter absolutes (`C:\...`); the split catches relative
|
||||
// rejected the same way POSIX `../escape` is; the split catches relative
|
||||
// backslash traversal that `.split('/')` would otherwise miss.
|
||||
if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
||||
// Why the drive check runs on every host: the same entry — per-user Shared
|
||||
// Paths setting or repo `orca.yaml` — is evaluated on every host Orca runs
|
||||
// on, so the verdict must not depend on which one is asking.
|
||||
if (!rel || WINDOWS_DRIVE_DESIGNATOR.test(rel) || rel.split(/[\\/]/).includes('..')) {
|
||||
return { safe: false }
|
||||
}
|
||||
return { safe: true, rel }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Route state dropped when a worktree mutation rewrites the `.git` marker it was derived from.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
invalidateWslLinkedWorktreeGitRouting,
|
||||
prepareWslLinkedWorktreeGitRouting,
|
||||
resetWslLinkedWorktreeGitRoutingForTests,
|
||||
type WslLinkedWorktreeRoutingFileSystem
|
||||
} from './wsl-linked-worktree-git-routing'
|
||||
|
||||
afterEach(() => resetWslLinkedWorktreeGitRoutingForTests())
|
||||
|
||||
const CWD = String.raw`C:\repo`
|
||||
const fileMarker = { isDirectory: () => false, isFile: () => true }
|
||||
const directoryMarker = { isDirectory: () => true, isFile: () => false }
|
||||
const hostGitdir = 'gitdir: C:/main/.git/worktrees/linked\n'
|
||||
|
||||
function prepare(fileSystem: WslLinkedWorktreeRoutingFileSystem): Promise<boolean> {
|
||||
return prepareWslLinkedWorktreeGitRouting(CWD, 'Ubuntu', { platform: 'win32', fileSystem })
|
||||
}
|
||||
|
||||
describe('invalidateWslLinkedWorktreeGitRouting', () => {
|
||||
it('re-probes a settled route after the worktree marker is rewritten', async () => {
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi
|
||||
.fn<WslLinkedWorktreeRoutingFileSystem['stat']>()
|
||||
.mockResolvedValueOnce(directoryMarker)
|
||||
.mockResolvedValueOnce(fileMarker),
|
||||
readFile: vi.fn(async () => hostGitdir)
|
||||
}
|
||||
|
||||
await expect(prepare(fileSystem)).resolves.toBe(false)
|
||||
invalidateWslLinkedWorktreeGitRouting(CWD)
|
||||
|
||||
await expect(prepare(fileSystem)).resolves.toBe(true)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears the retry backoff so a marker that just appeared is probed immediately', async () => {
|
||||
const currentTime = 1_000
|
||||
let markerExists = false
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi.fn(async () => {
|
||||
if (!markerExists) {
|
||||
throw Object.assign(new Error('device unavailable'), { code: 'EIO' })
|
||||
}
|
||||
return fileMarker
|
||||
}),
|
||||
readFile: vi.fn(async () => hostGitdir)
|
||||
}
|
||||
const probe = (): Promise<boolean> =>
|
||||
prepareWslLinkedWorktreeGitRouting(CWD, 'Ubuntu', {
|
||||
platform: 'win32',
|
||||
fileSystem,
|
||||
now: () => currentTime
|
||||
})
|
||||
|
||||
// The second miss enters the exponential retry window, so the third never probes.
|
||||
await expect(probe()).resolves.toBe(false)
|
||||
await expect(probe()).resolves.toBe(false)
|
||||
await expect(probe()).resolves.toBe(false)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
|
||||
|
||||
markerExists = true
|
||||
invalidateWslLinkedWorktreeGitRouting(CWD)
|
||||
await expect(probe()).resolves.toBe(true)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('drops routes cached for paths inside the mutated worktree', async () => {
|
||||
const submodule = String.raw`C:\repo\sub`
|
||||
const sibling = String.raw`C:\repo-other`
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi.fn(async () => fileMarker),
|
||||
readFile: vi.fn(async () => hostGitdir)
|
||||
}
|
||||
const probe = (cwd: string): Promise<boolean> =>
|
||||
prepareWslLinkedWorktreeGitRouting(cwd, 'Ubuntu', { platform: 'win32', fileSystem })
|
||||
|
||||
await expect(probe(submodule)).resolves.toBe(true)
|
||||
await expect(probe(sibling)).resolves.toBe(true)
|
||||
invalidateWslLinkedWorktreeGitRouting(CWD)
|
||||
|
||||
// Prefix match must not reach `C:\repo-other`, which shares the string prefix.
|
||||
await expect(probe(submodule)).resolves.toBe(true)
|
||||
await expect(probe(sibling)).resolves.toBe(true)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('leaves a probe that is already in flight alone', async () => {
|
||||
// Scope control: invalidation must not retire in-flight probes. Doing so leaves
|
||||
// callers waiting on them with no cached route, which `resolveGitCommand` reads
|
||||
// as "route through wsl.exe git" — the misroute this module exists to prevent.
|
||||
let releaseMarker: ((marker: typeof fileMarker) => void) | undefined
|
||||
const inFlight = new Promise<typeof fileMarker>((resolve) => {
|
||||
releaseMarker = resolve
|
||||
})
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi.fn<WslLinkedWorktreeRoutingFileSystem['stat']>().mockReturnValueOnce(inFlight),
|
||||
readFile: vi.fn(async () => hostGitdir)
|
||||
}
|
||||
|
||||
const first = prepare(fileSystem)
|
||||
invalidateWslLinkedWorktreeGitRouting(CWD)
|
||||
const joined = prepare(fileSystem)
|
||||
releaseMarker?.(fileMarker)
|
||||
|
||||
await expect(first).resolves.toBe(true)
|
||||
await expect(joined).resolves.toBe(true)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import type { Stats } from 'node:fs'
|
||||
|
||||
export type WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat(path: string): Promise<Pick<Stats, 'isDirectory' | 'isFile'>>
|
||||
readFile(path: string): Promise<string>
|
||||
}
|
||||
|
||||
/** `known: false` means the marker exists but cannot be classified — what a half-written `.git` file looks like mid `worktree add`. */
|
||||
export type WslLinkedWorktreeGitRouteProbeResult = {
|
||||
usesHostGit: boolean
|
||||
known: boolean
|
||||
}
|
||||
|
||||
const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[/\\]/
|
||||
|
||||
function parseLinkedGitdir(content: string): string | null {
|
||||
const firstLine = content.split(/\r?\n/, 1)[0] ?? ''
|
||||
return firstLine.match(/^gitdir:\s*(\S.*?)\s*$/i)?.[1] ?? null
|
||||
}
|
||||
|
||||
export function parseWindowsLinkedGitdir(content: string): string | null {
|
||||
const gitdir = parseLinkedGitdir(content)
|
||||
return gitdir !== null && WINDOWS_DRIVE_PATH.test(gitdir) ? gitdir : null
|
||||
}
|
||||
|
||||
export const defaultWslLinkedWorktreeRoutingFileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: (path) => fsPromises.stat(path),
|
||||
readFile: (path) => fsPromises.readFile(path, 'utf8')
|
||||
}
|
||||
|
||||
/** Walk parent directories until Git's worktree marker identifies which Git owns this checkout. */
|
||||
export async function probeWslLinkedWorktreeGitRoute(
|
||||
cwd: string,
|
||||
fileSystem: WslLinkedWorktreeRoutingFileSystem
|
||||
): Promise<WslLinkedWorktreeGitRouteProbeResult> {
|
||||
let candidate = cwd
|
||||
const driveRoot = win32.parse(candidate).root
|
||||
while (true) {
|
||||
const markerPath = win32.join(candidate, '.git')
|
||||
try {
|
||||
const marker = await fileSystem.stat(markerPath)
|
||||
if (!marker.isFile()) {
|
||||
// A `.git` directory (or anything that is not a file) is a normal main checkout.
|
||||
return { usesHostGit: false, known: true }
|
||||
}
|
||||
const gitdir = parseLinkedGitdir(await fileSystem.readFile(markerPath))
|
||||
// A POSIX gitdir is a settled answer too: the distro owns this checkout.
|
||||
return gitdir === null
|
||||
? { usesHostGit: false, known: false }
|
||||
: { usesHostGit: WINDOWS_DRIVE_PATH.test(gitdir), known: true }
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (candidate === driveRoot) {
|
||||
// No marker up to the drive root: not a worktree at all, and stable enough to cache.
|
||||
return { usesHostGit: false, known: true }
|
||||
}
|
||||
candidate = win32.dirname(candidate)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,25 @@ describe('prepareWslLinkedWorktreeGitRouting', () => {
|
||||
expect(fileSystem.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-probes a half-written marker rather than caching it as distro-owned', async () => {
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi.fn(async () => fileMarker),
|
||||
readFile: vi
|
||||
.fn<WslLinkedWorktreeRoutingFileSystem['readFile']>()
|
||||
.mockResolvedValueOnce('')
|
||||
.mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n')
|
||||
}
|
||||
const prepare = (): Promise<boolean> =>
|
||||
prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', {
|
||||
platform: 'win32',
|
||||
fileSystem
|
||||
})
|
||||
|
||||
await expect(prepare()).resolves.toBe(false)
|
||||
await expect(prepare()).resolves.toBe(true)
|
||||
expect(fileSystem.stat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails closed without caching a marker read error', async () => {
|
||||
const fileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: vi.fn(async () => fileMarker),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import type { Stats } from 'node:fs'
|
||||
import {
|
||||
defaultWslLinkedWorktreeRoutingFileSystem,
|
||||
probeWslLinkedWorktreeGitRoute,
|
||||
type WslLinkedWorktreeRoutingFileSystem
|
||||
} from './wsl-linked-worktree-git-route-probe'
|
||||
|
||||
export {
|
||||
parseWindowsLinkedGitdir,
|
||||
type WslLinkedWorktreeRoutingFileSystem
|
||||
} from './wsl-linked-worktree-git-route-probe'
|
||||
|
||||
type CachedRoute = { usesHostGit: boolean; expiresAt: number }
|
||||
type TransientRouteFailure = { count: number; retryAfter: number }
|
||||
@@ -46,16 +54,6 @@ function cachedRoute(cwd: string, now: number): boolean | undefined {
|
||||
return exact.usesHostGit
|
||||
}
|
||||
|
||||
export function parseWindowsLinkedGitdir(content: string): string | null {
|
||||
const firstLine = content.split(/\r?\n/, 1)[0] ?? ''
|
||||
return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null
|
||||
}
|
||||
|
||||
export type WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat(path: string): Promise<Pick<Stats, 'isDirectory' | 'isFile'>>
|
||||
readFile(path: string): Promise<string>
|
||||
}
|
||||
|
||||
export type WslLinkedWorktreeRoutingOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
fileSystem?: WslLinkedWorktreeRoutingFileSystem
|
||||
@@ -63,41 +61,6 @@ export type WslLinkedWorktreeRoutingOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = {
|
||||
stat: (path) => fsPromises.stat(path),
|
||||
readFile: (path) => fsPromises.readFile(path, 'utf8')
|
||||
}
|
||||
|
||||
async function shouldUseHostGit(
|
||||
cwd: string,
|
||||
fileSystem: WslLinkedWorktreeRoutingFileSystem
|
||||
): Promise<boolean> {
|
||||
let candidate = cwd
|
||||
const driveRoot = win32.parse(candidate).root
|
||||
while (true) {
|
||||
const markerPath = win32.join(candidate, '.git')
|
||||
try {
|
||||
const marker = await fileSystem.stat(markerPath)
|
||||
if (marker.isDirectory()) {
|
||||
return false
|
||||
}
|
||||
if (marker.isFile()) {
|
||||
return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (candidate === driveRoot) {
|
||||
return false
|
||||
}
|
||||
candidate = win32.dirname(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean {
|
||||
const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS
|
||||
if (
|
||||
@@ -119,6 +82,30 @@ function routingAbortError(): Error {
|
||||
return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
}
|
||||
|
||||
function forgetPathAndDescendants<T>(entries: Map<string, T>, root: string): void {
|
||||
const prefix = root.endsWith(win32.sep) ? root : root + win32.sep
|
||||
for (const path of entries.keys()) {
|
||||
if (path === root || path.startsWith(prefix)) {
|
||||
entries.delete(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached route and retry backoff after Git adds, moves, or removes a
|
||||
* worktree at `cwd`, so the next command re-reads the rewritten `.git` marker.
|
||||
* Descendants go too: a submodule under the worktree resolved its route from the
|
||||
* same marker walk.
|
||||
*
|
||||
* A probe already in flight is deliberately left alone: it still answers and still
|
||||
* caches, so a mutation that lands mid-probe is no worse off than the 30s TTL.
|
||||
*/
|
||||
export function invalidateWslLinkedWorktreeGitRouting(cwd: string): void {
|
||||
const normalizedCwd = normalize(cwd)
|
||||
forgetPathAndDescendants(routeByCwd, normalizedCwd)
|
||||
forgetPathAndDescendants(transientFailuresByCwd, normalizedCwd)
|
||||
}
|
||||
|
||||
function rememberTransientFailure(cwd: string, now: number): void {
|
||||
const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1
|
||||
const retryDelay =
|
||||
@@ -205,7 +192,7 @@ function discoverRoute(
|
||||
return new Promise((resolve) => {
|
||||
const generation = routeProbeGeneration
|
||||
let settled = false
|
||||
const finish = (usesHostGit: boolean, cacheable: boolean): void => {
|
||||
const finish = (usesHostGit: boolean, known: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
@@ -215,7 +202,7 @@ function discoverRoute(
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
if (cacheable) {
|
||||
if (known) {
|
||||
transientFailuresByCwd.delete(cwd)
|
||||
resolve(rememberRoute(cwd, usesHostGit, now()))
|
||||
} else {
|
||||
@@ -226,10 +213,10 @@ function discoverRoute(
|
||||
const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS)
|
||||
timer.unref()
|
||||
const releaseProbe = trackRouteProbe(cwd)
|
||||
void shouldUseHostGit(cwd, fileSystem).then(
|
||||
(usesHostGit) => {
|
||||
void probeWslLinkedWorktreeGitRoute(cwd, fileSystem).then(
|
||||
({ usesHostGit, known }) => {
|
||||
releaseProbe()
|
||||
finish(usesHostGit, true)
|
||||
finish(usesHostGit, known)
|
||||
},
|
||||
() => {
|
||||
releaseProbe()
|
||||
@@ -245,7 +232,7 @@ export async function prepareWslLinkedWorktreeGitRouting(
|
||||
options: WslLinkedWorktreeRoutingOptions = {}
|
||||
): Promise<boolean> {
|
||||
const platform = options.platform ?? process.platform
|
||||
const fileSystem = options.fileSystem ?? defaultFileSystem
|
||||
const fileSystem = options.fileSystem ?? defaultWslLinkedWorktreeRoutingFileSystem
|
||||
const now = options.now ?? Date.now
|
||||
if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslModule from '../wsl'
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslModule>()),
|
||||
getDefaultWslDistro: getDefaultWslDistroMock
|
||||
}))
|
||||
|
||||
import { setDefaultWslDistroOverride } from '../git/runner'
|
||||
import { _resetKnownHostsCache, getGlabKnownHosts } from './gitlab-known-host-probe'
|
||||
|
||||
describe('glab known-hosts probe on Windows', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
const hostGlabMissingWslLoggedIn = (): void => {
|
||||
execFileMock.mockImplementation((binary, _args, _options, callback) => {
|
||||
if (binary === 'wsl.exe') {
|
||||
callback(null, { stdout: 'Logged in to gitlab.wsl.test as user', stderr: '' })
|
||||
return
|
||||
}
|
||||
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
spawnMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
setDefaultWslDistroOverride(null)
|
||||
_resetKnownHostsCache()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setDefaultWslDistroOverride(null)
|
||||
_resetKnownHostsCache()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
})
|
||||
|
||||
it('does not wake the default WSL distro for the native execution key', async () => {
|
||||
hostGlabMissingWslLoggedIn()
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'glab',
|
||||
['auth', 'status'],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
// Why: glab has no SSH/relay dispatch, so a connection-keyed probe and the
|
||||
// `glab api` calls it gates both run the local CLI with no cwd. Suppressing the
|
||||
// fallback here would make the probe disagree with those calls.
|
||||
it('keeps the default-distro fallback for a connection execution key', async () => {
|
||||
hostGlabMissingWslLoggedIn()
|
||||
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'gitlab.wsl.test'])
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { glabExecFileAsyncMock } = vi.hoisted(() => ({ glabExecFileAsyncMock: vi.fn() }))
|
||||
|
||||
vi.mock('../git/runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock }))
|
||||
|
||||
import {
|
||||
_resetKnownHostsCache,
|
||||
getGlabKnownHosts,
|
||||
rememberGlabKnownHost,
|
||||
rememberGlabKnownHosts
|
||||
} from './gitlab-known-host-probe'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
describe('getGlabKnownHosts', () => {
|
||||
// A locally-keyed probe must never answer from the default WSL distro.
|
||||
const LOCAL_PROBE_OPTIONS = { timeout: 10_000, allowDefaultWslFallback: false }
|
||||
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
_resetKnownHostsCache()
|
||||
})
|
||||
|
||||
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], LOCAL_PROBE_OPTIONS)
|
||||
})
|
||||
|
||||
it('preserves WSL and background admission on the cold auth-status probe', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
|
||||
await getGlabKnownHosts(undefined, {
|
||||
wslDistro: 'Ubuntu',
|
||||
admissionTier: 'background'
|
||||
})
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
|
||||
...LOCAL_PROBE_OPTIONS,
|
||||
wslDistro: 'Ubuntu',
|
||||
admissionTier: 'background'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to default when glab auth status fails', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
})
|
||||
|
||||
it('caches the result across calls', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.com as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await getGlabKnownHosts()
|
||||
await getGlabKnownHosts()
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces many simultaneous callers in one execution context', async () => {
|
||||
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const probes = Array.from({ length: 64 }, () => getGlabKnownHosts())
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' })
|
||||
const results = await Promise.all(probes)
|
||||
expect(results.every((result) => result === results[0])).toBe(true)
|
||||
expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test'])
|
||||
})
|
||||
|
||||
it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
|
||||
|
||||
const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Debian' }),
|
||||
getGlabKnownHosts(),
|
||||
getGlabKnownHosts('conn-1')
|
||||
])
|
||||
|
||||
expect(ubuntuAgain).toBe(ubuntu)
|
||||
expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test'])
|
||||
expect(debian).toEqual(['gitlab.com', 'debian.test'])
|
||||
expect(native).toEqual(['gitlab.com', 'native.test'])
|
||||
expect(ssh).toEqual(['gitlab.com', 'ssh.test'])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4)
|
||||
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], {
|
||||
...LOCAL_PROBE_OPTIONS,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], {
|
||||
...LOCAL_PROBE_OPTIONS,
|
||||
wslDistro: 'Debian'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a native auth refresh while an older native probe is in flight', async () => {
|
||||
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const staleProbe = getGlabKnownHosts()
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
|
||||
|
||||
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
})
|
||||
|
||||
it('preserves a native auth refresh when an older native probe fails', async () => {
|
||||
let rejectProbe!: (error: Error) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
|
||||
const staleProbe = getGlabKnownHosts()
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
rejectProbe(new Error('stale auth probe failed'))
|
||||
|
||||
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
})
|
||||
|
||||
it('keeps a remembered native host out of WSL and SSH caches', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
|
||||
|
||||
await Promise.all([
|
||||
getGlabKnownHosts(),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts('conn-1')
|
||||
])
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'native.test',
|
||||
'gitlab.refreshed.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'wsl.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test'])
|
||||
})
|
||||
|
||||
it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => {
|
||||
rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST'])
|
||||
rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch')
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'native-b.test',
|
||||
'native-a.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'wsl-b.test',
|
||||
'wsl-a.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'ssh-b.test',
|
||||
'ssh-a.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recognizes a self-hosted host on a non-default port', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
// A second probe for the same connection is served from cache.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
// First probe fails → canonical default, NOT cached.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
|
||||
// Re-probe (e.g. after tunnel comes up) discovers the real host.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('removes a timed-out probe from in-flight state so a later call retries', async () => {
|
||||
let rejectProbe!: (error: Error) => void
|
||||
glabExecFileAsyncMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' })
|
||||
|
||||
const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
|
||||
const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
rejectProbe(new Error('wsl.exe timed out.'))
|
||||
|
||||
await expect(Promise.all([first, concurrent])).resolves.toEqual([
|
||||
['gitlab.com'],
|
||||
['gitlab.com']
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'recovered.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a successful result after an SSH provider reconnects', async () => {
|
||||
const connectionId = 'conn-reconnected'
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' })
|
||||
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'old-tunnel.test'
|
||||
])
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'new-tunnel.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
unregisterSshGitProvider(connectionId)
|
||||
})
|
||||
})
|
||||
@@ -150,6 +150,10 @@ async function probeGlabKnownHosts(
|
||||
// or reconnected SSH/relay results, and bound an otherwise global probe.
|
||||
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], {
|
||||
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
|
||||
// Why: a local probe would cache the default distro's auth under 'native'
|
||||
// and wake an idle VM. A connection-keyed probe keeps the retry — glab never
|
||||
// runs over SSH/relay, so the calls it gates take that same fallback.
|
||||
...(connectionId ? {} : { allowDefaultWslFallback: false }),
|
||||
...(!connectionId && localGitOptions.wslDistro
|
||||
? { wslDistro: localGitOptions.wslDistro }
|
||||
: {}),
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock('../git/runner', () => ({
|
||||
|
||||
import {
|
||||
_getProjectRefCacheSize,
|
||||
_resetKnownHostsCache,
|
||||
_resetProjectRefCache,
|
||||
classifyGlabError,
|
||||
classifyJobLogError,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
parseGlabJsonList,
|
||||
parseGlabPaginationHeader,
|
||||
isMissingJobLogError,
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
getProjectRefForRemote,
|
||||
parseGlabApiResponse,
|
||||
@@ -34,7 +32,6 @@ import {
|
||||
resolveIssueSource
|
||||
} from './gl-utils'
|
||||
import { GlabNonListResponseError } from './glab-api-response'
|
||||
import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
||||
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
|
||||
@@ -647,283 +644,6 @@ describe('parseGlabApiResponse', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGlabKnownHosts', () => {
|
||||
beforeEach(() => {
|
||||
glabExecFileAsyncMock.mockReset()
|
||||
_resetKnownHostsCache()
|
||||
})
|
||||
|
||||
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 })
|
||||
})
|
||||
|
||||
it('preserves WSL and background admission on the cold auth-status probe', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
||||
|
||||
await getGlabKnownHosts(undefined, {
|
||||
wslDistro: 'Ubuntu',
|
||||
admissionTier: 'background'
|
||||
})
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
|
||||
timeout: 10_000,
|
||||
wslDistro: 'Ubuntu',
|
||||
admissionTier: 'background'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to default when glab auth status fails', async () => {
|
||||
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
})
|
||||
|
||||
it('caches the result across calls', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.com as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await getGlabKnownHosts()
|
||||
await getGlabKnownHosts()
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces many simultaneous callers in one execution context', async () => {
|
||||
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const probes = Array.from({ length: 64 }, () => getGlabKnownHosts())
|
||||
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' })
|
||||
const results = await Promise.all(probes)
|
||||
expect(results.every((result) => result === results[0])).toBe(true)
|
||||
expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test'])
|
||||
})
|
||||
|
||||
it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
|
||||
|
||||
const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Debian' }),
|
||||
getGlabKnownHosts(),
|
||||
getGlabKnownHosts('conn-1')
|
||||
])
|
||||
|
||||
expect(ubuntuAgain).toBe(ubuntu)
|
||||
expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test'])
|
||||
expect(debian).toEqual(['gitlab.com', 'debian.test'])
|
||||
expect(native).toEqual(['gitlab.com', 'native.test'])
|
||||
expect(ssh).toEqual(['gitlab.com', 'ssh.test'])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4)
|
||||
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], {
|
||||
timeout: 10_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], {
|
||||
timeout: 10_000,
|
||||
wslDistro: 'Debian'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a native auth refresh while an older native probe is in flight', async () => {
|
||||
let resolveProbe!: (value: { stdout: string; stderr: string }) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const staleProbe = getGlabKnownHosts()
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
resolveProbe({ stdout: 'Logged in to gitlab.com as user\n', stderr: '' })
|
||||
|
||||
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
})
|
||||
|
||||
it('preserves a native auth refresh when an older native probe fails', async () => {
|
||||
let rejectProbe!: (error: Error) => void
|
||||
glabExecFileAsyncMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
|
||||
const staleProbe = getGlabKnownHosts()
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
rejectProbe(new Error('stale auth probe failed'))
|
||||
|
||||
await expect(staleProbe).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.refreshed.test'])
|
||||
})
|
||||
|
||||
it('keeps a remembered native host out of WSL and SSH caches', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to wsl.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' })
|
||||
|
||||
await Promise.all([
|
||||
getGlabKnownHosts(),
|
||||
getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }),
|
||||
getGlabKnownHosts('conn-1')
|
||||
])
|
||||
rememberGlabKnownHost('gitlab.refreshed.test')
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'native.test',
|
||||
'gitlab.refreshed.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'wsl.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com', 'ssh.test'])
|
||||
})
|
||||
|
||||
it('batch-normalizes and deduplicates hosts in first-seen order per execution context', async () => {
|
||||
rememberGlabKnownHosts([' Native-B.test ', 'native-a.test', 'NATIVE-B.TEST'])
|
||||
rememberGlabKnownHosts(['WSL-B.test', ' wsl-a.test ', 'wsl-b.test'], undefined, {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
rememberGlabKnownHosts(['SSH-B.test', 'ssh-a.test', ' ssh-b.test '], 'conn-batch')
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'native-b.test',
|
||||
'native-a.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'wsl-b.test',
|
||||
'wsl-a.test'
|
||||
])
|
||||
await expect(getGlabKnownHosts('conn-batch')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'ssh-b.test',
|
||||
'ssh-a.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recognizes a self-hosted host on a non-default port', async () => {
|
||||
glabExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
|
||||
})
|
||||
|
||||
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
// A second probe for the same connection is served from cache.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
|
||||
glabExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
// First probe fails → canonical default, NOT cached.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
|
||||
// Re-probe (e.g. after tunnel comes up) discovers the real host.
|
||||
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.example.com:8080'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('removes a timed-out probe from in-flight state so a later call retries', async () => {
|
||||
let rejectProbe!: (error: Error) => void
|
||||
glabExecFileAsyncMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' })
|
||||
|
||||
const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
|
||||
const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
rejectProbe(new Error('wsl.exe timed out.'))
|
||||
|
||||
await expect(Promise.all([first, concurrent])).resolves.toEqual([
|
||||
['gitlab.com'],
|
||||
['gitlab.com']
|
||||
])
|
||||
await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'recovered.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reuse a successful result after an SSH provider reconnects', async () => {
|
||||
const connectionId = 'conn-reconnected'
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
glabExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' })
|
||||
.mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' })
|
||||
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'old-tunnel.test'
|
||||
])
|
||||
registerSshGitProvider(connectionId, {} as never)
|
||||
await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([
|
||||
'gitlab.com',
|
||||
'new-tunnel.test'
|
||||
])
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
unregisterSshGitProvider(connectionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGlabPaginationHeader', () => {
|
||||
it('reads a usable header value', () => {
|
||||
expect(parseGlabPaginationHeader('25', 1)).toBe(25)
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
isRegisteredWorktreePath
|
||||
} from './registered-worktree-roots-cache'
|
||||
|
||||
// Compatibility exports for runtime command modules that historically imported these seams from
|
||||
// filesystem-auth. The implementations remain owned by their focused modules.
|
||||
export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
||||
export { isENOENT } from './filesystem-path-containment'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
||||
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
|
||||
|
||||
@@ -361,17 +361,13 @@ describe('registerPtyHandlers', () => {
|
||||
// resolved-worktree cache so this headless fixture resolves offline.
|
||||
const worktreeResolutionInternals = runtime as unknown as {
|
||||
buildResolvedWorktreeFromId(id: string): unknown
|
||||
resolvedWorktreeCache: {
|
||||
worktrees: unknown[]
|
||||
platformByRepoId: Map<string, NodeJS.Platform>
|
||||
expiresAt: number
|
||||
} | null
|
||||
resolvedWorktrees: object
|
||||
}
|
||||
worktreeResolutionInternals.resolvedWorktreeCache = {
|
||||
Reflect.set(worktreeResolutionInternals.resolvedWorktrees, 'resolved', {
|
||||
worktrees: [worktreeResolutionInternals.buildResolvedWorktreeFromId(worktreeId)],
|
||||
platformByRepoId: new Map([[repo.id, process.platform]]),
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({
|
||||
id: ptyId,
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict'
|
||||
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
|
||||
import {
|
||||
hasLocalWorktreeBaseRef,
|
||||
probeWorktreeBaseRefPresence
|
||||
} from '../git/worktree-base-ref-probe'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
||||
import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
@@ -718,46 +721,6 @@ function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean {
|
||||
return Object.keys(gitOptions).length > 0
|
||||
}
|
||||
|
||||
function hasLocalCommitObjectWithOptions(
|
||||
repoPath: string,
|
||||
ref: string,
|
||||
gitOptions: { wslDistro?: string }
|
||||
): Promise<boolean> {
|
||||
return hasCommitObjectViaGitExec(
|
||||
(gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...gitOptions }),
|
||||
ref
|
||||
)
|
||||
}
|
||||
|
||||
async function hasLocalWorktreeBaseRefWithOptions(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
gitOptions: { wslDistro?: string }
|
||||
): Promise<boolean> {
|
||||
const refExists = async (qualifiedRef: string) => {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
|
||||
{
|
||||
cwd: repoPath,
|
||||
...gitOptions
|
||||
}
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions)
|
||||
}
|
||||
|
||||
function getLocalGitHubPrForBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
@@ -2066,14 +2029,10 @@ export async function createLocalWorktree(
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return hasLocalWorktreeBaseRefWithOptions(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
localGitExecOptions
|
||||
)
|
||||
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions)
|
||||
}
|
||||
}
|
||||
return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions)
|
||||
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate, localGitExecOptions)
|
||||
}
|
||||
})
|
||||
const [username, resolvedBaseBranch] = await Promise.all([usernamePromise, baseBranchPromise])
|
||||
@@ -2103,15 +2062,11 @@ export async function createLocalWorktree(
|
||||
if (remoteTrackingBase) {
|
||||
const [hasRemoteTrackingBaseRef, hasNamedLocalBaseRef] = await Promise.all([
|
||||
runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs),
|
||||
hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)
|
||||
hasLocalWorktreeBaseRef(repo.path, baseBranch, localGitExecOptions)
|
||||
])
|
||||
const hasFallbackLocalBaseRef =
|
||||
!hasNamedLocalBaseRef &&
|
||||
(await hasLocalWorktreeBaseRefWithOptions(
|
||||
repo.path,
|
||||
remoteTrackingBase.branch,
|
||||
localGitExecOptions
|
||||
))
|
||||
(await hasLocalWorktreeBaseRef(repo.path, remoteTrackingBase.branch, localGitExecOptions))
|
||||
const hasLocalBaseRef =
|
||||
hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef
|
||||
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
|
||||
@@ -2136,9 +2091,7 @@ export async function createLocalWorktree(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
|
||||
) {
|
||||
} else if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) {
|
||||
// Why: non-remote-prefix bases (plain main/master/local) keep the legacy best-effort fetch; verified PR SHA bases already have the object.
|
||||
legacyFetchPromise = runtime
|
||||
.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs)
|
||||
@@ -2147,9 +2100,7 @@ export async function createLocalWorktree(
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
|
||||
) {
|
||||
if (!(await hasLocalWorktreeBaseRef(repo.path, baseBranch, localWorktreeGitOptions))) {
|
||||
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
|
||||
...localGitExecOptions,
|
||||
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
|
||||
|
||||
@@ -76,6 +76,15 @@ export {
|
||||
type HandlerMap
|
||||
} from './worktrees-test-ipc-surface'
|
||||
|
||||
/** The single repo every worktree harness test resolves; exported so a test can vary one field. */
|
||||
export const harnessRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
/** Registers worktree IPC handlers against freshly reset shared mocks and returns the runtime stub. */
|
||||
export function setupWorktreeHandlers(): WorktreeRuntimeStub {
|
||||
delete (store as typeof store & { getAllWorktreeMetaForHost?: (...args: unknown[]) => unknown })
|
||||
@@ -185,15 +194,8 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
|
||||
handlers[channel] = handler
|
||||
})
|
||||
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
|
||||
store.getRepos.mockReturnValue([harnessRepo])
|
||||
store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: null })
|
||||
store.getProjects.mockReturnValue([])
|
||||
store.getSparsePresets.mockReturnValue([])
|
||||
const settings = {
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
createSetupRunnerScriptMock,
|
||||
getEffectiveHooksFromConfigMock,
|
||||
shouldRunSetupForCreateMock,
|
||||
getBaseRefDefaultMock,
|
||||
gitExecFileAsyncMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
|
||||
import {
|
||||
createdWorktreeList,
|
||||
mockKnownFeatureWorktree,
|
||||
@@ -104,9 +106,65 @@ vi.mock('../runtime/worktree-teardown', async () =>
|
||||
)
|
||||
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
|
||||
|
||||
/** Every create-path git call, including the base-ref probe now shared with the
|
||||
* speculative prefetch, must read the distro's ref store rather than host git's. */
|
||||
function expectEveryGitCallRoutedTo(wslDistro: string): void {
|
||||
const callDistros = new Set(
|
||||
gitExecFileAsyncMock.mock.calls.map(
|
||||
([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro
|
||||
)
|
||||
)
|
||||
expect(callDistros).toEqual(new Set([wslDistro]))
|
||||
}
|
||||
|
||||
describe('registerWorktreeHandlers', () => {
|
||||
let runtimeStub: WorktreeRuntimeStub
|
||||
|
||||
beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
runtimeStub = setupWorktreeHandlers()
|
||||
})
|
||||
|
||||
it('routes the speculative create-base prefetch through the selected WSL project runtime', async () => {
|
||||
mockSelectedWslProjectRuntime()
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteTrackingBase)
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/main',
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
remoteTrackingBase,
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the prefetch remote-fetch fallback through the selected WSL project runtime', async () => {
|
||||
mockSelectedWslProjectRuntime()
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||||
repoId: 'repo-1',
|
||||
baseBranch: 'feature/topic'
|
||||
})
|
||||
|
||||
expect(runtimeStub.fetchRemoteWithCache).toHaveBeenCalledWith('/workspace/repo', 'origin', {
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes local worktree creation through the selected WSL project runtime', async () => {
|
||||
@@ -153,6 +211,36 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
|
||||
expectEveryGitCallRoutedTo('Ubuntu')
|
||||
})
|
||||
|
||||
it('routes local worktree creation with a remote tracking base through the selected WSL project runtime', async () => {
|
||||
mockSelectedWslProjectRuntime()
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue({
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
})
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'abc123\n', stderr: '' })
|
||||
// A persisted base that differs from the detected default also drives the usability probe.
|
||||
store.getRepo.mockReturnValue({ ...harnessRepo, worktreeBaseRef: 'custom-base' })
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expectEveryGitCallRoutedTo('Ubuntu')
|
||||
})
|
||||
|
||||
it('routes fork push target setup through the selected WSL project runtime', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch'
|
||||
import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation'
|
||||
import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
|
||||
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void {
|
||||
@@ -17,7 +18,8 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo
|
||||
const baseBranch = await prefetchWorktreeCreateBase({
|
||||
repo,
|
||||
baseBranch: args.baseBranch,
|
||||
runtime
|
||||
runtime,
|
||||
gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo)
|
||||
})
|
||||
if (baseBranch) {
|
||||
await prepareWorktreeCreateForRepo(store, repo, baseBranch)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from './persistence'
|
||||
import type { Project } from '../shared/project-types'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import {
|
||||
getLocalProjectGitExecOptions,
|
||||
getWorktreeCreatePrefetchGitOptions,
|
||||
getWorktreeMirrorDistro,
|
||||
resolveLocalProjectRuntimeForRepo
|
||||
} from './project-runtime-git-options'
|
||||
@@ -176,6 +177,55 @@ describe('project runtime git options', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreeCreatePrefetchGitOptions', () => {
|
||||
it('routes the warm-up through the distro a resolved WSL project runs in', () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
const project = makeProject({
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(
|
||||
withPlatform('win32', () =>
|
||||
getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo())
|
||||
)
|
||||
).toEqual({ wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
// A speculative warm-up must degrade to the host Git it used before routing
|
||||
// existed, never surface the repair state git execution raises.
|
||||
it('falls back to host git instead of throwing when the runtime needs repair', () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Debian'] })
|
||||
const project = makeProject({
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(
|
||||
withPlatform('win32', () =>
|
||||
getWorktreeCreatePrefetchGitOptions(makeStore(project), makeRepo())
|
||||
)
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('does not resolve a project runtime for folder workspaces', () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
const project = makeProject({
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
const store = makeStore(project)
|
||||
const getProjects = vi.fn(store.getProjects)
|
||||
|
||||
expect(
|
||||
withPlatform('win32', () =>
|
||||
getWorktreeCreatePrefetchGitOptions(
|
||||
{ ...store, getProjects } as unknown as Store,
|
||||
makeRepo({ kind: 'folder' })
|
||||
)
|
||||
)
|
||||
).toEqual({})
|
||||
expect(getProjects).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply local Windows runtime routing to runtime-owned repos', () => {
|
||||
const project = makeProject({
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Store } from './persistence'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import {
|
||||
resolveLocalProjectRuntimeForRepo,
|
||||
type ProjectRuntimeResolutionStore
|
||||
@@ -58,6 +59,28 @@ export function getLocalProjectWorktreeGitOptions(
|
||||
return wslDistro ? { wslDistro } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git routing for the speculative worktree-create warm-up.
|
||||
*
|
||||
* Deliberately non-throwing where `getLocalProjectWorktreeGitOptions` throws: an
|
||||
* optimistic prefetch must not report a repair-required runtime as a failure, so
|
||||
* an unresolved runtime falls back to the host Git the warm-up used before
|
||||
* routing existed.
|
||||
*/
|
||||
export function getWorktreeCreatePrefetchGitOptions(
|
||||
store: Store,
|
||||
repo: Repo
|
||||
): LocalProjectWorktreeGitOptions {
|
||||
if (isFolderRepo(repo)) {
|
||||
return {}
|
||||
}
|
||||
const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo)
|
||||
if (!projectRuntime || projectRuntime.status !== 'resolved') {
|
||||
return {}
|
||||
}
|
||||
return getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime)
|
||||
}
|
||||
|
||||
export function getLocalProjectWorktreeGitOptionsForRuntime(
|
||||
repo: Repo,
|
||||
projectRuntime: ProjectExecutionRuntimeResolution | undefined
|
||||
|
||||
@@ -177,4 +177,25 @@ describe('scoped automationsChanged publication', () => {
|
||||
)
|
||||
expect(published).toEqual([{ reason: 'definition', selector: { kind: 'self' } }])
|
||||
})
|
||||
|
||||
// Why: an unnameable destination is exactly when scoping is unsafe — a subscriber scoped
|
||||
// elsewhere would never hear about the row it is still rendering. The publication has to
|
||||
// degrade to one unscoped authority event rather than name only the stale source.
|
||||
it('degrades to an unscoped event when the store cannot name the destination', async () => {
|
||||
const { store, runtime, published } = await makeRuntime()
|
||||
const selector = store.automationChangeSelector.bind(store)
|
||||
let updated = false
|
||||
vi.spyOn(store, 'automationChangeSelector').mockImplementation((id: string) =>
|
||||
updated ? null : selector(id)
|
||||
)
|
||||
const update = runtime.updateAutomation(
|
||||
'local-1',
|
||||
{ enabled: false },
|
||||
{ expectedOwner: { selector: { kind: 'self' } } }
|
||||
)
|
||||
updated = true
|
||||
await update
|
||||
|
||||
expect(published).toEqual([{ reason: 'definition' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Every browser-tab placement must publish the created tab through one seam. A placement that
|
||||
// hand-rolls its own bookkeeping is how a client-placed page once lost its targetGroupId.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
@@ -91,7 +91,11 @@ function createPublicationHost(registeredTabs: readonly [string, number][] = [['
|
||||
}
|
||||
|
||||
function browserCommandsSource(): string {
|
||||
return readFileSync(join(__dirname, 'orca-runtime-browser.ts'), 'utf8')
|
||||
return readdirSync(__dirname)
|
||||
.filter((name) => /^runtime-browser-commands-.*\.ts$/.test(name))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(__dirname, name), 'utf8'))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('publishCreatedBrowserSessionTab', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const LIST_CALL = /\.listClientHostedBrowserRows\(/g
|
||||
describe('client-hosted row hydration caller census', () => {
|
||||
it('keeps the hydration delivery to its one runtime caller', async () => {
|
||||
expect(await countCallSites(DELIVER_CALL)).toEqual({
|
||||
'src/main/runtime/orca-runtime.ts': 1
|
||||
'src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts': 1
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ClientHostedPageReconciliationWindow,
|
||||
@@ -132,7 +133,7 @@ describe('holdFor', () => {
|
||||
// answer, which is invisible to any behavioral test that does not happen to cover that call site.
|
||||
describe('session-tabs projection census', () => {
|
||||
it('routes every client projection in orca-runtime through the per-client seam', () => {
|
||||
const source = readFileSync(new URL('./orca-runtime.ts', import.meta.url), 'utf8')
|
||||
const source = readOrcaRuntimeSourceFamily()
|
||||
const direct = source.match(/this\.clientSessionTabSelections\.project\(/g) ?? []
|
||||
|
||||
// Exactly two: inside `projectMobileSessionTabsForClient` itself, and the removed-worktree
|
||||
@@ -142,8 +143,24 @@ describe('session-tabs projection census', () => {
|
||||
})
|
||||
|
||||
it('keeps the unreconciled flag out of every other runtime publication site', () => {
|
||||
const source = readFileSync(new URL('./orca-runtime.ts', import.meta.url), 'utf8')
|
||||
const source = readOrcaRuntimeSourceFamily()
|
||||
|
||||
expect(source).not.toContain('clientHostedPagesUnreconciled')
|
||||
})
|
||||
})
|
||||
|
||||
function readOrcaRuntimeSourceFamily(): string {
|
||||
return readdirSync(import.meta.dirname)
|
||||
.filter(
|
||||
(name) =>
|
||||
(name === 'orca-runtime.ts' || name.startsWith('orca-runtime-')) &&
|
||||
name.endsWith('.ts') &&
|
||||
!name.includes('.test.') &&
|
||||
!name.endsWith('-fixtures.ts') &&
|
||||
!name.endsWith('-test-harness.ts') &&
|
||||
!name.endsWith('-mock-registry.ts')
|
||||
)
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(import.meta.dirname, name), 'utf8'))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import * as mobileSessionTerminalProjection from './mobile-session-terminal-projection'
|
||||
|
||||
// Freshness predicate of shouldApplyWebSessionTabsSnapshot in
|
||||
// src/renderer/src/runtime/web-session-tabs-sync.ts, copied as a literal
|
||||
@@ -147,7 +148,6 @@ function makeRendererSnapshot(args: {
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
buildHeadlessMobileSessionTerminalTabs: (...args: unknown[]) => unknown[]
|
||||
offscreenBrowserBackend: unknown
|
||||
agentBrowserBridge: unknown
|
||||
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
|
||||
@@ -210,13 +210,13 @@ describe('graph-sync mobile snapshot gating', () => {
|
||||
})
|
||||
|
||||
it('skips the serve-only hydrate rebuild and emits nothing when no serve ptys and no browser backend', () => {
|
||||
const { runtime, events, sync } = createRuntime(
|
||||
const { events, sync } = createRuntime(
|
||||
makeSession({
|
||||
tabsByWorktree: { [WT]: [makeTerminalTab('plain-tab', 'repo-1::wt@@abc')] }
|
||||
})
|
||||
)
|
||||
const buildSpy = vi.spyOn(
|
||||
runtime as unknown as RuntimeInternals,
|
||||
mobileSessionTerminalProjection,
|
||||
'buildHeadlessMobileSessionTerminalTabs'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { MobileNotificationEvent } from './orca-runtime'
|
||||
import type { MobileNotificationEvent } from './runtime-mobile-notification-controller'
|
||||
|
||||
// Why: when a mobile client's socket is reaped (background/sleep, or a warm
|
||||
// proxy that delays the heartbeat reap), notifications dispatched in that
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RuntimeMobileSessionBrowserTab } from '../../shared/runtime-types'
|
||||
import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement'
|
||||
|
||||
// Why: change detection for headless browser tabs. Compares the fields that
|
||||
// actually vary (a JSON.stringify equality was order-sensitive and silently
|
||||
// dropped `undefined` keys, so it only worked while both sides shared one
|
||||
// construction path).
|
||||
export function headlessBrowserTabsUnchanged(
|
||||
live: RuntimeMobileSessionBrowserTab[],
|
||||
existing: RuntimeMobileSessionBrowserTab[]
|
||||
): boolean {
|
||||
if (live.length !== existing.length) {
|
||||
return false
|
||||
}
|
||||
return live.every((tab, index) => {
|
||||
const prev = existing[index]
|
||||
return (
|
||||
tab.id === prev.id &&
|
||||
tab.title === prev.title &&
|
||||
tab.url === prev.url &&
|
||||
tab.loading === prev.loading &&
|
||||
tab.canGoBack === prev.canGoBack &&
|
||||
tab.canGoForward === prev.canGoForward &&
|
||||
tab.browserProfileId === prev.browserProfileId &&
|
||||
tab.executionHostKey === prev.executionHostKey &&
|
||||
((tab.placement === undefined && prev.placement === undefined) ||
|
||||
(tab.placement !== undefined &&
|
||||
prev.placement !== undefined &&
|
||||
sameRuntimeBrowserPlacement(tab.placement, prev.placement))) &&
|
||||
tab.isActive === prev.isActive &&
|
||||
(tab.isPinned ?? false) === (prev.isPinned ?? false) &&
|
||||
(tab.color ?? null) === (prev.color ?? null) &&
|
||||
browserLoadErrorsEqual(tab.loadError, prev.loadError) &&
|
||||
browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function browserLoadErrorsEqual(
|
||||
a: RuntimeMobileSessionBrowserTab['loadError'],
|
||||
b: RuntimeMobileSessionBrowserTab['loadError']
|
||||
): boolean {
|
||||
const left = a ?? null
|
||||
const right = b ?? null
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
left.code === right.code &&
|
||||
left.description === right.description &&
|
||||
left.validatedUrl === right.validatedUrl
|
||||
)
|
||||
}
|
||||
|
||||
export function browserCertificateFailuresEqual(
|
||||
a: RuntimeMobileSessionBrowserTab['certificateFailure'],
|
||||
b: RuntimeMobileSessionBrowserTab['certificateFailure']
|
||||
): boolean {
|
||||
const left = a ?? null
|
||||
const right = b ?? null
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
left.challengeId === right.challengeId &&
|
||||
left.browserPageId === right.browserPageId &&
|
||||
left.errorCode === right.errorCode &&
|
||||
left.error === right.error &&
|
||||
left.origin === right.origin &&
|
||||
left.displayHost === right.displayHost &&
|
||||
left.canProceed === right.canProceed &&
|
||||
left.observedAt === right.observedAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types'
|
||||
|
||||
// Why: browser session tabs have no parentTabId so the terminal-only group
|
||||
// builder drops them from tabOrder; this re-adds their ids to a group.
|
||||
// Browser tabs are live-only (no persisted session entry), but their GROUP
|
||||
// membership must still survive snapshot rebuilds like terminals'. The
|
||||
// passed-in groups already encode each browser's group (carried from the prior
|
||||
// snapshot / persisted tabGroups), so keep each existing browser id where it
|
||||
// is; only a genuinely-new browser id goes to its create-target group (when
|
||||
// that group exists) and otherwise to the first group. Previously every
|
||||
// browser was force-pushed into group[0], so opening a browser in the right
|
||||
// split group always snapped it back to the left on the next rebuild.
|
||||
export function appendBrowserTabOrder(
|
||||
groups: readonly RuntimeMobileSessionTabGroup[],
|
||||
browserTabIds: readonly string[],
|
||||
newTabAssignment?: { tabId: string; groupId: string },
|
||||
// browserPageId -> groupId from the prior/persisted groups. The terminal
|
||||
// distributor rebuilds tabOrder from terminal ids only and drops browser
|
||||
// ids, so this carries each browser's group across rebuilds.
|
||||
priorGroupByBrowserId?: ReadonlyMap<string, string>
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
if (browserTabIds.length === 0) {
|
||||
return [...groups]
|
||||
}
|
||||
const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
|
||||
if (next.length === 0) {
|
||||
return next
|
||||
}
|
||||
const groupById = new Map(next.map((group) => [group.id, group]))
|
||||
const ownerGroupByTabId = new Map<string, RuntimeMobileSessionTabGroup>()
|
||||
for (const group of next) {
|
||||
for (const id of group.tabOrder) {
|
||||
ownerGroupByTabId.set(id, group)
|
||||
}
|
||||
}
|
||||
for (const id of browserTabIds) {
|
||||
if (ownerGroupByTabId.has(id)) {
|
||||
continue
|
||||
}
|
||||
const priorGroupId = priorGroupByBrowserId?.get(id)
|
||||
const targetGroup =
|
||||
(newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ??
|
||||
(priorGroupId ? groupById.get(priorGroupId) : undefined) ??
|
||||
next[0]!
|
||||
targetGroup.tabOrder.push(id)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// browserPageId -> groupId from a set of groups (the persisted/prior layout),
|
||||
// so a browser stays in its group across rebuilds that drop browser ids.
|
||||
export function collectBrowserGroupAssignment(
|
||||
groups: readonly RuntimeMobileSessionTabGroup[] | undefined,
|
||||
browserTabIds: readonly string[]
|
||||
): Map<string, string> {
|
||||
const browserIdSet = new Set(browserTabIds)
|
||||
const assignment = new Map<string, string>()
|
||||
for (const group of groups ?? []) {
|
||||
for (const id of group.tabOrder) {
|
||||
if (browserIdSet.has(id)) {
|
||||
assignment.set(id, group.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return assignment
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { isTerminalLeafId } from '../../shared/stable-pane-id'
|
||||
import type {
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTabGroup,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
|
||||
|
||||
export function collectPersistedTerminalLeafIds(
|
||||
layout: TerminalLayoutSnapshot | undefined
|
||||
): string[] {
|
||||
if (!layout) {
|
||||
return []
|
||||
}
|
||||
const leafIds = new Set<string>()
|
||||
const visit = (node: TerminalLayoutSnapshot['root']): void => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
if (isTerminalLeafId(node.leafId)) {
|
||||
leafIds.add(node.leafId)
|
||||
}
|
||||
return
|
||||
}
|
||||
visit(node.first)
|
||||
visit(node.second)
|
||||
}
|
||||
visit(layout.root)
|
||||
if (layout.activeLeafId && isTerminalLeafId(layout.activeLeafId)) {
|
||||
leafIds.add(layout.activeLeafId)
|
||||
}
|
||||
for (const leafId of Object.keys(layout.ptyIdsByLeafId ?? {})) {
|
||||
if (isTerminalLeafId(leafId)) {
|
||||
leafIds.add(leafId)
|
||||
}
|
||||
}
|
||||
return [...leafIds]
|
||||
}
|
||||
|
||||
export function deriveHeadlessLegacyTerminalLeafId(tabId: string): string {
|
||||
const hash = createHash('sha256').update(`headless-terminal-leaf:${tabId}`).digest('hex')
|
||||
const variant = ((Number.parseInt(hash.slice(16, 17), 16) & 0x3) | 0x8).toString(16)
|
||||
const leafId = [
|
||||
hash.slice(0, 8),
|
||||
hash.slice(8, 12),
|
||||
`4${hash.slice(13, 16)}`,
|
||||
`${variant}${hash.slice(17, 20)}`,
|
||||
hash.slice(20, 32)
|
||||
].join('-')
|
||||
if (!isTerminalLeafId(leafId)) {
|
||||
return randomUUID()
|
||||
}
|
||||
return leafId
|
||||
}
|
||||
|
||||
export function cloneTerminalLayoutSnapshot(
|
||||
layout: TerminalLayoutSnapshot
|
||||
): TerminalLayoutSnapshot {
|
||||
const cloned: TerminalLayoutSnapshot = {
|
||||
root: layout.root,
|
||||
activeLeafId: layout.activeLeafId,
|
||||
expandedLeafId: layout.expandedLeafId
|
||||
}
|
||||
if (layout.ptyIdsByLeafId) {
|
||||
cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId }
|
||||
}
|
||||
if (layout.buffersByLeafId) {
|
||||
cloned.buffersByLeafId = { ...layout.buffersByLeafId }
|
||||
}
|
||||
if (layout.scrollbackRefsByLeafId) {
|
||||
cloned.scrollbackRefsByLeafId = { ...layout.scrollbackRefsByLeafId }
|
||||
}
|
||||
if (layout.titlesByLeafId) {
|
||||
cloned.titlesByLeafId = { ...layout.titlesByLeafId }
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
export function isPersistedTerminalLeafActive(
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
layout: TerminalLayoutSnapshot | undefined
|
||||
): boolean {
|
||||
const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId
|
||||
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
|
||||
}
|
||||
|
||||
export function pickHeadlessActiveTerminalTab(
|
||||
tabs: readonly RuntimeMobileSessionTerminalTab[]
|
||||
): RuntimeMobileSessionTerminalTab | null {
|
||||
return tabs.find((tab) => tab.isActive) ?? tabs.find((tab) => tab.parentTabId) ?? null
|
||||
}
|
||||
|
||||
export function collectHeadlessParentTabOrder(
|
||||
tabs: readonly RuntimeMobileSessionTerminalTab[]
|
||||
): string[] {
|
||||
const order: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
if (!seen.has(tab.parentTabId)) {
|
||||
seen.add(tab.parentTabId)
|
||||
order.push(tab.parentTabId)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// Why: the group tab order must follow actual creation/insertion order across
|
||||
// both terminals and browsers, not list terminals first. A terminal's top-level
|
||||
// id is its parentTabId (split leaves share one); a browser's is its own id.
|
||||
export function collectHeadlessTopLevelTabOrder(
|
||||
tabs: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
): string[] {
|
||||
const order: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id
|
||||
if (!seen.has(topLevelId)) {
|
||||
seen.add(topLevelId)
|
||||
order.push(topLevelId)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
export function getHeadlessMobileSessionGroupId(worktreeId: string): string {
|
||||
return `headless-terminals:${worktreeId}`
|
||||
}
|
||||
|
||||
export function buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId: string,
|
||||
tabs: readonly RuntimeMobileSessionSnapshotTab[],
|
||||
activeTab: RuntimeMobileSessionSnapshotTab | null,
|
||||
existingGroups?: readonly RuntimeMobileSessionTabGroup[],
|
||||
// Why: a new tab created via a specific group's "+" must land in THAT group,
|
||||
// not the active one — otherwise every "+" in a split funnels to one group.
|
||||
newTabAssignment?: { tabId: string; groupId: string }
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
// Why: order across terminals and browsers in their actual array order so a
|
||||
// tab opened after a browser tab lands to its right, not regrouped before it.
|
||||
const arrivalOrder = collectHeadlessTopLevelTabOrder(tabs)
|
||||
// Why: tabOrder is the user-visible order and must survive a republish. A
|
||||
// materialized idle surface can move to the end of the incoming array, so
|
||||
// retain stored positions and append only genuinely new ids.
|
||||
const liveTopLevelIds = new Set(arrivalOrder)
|
||||
const tabOrder: string[] = []
|
||||
const placed = new Set<string>()
|
||||
for (const group of existingGroups ?? []) {
|
||||
for (const tabId of group.tabOrder) {
|
||||
if (liveTopLevelIds.has(tabId) && !placed.has(tabId)) {
|
||||
tabOrder.push(tabId)
|
||||
placed.add(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tabId of arrivalOrder) {
|
||||
if (!placed.has(tabId)) {
|
||||
tabOrder.push(tabId)
|
||||
placed.add(tabId)
|
||||
}
|
||||
}
|
||||
const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string =>
|
||||
tab.type === 'terminal' ? tab.parentTabId : tab.id
|
||||
const activeTopLevelId =
|
||||
(activeTab ? topLevelOf(activeTab) : null) ??
|
||||
existingGroups?.[0]?.activeTabId ??
|
||||
(() => {
|
||||
const active = tabs.find((tab) => tab.isActive)
|
||||
return active ? topLevelOf(active) : null
|
||||
})() ??
|
||||
tabOrder[0] ??
|
||||
null
|
||||
|
||||
// Why: when the user has split tabs into multiple groups, preserve that
|
||||
// assignment across rebuilds instead of coalescing back to one group.
|
||||
if (existingGroups && existingGroups.length > 1) {
|
||||
return distributeHeadlessTabsAcrossGroups(
|
||||
existingGroups,
|
||||
tabOrder,
|
||||
activeTopLevelId,
|
||||
newTabAssignment
|
||||
)
|
||||
}
|
||||
|
||||
const groupId = existingGroups?.[0]?.id ?? getHeadlessMobileSessionGroupId(worktreeId)
|
||||
return [
|
||||
{
|
||||
id: groupId,
|
||||
activeTabId:
|
||||
activeTopLevelId && tabOrder.includes(activeTopLevelId)
|
||||
? activeTopLevelId
|
||||
: (tabOrder[0] ?? null),
|
||||
tabOrder
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Distribute live top-level tabs into the existing multi-group structure,
|
||||
// keeping each tab in its group; tabs new since the last snapshot join the
|
||||
// active group. Emptied groups are dropped so a closed split collapses.
|
||||
export function distributeHeadlessTabsAcrossGroups(
|
||||
existingGroups: readonly RuntimeMobileSessionTabGroup[],
|
||||
tabOrder: readonly string[],
|
||||
activeTopLevelId: string | null,
|
||||
newTabAssignment?: { tabId: string; groupId: string }
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
const groupIdByTabId = new Map<string, string>()
|
||||
for (const group of existingGroups) {
|
||||
for (const tabId of group.tabOrder) {
|
||||
groupIdByTabId.set(tabId, group.id)
|
||||
}
|
||||
}
|
||||
// Why: route a freshly-created tab to the group its "+" was clicked in,
|
||||
// when that group still exists; otherwise fall through to the active group.
|
||||
const hasTargetGroup =
|
||||
newTabAssignment !== undefined &&
|
||||
existingGroups.some((group) => group.id === newTabAssignment.groupId)
|
||||
if (hasTargetGroup) {
|
||||
groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId)
|
||||
}
|
||||
const activeGroupId =
|
||||
(activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id
|
||||
const orderByGroup = new Map<string, string[]>(existingGroups.map((group) => [group.id, []]))
|
||||
for (const tabId of tabOrder) {
|
||||
const groupId = groupIdByTabId.get(tabId) ?? activeGroupId
|
||||
orderByGroup.get(groupId)?.push(tabId)
|
||||
}
|
||||
return existingGroups
|
||||
.map((group) => {
|
||||
const nextOrder = orderByGroup.get(group.id) ?? []
|
||||
return {
|
||||
...group,
|
||||
tabOrder: nextOrder,
|
||||
activeTabId:
|
||||
activeTopLevelId && nextOrder.includes(activeTopLevelId)
|
||||
? activeTopLevelId
|
||||
: group.activeTabId && nextOrder.includes(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: (nextOrder[0] ?? null)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.tabOrder.length > 0)
|
||||
}
|
||||
|
||||
export function buildMaterializedHeadlessParentLayout(
|
||||
leafId: string,
|
||||
ptyId: string,
|
||||
existingLayout: TerminalLayoutSnapshot | undefined,
|
||||
split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' }
|
||||
): TerminalLayoutSnapshot {
|
||||
if (!existingLayout) {
|
||||
return {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
}
|
||||
// Why: a split must insert the new leaf into the live layout tree with the
|
||||
// requested direction, or the published snapshot keeps the old single-leaf
|
||||
// root and the split renders with a fallback direction ("Split Right" lands
|
||||
// as a top/bottom split). Reuse the persisted-split builder for parity.
|
||||
if (split) {
|
||||
return buildHeadlessTerminalSplitLayout(cloneTerminalLayoutSnapshot(existingLayout), {
|
||||
leafId,
|
||||
ptyId,
|
||||
splitFromLeafId: split.splitFromLeafId,
|
||||
direction: split.direction
|
||||
})
|
||||
}
|
||||
return {
|
||||
...cloneTerminalLayoutSnapshot(existingLayout),
|
||||
ptyIdsByLeafId: {
|
||||
...existingLayout.ptyIdsByLeafId,
|
||||
[leafId]: ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeMobileSessionTerminalTab,
|
||||
RuntimeSessionTabCloseReason,
|
||||
RuntimeSyncedTab
|
||||
} from '../../shared/runtime-types'
|
||||
import type { MobileSessionTabCloseOutcome } from './mobile-session-tab-close-outcome'
|
||||
import {
|
||||
delegatedMobileSessionTabClose,
|
||||
refusedMobileSessionTabClose
|
||||
} from './mobile-session-tab-close-outcome'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { worktreeIdsEqual } from '../../shared/worktree/id'
|
||||
|
||||
export type MobileSessionLifecycleCloseHost = {
|
||||
tabs: ReadonlyMap<string, RuntimeSyncedTab>
|
||||
leaves: ReadonlyMap<string, RuntimeLeafRecord>
|
||||
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
|
||||
findPtyForMobileTerminalTab: (
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
) => RuntimePtyWorktreeRecord | null
|
||||
republishSnapshot: (worktreeId: string) => void
|
||||
}
|
||||
|
||||
/** Everything a close needs about the surface the request addresses, including one already retired. */
|
||||
export type MobileSessionLifecycleCloseContext = {
|
||||
closeParentTabId: string | null
|
||||
closeLeafId: string | null
|
||||
parentLeaves: RuntimeMobileSessionTerminalTab[]
|
||||
rendererLeaves: RuntimeLeafRecord[]
|
||||
leafHasConnectedPty: (leaf: RuntimeMobileSessionTerminalTab) => boolean
|
||||
rendererLeafHasConnectedPty: (leaf: RuntimeLeafRecord) => boolean
|
||||
}
|
||||
|
||||
export function resolveMobileSessionLifecycleCloseContext(args: {
|
||||
host: MobileSessionLifecycleCloseHost
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
tab: RuntimeMobileSessionSnapshotTab | undefined
|
||||
authorityTab: RuntimeMobileSessionSnapshotTab | undefined
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
observedPtyIds: ReadonlySet<string> | null
|
||||
}): MobileSessionLifecycleCloseContext {
|
||||
const { host, worktreeId, tabId, tab, authorityTab, snapshot, observedPtyIds } = args
|
||||
const closeParentTabId =
|
||||
tab?.type === 'terminal'
|
||||
? tab.parentTabId
|
||||
: authorityTab?.type === 'terminal'
|
||||
? authorityTab.parentTabId
|
||||
: host.tabs.has(tabId)
|
||||
? tabId
|
||||
: ([...host.tabs.keys()]
|
||||
.filter((parentTabId) => tabId.startsWith(`${parentTabId}::`))
|
||||
.sort((a, b) => b.length - a.length)[0] ??
|
||||
(
|
||||
snapshot?.tabs.find(
|
||||
(candidate) =>
|
||||
candidate.type === 'terminal' && tabId.startsWith(`${candidate.parentTabId}::`)
|
||||
) as RuntimeMobileSessionTerminalTab | undefined
|
||||
)?.parentTabId ??
|
||||
null)
|
||||
const parentLeaves = closeParentTabId
|
||||
? (snapshot?.tabs.filter(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' && candidate.parentTabId === closeParentTabId
|
||||
) ?? [])
|
||||
: []
|
||||
const rendererLeaves = closeParentTabId
|
||||
? [...host.leaves.values()].filter(
|
||||
(leaf) => leaf.tabId === closeParentTabId && worktreeIdsEqual(leaf.worktreeId, worktreeId)
|
||||
)
|
||||
: []
|
||||
return {
|
||||
closeParentTabId,
|
||||
closeLeafId:
|
||||
closeParentTabId && tabId.startsWith(`${closeParentTabId}::`)
|
||||
? tabId.slice(closeParentTabId.length + 2)
|
||||
: null,
|
||||
parentLeaves,
|
||||
rendererLeaves,
|
||||
// Why: exited PTYs keep a disconnected record for status reads, so record
|
||||
// presence is not liveness — only `connected`, or a genuinely dead tab
|
||||
// never retires and the echo loops forever. Daemon discovery can still
|
||||
// prove a PTY live before its pane binding reconnects.
|
||||
leafHasConnectedPty: (leaf) => {
|
||||
const snapshotPtyIds = [leaf.ptyId, leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId]].filter(
|
||||
(ptyId): ptyId is string => Boolean(ptyId)
|
||||
)
|
||||
return (
|
||||
host.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true ||
|
||||
snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true)
|
||||
)
|
||||
},
|
||||
rendererLeafHasConnectedPty: (leaf) => {
|
||||
const ptyId = leaf.ptyId
|
||||
return Boolean(
|
||||
ptyId &&
|
||||
(host.ptysById.get(ptyId)?.connected === true || observedPtyIds?.has(ptyId) === true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjudicates a close whose addressed surface is already absent.
|
||||
* Lifecycle echoes are idempotent: a provider exit may have already retired the
|
||||
* surface before the viewer reports its stale close. A user close still fails
|
||||
* closed so an unknown target cannot be hidden.
|
||||
*/
|
||||
export function adjudicateAbsentMobileSessionTabClose(args: {
|
||||
host: MobileSessionLifecycleCloseHost
|
||||
context: MobileSessionLifecycleCloseContext
|
||||
worktreeId: string
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
reason: RuntimeSessionTabCloseReason | undefined
|
||||
addressedByPtyCloseAuthority: boolean
|
||||
}): MobileSessionTabCloseOutcome {
|
||||
const { host, context, worktreeId, snapshot, reason } = args
|
||||
if (reason === undefined || reason === 'user') {
|
||||
throw new Error(args.addressedByPtyCloseAuthority ? 'terminal_handle_stale' : 'tab_not_found')
|
||||
}
|
||||
// A missing leaf can still be part of a live split parent. Closing that
|
||||
// parent would take the surviving sibling down, so retain the refusal
|
||||
// even though the addressed leaf has already been retired.
|
||||
const hasLiveRendererParentLeaf = context.rendererLeaves.some(context.rendererLeafHasConnectedPty)
|
||||
if (context.parentLeaves.some(context.leafHasConnectedPty) || hasLiveRendererParentLeaf) {
|
||||
const addressedDeadRendererLeaf =
|
||||
context.closeLeafId !== null &&
|
||||
!context.rendererLeaves.some(
|
||||
(leaf) => leaf.leafId === context.closeLeafId && context.rendererLeafHasConnectedPty(leaf)
|
||||
)
|
||||
if (addressedDeadRendererLeaf) {
|
||||
return refusedMobileSessionTabClose('live-host-pty')
|
||||
}
|
||||
if (snapshot) {
|
||||
host.republishSnapshot(worktreeId)
|
||||
}
|
||||
return refusedMobileSessionTabClose('live-host-pty')
|
||||
}
|
||||
// The renderer owns a graph-visible parent, including a dead leaf whose
|
||||
// lifecycle echo arrived after main retired its mirror. Leave retirement
|
||||
// to that renderer instead of acknowledging a host-side close.
|
||||
if (context.closeParentTabId && host.tabs.has(context.closeParentTabId)) {
|
||||
return refusedMobileSessionTabClose('retirement-owner')
|
||||
}
|
||||
return delegatedMobileSessionTabClose()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
|
||||
// Why: content equality for the hydrate's idempotence check — compares every
|
||||
// client-visible field EXCEPT publicationEpoch/snapshotVersion (both are
|
||||
// freshly minted on each rebuild and would defeat the comparison). Tab and
|
||||
// group objects are rebuilt each hydrate, so compare by value, not identity.
|
||||
export function headlessMobileSnapshotContentUnchanged(
|
||||
existing: RuntimeMobileSessionTabsSnapshot,
|
||||
next: RuntimeMobileSessionTabsSnapshot
|
||||
): boolean {
|
||||
if (
|
||||
existing.worktree !== next.worktree ||
|
||||
existing.activeGroupId !== next.activeGroupId ||
|
||||
existing.activeTabId !== next.activeTabId ||
|
||||
existing.activeTabType !== next.activeTabType
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: this runs per persisted worktree on EVERY graph sync whenever a
|
||||
// serve PTY exists, so compare structurally instead of stable-stringifying
|
||||
// both sides (which allocated six full serialized trees per worktree).
|
||||
return (
|
||||
mobileSnapshotValueEqual(existing.tabs, next.tabs) &&
|
||||
mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) &&
|
||||
mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
// Deep structural equality over plain snapshot JSON (objects/arrays/scalars).
|
||||
// Key order is irrelevant; a mismatch only costs a coalesced no-op emit.
|
||||
export function mobileSnapshotValueEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < a.length; index++) {
|
||||
if (!mobileSnapshotValueEqual(a[index], b[index])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
||||
const aRecord = a as Record<string, unknown>
|
||||
const bRecord = b as Record<string, unknown>
|
||||
const aKeys = Object.keys(aRecord)
|
||||
if (aKeys.length !== Object.keys(bRecord).length) {
|
||||
return false
|
||||
}
|
||||
for (const key of aKeys) {
|
||||
if (!Object.hasOwn(bRecord, key) || !mobileSnapshotValueEqual(aRecord[key], bRecord[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
RuntimeMobileSessionSnapshotTab,
|
||||
RuntimeMobileSessionTabGroup,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import {
|
||||
collectHeadlessParentTabOrder,
|
||||
getHeadlessMobileSessionGroupId
|
||||
} from './mobile-session-layout-projection'
|
||||
|
||||
export function mergeMobileSessionSnapshotTabs(
|
||||
baseTabs: readonly RuntimeMobileSessionSnapshotTab[],
|
||||
extraTabs: readonly RuntimeMobileSessionSnapshotTab[]
|
||||
): RuntimeMobileSessionSnapshotTab[] {
|
||||
const seenIds = new Set<string>()
|
||||
const merged: RuntimeMobileSessionSnapshotTab[] = []
|
||||
const add = (tab: RuntimeMobileSessionSnapshotTab): void => {
|
||||
const ids = getMobileSessionSnapshotTabIdentityKeys(tab)
|
||||
if (ids.some((id) => seenIds.has(id))) {
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
seenIds.add(id)
|
||||
}
|
||||
merged.push(tab)
|
||||
}
|
||||
for (const tab of baseTabs) {
|
||||
add(tab)
|
||||
}
|
||||
for (const tab of extraTabs) {
|
||||
add(tab)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function getMobileSessionSnapshotTabIdentityKeys(
|
||||
tab: RuntimeMobileSessionSnapshotTab
|
||||
): string[] {
|
||||
if (tab.type === 'terminal') {
|
||||
// Why: split terminal leaves share one parent tab; merge dedup must stay
|
||||
// leaf-scoped or preserved siblings collapse into a single surface.
|
||||
const keys = [tab.id, `${tab.parentTabId}::${tab.leafId}`]
|
||||
if (typeof tab.ptyId === 'string' && tab.ptyId.length > 0) {
|
||||
// Why: renderer and headless sources can derive different leafIds for the same
|
||||
// terminal; real PTYs collapse those duplicates without merging pending splits.
|
||||
keys.push(`${tab.parentTabId}::pty:${tab.ptyId}`)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
return [tab.id, tab.browserWorkspaceId]
|
||||
}
|
||||
return [tab.id]
|
||||
}
|
||||
|
||||
export function mergeMobileSessionTabGroups(
|
||||
worktreeId: string,
|
||||
groups: readonly RuntimeMobileSessionTabGroup[],
|
||||
terminalTabs: readonly RuntimeMobileSessionTerminalTab[],
|
||||
activeTab: RuntimeMobileSessionTerminalTab | null
|
||||
): RuntimeMobileSessionTabGroup[] {
|
||||
const parentTabOrder = collectHeadlessParentTabOrder(terminalTabs)
|
||||
if (parentTabOrder.length === 0) {
|
||||
return [...groups]
|
||||
}
|
||||
const targetGroupId = groups[0]?.id ?? getHeadlessMobileSessionGroupId(worktreeId)
|
||||
const nextGroups =
|
||||
groups.length > 0
|
||||
? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] }))
|
||||
: [
|
||||
{
|
||||
id: targetGroupId,
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
// Why: keep each tab in the group that already owns it (a multi-group split
|
||||
// must survive the merge), drop tabs no longer present, and route only
|
||||
// genuinely-new tabs into the active group — never funnel everything into
|
||||
// group[0], which duplicated/coalesced tabs that lived in other groups.
|
||||
const ownerGroupId = new Map<string, string>()
|
||||
for (const group of nextGroups) {
|
||||
for (const tabId of group.tabOrder) {
|
||||
ownerGroupId.set(tabId, group.id)
|
||||
}
|
||||
}
|
||||
const liveTabIds = new Set(parentTabOrder)
|
||||
const activeParentId = activeTab?.parentTabId ?? null
|
||||
const activeGroupId =
|
||||
(activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id
|
||||
const retainedOrder = new Map<string, string[]>(nextGroups.map((group) => [group.id, []]))
|
||||
// Why: tabOrder is the canonical user-visible order, so it must survive a republish.
|
||||
// A materialized idle surface can move to the end of terminalTabs; retaining the
|
||||
// stored order prevents activation from rotating the tab bar.
|
||||
const placed = new Set<string>()
|
||||
for (const group of nextGroups) {
|
||||
for (const tabId of group.tabOrder) {
|
||||
if (liveTabIds.has(tabId) && !placed.has(tabId)) {
|
||||
retainedOrder.get(group.id)?.push(tabId)
|
||||
placed.add(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tabId of parentTabOrder) {
|
||||
if (placed.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
const groupId = ownerGroupId.get(tabId) ?? activeGroupId
|
||||
retainedOrder.get(groupId)?.push(tabId)
|
||||
placed.add(tabId)
|
||||
}
|
||||
return nextGroups
|
||||
.map((group) => {
|
||||
const tabOrder = retainedOrder.get(group.id) ?? []
|
||||
const keptActive =
|
||||
group.activeTabId &&
|
||||
tabOrder.includes(group.activeTabId) &&
|
||||
liveTabIds.has(group.activeTabId)
|
||||
? group.activeTabId
|
||||
: null
|
||||
return {
|
||||
...group,
|
||||
tabOrder,
|
||||
activeTabId:
|
||||
activeParentId && tabOrder.includes(activeParentId)
|
||||
? activeParentId
|
||||
: (keptActive ?? tabOrder[0] ?? null)
|
||||
}
|
||||
})
|
||||
.filter((group) => group.tabOrder.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types'
|
||||
import type { TerminalTab } from '../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
cloneTerminalLayoutSnapshot,
|
||||
collectPersistedTerminalLeafIds,
|
||||
deriveHeadlessLegacyTerminalLeafId,
|
||||
isPersistedTerminalLeafActive
|
||||
} from './mobile-session-layout-projection'
|
||||
|
||||
export function buildHeadlessMobileSessionTerminalTabs(
|
||||
worktreeId: string,
|
||||
persistedTabs: readonly TerminalTab[],
|
||||
session: WorkspaceSessionState
|
||||
): RuntimeMobileSessionTerminalTab[] {
|
||||
return [...persistedTabs]
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
|
||||
.flatMap((tab, index) => {
|
||||
const layout = session.terminalLayoutsByTabId?.[tab.id]
|
||||
const leafIds = collectPersistedTerminalLeafIds(layout)
|
||||
if (leafIds.length === 0) {
|
||||
leafIds.push(deriveHeadlessLegacyTerminalLeafId(tab.id))
|
||||
}
|
||||
return leafIds.flatMap((leafId) => {
|
||||
const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null)
|
||||
const title =
|
||||
tab.customTitle?.trim() ||
|
||||
tab.generatedTitle?.trim() ||
|
||||
tab.title?.trim() ||
|
||||
tab.defaultTitle?.trim() ||
|
||||
`Terminal ${index + 1}`
|
||||
return [
|
||||
{
|
||||
type: 'terminal' as const,
|
||||
id: `${tab.id}::${leafId}`,
|
||||
parentTabId: tab.id,
|
||||
leafId,
|
||||
title,
|
||||
...(ptyId ? { ptyId } : {}),
|
||||
...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}),
|
||||
...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}),
|
||||
...(layout ? { parentLayout: cloneTerminalLayoutSnapshot(layout) } : {}),
|
||||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
...(tab.isPinned ? { isPinned: true } : {}),
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
|
||||
isActive: isPersistedTerminalLeafActive(session, worktreeId, tab.id, leafId, layout)
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -892,9 +892,11 @@ describe('mobile subscribe integration', () => {
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
;(Reflect.get(runtime, 'terminalFitOverrides') as Map<string, unknown>).delete('pty-1')
|
||||
;(Reflect.get(runtime, 'currentDriver') as Map<string, { kind: string }>).set('pty-1', {
|
||||
kind: 'idle'
|
||||
})
|
||||
;(
|
||||
Reflect.get(runtime, 'terminalDrivers') as {
|
||||
set: (ptyId: string, driver: { kind: 'idle' }) => void
|
||||
}
|
||||
).set('pty-1', { kind: 'idle' })
|
||||
|
||||
const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map<string, unknown>
|
||||
const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map<string, unknown>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithListManagedWorktrees } from './orca-runtime-list-managed-worktrees'
|
||||
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
||||
import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type {
|
||||
WorktreeStartupDraftPaste,
|
||||
WorktreeStartupFollowup
|
||||
} from './runtime-worktree-agent-startup'
|
||||
import {
|
||||
buildWorktreeStartupForAgent,
|
||||
buildWorktreeStartupForDraft,
|
||||
markLocalWorktreeTrusted,
|
||||
markRemoteWorktreeTrusted
|
||||
} from './runtime-worktree-agent-startup'
|
||||
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
|
||||
import type { Worktree } from '../../shared/worktree/types'
|
||||
import type { WorktreeLineageResolution } from './runtime-worktree-lineage-resolution'
|
||||
import type {
|
||||
WorkspaceLineage,
|
||||
WorktreeLineage,
|
||||
WorktreeLineageWarning
|
||||
} from '../../shared/worktree/lineage-types'
|
||||
import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording'
|
||||
import {
|
||||
pasteWorktreeStartupDraftWhenReady,
|
||||
sendWorktreeStartupFollowupWhenReady
|
||||
} from './runtime-worktree-startup-readiness'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
|
||||
|
||||
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
|
||||
async activateManagedWorktree(
|
||||
worktreeSelector: string,
|
||||
opts: {
|
||||
notifyClients?: boolean
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
navigation?: RuntimeNavigationTarget
|
||||
} = {}
|
||||
): Promise<{
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
activated: boolean
|
||||
/** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no
|
||||
* renderer holds the sleeping records (headless `orca serve`), so nothing
|
||||
* woke — clients must not present the worktree's agents as resumed. */
|
||||
sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable'
|
||||
}> {
|
||||
this.assertGraphReady()
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
const repo = this.store?.getRepo(worktree.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all')
|
||||
const targetsHost = navigationTargetsHost(navigation)
|
||||
const targetsClients = navigationTargetsClients(navigation)
|
||||
|
||||
if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) {
|
||||
// Why: mobile/web session activation intentionally bypasses renderer
|
||||
// selection, so the runtime must acknowledge the unread state itself.
|
||||
this.store.setWorktreeMeta(worktree.id, { isUnread: false })
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
}
|
||||
|
||||
let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' =
|
||||
'not-applicable'
|
||||
if (targetsHost || targetsClients) {
|
||||
// Why: inactive worktree terminal panes are renderer-owned and may not have
|
||||
// live PTYs until the desktop activates the worktree and mounts them.
|
||||
if (targetsHost) {
|
||||
this.notifyHostActivateWorktree(repo.id, worktree.id)
|
||||
}
|
||||
if (targetsClients) {
|
||||
this.notifyClientsActivateWorktree(repo.id, worktree.id)
|
||||
}
|
||||
}
|
||||
if (!targetsHost) {
|
||||
// Why: mobile/web selection needs fresh session surfaces without forcing
|
||||
// every attached desktop renderer to navigate to the phone's workspace.
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
|
||||
allowAttachedWindow: true
|
||||
})
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
this.notifyMobileSessionTabsChanged(worktree.id)
|
||||
// Why: a phone open must also wake the worktree's slept agents (experimental
|
||||
// agent sleep). Only the host renderer holds the sleeping records + wake
|
||||
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
|
||||
// unaffected. Headless serve has no renderer to wake anything, so report
|
||||
// that explicitly instead of letting mobile assume the agents resumed.
|
||||
if (opts.clientKind === 'mobile') {
|
||||
if (this.getAvailableAuthoritativeWindow()) {
|
||||
this.notifier?.resumeSleepingAgents?.(worktree.id)
|
||||
sleepingAgentWake = 'requested'
|
||||
} else if (
|
||||
// Why: sleeping records are partitioned by execution host; reading
|
||||
// only the local partition would miss slept agents on SSH-host
|
||||
// worktrees and skip the headless warning for them.
|
||||
Object.values(
|
||||
this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo))
|
||||
.sleepingAgentSessionsByPaneKey ?? {}
|
||||
).some((record) => record.worktreeId === worktree.id)
|
||||
) {
|
||||
// Why: headless is only degraded when this worktree actually has a
|
||||
// persisted resume record. Ordinary mobile activation must not show
|
||||
// an unsupported warning merely because no desktop window is open.
|
||||
sleepingAgentWake = 'unsupported-headless'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake }
|
||||
}
|
||||
|
||||
protected async buildStartupForDraft(
|
||||
repo: Repo,
|
||||
draft: string,
|
||||
requestedAgent?: TuiAgent
|
||||
): Promise<{
|
||||
agent: TuiAgent
|
||||
startup: WorktreeStartupLaunch
|
||||
draftPaste?: WorktreeStartupDraftPaste
|
||||
} | null> {
|
||||
if (!this.store) {
|
||||
return null
|
||||
}
|
||||
return buildWorktreeStartupForDraft({
|
||||
repo,
|
||||
draft,
|
||||
...(requestedAgent ? { requestedAgent } : {}),
|
||||
settings: this.store.getSettings(),
|
||||
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo)
|
||||
})
|
||||
}
|
||||
|
||||
protected buildStartupForAgent(
|
||||
repo: Repo,
|
||||
agent: TuiAgent,
|
||||
prompt: string | undefined,
|
||||
launchPreferences?: AgentLaunchPreferences
|
||||
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return buildWorktreeStartupForAgent({
|
||||
repo,
|
||||
agent,
|
||||
...(prompt !== undefined ? { prompt } : {}),
|
||||
...(launchPreferences ? { launchPreferences } : {}),
|
||||
settings: this.store.getSettings(),
|
||||
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
|
||||
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
|
||||
})
|
||||
}
|
||||
|
||||
protected async markLocalWorkspaceTrustedForAgent(
|
||||
agent: TuiAgent,
|
||||
workspacePath: string
|
||||
): Promise<void> {
|
||||
await markLocalWorktreeTrusted(agent, workspacePath)
|
||||
}
|
||||
|
||||
protected async markWorkspaceTrustedForAgent(
|
||||
agent: TuiAgent,
|
||||
connectionId: string | null | undefined,
|
||||
workspacePath: string
|
||||
): Promise<void> {
|
||||
if (connectionId) {
|
||||
await this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, workspacePath)
|
||||
return
|
||||
}
|
||||
await this.markLocalWorkspaceTrustedForAgent(agent, workspacePath)
|
||||
}
|
||||
|
||||
protected async markRemoteWorkspaceTrustedForAgent(
|
||||
agent: TuiAgent,
|
||||
connectionId: string,
|
||||
workspacePath: string
|
||||
): Promise<void> {
|
||||
await markRemoteWorktreeTrusted(agent, connectionId, workspacePath)
|
||||
}
|
||||
|
||||
protected recordCreatedWorktreeLineage(
|
||||
worktree: Pick<Worktree, 'id' | 'instanceId'>,
|
||||
lineageResolution: WorktreeLineageResolution
|
||||
): {
|
||||
lineage: WorktreeLineage | null
|
||||
workspaceLineage: WorkspaceLineage | null
|
||||
warnings: WorktreeLineageWarning[]
|
||||
} {
|
||||
return recordCreatedWorktreeLineageState(this.store, worktree, lineageResolution)
|
||||
}
|
||||
|
||||
protected pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void {
|
||||
pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft)
|
||||
}
|
||||
|
||||
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
|
||||
sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup)
|
||||
}
|
||||
|
||||
protected async provisionManagedWorktreeTerminals(args: {
|
||||
worktreeSelector: string
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
setup?: CreateWorktreeResult['setup']
|
||||
defaultTabs?: CreateWorktreeResult['defaultTabs']
|
||||
primaryTerminalHandle?: string | null
|
||||
hasStartupTerminal: boolean
|
||||
setupCommandPlatform: 'windows' | 'posix'
|
||||
observeSetupCompletion?: boolean
|
||||
// Why: when the agent startup is sequenced to wait for setup
|
||||
// (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
|
||||
// the setup command. Pass that wrapped command through so the Setup tab runs
|
||||
// the same script the agent is waiting on instead of a bare runner.
|
||||
wrappedSetupCommand?: string
|
||||
// Why: a workspace provisioned in the background must not pull the sidebar
|
||||
// to itself; the user never asked to look at these tabs.
|
||||
surfaceOwner?: false
|
||||
}): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> {
|
||||
return provisionWorktreeTerminals(this.getWorktreeTerminalProvisioningHost(), args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeTerminalOrphanAdoptionRequest,
|
||||
RuntimeTerminalOrphanAdoptionResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { resolveTerminalSessionWorktreeId } from './runtime-worktree-path-identity'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
|
||||
|
||||
export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntimeWithSubscribeToTerminalResize {
|
||||
protected async adoptTerminalOrphansFromInventoryUnderMutation(
|
||||
request: RuntimeTerminalOrphanAdoptionRequest,
|
||||
workspace: TerminalWorkspaceLaunchScope,
|
||||
inventory: PtyControllerInventory
|
||||
): Promise<RuntimeTerminalOrphanAdoptionResult> {
|
||||
const store = this.store
|
||||
const session = this.getWorkspaceSessionForWorktree(workspace.id)
|
||||
if (
|
||||
!store?.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow) ||
|
||||
!session
|
||||
) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id)
|
||||
if (!sessionWorktreeId) {
|
||||
throw new Error('terminal_orphan_competing_owner')
|
||||
}
|
||||
const worktreeConnectionId = workspace.connectionId
|
||||
let worktreeWslDistro: string | null = null
|
||||
if (!worktreeConnectionId && workspace.repo) {
|
||||
try {
|
||||
worktreeWslDistro =
|
||||
getLocalProjectWorktreeGitOptions(this.requireStore(), workspace.repo).wslDistro ?? null
|
||||
} catch {
|
||||
throw new Error('terminal_orphan_owner_mismatch')
|
||||
}
|
||||
}
|
||||
return adoptRuntimeTerminalOrphansFromInventory({
|
||||
request,
|
||||
workspace,
|
||||
inventory,
|
||||
session,
|
||||
sessionWorktreeId,
|
||||
repoId: getRepoIdFromWorktreeId(workspace.id),
|
||||
worktreeWslDistro,
|
||||
currentRevision: this.getTerminalTopologyRevision(workspace.id),
|
||||
ports: {
|
||||
getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null,
|
||||
getLeaves: (ptyId) => this.getLeavesForPty(ptyId),
|
||||
getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)),
|
||||
getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(),
|
||||
getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
|
||||
setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next),
|
||||
flushSession: () => this.flushWorkspaceSessionOrThrowAsync(),
|
||||
hydrateSession: (worktreeId) =>
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
force: true,
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
}),
|
||||
notifySessionChanged: (worktreeId) => this.notifyMobileSessionTabsChanged(worktreeId),
|
||||
getSnapshot: (worktreeId) => this.getTerminalOrphanAdoptionSnapshot(worktreeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected getTerminalOrphanAdoptionSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
})
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
return this.getMobileSessionTabsForWorktree(worktreeId)
|
||||
}
|
||||
|
||||
// Why: when --terminal is omitted, the CLI auto-resolves to the active
|
||||
// terminal in the current worktree — matching browser's implicit active tab.
|
||||
async resolveActiveTerminal(worktreeSelector?: string): Promise<string> {
|
||||
if (this.graphStatus !== 'ready') {
|
||||
const targetWorktreeId = worktreeSelector
|
||||
? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
: null
|
||||
const snapshots = targetWorktreeId
|
||||
? [this.getMobileSessionTabsForWorktree(targetWorktreeId)]
|
||||
: await this.listAllMobileSessionTabs()
|
||||
for (const snapshot of snapshots) {
|
||||
const activeTerminal = snapshot.tabs.find(
|
||||
(tab) =>
|
||||
tab.type === 'terminal' &&
|
||||
tab.isActive &&
|
||||
tab.status === 'ready' &&
|
||||
typeof tab.terminal === 'string'
|
||||
)
|
||||
if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) {
|
||||
return activeTerminal.terminal
|
||||
}
|
||||
}
|
||||
const listed = await this.listTerminals(worktreeSelector, undefined, {
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
const first = listed.terminals[0]?.handle
|
||||
if (first) {
|
||||
return first
|
||||
}
|
||||
throw new Error('no_active_terminal')
|
||||
}
|
||||
this.assertGraphReady()
|
||||
|
||||
const targetWorktreeId = worktreeSelector
|
||||
? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
: null
|
||||
|
||||
// Prefer the tab's activeLeafId — this is the pane the user last focused
|
||||
for (const tab of this.tabs.values()) {
|
||||
if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!tab.activeLeafId) {
|
||||
continue
|
||||
}
|
||||
const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId)
|
||||
const leaf = this.leaves.get(leafKey)
|
||||
if (leaf) {
|
||||
return this.issueHandle(leaf)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: any leaf in the target worktree
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
|
||||
continue
|
||||
}
|
||||
return this.issueHandle(leaf)
|
||||
}
|
||||
|
||||
throw new Error('no_active_terminal')
|
||||
}
|
||||
|
||||
// Why: orchestration records the pane key as the remint-stable assignee
|
||||
// identity at dispatch time; null (best-effort) rather than throwing so
|
||||
// dispatch still works for handles without a resolvable pane.
|
||||
getTerminalPaneKey(handle: string): string | null {
|
||||
return this.getPaneKeyForTerminalHandle(handle)
|
||||
}
|
||||
|
||||
getLiveTerminalPaneKey(handle: string): string | null {
|
||||
const runtimePty = this.getLivePtyForHandle(handle)
|
||||
if (runtimePty) {
|
||||
return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null
|
||||
}
|
||||
try {
|
||||
const leaf = this.resolveLiveLeafForHandle(handle)
|
||||
if (!leaf?.ptyId) {
|
||||
return null
|
||||
}
|
||||
const pty = this.ptysById.get(leaf.ptyId)
|
||||
return pty?.connected === false ? null : this.getPaneKeyForTerminalHandle(handle)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getTerminalLivenessVerdict(handle: string): PtyLivenessVerdict | null {
|
||||
try {
|
||||
return this.getPtyLivenessVerdict(this.getTerminalAgentStatusPtyId(handle))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,4 +224,25 @@ describe('agent skill sharing runtime', () => {
|
||||
await expect(publishing).rejects.toThrow('upload-aborted')
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
|
||||
it('honors cancellation that arrives while preparation is completing', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const { runtime, publishVersion } = runtimeWithCloud({ isEnabled: () => true })
|
||||
let abortedReads = 0
|
||||
const signal = {
|
||||
get aborted() {
|
||||
abortedReads += 1
|
||||
return abortedReads > 1
|
||||
},
|
||||
reason: new Error('skill-share-cancelled'),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
} as unknown as AbortSignal
|
||||
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [alpha], signal)
|
||||
).rejects.toThrow('skill-share-cancelled')
|
||||
expect(publishVersion).not.toHaveBeenCalled()
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @ts-nocheck -- the launch-plan adapter is kept independent from the runtime mixin chain.
|
||||
import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import {
|
||||
addClaudeTeammateModeAuto,
|
||||
addClaudeTeammateModeInProcess,
|
||||
buildClaudeAgentTeamsLaunchPlan,
|
||||
inferCapturedClaudeAgentTeamsMode
|
||||
} from './orca-runtime-create-terminal-dependencies'
|
||||
|
||||
export async function buildRuntimeAgentTeamsLaunchPlan(args: {
|
||||
launchConfig: TerminalCreateOptions['launchConfig']
|
||||
command?: string
|
||||
claudeAgentTeamsSourceCommand?: string
|
||||
claudeAgentTeamsMode?: ClaudeAgentTeamsMode
|
||||
baseEnv: Record<string, string | undefined>
|
||||
adoptedBeforeLaunch: boolean
|
||||
createTeamEnv: (shimDir: string, shimBin: string) => Record<string, string>
|
||||
}): Promise<{
|
||||
plan: Awaited<ReturnType<typeof buildClaudeAgentTeamsLaunchPlan>> | undefined
|
||||
sequencedStartupCommand?: string
|
||||
effectiveLaunchConfig: TerminalCreateOptions['launchConfig']
|
||||
}> {
|
||||
const sourceCommand =
|
||||
args.claudeAgentTeamsSourceCommand?.trim() || args.command?.trim() || undefined
|
||||
const mode = inferCapturedClaudeAgentTeamsMode(
|
||||
args.launchConfig,
|
||||
sourceCommand,
|
||||
args.claudeAgentTeamsMode
|
||||
)
|
||||
const plan = args.adoptedBeforeLaunch
|
||||
? undefined
|
||||
: await buildClaudeAgentTeamsLaunchPlan({
|
||||
command: sourceCommand,
|
||||
mode,
|
||||
baseEnv: args.baseEnv,
|
||||
createTeamEnv: args.createTeamEnv
|
||||
})
|
||||
const sequencedStartupCommand =
|
||||
plan && sourceCommand && args.command && sourceCommand !== args.command
|
||||
? plan.command
|
||||
: undefined
|
||||
const effectiveLaunchConfig =
|
||||
args.launchConfig && plan
|
||||
? {
|
||||
...args.launchConfig,
|
||||
agentCommand: args.launchConfig.agentCommand
|
||||
? mode === 'in-process' || process.platform === 'win32'
|
||||
? addClaudeTeammateModeInProcess(args.launchConfig.agentCommand)
|
||||
: addClaudeTeammateModeAuto(args.launchConfig.agentCommand)
|
||||
: plan.command,
|
||||
agentEnv: { ...args.launchConfig.agentEnv, ...plan.env }
|
||||
}
|
||||
: args.launchConfig
|
||||
return { plan, sequencedStartupCommand, effectiveLaunchConfig }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithPickMostRecentActor } from './orca-runtime-pick-most-recent-actor'
|
||||
import type { ApplyLayoutResult, PtyLayoutState, PtyLayoutTarget } from './orca-runtime-core'
|
||||
|
||||
export class OrcaRuntimeWithApplyLayout extends OrcaRuntimeWithPickMostRecentActor {
|
||||
protected async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise<ApplyLayoutResult> {
|
||||
// Why: re-check pty-exit at the head of the slot — the queue may have
|
||||
// accepted this target before onPtyExit ran.
|
||||
if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) {
|
||||
return { ok: false, reason: 'pty-exited' }
|
||||
}
|
||||
|
||||
const prev = this.layouts.get(ptyId) ?? null
|
||||
const seq = (prev?.seq ?? 0) + 1
|
||||
const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() }
|
||||
|
||||
const currentSize = this.getTerminalSize(ptyId)
|
||||
const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows
|
||||
const modeChanged = (prev?.kind ?? 'desktop') !== target.kind
|
||||
|
||||
// Snapshot for rollback.
|
||||
const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null
|
||||
|
||||
// Tentative writes — the resize is the point of no return.
|
||||
this.layouts.set(ptyId, next)
|
||||
if (target.kind === 'phone') {
|
||||
// Why: pull baseline cols+rows atomically from the same subscriber so
|
||||
// they can't desync.
|
||||
const baseline = (() => {
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
if (!inner) {
|
||||
return null
|
||||
}
|
||||
return this.pickEarliestRestoreTarget(inner)
|
||||
})()
|
||||
this.terminalFitOverrides.set(ptyId, {
|
||||
mode: 'mobile-fit',
|
||||
cols: target.cols,
|
||||
rows: target.rows,
|
||||
previousCols: baseline?.previousCols ?? null,
|
||||
previousRows: baseline?.previousRows ?? null,
|
||||
updatedAt: next.appliedAt,
|
||||
clientId: target.ownerClientId
|
||||
})
|
||||
} else {
|
||||
this.terminalFitOverrides.delete(ptyId)
|
||||
}
|
||||
|
||||
if (dimsChanged) {
|
||||
let ok = false
|
||||
try {
|
||||
const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows)
|
||||
ok = r ?? true
|
||||
} catch (err) {
|
||||
console.error('[layout] ptyController.resize threw', { ptyId, err })
|
||||
ok = false
|
||||
}
|
||||
if (!ok) {
|
||||
// Roll back to pre-call snapshot. seq is NOT bumped on the wire
|
||||
// because we never emit below.
|
||||
if (prev) {
|
||||
this.layouts.set(ptyId, prev)
|
||||
} else {
|
||||
this.layouts.delete(ptyId)
|
||||
}
|
||||
if (prevFitOverride) {
|
||||
this.terminalFitOverrides.set(ptyId, prevFitOverride)
|
||||
} else {
|
||||
this.terminalFitOverrides.delete(ptyId)
|
||||
}
|
||||
return { ok: false, reason: 'resize-failed' }
|
||||
}
|
||||
this.resizeHeadlessTerminal(ptyId, target.cols, target.rows)
|
||||
}
|
||||
|
||||
// Why: remote desktop ownership is a fit hold for the host and passive
|
||||
// peer viewers. Emit every remote layout so owner changes at equal geometry
|
||||
// still park/release the correct clients without relying on resize deltas.
|
||||
// Defense-in-depth (#7588): also emit when the override's presence
|
||||
// changed even without a kind flip. applyLayout is the sole writer and
|
||||
// keeps override presence in lockstep with layout kind, so overrideChanged
|
||||
// ≡ modeChanged in every reachable state today; the extra clause fires
|
||||
// only if that invariant is ever violated, repairing the renderer instead
|
||||
// of stranding the held modal.
|
||||
const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone')
|
||||
if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) {
|
||||
// Why: phone→desktop arms the renderer-cascade suppress window
|
||||
// before the collateral safeFit IPCs arrive. See "Renderer cascade
|
||||
// suppression".
|
||||
if (target.kind === 'desktop') {
|
||||
this.lastRendererSizes.delete(ptyId)
|
||||
this.suppressResizesForMs(500)
|
||||
}
|
||||
this.notifier?.terminalFitOverrideChanged(
|
||||
ptyId,
|
||||
target.kind === 'phone'
|
||||
? 'mobile-fit'
|
||||
: target.kind === 'remote-desktop'
|
||||
? 'remote-desktop-fit'
|
||||
: 'desktop-fit',
|
||||
target.cols,
|
||||
target.rows
|
||||
)
|
||||
this.notifyFitOverrideListeners(
|
||||
ptyId,
|
||||
target.kind === 'phone'
|
||||
? 'mobile-fit'
|
||||
: target.kind === 'remote-desktop'
|
||||
? 'remote-desktop-fit'
|
||||
: 'desktop-fit',
|
||||
target.cols,
|
||||
target.rows
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile-facing event always fires (phone clients need to re-fit on
|
||||
// every dim change, not just mode flips).
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: target.cols,
|
||||
rows: target.rows,
|
||||
displayMode: target.kind === 'phone' ? 'phone' : 'desktop',
|
||||
reason: 'apply-layout',
|
||||
seq
|
||||
})
|
||||
|
||||
return { ok: true, state: next }
|
||||
}
|
||||
|
||||
// ─── Server-Authoritative Mobile Display Mode ─────────────────────
|
||||
|
||||
setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void {
|
||||
if (mode === 'auto') {
|
||||
this.mobileDisplayModes.delete(ptyId)
|
||||
} else {
|
||||
this.mobileDisplayModes.set(ptyId, mode)
|
||||
}
|
||||
}
|
||||
|
||||
getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' {
|
||||
return this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||||
}
|
||||
|
||||
isMobileSubscriberActive(ptyId: string): boolean {
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
return inner !== undefined && inner.size > 0
|
||||
}
|
||||
|
||||
// Why: late-bind viewport on an existing subscriber record. Subscribers
|
||||
// that registered before the mobile side measured (e.g. terminal first
|
||||
// mounted while the WebView was still loading) have null viewport, and
|
||||
// applyMobileDisplayMode's auto branch needs a viewport to phone-fit.
|
||||
// The setDisplayMode RPC carries the latest viewport so we can patch it
|
||||
// here just before applyMobileDisplayMode runs.
|
||||
updateMobileSubscriberViewport(
|
||||
ptyId: string,
|
||||
clientId: string,
|
||||
viewport: { cols: number; rows: number }
|
||||
): void {
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
const record = inner?.get(clientId)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
record.viewport = viewport
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithHandleMobileUnsubscribe } from './orca-runtime-handle-mobile-unsubscribe'
|
||||
|
||||
export class OrcaRuntimeWithApplyMobileDisplayMode extends OrcaRuntimeWithHandleMobileUnsubscribe {
|
||||
// Why: called when mode changes via terminal.setDisplayMode. Applies the
|
||||
// mode change immediately if there's an active subscriber, and emits a
|
||||
// 'resized' event so the mobile client can reinitialize xterm inline.
|
||||
//
|
||||
// Multi-mobile: the most recent mobile actor's viewport drives the active
|
||||
// phone-fit dims. The earliest-by-subscribe-time subscriber's
|
||||
// previousCols/Rows drive the desktop-restore target.
|
||||
//
|
||||
// Returns the post-condition "no fit-override remains held" (#7588): `true`
|
||||
// when it cleared a held override OR nothing was held to begin with, `false`
|
||||
// only when a restore was attempted and the resize failed (override rolled
|
||||
// back, still held). reclaimTerminalForDesktop gates its driver/mode
|
||||
// transitions on this; other callers ignore it.
|
||||
async applyMobileDisplayMode(ptyId: string): Promise<boolean> {
|
||||
const mode = this.getMobileDisplayMode(ptyId)
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
const subscriber = inner ? this.pickMostRecentActor(inner) : null
|
||||
const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null
|
||||
|
||||
if (mode === 'desktop') {
|
||||
// Reset wasResizedToPhone on every fitted subscriber so a future
|
||||
// toggle back to auto re-issues the resize. applyLayout owns the
|
||||
// actual PTY resize + override delete + renderer notify. Track which
|
||||
// subscribers we cleared so a failed resize can re-arm them.
|
||||
const clearedFitSubscribers = inner
|
||||
? [...inner.values()].filter((sub) => sub.wasResizedToPhone)
|
||||
: []
|
||||
for (const sub of clearedFitSubscribers) {
|
||||
sub.wasResizedToPhone = false
|
||||
}
|
||||
const anyWasResized = clearedFitSubscribers.length > 0
|
||||
// Why (#7588): also restore when a fit-override is still held but no
|
||||
// subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe
|
||||
// after an indefinite hold resets the flag yet leaves the override,
|
||||
// stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget
|
||||
// (the same resolver the anyWasResized branch uses) so the two adjacent
|
||||
// restore paths can never resolve to different dims for the same state.
|
||||
if (anyWasResized || this.terminalFitOverrides.has(ptyId)) {
|
||||
const restore = this.resolveDesktopRestoreTarget(ptyId)
|
||||
const result = await this.enqueueLayout(ptyId, {
|
||||
kind: 'desktop',
|
||||
cols: restore.cols,
|
||||
rows: restore.rows
|
||||
})
|
||||
// Why (#7588): a failed resize rolls the override back (still held), so
|
||||
// re-arm the flags we cleared. Otherwise a later unsubscribe under a
|
||||
// finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip
|
||||
// scheduling its auto-restore timer, and strand the held phone-fit.
|
||||
if (!result.ok) {
|
||||
for (const sub of clearedFitSubscribers) {
|
||||
sub.wasResizedToPhone = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nothing was fitted or held — emit a mode-change resize event so
|
||||
// the mobile client still learns the toggle landed.
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: size?.cols ?? 0,
|
||||
rows: size?.rows ?? 0,
|
||||
displayMode: 'desktop',
|
||||
reason: 'mode-change',
|
||||
seq: this.layouts.get(ptyId)?.seq
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// mode === 'auto' — the only non-desktop mode after the 'phone'
|
||||
// (sticky-fit) collapse. Phone-fit if the active subscriber has a
|
||||
// viewport and we haven't already applied it.
|
||||
if (subscriberRecord && !subscriberRecord.wasResizedToPhone) {
|
||||
const viewport = subscriberRecord.viewport
|
||||
if (viewport) {
|
||||
await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport)
|
||||
// After a phone-fit an override IS held, so this reports false. The
|
||||
// auto branch is never reached from reclaim (it sets 'desktop'
|
||||
// first); computed here only to keep the post-condition uniform.
|
||||
return !this.terminalFitOverrides.has(ptyId)
|
||||
}
|
||||
}
|
||||
// Why: always emit the mode change even when no resize occurred — the
|
||||
// mobile client needs to learn the toggle landed even if dims didn't
|
||||
// actually change. Carry the current seq (or undefined if no layout
|
||||
// entry yet) so the mobile-side stale-event filter behaves correctly.
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: size?.cols ?? 0,
|
||||
rows: size?.rows ?? 0,
|
||||
displayMode: 'auto',
|
||||
reason: 'mode-change',
|
||||
seq: this.layouts.get(ptyId)?.seq
|
||||
})
|
||||
}
|
||||
return !this.terminalFitOverrides.has(ptyId)
|
||||
}
|
||||
|
||||
// Why: called after a desktop renderer path has successfully resized the
|
||||
// PTY (local IPC or remote desktop viewport). The runtime mirror must take
|
||||
// the same accepted geometry so hidden-output restore parses at PTY width.
|
||||
onExternalPtyResize(ptyId: string, cols: number, rows: number): void {
|
||||
// The pty:resize IPC handler is supposed to gate via `isResizeSuppressed`
|
||||
// before calling here, but defend against callers that don't.
|
||||
if (this.isResizeSuppressed()) {
|
||||
return
|
||||
}
|
||||
// Why: while a mobile-fit override is in place, the desktop renderer's
|
||||
// safeFit echoes pty:resize(override.cols, override.rows). Treating that
|
||||
// echo as legitimate geometry would overwrite each subscriber's
|
||||
// previousCols/Rows baseline with phone dims, so the next take-back
|
||||
// enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm
|
||||
// stuck. Only filter reports that EXACTLY match the override — a fresh
|
||||
// measurement from a now-visible pane (e.g. user activated a previously
|
||||
// hidden tab on desktop, container went 0×0 → 1782×1195) reports
|
||||
// different dims and is the right baseline to remember.
|
||||
const activeOverride = this.terminalFitOverrides.get(ptyId)
|
||||
if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) {
|
||||
return
|
||||
}
|
||||
// Why: a successful host resize supersedes any target retained after a
|
||||
// failed viewer reclaim; a later viewer cycle must capture this new truth.
|
||||
this.remoteDesktopFloor.clearStaleHostReclaimTarget(ptyId)
|
||||
this.resizeHeadlessTerminal(ptyId, cols, rows)
|
||||
this.refreshRendererGeometry(ptyId, cols, rows)
|
||||
}
|
||||
|
||||
// Why: pty:reportGeometry IPC sibling. The renderer calls this when a
|
||||
// desktop pane container goes from 0×0 to a real size while a mobile-fit
|
||||
// override is active (e.g. user activates a previously-hidden tab on
|
||||
// desktop after the phone has already taken the floor). We need the
|
||||
// restore-target baseline to track real desktop dims even during the
|
||||
// fit period — otherwise resolveDesktopRestoreTarget falls back to the
|
||||
// PTY's spawn default (typically 80×24) and Take Back leaves the
|
||||
// terminal partially restored. This is a measurement-only channel: it
|
||||
// refreshes lastRendererSizes and non-null subscriber baselines, never
|
||||
// resizes the PTY, and bypasses both isResizeSuppressed and the
|
||||
// override-echo gate by design — the renderer only fires it when it
|
||||
// has just measured fresh real geometry. See docs/mobile-fit-hold.md.
|
||||
recordRendererGeometry(ptyId: string, cols: number, rows: number): void {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
return
|
||||
}
|
||||
// Why: a viewer may leave while phone-fit still owns the PTY. Keep its
|
||||
// deferred host reclaim cache aligned with later trusted pane measurements.
|
||||
this.remoteDesktopFloor.updateHostReclaimTarget(ptyId, cols, rows)
|
||||
this.refreshRendererGeometry(ptyId, cols, rows)
|
||||
}
|
||||
|
||||
// Why: test seam — exposes lastRendererSizes for assertions about
|
||||
// pty:reportGeometry / onExternalPtyResize side effects without making
|
||||
// the underlying Map writable from the outside.
|
||||
getLastRendererSize(ptyId: string): { cols: number; rows: number } | null {
|
||||
return this.lastRendererSizes.get(ptyId) ?? null
|
||||
}
|
||||
|
||||
protected refreshRendererGeometry(ptyId: string, cols: number, rows: number): void {
|
||||
this.lastRendererSizes.set(ptyId, { cols, rows })
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
if (!inner) {
|
||||
return
|
||||
}
|
||||
// Refresh the renderer-current size as the next-restore target on every
|
||||
// subscriber that already has a non-null baseline. Subscribers with null
|
||||
// baselines (joined while a peer had already phone-fitted) stay null.
|
||||
for (const sub of inner.values()) {
|
||||
if (sub.previousCols != null && sub.previousRows != null) {
|
||||
sub.previousCols = cols
|
||||
sub.previousRows = rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the pty:resize IPC handler calls this to check if the global
|
||||
// suppress window is active. During this window, all desktop renderer
|
||||
// pty:resize events are ignored to prevent collateral safeFit corruption.
|
||||
isResizeSuppressed(): boolean {
|
||||
return Date.now() < this.resizeSuppressedUntil
|
||||
}
|
||||
|
||||
protected suppressResizesForMs(ms: number): void {
|
||||
this.resizeSuppressedUntil = Date.now() + ms
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh } from './orca-runtime-perform-mobile-session-pty-records-refresh'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
||||
import { navigationTargetsClients } from '../../shared/runtime-navigation'
|
||||
import {
|
||||
activateClientSessionTabSelection,
|
||||
deriveClientSessionTabSelection,
|
||||
projectClientSessionTabSelection
|
||||
} from './client-session-tab-selection'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
import {
|
||||
buildHeadlessMobileSessionTabGroups,
|
||||
cloneTerminalLayoutSnapshot
|
||||
} from './mobile-session-layout-projection'
|
||||
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
|
||||
|
||||
export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh {
|
||||
protected applyMobileSessionTabNavigation(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
activeTabId: string,
|
||||
navigation: RuntimeNavigationTarget,
|
||||
clientNavigationId?: string
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
let callerSnapshot: RuntimeMobileSessionTabsResult | null = null
|
||||
if (navigationTargetsClients(navigation)) {
|
||||
// Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect.
|
||||
const ids = new Set(
|
||||
[...this.mobileSessionTabListeners]
|
||||
.map((subscription) => subscription.clientNavigationId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
if (clientNavigationId) {
|
||||
ids.add(clientNavigationId)
|
||||
}
|
||||
for (const id of ids) {
|
||||
const projected = this.clientSessionTabSelections.activate(
|
||||
this.withClientHostedPagesHold(snapshot, id),
|
||||
id,
|
||||
activeTabId
|
||||
)
|
||||
this.emitMobileSessionTabsSnapshotToClient(projected, id, true)
|
||||
if (id === clientNavigationId) {
|
||||
callerSnapshot = projected
|
||||
}
|
||||
}
|
||||
} else if (clientNavigationId) {
|
||||
// Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner.
|
||||
callerSnapshot = this.clientSessionTabSelections.activate(
|
||||
this.withClientHostedPagesHold(snapshot, clientNavigationId),
|
||||
clientNavigationId,
|
||||
activeTabId
|
||||
)
|
||||
this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId)
|
||||
}
|
||||
if (clientNavigationId) {
|
||||
return callerSnapshot ?? this.projectMobileSessionTabsForClient(snapshot, clientNavigationId)
|
||||
}
|
||||
if (navigation === 'caller') {
|
||||
const selection = activateClientSessionTabSelection(
|
||||
snapshot,
|
||||
deriveClientSessionTabSelection(snapshot),
|
||||
activeTabId
|
||||
)
|
||||
return projectClientSessionTabSelection(snapshot, selection).snapshot
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether persistence proves this pane's PTY was deliberately taken down and parked
|
||||
* (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect.
|
||||
* Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it
|
||||
* indefinitely — and respawning a parked pane re-launches its agent behind the user.
|
||||
* Only an automatic activation consults this; a user opening the tab is the wake gesture.
|
||||
*/
|
||||
protected isDeliberatelyParkedPane(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
const record =
|
||||
this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[
|
||||
makePaneKey(tab.parentTabId, tab.leafId)
|
||||
]
|
||||
// Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect
|
||||
// must still mint its replacement PTY (#11542). Only a worktree-owned capture records
|
||||
// a deliberate takedown the user did not ask to undo.
|
||||
return (
|
||||
record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
protected shouldMaterializeHeadlessMobileSessionTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
return (
|
||||
this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) ||
|
||||
this.hasServeOrSshOwnedBinding(tab)
|
||||
)
|
||||
}
|
||||
|
||||
protected shouldPersistHeadlessMobileSessionActivation(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
if (snapshot.publicationEpoch.includes(':headless-merge:')) {
|
||||
return false
|
||||
}
|
||||
if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') {
|
||||
return false
|
||||
}
|
||||
return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab)
|
||||
}
|
||||
|
||||
protected activateHeadlessMobileSessionTerminalTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
activeTab: RuntimeMobileSessionTerminalTab
|
||||
): void {
|
||||
const tabs = snapshot.tabs.map((candidate) => ({
|
||||
...candidate,
|
||||
isActive: candidate.id === activeTab.id
|
||||
}))
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: activeTab.id,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId,
|
||||
tabs,
|
||||
activeTab,
|
||||
snapshot.tabGroups
|
||||
),
|
||||
tabs
|
||||
}
|
||||
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
// Why: a headless split only updated the LIVE session snapshot, never the
|
||||
// persisted workspace session layout. So a later snapshot rebuild (e.g. on the
|
||||
// next terminal create) re-derived from the stale single-leaf persisted layout
|
||||
// and collapsed the split. Persist the new split leaf into the workspace
|
||||
// session's terminalLayoutsByTabId so the split survives rebuilds.
|
||||
protected persistHeadlessTerminalSplit(args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
splitFromLeafId: string
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}): boolean {
|
||||
const session = this.getWorkspaceSessionForWorktree(args.worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return false
|
||||
}
|
||||
const existing = session.terminalLayoutsByTabId?.[args.tabId]
|
||||
const nextLayout = buildHeadlessTerminalSplitLayout(
|
||||
existing ? cloneTerminalLayoutSnapshot(existing) : undefined,
|
||||
args
|
||||
)
|
||||
this.setWorkspaceSessionForWorktree(args.worktreeId, {
|
||||
...session,
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[args.tabId]: nextLayout
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
protected persistHeadlessTerminalActiveLeaf(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): void {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId]
|
||||
const nextLayouts = existingLayout
|
||||
? {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[tab.parentTabId]: {
|
||||
...cloneTerminalLayoutSnapshot(existingLayout),
|
||||
activeLeafId: tab.leafId
|
||||
}
|
||||
}
|
||||
: session.terminalLayoutsByTabId
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, {
|
||||
...session,
|
||||
activeTabId: tab.parentTabId,
|
||||
activeTabIdByWorktree: {
|
||||
...session.activeTabIdByWorktree,
|
||||
[worktreeId]: tab.parentTabId
|
||||
},
|
||||
terminalLayoutsByTabId: nextLayouts
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithGetUnpersistedTrackedTitleForPty } from './orca-runtime-get-unpersisted-tracked-title-for-pty'
|
||||
import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects'
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
|
||||
import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection'
|
||||
|
||||
export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty {
|
||||
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
|
||||
* Returns true when the PTY record's title or status changed. */
|
||||
protected applyTrackedPtyTitle(
|
||||
ptyId: string,
|
||||
rawTitle: string,
|
||||
normalizedTitle: string,
|
||||
meta?: TerminalTitleFactMeta
|
||||
): boolean {
|
||||
// Why: status is detected from the RAW title (mirrors the renderer tracker),
|
||||
// so working/idle transitions are unaffected by normalization; the records
|
||||
// store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to
|
||||
// one stable stored label (#7880) instead of churning `ps`/mobile tabs.
|
||||
//
|
||||
// Why the identity-only case: the bare cursor-agent literal identifies the pane without
|
||||
// asserting activity, so it records NO title/status evidence — only the tracker keeps it,
|
||||
// for display (#10258). Nulling the status here rather than trusting the detector keeps
|
||||
// that contract local, since every activity-gated effect below is keyed on status.
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const recordedTitle = identityOnlyTitle ? null : normalizedTitle
|
||||
const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle)
|
||||
this.recordAgentPromptLifecycleState(ptyId, agentStatus)
|
||||
let ptyRecordChanged = false
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
const prevStatus = pty.lastAgentStatus
|
||||
const prevTitle = pty.lastOscTitle
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
const observedAtEpochMs = identityOnlyTitle ? null : Date.now()
|
||||
pty.lastOscTitle = recordedTitle
|
||||
pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt
|
||||
pty.lastOscTitleEpochMs = observedAtEpochMs
|
||||
pty.lastAgentStatus = agentStatus
|
||||
pty.lastAgentStatusObservedLive = true
|
||||
if (prevStatus !== agentStatus) {
|
||||
pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs
|
||||
}
|
||||
if (
|
||||
identityOnlyTitle ||
|
||||
terminalTitleBlocksExplicitAgentStatus(recordedTitle) ||
|
||||
(prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus)
|
||||
) {
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now()
|
||||
}
|
||||
if (identityOnlyTitle) {
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
} else {
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
|
||||
}
|
||||
ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
const shouldDelayMobileSnapshot =
|
||||
ptyRecordChanged &&
|
||||
this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle)
|
||||
let foregroundRefresh: Promise<boolean> | undefined
|
||||
// Why: gate on an actual status transition — braille spinner frames
|
||||
// mutate the title every tick, so probing per-title-change would stream
|
||||
// a foreground query per frame during active work.
|
||||
if (prevStatus !== agentStatus) {
|
||||
foregroundRefresh = this.ptyForegroundAgent.refresh(ptyId, observedAt)
|
||||
} else if (shouldDelayMobileSnapshot) {
|
||||
// Why: same-status compatible title changes can arrive before the
|
||||
// foreground owner probe settles; publishing them would flicker.
|
||||
foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt)
|
||||
}
|
||||
if (foregroundRefresh && shouldDelayMobileSnapshot) {
|
||||
// Why: report "unchanged" so the per-chunk batch skips the mobile
|
||||
// snapshot fan-out; the delayed publish fires when the probe settles.
|
||||
ptyRecordChanged = false
|
||||
this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh)
|
||||
}
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
// Why: keep the latest OSC title on the leaf so worktree.ps can
|
||||
// recompute status from the live title each call. Without this,
|
||||
// daemon-hosted terminals (no renderer pushing pane titles) had no
|
||||
// way to clear a stale 'working' status after the agent exited and
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
const prevObservedLive = leaf.lastAgentStatusObservedLive
|
||||
leaf.lastOscTitle = recordedTitle
|
||||
leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence()
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
// bare shell title after the agent exits), clear lastAgentStatus so
|
||||
// it is no longer sticky. Tui-idle waiters that needed the previous
|
||||
// 'idle' transition were already resolved at the moment of the
|
||||
// transition below; only fresh waiters registered after the agent
|
||||
// exits would observe the cleared value, and they correctly fall
|
||||
// back to title-based detection / polling.
|
||||
leaf.lastAgentStatus = agentStatus
|
||||
leaf.lastAgentStatusObservedLive = true
|
||||
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
|
||||
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
|
||||
// and the coordinator's tui-idle waiter would hang forever waiting for a
|
||||
// working→idle transition that never comes. Permission→idle is excluded:
|
||||
// it means the agent was blocked on user approval and the user said no,
|
||||
// which isn't a task-completion signal.
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolveTuiIdleWaiters(leaf)
|
||||
}
|
||||
// Why the second condition: push delivery is gated on LIVE idle, so its
|
||||
// authorizing edge is liveness as well as status. A restore seed or a
|
||||
// status kept across a same-id respawn leaves a stale 'idle' behind, and
|
||||
// an agent whose first live title is already idle (claude --resume at its
|
||||
// prompt) then shows no transition — the row would strand, which is
|
||||
// exactly #12536. Waiter semantics stay transition-only above.
|
||||
if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) {
|
||||
this.deliverPendingMessagesForLeaf(leaf)
|
||||
}
|
||||
}
|
||||
return ptyRecordChanged
|
||||
}
|
||||
|
||||
/** Cancel the per-PTY title tracker (stale-title timer included) on PTY
|
||||
* teardown so it cannot fire into pruned records. */
|
||||
protected disposePtyTitleTracker(ptyId: string): void {
|
||||
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose()
|
||||
this.ptyTitleTrackersByPtyId.delete(ptyId)
|
||||
this.ptyForegroundAgent.clearDelayedSnapshot(ptyId)
|
||||
this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId)
|
||||
this.clientEvents.clearPtyTitleGate(ptyId)
|
||||
}
|
||||
|
||||
protected resetTrackedTerminalStateForProviderGeneration(ptyId: string): void {
|
||||
// Why: a replacement daemon session can reuse the PTY id, but title/parser
|
||||
// state from the prior process must not bleed into its snapshots or chunks.
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.osc7ScanTailByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.agentPromptLifecycleByPtyId.delete(ptyId)
|
||||
this.agentPromptPermissionSequenceByPtyId.delete(ptyId)
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
pty.lastOscTitle = null
|
||||
pty.lastOscTitleAt = null
|
||||
pty.lastOscTitleEpochMs = null
|
||||
pty.lastAgentStatus = null
|
||||
// Why: the prior process's live frames say nothing about the replacement,
|
||||
// so the seed a same-id restore applies must not inherit its authority.
|
||||
pty.lastAgentStatusObservedLive = false
|
||||
pty.lastAgentStatusStartedAtEpochMs = null
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now()
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
pty.waitBlockedAt = null
|
||||
pty.tailWaitState = undefined
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
leaf.lastOscTitle = null
|
||||
leaf.lastOscTitleAt = null
|
||||
leaf.lastAgentStatus = null
|
||||
leaf.lastAgentStatusObservedLive = false
|
||||
leaf.waitBlockedAt = null
|
||||
leaf.tailWaitState = undefined
|
||||
}
|
||||
this.primeWaitBlockedBaselineFromSeededTail(ptyId)
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
}
|
||||
|
||||
protected setTerminalSideEffectConsumerAvailable(available: boolean): void {
|
||||
this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null
|
||||
this.refreshTerminalSideEffectConsumerAvailability()
|
||||
}
|
||||
|
||||
protected refreshTerminalSideEffectConsumerAvailability(): void {
|
||||
const nextAvailable =
|
||||
this.terminalSideEffectLocalConsumerAvailable ||
|
||||
this.countTerminalSideEffectConsumingClientEventListeners() > 0
|
||||
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
this.terminalSideEffectConsumerAvailable = nextAvailable
|
||||
for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) {
|
||||
entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable)
|
||||
entry.commandCodeDetector = nextAvailable
|
||||
? this.createTerminalSideEffectCommandCodeDetector(ptyId)
|
||||
: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithRecordAgentPromptLifecycleState } from './orca-runtime-record-agent-prompt-lifecycle-state'
|
||||
import type {
|
||||
RemoteTerminalSourceRangeReplacementPublication,
|
||||
RemoteTerminalSourceRangeReplacementReservation,
|
||||
RemoteTerminalSourceRangeStreamIdentity
|
||||
} from './remote-terminal-source-range-consumer'
|
||||
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
|
||||
import type { DriverState } from './orca-runtime-core'
|
||||
import { addListenerToMap } from './orca-runtime-core'
|
||||
import { notifyRuntimeListeners } from './runtime-async-boundaries'
|
||||
import type { RuntimeTerminalBufferSnapshot } from './runtime-terminal-state-records'
|
||||
import { AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS } from './orca-runtime-postlude'
|
||||
import { assertTerminalInputWithinLimitWithYield } from './terminal-send-payload'
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
|
||||
export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends OrcaRuntimeWithRecordAgentPromptLifecycleState {
|
||||
attachRemoteTerminalSourceRangeConsumer(
|
||||
identity: RemoteTerminalSourceRangeStreamIdentity
|
||||
): boolean {
|
||||
return this.terminalStreamConsumers.attachSourceRangeConsumer(identity)
|
||||
}
|
||||
|
||||
settleRemoteTerminalSourceRanges(
|
||||
identity: RemoteTerminalSourceRangeStreamIdentity,
|
||||
ranges: readonly TerminalOutputSourceRange[]
|
||||
): void {
|
||||
this.terminalStreamConsumers.settleSourceRanges(identity, ranges)
|
||||
}
|
||||
|
||||
reserveRemoteTerminalSourceRangeReplacement(
|
||||
identity: RemoteTerminalSourceRangeStreamIdentity,
|
||||
requiredSeq: number,
|
||||
reason: string
|
||||
): RemoteTerminalSourceRangeReplacementReservation | null {
|
||||
return this.terminalStreamConsumers.reserveSourceRangeReplacement(identity, requiredSeq, reason)
|
||||
}
|
||||
|
||||
commitRemoteTerminalSourceRangeReplacement(
|
||||
reservation: RemoteTerminalSourceRangeReplacementReservation,
|
||||
publication: RemoteTerminalSourceRangeReplacementPublication
|
||||
): boolean {
|
||||
return this.terminalStreamConsumers.commitSourceRangeReplacement(reservation, publication)
|
||||
}
|
||||
|
||||
rollbackRemoteTerminalSourceRangeReplacement(
|
||||
reservation: RemoteTerminalSourceRangeReplacementReservation,
|
||||
reason: string
|
||||
): boolean {
|
||||
return this.terminalStreamConsumers.rollbackSourceRangeReplacement(reservation, reason)
|
||||
}
|
||||
|
||||
cancelRemoteTerminalSourceRanges(
|
||||
identity: RemoteTerminalSourceRangeStreamIdentity,
|
||||
ranges: readonly TerminalOutputSourceRange[],
|
||||
reason: string
|
||||
): void {
|
||||
this.terminalStreamConsumers.cancelSourceRanges(identity, ranges, reason)
|
||||
}
|
||||
|
||||
protected notifyRemoteTerminalViewPresenceChanged(ptyId: string): void {
|
||||
try {
|
||||
this.onRemoteTerminalViewPresenceChanged?.(ptyId)
|
||||
} catch (err) {
|
||||
console.error('[runtime] remote view presence listener threw', { ptyId, err })
|
||||
}
|
||||
}
|
||||
|
||||
/** Registered by terminal-RPC subscribe/multiplex streams: while a remote
|
||||
* view subscriber is attached its xterm answers queries with view
|
||||
* authority and the model responder must stay silent. Returns an
|
||||
* idempotent release. */
|
||||
registerRemoteTerminalViewSubscriber(ptyId: string): () => void {
|
||||
return this.terminalViewSubscribers.registerRemote(ptyId)
|
||||
}
|
||||
|
||||
/** A local daemon session main knows is live but has never ingested a byte
|
||||
* from — i.e. no pane ever attached it, so the daemon is not emitting.
|
||||
* Headless state exists only after the first ingested byte; a snapshot
|
||||
* reconcile in flight implies a spawn-path attach already happened. */
|
||||
protected isKnownUnattachedLocalDaemonPty(ptyId: string): boolean {
|
||||
return this.terminalViewSubscribers.isKnownUnattachedLocal(ptyId)
|
||||
}
|
||||
|
||||
protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void {
|
||||
this.terminalViewSubscribers.reconcileProviderAttach(ptyId)
|
||||
}
|
||||
|
||||
/** Mark a raw-output viewer without transferring terminal query authority. */
|
||||
registerRawTerminalViewSubscriber(ptyId: string): () => void {
|
||||
return this.terminalViewSubscribers.registerRaw(ptyId)
|
||||
}
|
||||
|
||||
/** Raw stream presence prevents provider thinning without changing reply ownership. */
|
||||
hasRawTerminalViewSubscriber(ptyId: string): boolean {
|
||||
return this.terminalViewSubscribers.hasRaw(ptyId)
|
||||
}
|
||||
|
||||
hasRemoteTerminalViewSubscriber(ptyId: string): boolean {
|
||||
return this.terminalViewSubscribers.hasRemote(ptyId)
|
||||
}
|
||||
|
||||
isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
|
||||
// Why: a passive phone watching desktop-sized output must not race the
|
||||
// desktop xterm. Mobile becomes reply authority only with the mobile floor.
|
||||
if (this.getDriver(ptyId).kind !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
const subscribers = this.mobileSubscribers.get(ptyId)
|
||||
if (!subscribers) {
|
||||
return false
|
||||
}
|
||||
// Why: soft-leave resubscribe preserves the original subscription time but
|
||||
// reinserts the record. Elect fitted responders from that stable age, not
|
||||
// mutable Map order or passive desktop-mode watchers.
|
||||
let earliest: { clientId: string; subscribedAt: number } | null = null
|
||||
for (const subscriber of subscribers.values()) {
|
||||
if (!subscriber.wasResizedToPhone) {
|
||||
continue
|
||||
}
|
||||
if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) {
|
||||
earliest = subscriber
|
||||
}
|
||||
}
|
||||
return earliest?.clientId === clientId
|
||||
}
|
||||
|
||||
subscribeToFitOverrideChanges(
|
||||
ptyId: string,
|
||||
listener: (event: {
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
): () => void {
|
||||
return addListenerToMap(this.fitOverrideListeners, ptyId, listener)
|
||||
}
|
||||
|
||||
subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void {
|
||||
return this.terminalDrivers.subscribe(ptyId, listener)
|
||||
}
|
||||
|
||||
protected notifyFitOverrideListeners(
|
||||
ptyId: string,
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit',
|
||||
cols: number,
|
||||
rows: number
|
||||
): void {
|
||||
const listeners = this.fitOverrideListeners.get(ptyId)
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
notifyRuntimeListeners(listeners, (listener) => listener({ mode, cols, rows }), 'fit-override')
|
||||
}
|
||||
|
||||
serializeTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<RuntimeTerminalBufferSnapshot | null> {
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
async serializeAuthoritativeTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
): Promise<RuntimeTerminalBufferSnapshot | null> {
|
||||
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts, {
|
||||
timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
|
||||
retireOnTimeout: true
|
||||
})
|
||||
if (providerSnapshot) {
|
||||
return providerSnapshot
|
||||
}
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
/** Raw keystroke pass-through for the pop-out dashboard's terminal preview.
|
||||
* Honors the mobile-presence lock like the main window's pty:write path. */
|
||||
async writeTerminalPreviewInput(ptyId: string, data: string): Promise<boolean> {
|
||||
if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await assertTerminalInputWithinLimitWithYield(data)
|
||||
const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId)
|
||||
await this.writeTerminalInputChunks(
|
||||
ptyId,
|
||||
data,
|
||||
{
|
||||
// Why: a phone can claim the floor while a paste yields between chunks.
|
||||
beforeWrite: () => {
|
||||
if (this.getDriver(ptyId).kind === 'mobile') {
|
||||
throw new Error('terminal_mobile_driver_active')
|
||||
}
|
||||
}
|
||||
},
|
||||
admitted
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
hasHeadlessTerminalState(ptyId: string): boolean {
|
||||
return this.headlessTerminals.has(ptyId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithNotifySshStateChanged } from './orca-runtime-notify-ssh-state-changed'
|
||||
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
|
||||
export class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged {
|
||||
attachWindow(windowId: number): void {
|
||||
if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) {
|
||||
if (
|
||||
this.pendingHeadlessPromotionWindowId !== null &&
|
||||
windowId !== this.pendingHeadlessPromotionWindowId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: promotion is a renderer reload of the same graph owner, not a new
|
||||
// runtime; stale handles must transition before the real window publishes.
|
||||
this.persistWindowlessPtyBindingsForDesktopAttach()
|
||||
this.pendingHeadlessPromotionWindowId = windowId
|
||||
this.authoritativeWindowId = windowId
|
||||
this.beginGraphReload(windowId)
|
||||
return
|
||||
}
|
||||
if (this.authoritativeWindowId === null) {
|
||||
// Why: a promoted serve can close and later reopen its window while new
|
||||
// background PTYs keep arriving; every windowless gap needs this handoff.
|
||||
this.persistWindowlessPtyBindingsForDesktopAttach()
|
||||
this.authoritativeWindowId = windowId
|
||||
}
|
||||
}
|
||||
|
||||
protected persistWindowlessPtyBindingsForDesktopAttach(): void {
|
||||
if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const partitions = new Map<
|
||||
ExecutionHostId,
|
||||
{ session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] }
|
||||
>()
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (!pty.connected || !pty.tabId) {
|
||||
continue
|
||||
}
|
||||
const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId)
|
||||
const session = this.store.getWorkspaceSession(hostId)
|
||||
const tab = session.tabsByWorktree[pty.worktreeId]?.find(
|
||||
(candidate) => candidate.id === pty.tabId
|
||||
)
|
||||
if (!tab) {
|
||||
continue
|
||||
}
|
||||
const layoutPtyIds = Object.values(
|
||||
session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {}
|
||||
)
|
||||
if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) {
|
||||
continue
|
||||
}
|
||||
const partition = partitions.get(hostId) ?? { session, ptys: [] }
|
||||
partition.ptys.push(pty)
|
||||
partitions.set(hostId, partition)
|
||||
}
|
||||
|
||||
for (const [hostId, { session, ptys }] of partitions) {
|
||||
// Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session.
|
||||
const activeWorktreeIdsOnShutdown = [
|
||||
...new Set([
|
||||
...(session.activeWorktreeIdsOnShutdown ?? []),
|
||||
...ptys.map((pty) => pty.worktreeId)
|
||||
])
|
||||
]
|
||||
const activeConnectionIdsAtShutdown = [
|
||||
...new Set([
|
||||
...(session.activeConnectionIdsAtShutdown ?? []),
|
||||
...ptys
|
||||
.map((pty) => pty.connectionId)
|
||||
.filter((connectionId): connectionId is string => connectionId !== null)
|
||||
])
|
||||
]
|
||||
const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId }
|
||||
for (const pty of ptys) {
|
||||
if (pty.connectionId && pty.tabId) {
|
||||
remoteSessionIdsByTabId[pty.tabId] = pty.ptyId
|
||||
}
|
||||
}
|
||||
|
||||
this.store.setWorkspaceSession(
|
||||
{
|
||||
...session,
|
||||
activeWorktreeIdsOnShutdown,
|
||||
...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}),
|
||||
...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {})
|
||||
},
|
||||
hostId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithBuildPtyTerminalSummary } from './orca-runtime-build-pty-terminal-summary'
|
||||
import type { PtyIncarnationHandleRecord } from './orca-runtime-core'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuildPtyTerminalSummary {
|
||||
protected bindPtyIncarnationHandle(
|
||||
retained: PtyIncarnationHandleRecord,
|
||||
leaf: RuntimeLeafRecord
|
||||
): void {
|
||||
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
if (retained.leafKey !== leafKey) {
|
||||
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
|
||||
this.handleByLeafKey.delete(retained.leafKey)
|
||||
}
|
||||
retained.leafKey = leafKey
|
||||
}
|
||||
this.handles.set(retained.handle, {
|
||||
handle: retained.handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
ptyId: leaf.ptyId,
|
||||
ptyGeneration: leaf.ptyGeneration
|
||||
})
|
||||
this.handleByLeafKey.set(leafKey, retained.handle)
|
||||
}
|
||||
|
||||
protected invalidatePtyIncarnationHandle(ptyId: string): void {
|
||||
const retained = this.handleByPtyIncarnation.get(ptyId)
|
||||
if (!retained) {
|
||||
return
|
||||
}
|
||||
this.handleByPtyIncarnation.delete(ptyId)
|
||||
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
|
||||
this.handleByLeafKey.delete(retained.leafKey)
|
||||
}
|
||||
this.handles.delete(retained.handle)
|
||||
this.syntheticTerminalHandles.delete(retained.handle)
|
||||
this.rejectWaitersForHandle(retained.handle, 'terminal_handle_stale')
|
||||
}
|
||||
|
||||
protected clearPtyIncarnationHandles(): void {
|
||||
for (const retained of this.handleByPtyIncarnation.values()) {
|
||||
this.syntheticTerminalHandles.delete(retained.handle)
|
||||
}
|
||||
this.handleByPtyIncarnation.clear()
|
||||
}
|
||||
|
||||
protected reconcilePtyIncarnationHandles(): void {
|
||||
for (const [ptyId, retained] of this.handleByPtyIncarnation) {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const leaves = this.getLeavesForPty(ptyId)
|
||||
if (
|
||||
!pty?.incarnationId ||
|
||||
pty.incarnationId !== retained.incarnationId ||
|
||||
leaves.length !== 1 ||
|
||||
this.handleByPtyId.has(ptyId)
|
||||
) {
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
continue
|
||||
}
|
||||
this.bindPtyIncarnationHandle(retained, leaves[0])
|
||||
}
|
||||
}
|
||||
|
||||
protected adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null {
|
||||
if (!leaf.ptyId) {
|
||||
return null
|
||||
}
|
||||
const preAllocated = this.handleByPtyId.get(leaf.ptyId)
|
||||
if (!preAllocated) {
|
||||
return null
|
||||
}
|
||||
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
this.handles.set(preAllocated, {
|
||||
handle: preAllocated,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
ptyId: leaf.ptyId,
|
||||
ptyGeneration: leaf.ptyGeneration
|
||||
})
|
||||
this.handleByLeafKey.set(leafKey, preAllocated)
|
||||
return preAllocated
|
||||
}
|
||||
|
||||
protected issuePtyHandle(pty: RuntimePtyWorktreeRecord): string {
|
||||
const existingHandle =
|
||||
this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
|
||||
if (existingHandle) {
|
||||
const existingRecord = this.handles.get(existingHandle)
|
||||
if (
|
||||
existingRecord &&
|
||||
existingRecord.runtimeId === this.runtimeId &&
|
||||
existingRecord.ptyId === pty.ptyId
|
||||
) {
|
||||
this.handleByPtyId.set(pty.ptyId, existingHandle)
|
||||
return existingHandle
|
||||
}
|
||||
}
|
||||
|
||||
const handle = existingHandle ?? `term_${randomUUID()}`
|
||||
if (!existingHandle) {
|
||||
this.syntheticTerminalHandles.add(handle)
|
||||
}
|
||||
const syntheticId = `pty:${pty.ptyId}`
|
||||
this.handles.set(handle, {
|
||||
handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: pty.worktreeId,
|
||||
tabId: syntheticId,
|
||||
leafId: syntheticId,
|
||||
ptyId: pty.ptyId,
|
||||
ptyGeneration: 0
|
||||
})
|
||||
this.handleByPtyId.set(pty.ptyId, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
protected findHandleForPtyRecord(ptyId: string): string | null {
|
||||
for (const [handle, record] of this.handles) {
|
||||
if (
|
||||
record.runtimeId === this.runtimeId &&
|
||||
record.ptyId === ptyId &&
|
||||
record.tabId.startsWith('pty:')
|
||||
) {
|
||||
return handle
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected refreshWritableFlags(): void {
|
||||
for (const leaf of this.leaves.values()) {
|
||||
leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null
|
||||
}
|
||||
}
|
||||
|
||||
protected invalidateLeafHandle(leafKey: string): void {
|
||||
const handle = this.handleByLeafKey.get(leafKey)
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
const record = this.handles.get(handle)
|
||||
if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) {
|
||||
this.handleByPtyIncarnation.delete(record.ptyId)
|
||||
}
|
||||
this.handleByLeafKey.delete(leafKey)
|
||||
this.handles.delete(handle)
|
||||
this.syntheticTerminalHandles.delete(handle)
|
||||
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
|
||||
}
|
||||
|
||||
protected adoptFirstPtyForLeafHandle(
|
||||
leafKey: string,
|
||||
ptyId: string | null,
|
||||
ptyGeneration: number
|
||||
): boolean {
|
||||
const handle = this.handleByLeafKey.get(leafKey)
|
||||
const record = handle ? this.handles.get(handle) : null
|
||||
if (!handle || !record || record.ptyId !== null || ptyId === null) {
|
||||
return false
|
||||
}
|
||||
this.handles.set(handle, { ...record, ptyId, ptyGeneration })
|
||||
return true
|
||||
}
|
||||
|
||||
protected rememberDetachedPreAllocatedLeaves(): void {
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) {
|
||||
// Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control survives renderer graph loss while the PTY is alive.
|
||||
this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import type { PtyControllerTerminalIdentity } from './runtime-pty-controller-contract'
|
||||
import type { PtyProcessInfo } from '../providers/types'
|
||||
|
||||
export function buildControllerTerminalIdentities(sessions: PtyProcessInfo[]): {
|
||||
controllerIdentityByPtyId: Map<string, PtyControllerTerminalIdentity>
|
||||
ptyIdByControllerHandle: Map<string, string>
|
||||
} {
|
||||
const controllerIdentityByPtyId = new Map<string, PtyControllerTerminalIdentity>()
|
||||
const ptyIdByControllerHandle = new Map<string, string>()
|
||||
const ambiguousControllerPtyIds = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
const handle = session.terminalHandle?.trim()
|
||||
const incarnationId = session.incarnationId?.trim()
|
||||
if (!handle?.startsWith('term_') || !incarnationId) {
|
||||
continue
|
||||
}
|
||||
const priorPtyId = ptyIdByControllerHandle.get(handle)
|
||||
if (priorPtyId && priorPtyId !== session.id) {
|
||||
ambiguousControllerPtyIds.add(priorPtyId)
|
||||
ambiguousControllerPtyIds.add(session.id)
|
||||
controllerIdentityByPtyId.delete(priorPtyId)
|
||||
continue
|
||||
}
|
||||
if (controllerIdentityByPtyId.has(session.id)) {
|
||||
ambiguousControllerPtyIds.add(session.id)
|
||||
controllerIdentityByPtyId.delete(session.id)
|
||||
continue
|
||||
}
|
||||
ptyIdByControllerHandle.set(handle, session.id)
|
||||
controllerIdentityByPtyId.set(session.id, {
|
||||
handle,
|
||||
incarnationId,
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {})
|
||||
})
|
||||
}
|
||||
for (const ptyId of ambiguousControllerPtyIds) {
|
||||
controllerIdentityByPtyId.delete(ptyId)
|
||||
}
|
||||
return { controllerIdentityByPtyId, ptyIdByControllerHandle }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './orca-runtime-persist-terminal-surface-retirements'
|
||||
import type {
|
||||
RuntimeMobileSessionBrowserTab,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import type { Tab } from '../../shared/tab-types'
|
||||
import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close'
|
||||
import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority'
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback'
|
||||
|
||||
export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements {
|
||||
// Why: headless serve backs browser panes with offscreen WebContents that live
|
||||
// only in the BrowserManager, never in a renderer graph. Without surfacing them
|
||||
// as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the
|
||||
// paired browser tab and closing it fails with tab_not_found. Synthesize browser
|
||||
// session tabs from the live bridge so they are first-class alongside terminals.
|
||||
protected buildHeadlessMobileSessionBrowserTabs(
|
||||
worktreeId: string
|
||||
): RuntimeMobileSessionBrowserTab[] {
|
||||
const serverTabs =
|
||||
this.offscreenBrowserBackend && this.agentBrowserBridge?.tabList
|
||||
? this.agentBrowserBridge.tabList(worktreeId).tabs
|
||||
: []
|
||||
const publishedServerTabs = serverTabs.map((tab) => {
|
||||
const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId)
|
||||
return {
|
||||
type: 'browser' as const,
|
||||
// Why: an offscreen page has no separate workspace identity, so the page id
|
||||
// is its own workspace id (matches the server's browserWorkspaceId fallback).
|
||||
id: tab.browserPageId,
|
||||
title: tab.title || tab.url || 'Browser',
|
||||
browserWorkspaceId: tab.browserPageId,
|
||||
browserPageId: tab.browserPageId,
|
||||
url: tab.url || 'about:blank',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: tab.loadError ?? undefined,
|
||||
certificateFailure: tab.certificateFailure ?? undefined,
|
||||
...(persistedProps ? { color: persistedProps.color } : {}),
|
||||
...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}),
|
||||
isActive: tab.active === true
|
||||
}
|
||||
})
|
||||
const publishedClientTabs = getRuntimeBrowserPageRegistry(this)
|
||||
.listPages(worktreeId)
|
||||
.map((page) => ({
|
||||
type: 'browser' as const,
|
||||
id: page.browserPageId,
|
||||
title: page.title || page.url || 'Browser',
|
||||
browserWorkspaceId: page.browserPageId,
|
||||
browserPageId: page.browserPageId,
|
||||
browserProfileId: page.browserProfileId,
|
||||
executionHostKey: page.executionHostKey,
|
||||
placement: page.placement,
|
||||
url: page.url,
|
||||
loading: page.loading,
|
||||
canGoBack: page.canGoBack,
|
||||
canGoForward: page.canGoForward,
|
||||
isActive: page.active
|
||||
}))
|
||||
return [...publishedServerTabs, ...publishedClientTabs]
|
||||
}
|
||||
|
||||
protected getPersistedUnifiedSessionTabProps(
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): Pick<Tab, 'color' | 'isPinned'> | null {
|
||||
const tab =
|
||||
this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find(
|
||||
(candidate) => candidate.id === tabId || candidate.entityId === tabId
|
||||
) ?? null
|
||||
return tab ? { color: tab.color, isPinned: tab.isPinned } : null
|
||||
}
|
||||
|
||||
protected commitHeadlessTerminalTabRetirement(
|
||||
worktreeId: string,
|
||||
parentTabId: string,
|
||||
options: { allowMissing?: boolean } = {}
|
||||
): string[] {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) {
|
||||
throw new Error('workspace_session_unavailable')
|
||||
}
|
||||
const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId)
|
||||
if (result.pinned) {
|
||||
throw new Error('terminal_tab_pinned')
|
||||
}
|
||||
if (!result.closed) {
|
||||
if (!options.allowMissing) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
}
|
||||
const persisted = result.closed
|
||||
? advanceTerminalTopologyRevision(result.session, worktreeId)
|
||||
: session
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, persisted)
|
||||
const staged = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
try {
|
||||
this.store.flushOrThrow()
|
||||
} catch (error) {
|
||||
const current = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (staged && current) {
|
||||
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
|
||||
if (rolledBack !== current) {
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, rolledBack)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return result.ptyIdsToKill
|
||||
}
|
||||
|
||||
protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void {
|
||||
const session = this.getWorkspaceSessionForWorktree(worktreeId)
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
|
||||
const tabs = session.tabsByWorktree[worktreeId] ?? []
|
||||
const reordered = [...tabs]
|
||||
.sort((a, b) => {
|
||||
const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt
|
||||
})
|
||||
.map((tab, index) => ({
|
||||
...tab,
|
||||
sortOrder: index
|
||||
}))
|
||||
this.setWorkspaceSessionForWorktree(worktreeId, {
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[worktreeId]: reordered
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void {
|
||||
if (this.mobileSessionTabListeners.size === 0) {
|
||||
return
|
||||
}
|
||||
const result = this.toMobileSessionTabsResult(snapshot)
|
||||
const changeSequence = ++this.mobileSessionTabsChangeSequence
|
||||
for (const subscription of this.mobileSessionTabListeners) {
|
||||
subscription.listener(
|
||||
this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId),
|
||||
changeSequence
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers one client's session-tabs question: whether this runtime has taken back *that* client's
|
||||
* client-hosted pages yet, then that client's own tab selection.
|
||||
*
|
||||
* The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a
|
||||
* frame built for one client can never carry another client's answer.
|
||||
*/
|
||||
protected projectMobileSessionTabsForClient(
|
||||
result: RuntimeMobileSessionTabsResult,
|
||||
clientNavigationId?: string
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return this.clientSessionTabSelections.project(
|
||||
this.withClientHostedPagesHold(result, clientNavigationId),
|
||||
clientNavigationId
|
||||
)
|
||||
}
|
||||
|
||||
protected withClientHostedPagesHold(
|
||||
result: RuntimeMobileSessionTabsResult,
|
||||
clientNavigationId: string | undefined
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now())
|
||||
}
|
||||
|
||||
protected async refreshMobileSessionPtyRecords(
|
||||
targetWorktreeId: string | null = null
|
||||
): Promise<Set<string> | null> {
|
||||
const inventory = await this.refreshMobileSessionPtyInventory(targetWorktreeId)
|
||||
return inventory ? new Set(inventory.livePtyIds) : null
|
||||
}
|
||||
|
||||
protected async refreshMobileSessionPtyInventory(
|
||||
targetWorktreeId: string | null = null
|
||||
): Promise<PtyControllerInventory | null> {
|
||||
// Targeted mobile polls must not queue behind an aggregate census that may
|
||||
// be waiting on an unrelated SSH provider.
|
||||
if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
|
||||
}
|
||||
if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
// Fleet-wide refreshes share one aggregate controller inventory.
|
||||
const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
// Why: reconnect exit bursts share one authoritative daemon inventory
|
||||
// instead of multiplying a full cross-generation list RPC per stale tab.
|
||||
const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => {
|
||||
if (this.pendingMobileSessionPtyAggregateInventoryRefresh === refresh) {
|
||||
this.pendingMobileSessionPtyAggregateInventoryRefresh = null
|
||||
}
|
||||
})
|
||||
this.pendingMobileSessionPtyAggregateInventoryRefresh = refresh
|
||||
return refresh
|
||||
}
|
||||
return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithGetPtyRecordForPaneKey } from './orca-runtime-get-pty-record-for-pane-key'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../shared/runtime-types'
|
||||
import { getLatestPtyTitle } from './runtime-worktree-status-projection'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { TerminalHandleRecord } from './runtime-terminal-contracts'
|
||||
import { readTerminalTail } from './terminal-tail-read'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPtyRecordForPaneKey {
|
||||
protected buildPtyTerminalSummary(
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
worktreesById: Map<string, ResolvedWorktree>
|
||||
): RuntimeTerminalSummary {
|
||||
const worktree = worktreesById.get(pty.worktreeId)
|
||||
|
||||
const title = getLatestPtyTitle(pty)
|
||||
const pane = parsePaneKey(pty.paneKey ?? '')
|
||||
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
|
||||
return {
|
||||
handle: this.issuePtyHandle(pty),
|
||||
ptyId: pty.ptyId,
|
||||
incarnationId: pty.incarnationId,
|
||||
orphaned,
|
||||
worktreeId: pty.worktreeId,
|
||||
worktreePath: worktree?.path ?? '',
|
||||
branch: worktree?.branch ?? '',
|
||||
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
|
||||
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
|
||||
title,
|
||||
connected: pty.connected,
|
||||
writable: pty.connected,
|
||||
lastOutputAt: pty.lastOutputAt,
|
||||
preview: pty.preview,
|
||||
...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}),
|
||||
...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId),
|
||||
...this.resolvePaneAgentIdentityField(
|
||||
pty.launchAgent,
|
||||
pty.foregroundAgent,
|
||||
title,
|
||||
pty.paneKey ?? null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected getLiveLeafForHandle(handle: string): {
|
||||
record: TerminalHandleRecord
|
||||
leaf: RuntimeLeafRecord
|
||||
} {
|
||||
this.assertGraphReady()
|
||||
const record = this.handles.get(handle)
|
||||
if (!record || record.runtimeId !== this.runtimeId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
if (record.rendererGraphEpoch !== this.rendererGraphEpoch) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
|
||||
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
|
||||
if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return { record, leaf }
|
||||
}
|
||||
|
||||
protected getLivePtyForHandle(handle: string): {
|
||||
record: TerminalHandleRecord
|
||||
pty: RuntimePtyWorktreeRecord
|
||||
} | null {
|
||||
let record = this.handles.get(handle)
|
||||
if (!record) {
|
||||
const ptyId = [...this.handleByPtyId.entries()].find(
|
||||
([, mappedHandle]) => mappedHandle === handle
|
||||
)?.[0]
|
||||
const pty = ptyId ? this.ptysById.get(ptyId) : null
|
||||
if (pty) {
|
||||
// Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity.
|
||||
this.issuePtyHandle(pty)
|
||||
record = this.handles.get(handle)
|
||||
}
|
||||
}
|
||||
if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) {
|
||||
return null
|
||||
}
|
||||
if (!record.ptyId) {
|
||||
return null
|
||||
}
|
||||
const pty = this.ptysById.get(record.ptyId)
|
||||
if (!pty || pty.ptyId !== record.ptyId) {
|
||||
return null
|
||||
}
|
||||
// Why: renderer adoption can race with CLI reads; keep ptyId → handle populated so summaries don't mint a second handle for the same terminal.
|
||||
this.handleByPtyId.set(record.ptyId, handle)
|
||||
return { record, pty }
|
||||
}
|
||||
|
||||
protected assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void {
|
||||
const runtimePty = this.getLivePtyForHandle(handle)
|
||||
if (runtimePty) {
|
||||
if (runtimePty.pty.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return
|
||||
}
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
if (leaf.ptyId !== expectedPtyId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
|
||||
protected readPtyTerminal(
|
||||
handle: string,
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
opts: { cursor?: number; limit?: number } = {}
|
||||
): RuntimeTerminalRead {
|
||||
return readTerminalTail({
|
||||
handle,
|
||||
status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown',
|
||||
previewLines: pty.tailBuffer,
|
||||
completedLines: pty.tailTranscriptBuffer,
|
||||
partialLine: pty.tailPartialLine,
|
||||
completedLineCount: pty.tailLinesTotal,
|
||||
bufferTruncated: pty.tailTruncated,
|
||||
cursor: opts.cursor,
|
||||
limit: opts.limit
|
||||
})
|
||||
}
|
||||
|
||||
protected issueHandle(leaf: RuntimeLeafRecord): string {
|
||||
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
const existingHandle = this.handleByLeafKey.get(leafKey)
|
||||
if (existingHandle) {
|
||||
const existingRecord = this.handles.get(existingHandle)
|
||||
if (
|
||||
existingRecord &&
|
||||
existingRecord.rendererGraphEpoch === this.rendererGraphEpoch &&
|
||||
existingRecord.ptyId === leaf.ptyId &&
|
||||
existingRecord.ptyGeneration === leaf.ptyGeneration
|
||||
) {
|
||||
return existingHandle
|
||||
}
|
||||
}
|
||||
|
||||
const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf)
|
||||
if (preAllocatedHandle) {
|
||||
return preAllocatedHandle
|
||||
}
|
||||
const incarnationId = leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null
|
||||
const retained = leaf.ptyId ? this.handleByPtyIncarnation.get(leaf.ptyId) : undefined
|
||||
if (retained && leaf.ptyId && retained.incarnationId !== incarnationId) {
|
||||
this.invalidatePtyIncarnationHandle(leaf.ptyId)
|
||||
} else if (retained) {
|
||||
this.bindPtyIncarnationHandle(retained, leaf)
|
||||
return retained.handle
|
||||
}
|
||||
const handle = `term_${randomUUID()}`
|
||||
this.syntheticTerminalHandles.add(handle)
|
||||
this.handles.set(handle, {
|
||||
handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
ptyId: leaf.ptyId,
|
||||
ptyGeneration: leaf.ptyGeneration
|
||||
})
|
||||
this.handleByLeafKey.set(leafKey, handle)
|
||||
if (leaf.ptyId && incarnationId) {
|
||||
this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey })
|
||||
}
|
||||
return handle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithSerializeTerminalBufferFromAvailableState } from './orca-runtime-serialize-terminal-buffer-from-available-state'
|
||||
import type { PtyProviderBufferSnapshot } from '../providers/types'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import type { RuntimeProviderSnapshotReadOptions } from './runtime-terminal-contracts'
|
||||
import {
|
||||
buildVisibleSnapshotReadFallback,
|
||||
shouldFallbackToVisibleTerminalSnapshot,
|
||||
terminalReadLimit
|
||||
} from './terminal-tail-read'
|
||||
import type { RuntimeTerminalProjection } from './orca-runtime-core'
|
||||
import { DEFAULT_TERMINAL_READ_LIMIT } from './terminal-tail-limits'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { projectTerminalTailLines } from './orca-runtime-terminal-projection'
|
||||
|
||||
export class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaRuntimeWithSerializeTerminalBufferFromAvailableState {
|
||||
protected async captureProviderTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number },
|
||||
generation: number
|
||||
): Promise<PtyProviderBufferSnapshot | null> {
|
||||
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
|
||||
if (!liveModeTrackers) {
|
||||
liveModeTrackers = new Set()
|
||||
this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers)
|
||||
}
|
||||
liveModeTrackers.add(liveModeTracker)
|
||||
try {
|
||||
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
|
||||
// Mobile still needs their retained history without navigating desktop.
|
||||
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
|
||||
if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) {
|
||||
return null
|
||||
}
|
||||
const snapshotModeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
if (typeof snapshot.alternateScreen === 'boolean') {
|
||||
snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
} else {
|
||||
// Why: older providers omit mode metadata, but their ANSI snapshot
|
||||
// still carries the DECSET/DECRST needed to classify the active screen.
|
||||
snapshotModeTracker.scanReplay(snapshot.data)
|
||||
}
|
||||
const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch
|
||||
let effectiveAlternateScreen: boolean | undefined
|
||||
if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
const modeTracker = new TerminalKittyKeyboardModeTracker()
|
||||
if (observedSnapshotMode) {
|
||||
modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
}
|
||||
// Why: stream bytes received after the request began can be newer
|
||||
// than snapshot metadata, so an observed live transition wins.
|
||||
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
|
||||
}
|
||||
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
|
||||
effectiveAlternateScreen = modeTracker.isAlternateScreen
|
||||
}
|
||||
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
|
||||
const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, {
|
||||
...snapshot,
|
||||
seq: providerOffset + snapshot.seq,
|
||||
...(effectiveAlternateScreen !== undefined
|
||||
? { alternateScreen: effectiveAlternateScreen }
|
||||
: {})
|
||||
})
|
||||
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
|
||||
this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot)
|
||||
}
|
||||
return reconciledSnapshot
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
liveModeTrackers.delete(liveModeTracker)
|
||||
if (liveModeTrackers.size === 0) {
|
||||
this.providerModeSnapshotScansByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async withVisibleSnapshotFallback(
|
||||
ptyId: string,
|
||||
read: RuntimeTerminalRead,
|
||||
opts: { cursor?: number; limit?: number } = {},
|
||||
providerSnapshot: RuntimeProviderSnapshotReadOptions = {}
|
||||
): Promise<RuntimeTerminalRead> {
|
||||
if (typeof opts.cursor === 'number') {
|
||||
return read
|
||||
}
|
||||
const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts)
|
||||
const recoveredWorkerFallback =
|
||||
read.tail.length === 0 && this.legacyWorkerRecovery.hasRecoveredPty(ptyId)
|
||||
// Why: a live daemon session no pane ever attached has ingested zero bytes,
|
||||
// so only the provider holds its screen. Unprovable state stays empty.
|
||||
const neverAttachedProviderFallback =
|
||||
read.tail.length === 0 &&
|
||||
!recoveredWorkerFallback &&
|
||||
this.isKnownUnattachedLocalDaemonPty(ptyId)
|
||||
if (recoveredWorkerFallback || neverAttachedProviderFallback) {
|
||||
const providerProjection = await this.readProviderTerminalTailLines(
|
||||
ptyId,
|
||||
opts.limit,
|
||||
providerSnapshot
|
||||
)
|
||||
if (providerProjection.lines.length > 0) {
|
||||
return buildVisibleSnapshotReadFallback(
|
||||
read,
|
||||
providerProjection.lines,
|
||||
opts.limit,
|
||||
providerProjection.draft
|
||||
)
|
||||
}
|
||||
}
|
||||
const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId)
|
||||
const providerModeUnknown =
|
||||
this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId)
|
||||
if (
|
||||
!blankFallback &&
|
||||
!recoveredWorkerFallback &&
|
||||
!providerModeUnknown &&
|
||||
!knownAlternateScreen &&
|
||||
!this.headlessTerminals.has(ptyId)
|
||||
) {
|
||||
return read
|
||||
}
|
||||
const visibleState = await this.readVisibleTerminalState(ptyId)
|
||||
if (
|
||||
!blankFallback &&
|
||||
!recoveredWorkerFallback &&
|
||||
!knownAlternateScreen &&
|
||||
!visibleState?.isAlternateScreen
|
||||
) {
|
||||
return read
|
||||
}
|
||||
let projection: RuntimeTerminalProjection = visibleState ?? { lines: [] }
|
||||
if (projection.lines.length === 0) {
|
||||
projection = await this.readRendererVisibleSnapshotLines(ptyId)
|
||||
}
|
||||
if (projection.lines.length === 0) {
|
||||
return read
|
||||
}
|
||||
return buildVisibleSnapshotReadFallback(read, projection.lines, opts.limit, projection.draft)
|
||||
}
|
||||
|
||||
protected async readProviderTerminalTailLines(
|
||||
ptyId: string,
|
||||
limit: number | undefined,
|
||||
snapshotOptions: RuntimeProviderSnapshotReadOptions = {}
|
||||
): Promise<RuntimeTerminalProjection> {
|
||||
const generation = this.getPtyLifecycleGeneration(ptyId)
|
||||
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
|
||||
const snapshot = await this.serializeProviderTerminalBuffer(
|
||||
ptyId,
|
||||
{ scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit },
|
||||
snapshotOptions
|
||||
)
|
||||
if (!snapshot) {
|
||||
return { lines: [] }
|
||||
}
|
||||
// Why: a cached acquisition can carry scrollback this caller did not ask for,
|
||||
// so visible-only reads parse the grid itself rather than trusting the request.
|
||||
if (snapshotOptions.visibleScreenOnly) {
|
||||
const projection = await this.parseVisibleSnapshot(snapshot)
|
||||
// Live bytes ordered after the provider frame make that frame stale.
|
||||
return this.getPtyLifecycleGeneration(ptyId) === generation &&
|
||||
this.getPtyOutputSequence(ptyId) <= snapshot.seq
|
||||
? projection
|
||||
: { lines: [] }
|
||||
}
|
||||
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
|
||||
if (data.length === 0) {
|
||||
return { lines: [] }
|
||||
}
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
scrollback: lineLimit
|
||||
})
|
||||
try {
|
||||
await emulator.write(data)
|
||||
const projection = projectTerminalTailLines(emulator, lineLimit)
|
||||
return this.getPtyLifecycleGeneration(ptyId) === generation &&
|
||||
this.getPtyOutputSequence(ptyId) <= snapshot.seq
|
||||
? projection
|
||||
: { lines: [] }
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab'
|
||||
import type {
|
||||
RuntimeMobileSessionTabMove,
|
||||
RuntimeMobileSessionTabMoveResult,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import { buildHeadlessMobileSessionTabGroups } from './mobile-session-layout-projection'
|
||||
import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types'
|
||||
|
||||
export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab {
|
||||
protected closeHeadlessMobileTerminalTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
options: {
|
||||
allowMissingPersistedTab?: boolean
|
||||
killPtys?: boolean
|
||||
authorizedPty?: RuntimePtyWorktreeRecord
|
||||
} = {}
|
||||
): void {
|
||||
const closedParentTabId = tab.parentTabId
|
||||
const retirementProofs = snapshot.tabs.flatMap((candidate) => {
|
||||
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
|
||||
return []
|
||||
}
|
||||
const proof = this.getMobileSessionTerminalRetirementProof(
|
||||
worktreeId,
|
||||
candidate,
|
||||
options.authorizedPty
|
||||
)
|
||||
return proof ? [proof] : []
|
||||
})
|
||||
const projectedPtyIds = this.commitHeadlessTerminalTabRetirement(
|
||||
worktreeId,
|
||||
closedParentTabId,
|
||||
{ allowMissing: options.allowMissingPersistedTab }
|
||||
)
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
|
||||
if (options.authorizedPty) {
|
||||
options.authorizedPty.runtimeSessionOwned = false
|
||||
this.setPairedRendererSessionOwnership(options.authorizedPty.ptyId, false)
|
||||
}
|
||||
// Why: local provider ids can be reused after restart, so a dormant
|
||||
// persisted id is not kill authority. SSH relay ids remain durable exact
|
||||
// identities even before pane metadata reconnects.
|
||||
const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId)))
|
||||
for (const candidate of snapshot.tabs) {
|
||||
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
|
||||
continue
|
||||
}
|
||||
const authorizedPty =
|
||||
options.authorizedPty &&
|
||||
this.getMobileTerminalLeafPtyIds(candidate).includes(options.authorizedPty.ptyId)
|
||||
? options.authorizedPty
|
||||
: null
|
||||
const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) ?? authorizedPty
|
||||
const ptyId = livePty?.ptyId ?? candidate.ptyId
|
||||
const hasOtherOwner = snapshot.tabs.some(
|
||||
(other) =>
|
||||
other.type === 'terminal' &&
|
||||
other.parentTabId !== closedParentTabId &&
|
||||
other.ptyId === ptyId
|
||||
)
|
||||
if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) {
|
||||
// Why: a live serve leaf can exist before its debounced binding reaches
|
||||
// persistence. Include it from the authoritative snapshot so split
|
||||
// close cannot leave a provider process behind.
|
||||
ptyIdsToKill.add(ptyId)
|
||||
}
|
||||
}
|
||||
if (options.killPtys !== false) {
|
||||
for (const ptyId of ptyIdsToKill) {
|
||||
this.ptyController?.kill(ptyId)
|
||||
}
|
||||
}
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => {
|
||||
if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
tabGroups: buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId,
|
||||
nextTabs,
|
||||
active,
|
||||
snapshot.tabGroups
|
||||
),
|
||||
...(retirementProofs.length > 0
|
||||
? {
|
||||
retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs(
|
||||
snapshot.retiredTerminalSurfaces,
|
||||
retirementProofs
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
tabs: nextTabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
async moveMobileSessionTab(
|
||||
worktreeSelector: string,
|
||||
move: RuntimeMobileSessionTabMove
|
||||
): Promise<RuntimeMobileSessionTabMoveResult> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
if (!this.notifier?.moveSessionTab) {
|
||||
return this.moveHeadlessMobileSessionTab(worktreeId, snapshot, move)
|
||||
}
|
||||
const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId)
|
||||
if (!hostTabId) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
const publicSnapshot = this.toMobileSessionTabsResult(snapshot)
|
||||
const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId)
|
||||
if (!targetGroup) {
|
||||
throw new Error('target_group_not_found')
|
||||
}
|
||||
|
||||
// Why: web clients address terminal surfaces as tab::leaf, while desktop
|
||||
// tab grouping is owned by the outer terminal tab id.
|
||||
if (move.kind === 'reorder') {
|
||||
const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder)
|
||||
if (!tabOrder.includes(hostTabId)) {
|
||||
throw new Error('invalid_tab_order')
|
||||
}
|
||||
this.notifier.moveSessionTab(worktreeId, {
|
||||
...move,
|
||||
tabId: hostTabId,
|
||||
tabOrder
|
||||
})
|
||||
return { moved: true }
|
||||
}
|
||||
this.notifier.moveSessionTab(worktreeId, {
|
||||
...move,
|
||||
tabId: hostTabId
|
||||
})
|
||||
return { moved: true }
|
||||
}
|
||||
|
||||
// Why: pane geometry inside a tab (split ratios, expanded pane, pane titles)
|
||||
// is host-authoritative for remote-server tabs but had no push path, so a
|
||||
// client divider-drag / expand / pane-rename reverted on the next snapshot.
|
||||
// Persist the structural fields onto the tab's layout, keeping host-owned
|
||||
// pty bindings and active leaf.
|
||||
async updateMobileSessionPaneLayout(
|
||||
worktreeSelector: string,
|
||||
args: {
|
||||
tabId: string
|
||||
root: TerminalPaneLayoutNode | null
|
||||
expandedLeafId: string | null
|
||||
titlesByLeafId?: Record<string, string>
|
||||
}
|
||||
): Promise<{ updated: true }> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
// Why: when a renderer is authoritative (desktop host reached via shared
|
||||
// control), it owns pane geometry and republishes it — a headless write here
|
||||
// would be overwritten and could fight the renderer. Persist only headlessly.
|
||||
if (this.getAvailableAuthoritativeWindow()) {
|
||||
return { updated: true }
|
||||
}
|
||||
// Why: resolve to the host tab id (older/raw-id clients) so the persisted
|
||||
// layout entry matches, matching setMobileSessionTabProps.
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const hostTabId = snapshot
|
||||
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
|
||||
: args.tabId
|
||||
const resolvedArgs = { ...args, tabId: hostTabId }
|
||||
const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs)
|
||||
if (acceptedLayout) {
|
||||
this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, {
|
||||
tabId: hostTabId,
|
||||
root: acceptedLayout.root,
|
||||
expandedLeafId: acceptedLayout.expandedLeafId,
|
||||
...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {})
|
||||
})
|
||||
}
|
||||
return { updated: true }
|
||||
}
|
||||
|
||||
// Why: tab color/pin are host-authoritative for remote-server tabs but had no
|
||||
// push path, so pinning or coloring a tab reverted on the next snapshot and
|
||||
// was never persisted. Persist to the workspace session + live snapshot.
|
||||
async setMobileSessionTabProps(
|
||||
worktreeSelector: string,
|
||||
args: {
|
||||
tabId: string
|
||||
color?: string | null
|
||||
isPinned?: boolean
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
}
|
||||
): Promise<{ updated: true }> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
// Why: a renderer-authoritative host owns + republishes tab props, so a
|
||||
// headless write would be overwritten. Persist only when headless.
|
||||
if (this.getAvailableAuthoritativeWindow()) {
|
||||
return { updated: true }
|
||||
}
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const hostTabId = snapshot
|
||||
? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId)
|
||||
: args.tabId
|
||||
this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args)
|
||||
this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args)
|
||||
return { updated: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose } from './orca-runtime-refuse-unattributed-mobile-session-tab-close'
|
||||
import type {
|
||||
RuntimeMobileSessionTerminalTab,
|
||||
RuntimeSessionTabCloseReason
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records'
|
||||
import {
|
||||
adjudicateAbsentMobileSessionTabClose,
|
||||
resolveMobileSessionLifecycleCloseContext,
|
||||
type MobileSessionLifecycleCloseHost
|
||||
} from './mobile-session-lifecycle-close-adjudication'
|
||||
import type { MobileSessionTabCloseOutcome } from './mobile-session-tab-close-outcome'
|
||||
import {
|
||||
committedMobileSessionTabClose,
|
||||
delegatedMobileSessionTabClose,
|
||||
refusedMobileSessionTabClose
|
||||
} from './mobile-session-tab-close-outcome'
|
||||
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
|
||||
|
||||
export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
|
||||
async closeMobileSessionTab(
|
||||
worktreeSelector: string,
|
||||
tabId: string,
|
||||
options: {
|
||||
reason?: RuntimeSessionTabCloseReason
|
||||
expectedPublicationEpoch?: string
|
||||
expectedTerminalHandle?: string
|
||||
clientNavigationId?: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority
|
||||
} = {}
|
||||
): Promise<MobileSessionTabCloseOutcome> {
|
||||
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
|
||||
if (graphEpoch !== null) {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
}
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId)
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) {
|
||||
// Why: keep-on-unknown must also restore the mirror the caller already pruned.
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return refusedMobileSessionTabClose('unknown-liveness', {
|
||||
snapshotRepublished: Boolean(snapshot)
|
||||
})
|
||||
}
|
||||
if (
|
||||
options.expectedPublicationEpoch !== undefined &&
|
||||
snapshot?.publicationEpoch !== options.expectedPublicationEpoch
|
||||
) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return refusedMobileSessionTabClose('stale-publication', {
|
||||
snapshotRepublished: Boolean(snapshot)
|
||||
})
|
||||
}
|
||||
const ptyCloseAuthority = options.expectedPtyCloseAuthority
|
||||
? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority)
|
||||
: null
|
||||
const tab = options.expectedPtyCloseAuthority
|
||||
? ptyCloseAuthority?.surface.tab
|
||||
: (snapshot?.tabs.find((candidate) => candidate.id === tabId) ??
|
||||
snapshot?.tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId
|
||||
) ??
|
||||
snapshot?.tabs.find(
|
||||
(candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId
|
||||
))
|
||||
const lifecycleClose = resolveMobileSessionLifecycleCloseContext({
|
||||
host: this.getMobileSessionLifecycleCloseHost(),
|
||||
worktreeId,
|
||||
tabId,
|
||||
tab,
|
||||
authorityTab: ptyCloseAuthority?.surface.tab,
|
||||
snapshot,
|
||||
observedPtyIds
|
||||
})
|
||||
if (!snapshot || !tab) {
|
||||
return adjudicateAbsentMobileSessionTabClose({
|
||||
host: this.getMobileSessionLifecycleCloseHost(),
|
||||
context: lifecycleClose,
|
||||
worktreeId,
|
||||
snapshot,
|
||||
reason: options.reason,
|
||||
addressedByPtyCloseAuthority: options.expectedPtyCloseAuthority !== undefined
|
||||
})
|
||||
}
|
||||
if (options.expectedTerminalHandle !== undefined) {
|
||||
const terminalIncarnationMatches =
|
||||
tab.type === 'terminal' &&
|
||||
snapshot.tabs.some(
|
||||
(candidate) =>
|
||||
candidate.type === 'terminal' &&
|
||||
candidate.parentTabId === tab.parentTabId &&
|
||||
this.getMobileSessionTerminalHandle(worktreeId, candidate) ===
|
||||
options.expectedTerminalHandle
|
||||
)
|
||||
if (!terminalIncarnationMatches) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
return refusedMobileSessionTabClose('stale-terminal', {
|
||||
snapshotRepublished: true
|
||||
})
|
||||
}
|
||||
}
|
||||
let closedSelectionTabIds = [tab.id]
|
||||
const finishCommittedClose = (): MobileSessionTabCloseOutcome =>
|
||||
committedMobileSessionTabClose(
|
||||
this.clientSessionTabSelections,
|
||||
worktreeId,
|
||||
closedSelectionTabIds
|
||||
)
|
||||
if (tab.type === 'terminal') {
|
||||
const parentLeafCount = snapshot.tabs.filter(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
).length
|
||||
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
|
||||
if (closingWholeParent) {
|
||||
closedSelectionTabIds = snapshot.tabs.flatMap((candidate) =>
|
||||
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
? [candidate.id, candidate.parentTabId]
|
||||
: []
|
||||
)
|
||||
}
|
||||
// Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"),
|
||||
// not authorization to kill. Every destructive branch below can take the
|
||||
// whole parent down, so any live PTY under the parent means the echo is a
|
||||
// transport artifact: refuse the close and republish the snapshot so the
|
||||
// echoing client re-syncs and re-attaches. A reasonless close keeps
|
||||
// legacy behavior — old clients send user closes without the field.
|
||||
if (options.reason !== undefined && options.reason !== 'user') {
|
||||
const leafHasConnectedPty = lifecycleClose.leafHasConnectedPty
|
||||
if (lifecycleClose.parentLeaves.some(leafHasConnectedPty)) {
|
||||
// Why: when the echo addresses a dead leaf under a live sibling we
|
||||
// still refuse (every reachable close path below destroys the whole
|
||||
// parent, live sibling included) but skip the republish — re-adding
|
||||
// the dead leaf on the echoing client would feed an endless
|
||||
// refuse→republish→re-echo cycle.
|
||||
const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab)
|
||||
if (!addressedDeadLeaf) {
|
||||
this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
}
|
||||
// Why: both markers are skew-safe; clients must restore a mirror only
|
||||
// when the host actually republished it, not for a dead leaf.
|
||||
return refusedMobileSessionTabClose('live-host-pty', {
|
||||
snapshotRepublished: !addressedDeadLeaf
|
||||
})
|
||||
}
|
||||
if (!closingWholeParent || this.tabs.has(tab.parentTabId)) {
|
||||
// Why: only the renderer may retire its own tab or split leaf; a
|
||||
// remote lifecycle echo must never cross that boundary into a kill.
|
||||
return refusedMobileSessionTabClose('retirement-owner')
|
||||
}
|
||||
}
|
||||
// Why: a runtime-owned headless tab is absent from renderer state, so the
|
||||
// closeTerminalTab relay below would ack success without killing its PTY,
|
||||
// and syncMobileSessionTabs would republish the "closed" tab. Only bypass
|
||||
// the relay when no renderer owns the parent: an adopted tab needs the
|
||||
// renderer's live pin guard and durable close transaction.
|
||||
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
|
||||
allowMissingPersistedTab: Boolean(ptyCloseAuthority),
|
||||
killPtys:
|
||||
options.localPtyTeardownOwnedExternally !== true &&
|
||||
(options.reason === undefined || options.reason === 'user'),
|
||||
...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {})
|
||||
})
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
return finishCommittedClose()
|
||||
}
|
||||
if (closingWholeParent && this.notifier?.closeTerminalTab) {
|
||||
// Why: whole-tab close is a lifecycle transaction. The renderer reply
|
||||
// arrives only after canonical retirement and a forced session flush.
|
||||
const win = this.getAvailableAuthoritativeWindow()
|
||||
if (win?.webContents.isDestroyed?.()) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const releasePublicationThrottle =
|
||||
options.clientNavigationId && win
|
||||
? this.rendererPublicationThrottle.acquire(win.webContents)
|
||||
: () => {}
|
||||
try {
|
||||
await (options.localPtyTeardownOwnedExternally
|
||||
? this.notifier.closeTerminalTab(tab.parentTabId, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
: this.notifier.closeTerminalTab(tab.parentTabId))
|
||||
} finally {
|
||||
releasePublicationThrottle()
|
||||
}
|
||||
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const remainingTab = remainingSnapshot?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
)
|
||||
if (
|
||||
remainingSnapshot &&
|
||||
remainingTab &&
|
||||
this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab)
|
||||
) {
|
||||
const remainingPtyCloseAuthority = options.expectedPtyCloseAuthority
|
||||
? this.resolvePtyTabCloseSurfaceAuthority(options.expectedPtyCloseAuthority)
|
||||
: null
|
||||
// Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface.
|
||||
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, {
|
||||
// Why: the renderer may already have durably removed the tab before acknowledging.
|
||||
allowMissingPersistedTab: true,
|
||||
...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {})
|
||||
})
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
}
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
|
||||
return finishCommittedClose()
|
||||
}
|
||||
// Why: notifier implementations without the acknowledged relay may expose
|
||||
// only raw pane close. Runtime-owned parents still need de-persist + kill.
|
||||
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
|
||||
this.closeHeadlessMobileTerminalTab(
|
||||
worktreeId,
|
||||
snapshot,
|
||||
tab,
|
||||
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
|
||||
)
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
return finishCommittedClose()
|
||||
}
|
||||
if (!this.notifier?.closeTerminal) {
|
||||
this.closeHeadlessMobileTerminalTab(
|
||||
worktreeId,
|
||||
snapshot,
|
||||
tab,
|
||||
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
|
||||
)
|
||||
return finishCommittedClose()
|
||||
}
|
||||
if (tab.id === tabId) {
|
||||
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
|
||||
if (pty) {
|
||||
if (this.ptyController?.kill(pty.ptyId) !== true) {
|
||||
throw new Error('terminal_close_failed')
|
||||
}
|
||||
return finishCommittedClose()
|
||||
}
|
||||
this.notifier.closeTerminal(tab.parentTabId)
|
||||
return delegatedMobileSessionTabClose()
|
||||
}
|
||||
// Why: paired web tab bars represent a split terminal with one local
|
||||
// parent tab id. Closing that parent should close the desktop tab, not
|
||||
// just whichever leaf happened to be first in the session snapshot.
|
||||
this.notifier.closeTerminal(tab.parentTabId)
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
|
||||
return delegatedMobileSessionTabClose()
|
||||
} else if (tab.type === 'browser') {
|
||||
// Why: a browser tab can be hosted by a client, by the offscreen backend,
|
||||
// or by the renderer; each surface owns a different retirement path.
|
||||
const clientPage = tab.browserPageId
|
||||
? getRuntimeBrowserPageRegistry(this).getPage(tab.browserPageId)
|
||||
: undefined
|
||||
if (clientPage) {
|
||||
await (this as RuntimeCommandSurfaceHost<this>).browserTabClose({
|
||||
worktree: `id:${worktreeId}`,
|
||||
page: clientPage.browserPageId
|
||||
})
|
||||
} else if (this.isOffscreenMobileSessionBrowserTab(snapshot, tab)) {
|
||||
await this.offscreenBrowserBackend!.closeTab(tab.browserPageId!).catch(() => {})
|
||||
this.retireRuntimeOwnedBrowserSessionTab(worktreeId, tab.browserPageId!)
|
||||
} else {
|
||||
if (!this.notifier?.closeSessionTab) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
await this.notifier.closeSessionTab(tab.id, worktreeId)
|
||||
}
|
||||
} else if (tab.type === 'agent-session') {
|
||||
if (this.notifier?.closeSessionTab) {
|
||||
try {
|
||||
await this.notifier.closeSessionTab(
|
||||
structuredAgentSessionTabId(tab.sessionId),
|
||||
worktreeId
|
||||
)
|
||||
} catch (error) {
|
||||
// The renderer already having removed the tab is an idempotent close, not a veto.
|
||||
if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab)
|
||||
} else {
|
||||
if (!this.notifier?.closeSessionTab) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
await this.notifier.closeSessionTab(tab.id, worktreeId)
|
||||
}
|
||||
return finishCommittedClose()
|
||||
}
|
||||
|
||||
protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost {
|
||||
return {
|
||||
tabs: this.tabs,
|
||||
leaves: this.leaves,
|
||||
ptysById: this.ptysById,
|
||||
findPtyForMobileTerminalTab: (worktreeId, tab) =>
|
||||
this.findPtyForMobileTerminalTab(worktreeId, tab),
|
||||
republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithCloseMobileSessionTab } from './orca-runtime-close-mobile-session-tab'
|
||||
import type {
|
||||
RuntimeMobileSessionAgentTab,
|
||||
RuntimeMobileSessionBrowserTab,
|
||||
RuntimeMobileSessionRetiredTerminalSurface,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge'
|
||||
import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication'
|
||||
import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
|
||||
export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab {
|
||||
protected async closeStructuredAgentSessionTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionAgentTab
|
||||
): Promise<void> {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (typeof host?.setSessionTabVisibility === 'function') {
|
||||
await host.setSessionTabVisibility(tab.sessionId, false)
|
||||
}
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id)
|
||||
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
tabGroups: (snapshot.tabGroups ?? []).map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => id !== tab.id),
|
||||
activeTabId: group.activeTabId === tab.id ? null : group.activeTabId,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id)
|
||||
})),
|
||||
tabs: nextTabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
// Why: a refused echoed close means the echoing client already pruned its
|
||||
// local mirror. Bump the version and emit the unchanged snapshot so clients
|
||||
// that dedupe by snapshotVersion re-add and re-attach the still-live tab.
|
||||
protected republishMobileSessionTabsSnapshot(worktreeId: string): void {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (snapshot) {
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1
|
||||
})
|
||||
}
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
}
|
||||
|
||||
protected getMobileSessionTerminalHandle(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): string | null {
|
||||
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
|
||||
if (!pty) {
|
||||
return null
|
||||
}
|
||||
return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
|
||||
}
|
||||
|
||||
protected getMobileSessionTerminalRetirementProof(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
authorizedPty?: RuntimePtyWorktreeRecord
|
||||
): RuntimeMobileSessionRetiredTerminalSurface | null {
|
||||
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) ?? authorizedPty ?? null
|
||||
if (!pty || !this.getMobileTerminalLeafPtyIds(tab).includes(pty.ptyId)) {
|
||||
return null
|
||||
}
|
||||
const terminal = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId)
|
||||
if (!terminal) {
|
||||
return null
|
||||
}
|
||||
const incarnationId =
|
||||
pty.incarnationId ??
|
||||
this.getWorkspaceSessionForWorktree(worktreeId)?.terminalPtyIncarnationsByPaneKey?.[
|
||||
this.getMobileTerminalPaneKey(tab)
|
||||
]
|
||||
return {
|
||||
parentTabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
ptyId: pty.ptyId,
|
||||
terminal,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
protected notifyRendererOfHeadlessTerminalClose(parentTabId: string): void {
|
||||
// Why: this relay is advisory after main owns teardown; renderer failure must
|
||||
// not prevent the authoritative session flush or turn the close into failure.
|
||||
try {
|
||||
this.notifier?.closeTerminal(parentTabId)
|
||||
} catch (error) {
|
||||
console.warn('[runtime] failed to notify renderer after headless terminal close', {
|
||||
parentTabId,
|
||||
error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected isOffscreenMobileSessionBrowserTab(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionBrowserTab
|
||||
): boolean {
|
||||
if (!this.offscreenBrowserBackend || !tab.browserPageId) {
|
||||
return false
|
||||
}
|
||||
if (this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch)) {
|
||||
return true
|
||||
}
|
||||
const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree)
|
||||
return (
|
||||
snapshot.publicationEpoch.includes(':headless-merge:') &&
|
||||
accepted !== undefined &&
|
||||
!getMobileSessionSnapshotTabIdentityKeys(tab).some((id) =>
|
||||
accepted.rendererTabIdentityKeys.has(id)
|
||||
) &&
|
||||
this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)
|
||||
)
|
||||
}
|
||||
|
||||
// Public so runtime-side page release (lease fencing) can prune a tab whose page is gone.
|
||||
retireRuntimeOwnedBrowserSessionTab(worktreeId: string, browserPageId: string): boolean {
|
||||
// Why: before the snapshot guard — worktree removal drops the snapshot first, and the host
|
||||
// rows for its client pages would otherwise be stranded on screen with nothing to retract them.
|
||||
this.clientHostedBrowserRows.publish(worktreeId)
|
||||
this.persistClientHostedBrowserPagesForWorktree(worktreeId)
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
const retiredTab = snapshot.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionBrowserTab =>
|
||||
candidate.type === 'browser' && candidate.browserPageId === browserPageId
|
||||
)
|
||||
if (!retiredTab) {
|
||||
return false
|
||||
}
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== retiredTab.id)
|
||||
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
tabGroups: (snapshot.tabGroups ?? []).map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => id !== retiredTab.id),
|
||||
activeTabId: group.activeTabId === retiredTab.id ? null : group.activeTabId
|
||||
})),
|
||||
tabs: nextTabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
protected markHeadlessBrowserSessionTabActive(
|
||||
worktreeId: string | undefined,
|
||||
browserPageId: string,
|
||||
options: BrowserSessionTabSelectionOptions
|
||||
): void {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const { targetGroupId, focusesHost } = options
|
||||
// Why: client-placed pages publish through the page registry and need no offscreen backing.
|
||||
if (
|
||||
!this.offscreenBrowserBackend &&
|
||||
!getRuntimeBrowserPageRegistry(this).getPage(browserPageId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Hydrate first so the freshly created browser tab is present in the snapshot.
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const tab = snapshot?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionBrowserTab =>
|
||||
candidate.type === 'browser' && candidate.browserPageId === browserPageId
|
||||
)
|
||||
if (!snapshot || !tab) {
|
||||
return
|
||||
}
|
||||
const {
|
||||
snapshot: nextSnapshot,
|
||||
groups: nextGroups,
|
||||
placedInTargetGroup
|
||||
} = applyBrowserSessionTabSelection({
|
||||
snapshot,
|
||||
tabId: tab.id,
|
||||
...(targetGroupId !== undefined ? { targetGroupId } : {}),
|
||||
focusesHost,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`
|
||||
})
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
// Why: browser group membership is otherwise live-only; persist it so a
|
||||
// later rebuild keeps the browser in its group instead of coalescing left.
|
||||
if (placedInTargetGroup && nextSnapshot.tabGroupLayout) {
|
||||
this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout)
|
||||
}
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
if (options.caller) {
|
||||
// Why: the originating device still lands on the tab it just created; only the shared
|
||||
// snapshot stayed put. Local creates keep the pre-navigation shape by having no caller.
|
||||
this.applyMobileSessionTabNavigation(
|
||||
this.getMobileSessionTabsForWorktree(worktreeId),
|
||||
tab.id,
|
||||
options.caller.navigation,
|
||||
options.caller.clientNavigationId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithSyncWindowGraph } from './orca-runtime-sync-window-graph'
|
||||
import type { RuntimeMobileSessionTabsResult, RuntimeSyncedTab } from '../../shared/runtime-types'
|
||||
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
|
||||
import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph {
|
||||
// Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and
|
||||
// this.leaves, so any tab/leaf delta a graph sync installs can flip the
|
||||
// client payload (pending-handle → ready, tab title) with zero change to the
|
||||
// stored snapshot. Compare exactly the projection-relevant fields and report
|
||||
// the affected worktrees; false positives only cost a coalesced no-op emit.
|
||||
protected collectMobileVisibleGraphChangedWorktrees(
|
||||
previousTabs: Map<string, RuntimeSyncedTab>,
|
||||
previousLeaves: Map<string, RuntimeLeafRecord>
|
||||
): Set<string> {
|
||||
const changed = new Set<string>()
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
const prev = previousTabs.get(tabId)
|
||||
if (!prev || prev.title !== tab.title) {
|
||||
changed.add(tab.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [tabId, tab] of previousTabs) {
|
||||
if (!this.tabs.has(tabId)) {
|
||||
changed.add(tab.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [leafKey, leaf] of this.leaves) {
|
||||
const prev = previousLeaves.get(leafKey)
|
||||
if (
|
||||
!prev ||
|
||||
prev.ptyId !== leaf.ptyId ||
|
||||
prev.connected !== leaf.connected ||
|
||||
prev.paneTitle !== leaf.paneTitle
|
||||
) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const [leafKey, leaf] of previousLeaves) {
|
||||
if (!this.leaves.has(leafKey)) {
|
||||
changed.add(leaf.worktreeId)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
async listMobileSessionTabs(
|
||||
worktreeSelector: string,
|
||||
clientNavigationId?: string
|
||||
): Promise<RuntimeMobileSessionTabsResult> {
|
||||
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
|
||||
if (explicitWorktreeId) {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, {
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
})
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
|
||||
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId)
|
||||
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId)
|
||||
}
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
})
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id)
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id)
|
||||
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId)
|
||||
}
|
||||
|
||||
async listAllMobileSessionTabs(
|
||||
clientNavigationId?: string
|
||||
): Promise<RuntimeMobileSessionTabsResult[]> {
|
||||
return (await this.listAllMobileSessionTabsWithChangeSequence(clientNavigationId)).snapshots
|
||||
}
|
||||
|
||||
async listAllMobileSessionTabsWithChangeSequence(clientNavigationId?: string): Promise<{
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
changeSequence: number
|
||||
}> {
|
||||
const inventory = await this.collectAllMobileSessionTabs(clientNavigationId)
|
||||
return { snapshots: inventory.snapshots, changeSequence: inventory.changeSequence }
|
||||
}
|
||||
|
||||
protected async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
ptyInventory: PtyControllerInventory | null
|
||||
changeSequence: number
|
||||
}> {
|
||||
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
allowAttachedWindow: true,
|
||||
onlyRuntimeOwnedTerminals: true
|
||||
})
|
||||
}
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
|
||||
const ptyInventory = await this.refreshMobileSessionPtyInventory()
|
||||
this.restoreLivePairedRendererSessionOwnedMobileTerminals(null)
|
||||
const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) =>
|
||||
this.projectMobileSessionTabsForClient(
|
||||
this.toMobileSessionTabsResult(snapshot),
|
||||
clientNavigationId
|
||||
)
|
||||
)
|
||||
return { snapshots, ptyInventory, changeSequence: this.mobileSessionTabsChangeSequence }
|
||||
}
|
||||
|
||||
async listAllMobileSessionTabsInventory(
|
||||
clientNavigationId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> {
|
||||
const { snapshots, authoritative } =
|
||||
await this.listAllMobileSessionTabsInventoryWithChangeSequence(clientNavigationId, signal)
|
||||
return { snapshots, ...(authoritative ? { authoritative } : {}) }
|
||||
}
|
||||
|
||||
async listAllMobileSessionTabsInventoryWithChangeSequence(
|
||||
clientNavigationId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
authoritative?: true
|
||||
changeSequence: number
|
||||
}> {
|
||||
this.assertSessionTabsInventoryRequestActive(signal)
|
||||
const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
|
||||
const primed = await this.collectAllMobileSessionTabs(clientNavigationId)
|
||||
this.assertSessionTabsInventoryRequestActive(signal)
|
||||
if (
|
||||
primedPublicationEpoch !== null &&
|
||||
this.getAuthoritativeSessionTabsInventoryEpoch() === primedPublicationEpoch
|
||||
) {
|
||||
return await this.settleSessionTabsInventory(primed, clientNavigationId, signal)
|
||||
}
|
||||
while (true) {
|
||||
const publicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch()
|
||||
if (publicationEpoch === null) {
|
||||
await this.waitForSessionTabsInventoryPublication(signal)
|
||||
continue
|
||||
}
|
||||
const inventory = await this.collectAllMobileSessionTabs(clientNavigationId)
|
||||
this.assertSessionTabsInventoryRequestActive(signal)
|
||||
if (this.getAuthoritativeSessionTabsInventoryEpoch() === publicationEpoch) {
|
||||
return await this.settleSessionTabsInventory(inventory, clientNavigationId, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async settleSessionTabsInventory(
|
||||
inventory: {
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
ptyInventory: PtyControllerInventory | null
|
||||
changeSequence: number
|
||||
},
|
||||
clientNavigationId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
snapshots: RuntimeMobileSessionTabsResult[]
|
||||
authoritative?: true
|
||||
changeSequence: number
|
||||
}> {
|
||||
if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) {
|
||||
return {
|
||||
snapshots: inventory.snapshots,
|
||||
authoritative: true,
|
||||
changeSequence: inventory.changeSequence
|
||||
}
|
||||
}
|
||||
const retried = await this.collectAllMobileSessionTabs(clientNavigationId)
|
||||
this.assertSessionTabsInventoryRequestActive(signal)
|
||||
return { snapshots: retried.snapshots, changeSequence: retried.changeSequence }
|
||||
}
|
||||
|
||||
supportsAuthoritativeSessionTabsInventory(): boolean {
|
||||
return process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1'
|
||||
}
|
||||
|
||||
protected assertSessionTabsInventoryRequestActive(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
protected isCompleteSessionTabsPtyCensus(inventory: PtyControllerInventory | null): boolean {
|
||||
if (!inventory) {
|
||||
return false
|
||||
}
|
||||
const knownHostIds = this.listKnownExecutionHostIds(inventory.queriedHostIds)
|
||||
return ![...knownHostIds].some((hostId) => {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
return parsed?.kind !== 'runtime' && !inventory.queriedHostIds.has(hostId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane'
|
||||
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core'
|
||||
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
|
||||
import {
|
||||
assertTerminalInputWithinLimitWithYield,
|
||||
buildTerminalSendPayload
|
||||
} from './terminal-send-payload'
|
||||
import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection'
|
||||
|
||||
export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane {
|
||||
protected controllerKnowsPtyIsLive(ptyId: string): boolean {
|
||||
try {
|
||||
return this.ptyController?.hasPty?.(ptyId) === true
|
||||
} catch {
|
||||
// Why: liveness lookup failures are doubt; doubt never gates a write.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** True only on controller-proven absence; live, unknown, and probe errors all answer false. */
|
||||
protected isLeafPtyProvenAbsent(ptyId: string): Promise<boolean> {
|
||||
// Why hasPty and not ptysById: graph sync mirrors a connected record for
|
||||
// every leaf ptyId — including a prior process's — so runtime records can't
|
||||
// distinguish live from stale. The controller's exact-id hasPty is the
|
||||
// provider's own synchronous inventory: a known id is alive, skip probing
|
||||
// and supersede any cached verdict (the id came back).
|
||||
if (this.controllerKnowsPtyIsLive(ptyId)) {
|
||||
this.provenAbsentLeafPtyVerdicts.delete(ptyId)
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const verdictAt = this.provenAbsentLeafPtyVerdicts.get(ptyId)
|
||||
if (verdictAt !== undefined) {
|
||||
if (Date.now() - verdictAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
this.provenAbsentLeafPtyVerdicts.delete(ptyId)
|
||||
}
|
||||
const probeLiveness = this.ptyController?.probePtyLiveness?.bind(this.ptyController)
|
||||
if (!probeLiveness) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const inFlight = this.leafPtyAbsenceProbes.get(ptyId)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const probe = (async () => {
|
||||
try {
|
||||
if ((await probeLiveness(ptyId)) !== false) {
|
||||
return false
|
||||
}
|
||||
this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now())
|
||||
return true
|
||||
} catch {
|
||||
// Why: a failed probe is unknown, and unknown never rejects a write.
|
||||
return false
|
||||
} finally {
|
||||
this.leafPtyAbsenceProbes.delete(ptyId)
|
||||
}
|
||||
})()
|
||||
this.leafPtyAbsenceProbes.set(ptyId, probe)
|
||||
return probe
|
||||
}
|
||||
|
||||
async sendTerminal(
|
||||
handle: string,
|
||||
action: {
|
||||
text?: string
|
||||
enter?: boolean
|
||||
interrupt?: boolean
|
||||
},
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
beforeWrite?: (ptyId: string) => void | Promise<void>
|
||||
reserveWrite?: (ptyId: string) => void
|
||||
afterWrite?: (ptyId: string) => void | Promise<void>
|
||||
suffixFailureError?: string
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalSend> {
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
if (!pty.pty.connected) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
const payload = buildTerminalSendPayload(action)
|
||||
if (payload === null) {
|
||||
throw new Error('invalid_terminal_send')
|
||||
}
|
||||
await assertTerminalInputWithinLimitWithYield(action.text)
|
||||
await this.writeTerminalAction(pty.pty.ptyId, action, payload, options)
|
||||
return {
|
||||
handle,
|
||||
accepted: true,
|
||||
bytesWritten: Buffer.byteLength(payload, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
if (!leaf.writable || !leaf.ptyId) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
const payload = buildTerminalSendPayload(action)
|
||||
if (payload === null) {
|
||||
throw new Error('invalid_terminal_send')
|
||||
}
|
||||
await assertTerminalInputWithinLimitWithYield(action.text)
|
||||
// Why: leaf.writable mirrors the renderer graph, which can still answer for
|
||||
// a prior process's ptyId — and provider writes to unknown ids are accepted
|
||||
// no-ops. Only controller-proven absence rejects; unknown proceeds (a
|
||||
// restored daemon session takes writes before its pane remounts).
|
||||
if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
|
||||
await this.writeTerminalAction(leaf.ptyId, action, payload, options)
|
||||
|
||||
return {
|
||||
handle,
|
||||
accepted: true,
|
||||
bytesWritten: Buffer.byteLength(payload, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
async sendTerminalAgentPrompt(
|
||||
handle: string,
|
||||
prompt: string,
|
||||
options: {
|
||||
beforeWrite?: (ptyId: string) => void | Promise<void>
|
||||
suffixFailureError?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<RuntimeTerminalSend> {
|
||||
const payload = buildAgentPromptPasteBytes(prompt)
|
||||
const pty = this.getLivePtyForHandle(handle)
|
||||
if (pty) {
|
||||
if (!pty.pty.connected) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
await assertTerminalInputWithinLimitWithYield(payload)
|
||||
const generation = this.getPtyLifecycleGeneration(pty.pty.ptyId)
|
||||
const submits = await this.serializeAgentPromptSubmission(
|
||||
pty.pty.ptyId,
|
||||
generation,
|
||||
async () => {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
|
||||
this.assertAgentPromptGeneration(pty.pty.ptyId, generation)
|
||||
return await this.writeTerminalAgentPrompt(
|
||||
handle,
|
||||
pty.pty.ptyId,
|
||||
generation,
|
||||
payload,
|
||||
options
|
||||
)
|
||||
}
|
||||
)
|
||||
const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits
|
||||
return { handle, accepted: true, bytesWritten }
|
||||
}
|
||||
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
if (!leaf.writable || !leaf.ptyId) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
await assertTerminalInputWithinLimitWithYield(payload)
|
||||
// Why: same absence gate as sendTerminal — a stale graph mirror must not
|
||||
// accept a prompt into a void; unknown liveness still proceeds.
|
||||
if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) {
|
||||
throw new Error('terminal_not_writable')
|
||||
}
|
||||
const generation = this.getPtyLifecycleGeneration(leaf.ptyId)
|
||||
const submits = await this.serializeAgentPromptSubmission(leaf.ptyId, generation, async () => {
|
||||
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId!)
|
||||
this.assertAgentPromptGeneration(leaf.ptyId!, generation)
|
||||
return await this.writeTerminalAgentPrompt(handle, leaf.ptyId!, generation, payload, options)
|
||||
})
|
||||
const bytesWritten = Buffer.byteLength(payload, 'utf8') + submits
|
||||
return { handle, accepted: true, bytesWritten }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
|
||||
import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
|
||||
import { isCursorAgentTitle } from '../../shared/agent-detection'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
import type {
|
||||
RuntimeTerminalDriverState,
|
||||
RuntimeTerminalPresentation
|
||||
} from '../../shared/runtime-types'
|
||||
import type { RuntimeEdgeCommandSurface } from './runtime-edge-command-controller'
|
||||
import type { RuntimeLinearCommandSurface } from './runtime-linear-command-surface'
|
||||
import type { RuntimeFileCommandSurface } from './runtime-file-command-surface'
|
||||
import type { RuntimeGitCommandSurface } from './runtime-git-command-surface'
|
||||
import type { RuntimeRepositoryCommandSurface } from './runtime-repository-command-surface'
|
||||
import type { RuntimeReviewCommandSurface } from './runtime-review-command-surface'
|
||||
import type { RuntimeServiceCommandSurface } from './runtime-service-command-surface'
|
||||
import type { RuntimeSkillCommandSurface } from './runtime-skill-command-surface'
|
||||
|
||||
export type PtyIncarnationHandleRecord = {
|
||||
handle: string
|
||||
incarnationId: string | null
|
||||
leafKey: string
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeScanCache = {
|
||||
generation: number
|
||||
runtimeKey: string
|
||||
result: RuntimeWorktreeScanResult
|
||||
expiresAt: number
|
||||
adminFingerprint: string | null
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeScanInFlight = {
|
||||
generation: number
|
||||
runtimeKey: string
|
||||
promise: Promise<RuntimeWorktreeScanRefresh>
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeScanRefresh = {
|
||||
result: RuntimeWorktreeScanResult
|
||||
adminFingerprint: string | null
|
||||
adminFingerprintProbe: Promise<string | null> | null
|
||||
scannedAt: number
|
||||
}
|
||||
|
||||
export type ResolvedTerminalWorkspaceLaunchTarget = {
|
||||
scope: TerminalWorkspaceLaunchScope
|
||||
managedWorktree: ResolvedWorktree | null
|
||||
}
|
||||
|
||||
export function isCursorAgentOrchestrationTarget(
|
||||
leaf: RuntimeLeafRecord,
|
||||
tabTitle: string | null | undefined
|
||||
): boolean {
|
||||
return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle)
|
||||
}
|
||||
|
||||
export const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512
|
||||
|
||||
export const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096
|
||||
|
||||
// Why: long enough for a phone to reconnect and retry a create whose response
|
||||
// was lost, short enough that an intentional later re-resume forks fresh.
|
||||
export const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000
|
||||
|
||||
// Why: same idempotency window for worktree.create — a phone whose create was
|
||||
// interrupted by a connection migration retries with the same clientMutationId
|
||||
// and reuses the just-created worktree instead of spawning a duplicate.
|
||||
export const WORKTREE_CREATE_RESULT_TTL_MS = 60_000
|
||||
|
||||
export const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
export const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
|
||||
|
||||
export const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000
|
||||
|
||||
export const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
|
||||
|
||||
export const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
|
||||
|
||||
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
|
||||
export const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
|
||||
|
||||
export const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
|
||||
|
||||
export const SSH_PANE_RECOVERY_GRACE_MS = 30_000
|
||||
|
||||
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
|
||||
// short enough that a recreated session id regains writability quickly even if
|
||||
// its runtime record (which also invalidates the verdict) is late.
|
||||
export const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000
|
||||
|
||||
export const TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
export type RuntimeTerminalProjection = { lines: string[]; draft?: string }
|
||||
|
||||
export function assertAgentPromptRequestActive(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('request_aborted')
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentPromptPromise<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return await promise
|
||||
}
|
||||
assertAgentPromptRequestActive(signal)
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (result: { value: T } | { error: unknown }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
if ('error' in result) {
|
||||
reject(result.error)
|
||||
} else {
|
||||
resolve(result.value)
|
||||
}
|
||||
}
|
||||
const onAbort = (): void => finish({ error: new Error('request_aborted') })
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
promise.then(
|
||||
(value) => finish({ value }),
|
||||
(error: unknown) => finish({ error })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Generic terminal.send uses setImmediate to let abort/permission/data callbacks run between
|
||||
// chunks without paying a full Windows timer tick for every 16 KiB write. Agent prompts use an
|
||||
// atomic bracketed-paste write, so they do not rely on this scheduler.
|
||||
// Why the global and not node:timers/promises: only the global is intercepted by fake timers,
|
||||
// so a chunked paste stays observable on the test clock.
|
||||
export function yieldBetweenTerminalInputChunks(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForAgentPromptDelay(
|
||||
delayMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
return
|
||||
}
|
||||
assertAgentPromptRequestActive(signal)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('request_aborted'))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, delayMs)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function findLastCompleteOscTitleRange(data: string): { start: number; end: number } | null {
|
||||
let last: { start: number; end: number } | null = null
|
||||
let searchFrom = 0
|
||||
while (searchFrom < data.length) {
|
||||
const start = data.indexOf('\x1b]', searchFrom)
|
||||
if (start === -1) {
|
||||
break
|
||||
}
|
||||
const command = data[start + 2]
|
||||
if ((command !== '0' && command !== '1' && command !== '2') || data[start + 3] !== ';') {
|
||||
searchFrom = start + 2
|
||||
continue
|
||||
}
|
||||
let cursor = start + 4
|
||||
for (; cursor < data.length; cursor += 1) {
|
||||
if (data[cursor] === '\x07') {
|
||||
last = { start, end: cursor + 1 }
|
||||
searchFrom = cursor + 1
|
||||
break
|
||||
}
|
||||
if (data[cursor] !== '\x1b') {
|
||||
continue
|
||||
}
|
||||
if (data[cursor + 1] === '\\') {
|
||||
last = { start, end: cursor + 2 }
|
||||
searchFrom = cursor + 2
|
||||
} else {
|
||||
searchFrom = cursor
|
||||
}
|
||||
break
|
||||
}
|
||||
if (cursor === data.length) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
export function isClientDisconnectedError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === 'client_disconnected'
|
||||
}
|
||||
|
||||
export function createTerminalRevealWarning(handle: string, error?: unknown): string {
|
||||
const reason =
|
||||
error instanceof Error && error.message.trim().length > 0
|
||||
? ` Reason: ${error.message.trim()}.`
|
||||
: ''
|
||||
return [
|
||||
`Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`,
|
||||
`Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.`
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit
|
||||
// the key rather than send `true`.
|
||||
export function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } {
|
||||
return shouldSurface ? {} : { surfaceOwner: false }
|
||||
}
|
||||
|
||||
export function resolveTerminalPresentation(opts: {
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
focus?: boolean
|
||||
activate?: boolean
|
||||
}): RuntimeTerminalPresentation | undefined {
|
||||
if (opts.presentation) {
|
||||
return opts.presentation
|
||||
}
|
||||
if (opts.focus === true || opts.activate === true) {
|
||||
return 'focused'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Subscribe a listener to a per-key Set, pruning the key's entry once its last
|
||||
// listener unsubscribes. Returns the unsubscribe callback.
|
||||
export function addListenerToMap<T>(
|
||||
map: Map<string, Set<T>>,
|
||||
key: string,
|
||||
listener: T
|
||||
): () => void {
|
||||
let listeners = map.get(key)
|
||||
if (!listeners) {
|
||||
listeners = new Set<T>()
|
||||
map.set(key, listeners)
|
||||
}
|
||||
const set = listeners
|
||||
set.add(listener)
|
||||
return () => {
|
||||
set.delete(listener)
|
||||
if (set.size === 0) {
|
||||
map.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPathWithinDirectory(directory: string, candidate: string): boolean {
|
||||
const relativePath = relative(resolve(directory), resolve(candidate))
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
export const AGENT_HOOK_RUNTIME_ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_AGENT_HOOK_ENV',
|
||||
'ORCA_AGENT_HOOK_VERSION',
|
||||
'ORCA_AGENT_HOOK_TRANSPORT',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT'
|
||||
] as const
|
||||
|
||||
// Why: notificationSeq is the desktop-assigned monotonic sequence used for
|
||||
// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a
|
||||
// client can watermark the last event it delivered and request exactly the
|
||||
// events after it — idempotent, no duplicate local pushes.
|
||||
export type RuntimeWorktreeLifecycleEvent =
|
||||
| { kind: 'created'; worktreeId: string; path: string; branch: string }
|
||||
| { kind: 'removed'; worktreeId: string; path: string }
|
||||
|
||||
// Why: presence-based driver state for the mobile-presence lock. Exactly one
|
||||
// driver per PTY at any moment. See docs/mobile-presence-lock.md.
|
||||
// - `idle`: no mobile subscribers; desktop input flows freely
|
||||
// - `desktop`: at least one mobile client subscribed but desktop reclaimed
|
||||
// (or all mobile clients are passive `desktop`-mode watchers); desktop
|
||||
// input flows freely
|
||||
// - `mobile{clientId}`: a mobile client is the active driver; desktop
|
||||
// input/resize are dropped server-side and the lock banner is mounted.
|
||||
// `clientId` is the most recent mobile actor for this PTY.
|
||||
export type DriverState = RuntimeTerminalDriverState
|
||||
|
||||
// Why: per-PTY layout target — what the PTY *should* be at right now.
|
||||
// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive
|
||||
// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at
|
||||
// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed.
|
||||
// See docs/mobile-terminal-layout-state-machine.md.
|
||||
export type PtyLayoutTarget =
|
||||
| { kind: 'desktop'; cols: number; rows: number }
|
||||
| { kind: 'phone'; cols: number; rows: number; ownerClientId: string }
|
||||
| { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string }
|
||||
|
||||
// Why: authoritative layout state with monotonic seq. Bumped on every
|
||||
// applyLayout success; emitted on mobile subscribe-stream events so clients
|
||||
// drop stale events that arrive after a newer transition.
|
||||
export type PtyLayoutState = PtyLayoutTarget & {
|
||||
seq: number
|
||||
appliedAt: number
|
||||
}
|
||||
|
||||
// Why: applyLayout result discriminator. Callers (especially RPC handlers)
|
||||
// need to distinguish "shipped a new state at seq N" from "no-op — caller
|
||||
// should not claim a seq it didn't produce." `pty-exited` is terminal;
|
||||
// `resize-failed` is transient and the caller may retry.
|
||||
export type ApplyLayoutResult =
|
||||
| { ok: true; state: PtyLayoutState }
|
||||
| { ok: false; reason: 'pty-exited' | 'resize-failed' }
|
||||
|
||||
export type LayoutQueueEntry = {
|
||||
running: Promise<ApplyLayoutResult> | null
|
||||
pending: {
|
||||
target: PtyLayoutTarget
|
||||
waiters: ((r: ApplyLayoutResult) => void)[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export type RuntimeInstalledCommandSurfaces = RuntimeEdgeCommandSurface &
|
||||
RuntimeLinearCommandSurface &
|
||||
RuntimeFileCommandSurface &
|
||||
RuntimeGitCommandSurface &
|
||||
RuntimeRepositoryCommandSurface &
|
||||
RuntimeReviewCommandSurface &
|
||||
RuntimeServiceCommandSurface &
|
||||
RuntimeSkillCommandSurface
|
||||
|
||||
export type RuntimeCommandSurfaceHost<T> = T & RuntimeInstalledCommandSurfaces
|
||||
|
||||
export type RuntimeRendererReloadFence = Readonly<{
|
||||
revision: number
|
||||
recovery: 'renderer' | 'headless' | 'reloading'
|
||||
}>
|
||||
@@ -0,0 +1,141 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithWriteTerminalAgentPrompt } from './orca-runtime-write-terminal-agent-prompt'
|
||||
import {
|
||||
CLAUDE_AGENT_PROMPT_RENDER_MARKER,
|
||||
CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS,
|
||||
CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS
|
||||
} from './orca-runtime-core'
|
||||
import type { RuntimeTerminalWait, RuntimeTerminalWaitCondition } from '../../shared/runtime-types'
|
||||
|
||||
export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithWriteTerminalAgentPrompt {
|
||||
protected createAgentPromptRenderGate(
|
||||
ptyId: string,
|
||||
pasteIngestMs: number
|
||||
): {
|
||||
arm: () => void
|
||||
wait: () => Promise<void>
|
||||
dispose: () => void
|
||||
} | null {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!['claude', 'codex'].includes(pty?.launchAgent ?? pty?.foregroundAgent ?? '')) {
|
||||
return null
|
||||
}
|
||||
let armed = false
|
||||
let observedMarker = false
|
||||
let settled = false
|
||||
let ingested = pasteIngestMs <= 0
|
||||
// Why absolute: the ingest clock starts once, here, but the cap is armed twice (at arm()
|
||||
// and again on the marker). Re-adding the whole window would charge ingest twice.
|
||||
const ingestDeadlineAt = Date.now() + pasteIngestMs
|
||||
let markerCarry = ''
|
||||
let quietTimer: NodeJS.Timeout | null = null
|
||||
let hardTimer: NodeJS.Timeout | null = null
|
||||
let ingestTimer: NodeJS.Timeout | null = null
|
||||
let resolveRender!: () => void
|
||||
const rendered = new Promise<void>((resolve) => {
|
||||
resolveRender = resolve
|
||||
})
|
||||
|
||||
const clearGateTimers = (): void => {
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
quietTimer = null
|
||||
}
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer)
|
||||
hardTimer = null
|
||||
}
|
||||
if (ingestTimer) {
|
||||
clearTimeout(ingestTimer)
|
||||
ingestTimer = null
|
||||
}
|
||||
}
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearGateTimers()
|
||||
resolveRender()
|
||||
}
|
||||
const armQuietTimer = (): void => {
|
||||
// Why: the quiet window measures the agent going still after a *complete* paste.
|
||||
// Silence during ingest is not settlement, so it cannot start the clock.
|
||||
if (!ingested) {
|
||||
return
|
||||
}
|
||||
if (quietTimer) {
|
||||
clearTimeout(quietTimer)
|
||||
}
|
||||
quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS)
|
||||
}
|
||||
const armHardTimer = (): void => {
|
||||
if (hardTimer) {
|
||||
clearTimeout(hardTimer)
|
||||
}
|
||||
hardTimer = setTimeout(
|
||||
finish,
|
||||
CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS + Math.max(0, ingestDeadlineAt - Date.now())
|
||||
)
|
||||
}
|
||||
const armIngestTimer = (): void => {
|
||||
if (ingested || ingestTimer) {
|
||||
return
|
||||
}
|
||||
ingestTimer = setTimeout(
|
||||
() => {
|
||||
ingestTimer = null
|
||||
ingested = true
|
||||
if (observedMarker) {
|
||||
armQuietTimer()
|
||||
}
|
||||
},
|
||||
Math.max(0, ingestDeadlineAt - Date.now())
|
||||
)
|
||||
}
|
||||
const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => {
|
||||
if (!armed || settled) {
|
||||
return
|
||||
}
|
||||
if (!observedMarker) {
|
||||
const combined = markerCarry + data
|
||||
markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1))
|
||||
if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) {
|
||||
return
|
||||
}
|
||||
observedMarker = true
|
||||
armHardTimer()
|
||||
}
|
||||
armQuietTimer()
|
||||
})
|
||||
return {
|
||||
arm: () => {
|
||||
armed = true
|
||||
markerCarry = ''
|
||||
armIngestTimer()
|
||||
armHardTimer()
|
||||
},
|
||||
wait: async () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
await rendered
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribe()
|
||||
clearGateTimers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitForTerminal(
|
||||
handle: string,
|
||||
options?: {
|
||||
condition?: RuntimeTerminalWaitCondition
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
return this.terminalWait.wait(handle, options)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithGetAgentSessionExecutionNamespace } from './orca-runtime-get-agent-session-execution-namespace'
|
||||
import type {
|
||||
RuntimeAgentSessionRpcCaller,
|
||||
RuntimeCreateAgentSessionRequest,
|
||||
RuntimeCreateAgentSessionResult
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import {
|
||||
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
|
||||
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
|
||||
parseAgentSessionOperationTimestamp
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
AGENT_SESSION_OPERATION_GLOBAL_LIMIT,
|
||||
AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT
|
||||
} from './orca-runtime-core'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { repoIsRemote } from '../../shared/agent-launch-remote'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||
import {
|
||||
deterministicAgentSessionUuid,
|
||||
isAgentSessionOperationOutcomeUnknown
|
||||
} from './runtime-agent-launch-resolution'
|
||||
|
||||
export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSessionExecutionNamespace {
|
||||
async createAgentSession(
|
||||
request: RuntimeCreateAgentSessionRequest,
|
||||
caller: RuntimeAgentSessionRpcCaller = {}
|
||||
): Promise<RuntimeCreateAgentSessionResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const now = Date.now()
|
||||
const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId)
|
||||
if (
|
||||
operationTimestamp === null ||
|
||||
operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
|
||||
) {
|
||||
throw new Error('agent_session_operation_invalid')
|
||||
}
|
||||
const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}`
|
||||
const operationKey = `${callerKey}\0${request.clientOperationId}`
|
||||
const requestFingerprint = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
request.worktree,
|
||||
request.agent,
|
||||
request.prompt ?? null,
|
||||
request.promptDelivery ?? null,
|
||||
request.agentArgs ?? null,
|
||||
request.agentArgs === undefined ? 'host-default' : 'client-override',
|
||||
request.launchPreferences?.model ?? null,
|
||||
request.launchPreferences?.effort ?? null,
|
||||
request.launchPreferences?.mode ?? null,
|
||||
request.startupCwd ?? null,
|
||||
request.presentation ?? null,
|
||||
request.placement?.tabId ?? null,
|
||||
request.placement?.leafId ?? null,
|
||||
request.viewMode ?? null
|
||||
])
|
||||
)
|
||||
.digest('base64url')
|
||||
const existing = this.agentSessionCreateOperations.get(operationKey)
|
||||
if (existing) {
|
||||
if (existing.fingerprint !== requestFingerprint) {
|
||||
throw new Error('agent_session_operation_conflict')
|
||||
}
|
||||
const replayed = await existing.promise
|
||||
return { ...replayed, disposition: 'replayed' }
|
||||
}
|
||||
if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) {
|
||||
// Why: once a tombstone could have expired, an unseen replay must never
|
||||
// be reinterpreted as permission to start another fresh agent.
|
||||
throw new Error('agent_session_operation_expired')
|
||||
}
|
||||
let callerOperationCount = 0
|
||||
const callerPrefix = `${callerKey}\0`
|
||||
for (const key of this.agentSessionCreateOperations.keys()) {
|
||||
if (key.startsWith(callerPrefix)) {
|
||||
callerOperationCount += 1
|
||||
}
|
||||
}
|
||||
if (
|
||||
callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT ||
|
||||
this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT
|
||||
) {
|
||||
// Why: tombstones cannot be evicted early without making an old replay
|
||||
// capable of spawning again; reject new IDs until retained entries age out.
|
||||
throw new Error('agent_session_operation_capacity')
|
||||
}
|
||||
let retainReplayFence = false
|
||||
const operation = (async (): Promise<RuntimeCreateAgentSessionResult> => {
|
||||
// Why: reserve the client operation before any async preflight so concurrent retries cannot
|
||||
// both observe an empty ledger and reach the execution owner independently.
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree)
|
||||
if (
|
||||
!(await this.executionOwnerSupportsAgentSessionOperation(
|
||||
workspace,
|
||||
'create',
|
||||
caller.signal
|
||||
))
|
||||
) {
|
||||
// Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds.
|
||||
throw new Error('agent_session_legacy_required')
|
||||
}
|
||||
const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd)
|
||||
// Why: aliases and object property order are client syntax, not authority;
|
||||
// fingerprint the host-resolved fields in one fixed order.
|
||||
const resolvedFingerprint = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
workspace.id,
|
||||
request.agent,
|
||||
request.prompt ?? null,
|
||||
request.promptDelivery ?? null,
|
||||
request.agentArgs ?? null,
|
||||
request.agentArgs === undefined ? 'host-default' : 'client-override',
|
||||
request.launchPreferences?.model ?? null,
|
||||
request.launchPreferences?.effort ?? null,
|
||||
request.launchPreferences?.mode ?? null,
|
||||
startupCwd ?? null,
|
||||
request.presentation ?? null,
|
||||
request.placement?.tabId ?? null,
|
||||
request.placement?.leafId ?? null,
|
||||
request.viewMode ?? null
|
||||
])
|
||||
)
|
||||
.digest('base64url')
|
||||
const settings = this.store!.getSettings()
|
||||
if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
}
|
||||
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
const isRemote = workspace.repo
|
||||
? repoIsRemote(workspace.repo)
|
||||
: Boolean(workspace.connectionId)
|
||||
const shell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const startupArgs = {
|
||||
agent: request.agent,
|
||||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
agentArgs:
|
||||
request.agentArgs !== undefined
|
||||
? request.agentArgs
|
||||
: resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv),
|
||||
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
|
||||
platform,
|
||||
shell,
|
||||
isRemote
|
||||
}
|
||||
const startup =
|
||||
request.promptDelivery === 'draft'
|
||||
? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' })
|
||||
: buildAgentStartupPlan({
|
||||
...startupArgs,
|
||||
prompt: request.prompt ?? '',
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
if (!startup) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
await this.markWorkspaceTrustedForAgent(request.agent, workspace.connectionId, workspace.path)
|
||||
if (caller.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
let terminal: RuntimeTerminalCreate
|
||||
const executionOperationId = createHash('sha256')
|
||||
.update(this.runtimeId)
|
||||
.update('\0')
|
||||
.update(operationKey)
|
||||
.update('\0')
|
||||
.update(resolvedFingerprint)
|
||||
.digest('base64url')
|
||||
const operationTabId =
|
||||
request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`)
|
||||
const operationLeafId =
|
||||
request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`)
|
||||
const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}`
|
||||
try {
|
||||
terminal = await this.createTerminal(`id:${workspace.id}`, {
|
||||
command: startup.launchCommand,
|
||||
env: startup.env,
|
||||
launchConfig: startup.launchConfig,
|
||||
launchAgent: request.agent,
|
||||
startupCommandDelivery: startup.startupCommandDelivery,
|
||||
cwd: startupCwd,
|
||||
presentation: request.presentation ?? 'background',
|
||||
tabId: operationTabId,
|
||||
leafId: operationLeafId,
|
||||
preAllocatedHandle: operationHandle,
|
||||
viewMode: request.viewMode,
|
||||
agentSessionCreateOperationId: executionOperationId,
|
||||
signal: caller.signal,
|
||||
onPtySpawnCommitted: () => {
|
||||
retainReplayFence = true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAgentSessionOperationOutcomeUnknown(error)) {
|
||||
retainReplayFence = true
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { terminal, disposition: 'created' }
|
||||
})()
|
||||
this.agentSessionCreateOperations.set(operationKey, {
|
||||
fingerprint: requestFingerprint,
|
||||
promise: operation
|
||||
})
|
||||
const expireOperation = (): void => {
|
||||
const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
|
||||
this.agentSessionCreateOperations.delete(operationKey)
|
||||
}
|
||||
},
|
||||
Math.max(1, expiresAt - Date.now())
|
||||
)
|
||||
timer.unref?.()
|
||||
}
|
||||
try {
|
||||
const result = await operation
|
||||
expireOperation()
|
||||
return result
|
||||
} catch (error) {
|
||||
if (retainReplayFence) {
|
||||
// Why: the first PTY may still be alive; replay the same failure until
|
||||
// expiry instead of interpreting a lost outcome as a fresh spawn grant.
|
||||
expireOperation()
|
||||
} else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) {
|
||||
this.agentSessionCreateOperations.delete(operationKey)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeCreatePreparation from '../worktree-create-preparation'
|
||||
import type { Project } from '../../shared/project-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prefetchWorktreeCreateBase: vi.fn(),
|
||||
prepareWorktreeCreateForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../worktree-create-base-prefetch', () => ({
|
||||
prefetchWorktreeCreateBase: mocks.prefetchWorktreeCreateBase
|
||||
}))
|
||||
vi.mock('../worktree-create-preparation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WorktreeCreatePreparation>()),
|
||||
prepareWorktreeCreateForRepo: mocks.prepareWorktreeCreateForRepo
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
displayName: 'Repo',
|
||||
path: String.raw`C:\workspace\repo`,
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
}
|
||||
|
||||
function makeStore(overrides: Partial<Project> = {}): unknown {
|
||||
return {
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === repo.id ? repo : undefined),
|
||||
getProjects: () => [{ ...project, ...overrides }],
|
||||
getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
|
||||
}
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
const hostPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.prefetchWorktreeCreateBase.mockReset().mockResolvedValue(undefined)
|
||||
mocks.prepareWorktreeCreateForRepo.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(hostPlatform)
|
||||
_resetWslCachesForTests()
|
||||
})
|
||||
|
||||
// The RPC/relay prefetch is the only warm-up a remote client reaches, so it has
|
||||
// to resolve the same project runtime the IPC handler does. Constructed through
|
||||
// the barrel because the split chain resolves selectors in a later subclass.
|
||||
describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal-provisioning-host)', () => {
|
||||
it('warms up in the distro a WSL-routed project runs in', async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
|
||||
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitOptions: { wslDistro: 'Ubuntu' } })
|
||||
)
|
||||
})
|
||||
|
||||
it('warms up on host git when no project runtime routes the repo', async () => {
|
||||
setPlatform('darwin')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
|
||||
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitOptions: {} })
|
||||
)
|
||||
})
|
||||
|
||||
// A repair-required runtime must degrade to host git, not fail the warm-up.
|
||||
it('does not surface a repair-required project runtime as a prefetch failure', async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Debian'] })
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await expect(
|
||||
runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.prefetchWorktreeCreateBase).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gitOptions: {} })
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares the checkout the prefetch resolved', async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
setPlatform('win32')
|
||||
mocks.prefetchWorktreeCreateBase.mockResolvedValue('origin/main')
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
await runtime.prefetchManagedWorktreeCreateBase({ repoSelector: 'repo-1' })
|
||||
|
||||
expect(mocks.prepareWorktreeCreateForRepo).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
repo,
|
||||
'origin/main'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithCreateManagedWorktree } from './orca-runtime-create-managed-worktree'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { RuntimeRemoteWorktreeCreateArgs } from './runtime-remote-worktree-create-request'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { createRuntimeRemoteManagedWorktree } from './runtime-remote-managed-worktree-create'
|
||||
import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller'
|
||||
import type { WorktreeBaseStatusEvent } from '../../shared/worktree/base-ref-drift-types'
|
||||
import { probeRuntimeWorktreeDrift } from './runtime-worktree-drift-probe'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/worktree/types'
|
||||
import {
|
||||
persistRuntimeManagedWorktreeSortOrder,
|
||||
updateRuntimeManagedWorktreeMetadata
|
||||
} from './runtime-managed-worktree-metadata'
|
||||
import { resolveRuntimeGitHubWorktreeBase } from './runtime-github-worktree-base'
|
||||
import { resolveRuntimeGitLabWorktreeBase } from './runtime-gitlab-worktree-base'
|
||||
|
||||
export class OrcaRuntimeWithCreateManagedRemoteWorktree extends OrcaRuntimeWithCreateManagedWorktree {
|
||||
protected createManagedRemoteWorktree(
|
||||
repo: Repo,
|
||||
args: RuntimeRemoteWorktreeCreateArgs
|
||||
): Promise<CreateWorktreeResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return createRuntimeRemoteManagedWorktree(repo, args, {
|
||||
store: this.store,
|
||||
canSpawn: () => Boolean(this.ptyController?.spawn),
|
||||
markTrusted: (agent, connectionId, path) =>
|
||||
this.markRemoteWorkspaceTrustedForAgent(agent, connectionId, path),
|
||||
createTerminal: (selector, options) => this.createTerminal(selector, options),
|
||||
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
|
||||
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
|
||||
provision: (options) => this.provisionManagedWorktreeTerminals(options),
|
||||
activate: (repoId, worktreeId, setup, startup, defaultTabs) =>
|
||||
this.notifyActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
defaultTabs,
|
||||
args.navigation
|
||||
),
|
||||
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
|
||||
invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
|
||||
notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId)
|
||||
})
|
||||
}
|
||||
|
||||
async getCanonicalFetchKey(
|
||||
repoPath: string,
|
||||
remote: string,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<string> {
|
||||
return await this.remoteFetches.getCanonicalFetchKey(repoPath, remote, gitOptions)
|
||||
}
|
||||
|
||||
async getOrStartRemoteFetch(
|
||||
repoPath: string,
|
||||
remote: string,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<RemoteFetchResult> {
|
||||
return await this.remoteFetches.getOrStartRemoteFetch(repoPath, remote, gitOptions)
|
||||
}
|
||||
|
||||
async getOrStartRemoteTrackingBaseRefresh(
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBase,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<RemoteFetchResult> {
|
||||
return await this.remoteFetches.getOrStartRemoteTrackingBaseRefresh(repoPath, base, gitOptions)
|
||||
}
|
||||
|
||||
async fetchRemoteWithCache(
|
||||
repoPath: string,
|
||||
remote: string,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<void> {
|
||||
await this.remoteFetches.fetchRemoteWithCache(repoPath, remote, gitOptions)
|
||||
}
|
||||
|
||||
async resolveRemoteTrackingBase(
|
||||
repoPath: string,
|
||||
baseBranch: string,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<RemoteTrackingBase | null> {
|
||||
return await this.remoteFetches.resolveRemoteTrackingBase(repoPath, baseBranch, gitOptions)
|
||||
}
|
||||
|
||||
async hasRemoteTrackingRef(
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBase,
|
||||
gitOptions: { wslDistro?: string } = {}
|
||||
): Promise<boolean> {
|
||||
return await this.remoteFetches.hasRemoteTrackingRef(repoPath, base, gitOptions)
|
||||
}
|
||||
|
||||
recordOptimisticReconcileToken(worktreeId: string): string {
|
||||
return this.worktreeBaseReconciliation.recordToken(worktreeId)
|
||||
}
|
||||
|
||||
clearOptimisticReconcileToken(worktreeId: string): void {
|
||||
this.worktreeBaseReconciliation.clearToken(worktreeId)
|
||||
}
|
||||
|
||||
emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void {
|
||||
this.worktreeBaseReconciliation.emitStatus(event)
|
||||
}
|
||||
|
||||
async reconcileWorktreeBaseStatus(args: {
|
||||
repoId: string
|
||||
repoPath: string
|
||||
worktreeId: string
|
||||
base: RemoteTrackingBase
|
||||
branchName: string
|
||||
createdBaseSha: string
|
||||
token: string
|
||||
fetchPromise: Promise<RemoteFetchResult>
|
||||
}): Promise<void> {
|
||||
await this.worktreeBaseReconciliation.reconcile(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe how far the worktree's HEAD is behind its tracking remote. Returns
|
||||
* null when the probe cannot establish a signal (no default base ref, or
|
||||
* git failure). Dispatch treats null as "unknown — proceed" (§3.1); only
|
||||
* knowing-and-stale refuses.
|
||||
*/
|
||||
async probeWorktreeDrift(worktreeSelector: string): Promise<{
|
||||
base: string
|
||||
behind: number
|
||||
recentSubjects: string[]
|
||||
} | null> {
|
||||
return probeRuntimeWorktreeDrift({
|
||||
selector: worktreeSelector,
|
||||
store: this.store ? this.requireStore() : null,
|
||||
resolveWorktree: (selector) => this.resolveWorktreeSelector(selector),
|
||||
resolveRemoteTrackingBase: (repoPath, base, options) =>
|
||||
this.resolveRemoteTrackingBase(repoPath, base, options),
|
||||
fetchRemote: (repoPath, remote, options) =>
|
||||
this.fetchRemoteWithCache(repoPath, remote, options)
|
||||
})
|
||||
}
|
||||
|
||||
async updateManagedWorktreeMeta(
|
||||
worktreeSelector: string,
|
||||
updates: Omit<Partial<WorktreeMeta>, 'pushTarget'> & {
|
||||
pushTarget?: GitPushTarget | null
|
||||
lineage?: {
|
||||
parentWorktree?: string
|
||||
noParent?: boolean
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return updateRuntimeManagedWorktreeMetadata({
|
||||
selector: worktreeSelector,
|
||||
updates,
|
||||
store: this.store,
|
||||
ports: {
|
||||
resolveWorktree: (selector) => this.resolveWorktreeSelector(selector),
|
||||
validateParent: (worktree, parent) => this.worktreeLineage.validateParent(worktree, parent),
|
||||
invalidateResolved: () => this.invalidateResolvedWorktreeCache(),
|
||||
invalidateScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
|
||||
notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId),
|
||||
showWorktree: (selector) => this.showManagedWorktree(selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return persistRuntimeManagedWorktreeSortOrder({
|
||||
orderedIds,
|
||||
store: this.store,
|
||||
invalidateResolved: () => this.invalidateResolvedWorktreeCache(),
|
||||
notifyChanged: (repoId) => this.notifyWorktreesChanged(repoId)
|
||||
})
|
||||
}
|
||||
|
||||
async resolveManagedPrBase(args: {
|
||||
repoSelector: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<GitHubPrStartPoint | { error: string }> {
|
||||
return resolveRuntimeGitHubWorktreeBase(args, {
|
||||
store: this.store ? this.requireStore() : null,
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector)
|
||||
})
|
||||
}
|
||||
|
||||
async resolveManagedMrBase(args: {
|
||||
repoSelector: string
|
||||
mrIid: number
|
||||
sourceBranch?: string
|
||||
targetBranch?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<
|
||||
{ baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string }
|
||||
> {
|
||||
return resolveRuntimeGitLabWorktreeBase(args, {
|
||||
store: this.store ? this.requireStore() : null,
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithGetWorktreeTerminalProvisioningHost } from './orca-runtime-get-worktree-terminal-provisioning-host'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { isTuiAgentEnabled } from '../../shared/tui-agent-selection'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
|
||||
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
||||
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
|
||||
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||||
import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup'
|
||||
|
||||
export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost {
|
||||
async createManagedWorktree(
|
||||
args: RuntimeManagedWorktreeCreateArgs
|
||||
): Promise<CreateWorktreeResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
const createSettings = this.store.getSettings()
|
||||
const requestedAgent = args.startupAgent ?? args.createdWithAgent
|
||||
const requestedAgentEnabled =
|
||||
requestedAgent !== undefined
|
||||
? isTuiAgentEnabled(requestedAgent, createSettings.disabledTuiAgents)
|
||||
: false
|
||||
if ((args.startup || args.startupAgent) && requestedAgent && !requestedAgentEnabled) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
}
|
||||
if (
|
||||
args.startup &&
|
||||
args.startupDraftPaste &&
|
||||
!isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents)
|
||||
) {
|
||||
throw new Error('Selected agent is disabled. Choose an enabled agent before creating.')
|
||||
}
|
||||
const agentStartup =
|
||||
!args.startup && args.startupAgent
|
||||
? this.buildStartupForAgent(
|
||||
repo,
|
||||
args.startupAgent,
|
||||
args.startupPrompt,
|
||||
args.startupLaunchPreferences
|
||||
)
|
||||
: null
|
||||
const draftStartup =
|
||||
!args.startup && !agentStartup && args.startupDraft
|
||||
? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent)
|
||||
: null
|
||||
const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup
|
||||
const effectiveStartupFollowup = agentStartup?.followup
|
||||
const effectiveCreatedWithAgent = args.startup
|
||||
? args.createdWithAgent
|
||||
: (agentStartup?.agent ??
|
||||
draftStartup?.agent ??
|
||||
(requestedAgentEnabled ? requestedAgent : undefined))
|
||||
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
|
||||
if (isFolderRepo(repo)) {
|
||||
return createRuntimeFolderWorktree({
|
||||
request: args,
|
||||
repo,
|
||||
startup: effectiveStartup,
|
||||
startupFollowup: effectiveStartupFollowup,
|
||||
createdWithAgent: effectiveCreatedWithAgent,
|
||||
draftPaste: effectiveDraftPaste,
|
||||
deps: {
|
||||
store: this.store,
|
||||
ptySpawnAvailable: Boolean(this.ptyController?.spawn),
|
||||
createTerminal: (selector, options) => this.createTerminal(selector, options),
|
||||
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
|
||||
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
|
||||
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
|
||||
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
|
||||
notifyWorktreesChanged: (repoId) => this.notifyWorktreesChanged(repoId),
|
||||
emitCreated: (event) => this.emitWorktreeLifecycle(event),
|
||||
activate: (repoId, worktreeId, setup, startup) =>
|
||||
this.notifyActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
setup,
|
||||
startup,
|
||||
undefined,
|
||||
args.navigation
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
const lineageInput =
|
||||
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
|
||||
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
|
||||
if (repo.connectionId) {
|
||||
const result = await this.createManagedRemoteWorktree(repo, {
|
||||
...args,
|
||||
activate: args.activate,
|
||||
...(effectiveStartup ? { startup: effectiveStartup } : {}),
|
||||
...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}),
|
||||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
|
||||
})
|
||||
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: result.worktree.id,
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
worktree: {
|
||||
...result.worktree,
|
||||
parentWorktreeId: recordedLineage.lineage?.parentWorktreeId ?? null,
|
||||
childWorktreeIds: result.worktree.childWorktreeIds ?? [],
|
||||
lineage: recordedLineage.lineage,
|
||||
workspaceLineage: recordedLineage.workspaceLineage
|
||||
},
|
||||
...(lineageInput
|
||||
? {
|
||||
lineage: recordedLineage.lineage,
|
||||
workspaceLineage: recordedLineage.workspaceLineage,
|
||||
warnings: recordedLineage.warnings
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
const { worktree, worktreePath, includeCopyWarning, created, addResult, metadataResult } =
|
||||
await createRuntimeLocalManagedWorktree({
|
||||
request: args,
|
||||
repo,
|
||||
store: this.requireStore(),
|
||||
createdWithAgent: effectiveCreatedWithAgent,
|
||||
hostedReviewExecutionContext: this.getHostedReviewExecutionOptions(repo),
|
||||
resolveRemoteTrackingBase: (path, base, ...options) =>
|
||||
this.resolveRemoteTrackingBase(path, base, ...options),
|
||||
hasRemoteTrackingRef: (path, base, ...options) =>
|
||||
this.hasRemoteTrackingRef(path, base, ...options),
|
||||
refreshRemoteTrackingBase: (path, base, ...options) =>
|
||||
this.getOrStartRemoteTrackingBaseRefresh(path, base, ...options),
|
||||
fetchRemote: (path, remote, ...options) =>
|
||||
this.fetchRemoteWithCache(path, remote, ...options),
|
||||
onWorktreeMetadataPersisted: (persistedWorktree) =>
|
||||
this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution)
|
||||
})
|
||||
const settings = createSettings
|
||||
const { lineage, workspaceLineage, warnings: lineageWarnings } = metadataResult
|
||||
|
||||
let {
|
||||
setup,
|
||||
defaultTabs,
|
||||
warning,
|
||||
effectiveDecision,
|
||||
hookFound,
|
||||
shouldRunSetup,
|
||||
didStartInProcessSetupHook
|
||||
} = await prepareRuntimeLocalWorktreeSetup({
|
||||
request: args,
|
||||
repo,
|
||||
worktreePath,
|
||||
settings,
|
||||
runtimeTarget: this.getLocalGitExecutionOptionArgs(repo)[0],
|
||||
shouldUseSetupRunner:
|
||||
this.authoritativeWindowId !== null ||
|
||||
Boolean(effectiveStartup) ||
|
||||
Boolean(this.ptyController?.spawn),
|
||||
warning: includeCopyWarning
|
||||
})
|
||||
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(repo.id)
|
||||
// Why: the filesystem-auth layer maintains a separate cache of registered
|
||||
// worktree roots used by git IPC handlers (branchCompare, diff, status, etc.)
|
||||
// to authorize paths. Without invalidating it here, CLI-created worktrees
|
||||
// are not recognized and all git operations fail with "Access denied:
|
||||
// unknown repository or worktree path".
|
||||
invalidateAuthorizedRootsCache()
|
||||
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
const {
|
||||
warning: terminalWarning,
|
||||
returnedSetup,
|
||||
didSpawnSetup,
|
||||
didSpawnStartup,
|
||||
setupTerminalHandle,
|
||||
startupTerminalHandle,
|
||||
startupTerminalTabId,
|
||||
startupTerminalPaneKey,
|
||||
startupTerminalPtyId
|
||||
} = await startRuntimeLocalWorktreeTerminals({
|
||||
request: args,
|
||||
repo,
|
||||
worktree,
|
||||
setup,
|
||||
defaultTabs,
|
||||
startup: effectiveStartup,
|
||||
startupFollowup: effectiveStartupFollowup,
|
||||
createdWithAgent: effectiveCreatedWithAgent,
|
||||
draftPaste: effectiveDraftPaste,
|
||||
warning,
|
||||
ports: {
|
||||
canSpawn: Boolean(this.ptyController?.spawn),
|
||||
markTrusted: (agent, path) => this.markLocalWorkspaceTrustedForAgent(agent, path),
|
||||
createTerminal: (selector, options) => this.createTerminal(selector, options),
|
||||
pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft),
|
||||
sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup),
|
||||
provision: (options) => this.provisionManagedWorktreeTerminals(options),
|
||||
activate: (repoId, worktreeId, activationSetup, startup, activationDefaultTabs) =>
|
||||
this.notifyActivateWorktree(
|
||||
repoId,
|
||||
worktreeId,
|
||||
activationSetup,
|
||||
startup,
|
||||
activationDefaultTabs,
|
||||
args.navigation
|
||||
)
|
||||
}
|
||||
})
|
||||
warning = terminalWarning
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: worktree.id,
|
||||
path: worktree.path,
|
||||
branch: worktree.branch
|
||||
})
|
||||
return {
|
||||
worktree: {
|
||||
...worktree,
|
||||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||||
childWorktreeIds: [],
|
||||
lineage,
|
||||
workspaceLineage,
|
||||
git: created
|
||||
},
|
||||
...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}),
|
||||
...(returnedSetup ? { setup: returnedSetup } : {}),
|
||||
...(args.awaitTerminalProvisioning
|
||||
? {
|
||||
setupReceipt: {
|
||||
requested: effectiveDecision,
|
||||
hookFound,
|
||||
startupPolicy: setup?.waitForAgentStartup
|
||||
? ('wait-for-setup' as const)
|
||||
: ('start-immediately' as const),
|
||||
state: !hookFound
|
||||
? ('not_configured' as const)
|
||||
: effectiveDecision === 'skip' || !shouldRunSetup
|
||||
? ('skipped' as const)
|
||||
: // Why: the in-process hook is already executing, so reporting
|
||||
// spawn_failed would strand callers that retry on it.
|
||||
didSpawnSetup || didStartInProcessSetupHook
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {})
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(warning ? { warning } : {}),
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
: {}),
|
||||
...(addResult.localBaseRefUpdateSuggestion
|
||||
? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion }
|
||||
: {}),
|
||||
...(didSpawnStartup && startupTerminalHandle
|
||||
? {
|
||||
startupTerminal: {
|
||||
spawned: true,
|
||||
handle: startupTerminalHandle,
|
||||
...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}),
|
||||
...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}),
|
||||
...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}),
|
||||
surface: 'background' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
||||
import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types'
|
||||
import { navigationTargetsHost } from '../../shared/runtime-navigation'
|
||||
import { MOBILE_TERMINAL_CREATE_RESULT_TTL_MS } from './orca-runtime-core'
|
||||
|
||||
export class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithCreateTerminal {
|
||||
async createMobileSessionTerminal(
|
||||
worktreeSelector: string,
|
||||
opts: {
|
||||
afterTabId?: string
|
||||
targetGroupId?: string
|
||||
command?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
agent?: TuiAgent
|
||||
agentPrompt?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
activate?: boolean
|
||||
select?: boolean
|
||||
clientNavigationId?: string
|
||||
navigation?: RuntimeNavigationTarget
|
||||
clientMutationId?: string
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const navigation = opts.navigation ?? 'all'
|
||||
const select = opts.select ?? opts.activate !== false
|
||||
const runOpts = {
|
||||
...opts,
|
||||
activate: select && navigationTargetsHost(navigation)
|
||||
}
|
||||
const mutationId = opts.clientMutationId
|
||||
let result: RuntimeMobileSessionCreateTerminalResult
|
||||
if (!mutationId) {
|
||||
result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts)
|
||||
} else {
|
||||
// Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result.
|
||||
const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}`
|
||||
// Why: a retried create (double-tap, reconnect replay) with the same
|
||||
// idempotency key must return the in-flight operation instead of spawning a
|
||||
// duplicate terminal. Successes are kept briefly so a retry whose response
|
||||
// was lost in transit reuses the created terminal; failures are dropped
|
||||
// immediately so a retry can start a fresh create.
|
||||
const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey)
|
||||
const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts)
|
||||
if (!inflight) {
|
||||
this.mobileTerminalCreateByMutationId.set(mutationKey, run)
|
||||
const drop = (): void => {
|
||||
if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) {
|
||||
this.mobileTerminalCreateByMutationId.delete(mutationKey)
|
||||
}
|
||||
}
|
||||
void run.then(() => {
|
||||
setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.()
|
||||
}, drop)
|
||||
}
|
||||
result = await run
|
||||
}
|
||||
if (select) {
|
||||
const worktreeId =
|
||||
this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ??
|
||||
(await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.applyMobileSessionTabNavigation(
|
||||
this.getMobileSessionTabsForWorktree(worktreeId),
|
||||
result.tab.id,
|
||||
navigation,
|
||||
opts.clientNavigationId
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer } from './orca-runtime-maybe-hydrate-headless-from-renderer'
|
||||
import type { RuntimeHeadlessTerminal } from './runtime-terminal-state-records'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
|
||||
import { isNativeWindowsConptyPty } from './terminal-model-query-authority'
|
||||
import { getTerminalViewAttributes } from './terminal-view-attribute-store'
|
||||
import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror'
|
||||
|
||||
export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer {
|
||||
/** Shared factory for the per-PTY runtime emulators (seed, hydration, and
|
||||
* lazy live-byte creation): wires the Phase-5 query-reply sink and the
|
||||
* ConPTY DA1 override. The daemon emulator never goes through here. */
|
||||
protected createPtyHeadlessTerminalState(
|
||||
ptyId: string,
|
||||
dims: { cols: number; rows: number }
|
||||
): RuntimeHeadlessTerminal {
|
||||
let state: RuntimeHeadlessTerminal | null = null
|
||||
const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId))
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
pathFlavor,
|
||||
remotePosixFileUriAuthority:
|
||||
!!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32',
|
||||
wslDistro: this.ptysById.get(ptyId)?.connectionId
|
||||
? undefined
|
||||
: (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined),
|
||||
// Why: replies take the provider input path (same entry as pty:write —
|
||||
// daemon shell-ready gating and the SSH relay write apply unchanged),
|
||||
// NOT writePtyInput, so renderer interactive-output metering never
|
||||
// counts responder traffic as user-input echo.
|
||||
onQueryReply: (reply) => {
|
||||
// Why the identity check: queued writeChain links can parse after
|
||||
// disposeHeadlessTerminal, and daemon respawns reuse session ids — a
|
||||
// stale link's reply must never reach a successor PTY under this id.
|
||||
if (state !== null && this.headlessTerminals.get(ptyId) === state) {
|
||||
if (
|
||||
!shouldForwardHeadlessTerminalQueryReply(this.ptysById.get(ptyId)?.launchAgent, reply)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why this write is safe pre-shell-ready: daemon Session.write
|
||||
// QUEUES (never drops) input while the POSIX shell-ready gate is
|
||||
// pending and flushes at the ready marker or the 15s
|
||||
// SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query
|
||||
// reply is delayed at most that bound, not lost.
|
||||
this.ptyController?.write(ptyId, reply)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (isNativeWindowsConptyPty(ptyId)) {
|
||||
emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
}
|
||||
// Why the lazy getter: replies must use the freshest renderer push at
|
||||
// parse time, and stay silent (never default) before the first push.
|
||||
emulator.installViewAttributeResponder(() => getTerminalViewAttributes())
|
||||
const viewAttributes = getTerminalViewAttributes()
|
||||
if (viewAttributes) {
|
||||
emulator.applyPushedViewAttributes(viewAttributes)
|
||||
}
|
||||
const constructed: RuntimeHeadlessTerminal = {
|
||||
emulator,
|
||||
outputSequence: 0,
|
||||
writeChain: Promise.resolve(),
|
||||
ownership: new PtyShellOwnershipMirror(async () => {
|
||||
const controller = this.ptyController
|
||||
const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId)
|
||||
if (
|
||||
!controller?.confirmShellForeground ||
|
||||
this.headlessTerminals.get(ptyId) !== constructed
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const confirmed = await controller.confirmShellForeground(ptyId)
|
||||
return (
|
||||
confirmed &&
|
||||
this.headlessTerminals.get(ptyId) === constructed &&
|
||||
this.getPtyLifecycleGeneration(ptyId) === lifecycleGeneration
|
||||
)
|
||||
})
|
||||
}
|
||||
state = constructed
|
||||
return state
|
||||
}
|
||||
|
||||
/** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via
|
||||
* markNativeWindowsConptyPty when the spawn mark lands after daemon stream
|
||||
* data already created this PTY's emulator. Idempotent emulator-side. */
|
||||
protected ensureNativeWindowsConptyDa1Override(ptyId: string): void {
|
||||
if (isNativeWindowsConptyPty(ptyId)) {
|
||||
this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride()
|
||||
}
|
||||
}
|
||||
|
||||
protected getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal {
|
||||
const existing = this.headlessTerminals.get(ptyId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, size)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
return state
|
||||
}
|
||||
|
||||
protected replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void {
|
||||
this.disposeHeadlessTerminal(ptyId)
|
||||
this.providerSnapshotPreferredPtys.add(ptyId)
|
||||
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
state.writeChain = state.writeChain
|
||||
.then(async () => {
|
||||
const snapshot = await this.serializeProviderTerminalBuffer(ptyId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
|
||||
// Why: a newer live OSC 7 can arrive while the snapshot is in flight;
|
||||
// only seed metadata while no post-correction CWD has won the race.
|
||||
if (!this.terminalCwdByPtyId.has(ptyId)) {
|
||||
this.recordOsc7MetadataForPty(ptyId, data)
|
||||
}
|
||||
await state.emulator.write(data)
|
||||
if (snapshot.cwd !== undefined) {
|
||||
state.emulator.setCwd(snapshot.cwd)
|
||||
if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) {
|
||||
this.terminalCwdByPtyId.set(ptyId, snapshot.cwd)
|
||||
}
|
||||
}
|
||||
if (snapshot.oscLinks !== undefined) {
|
||||
state.emulator.setRestoredOscLinks(snapshot.oscLinks)
|
||||
}
|
||||
state.ownership.seedOwner(snapshot.terminalOwner, {
|
||||
alternateScreen: state.emulator.isAlternateScreen
|
||||
})
|
||||
state.outputSequence = snapshot.seq
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: live bytes already chain behind this replacement state.
|
||||
})
|
||||
.finally(() => {
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
protected resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
// Why: terminal reflow is a parser operation. It must sit in the same
|
||||
// per-PTY stream as output bytes or restore snapshots can bake in wraps
|
||||
// from the wrong terminal width.
|
||||
state.writeChain = state.writeChain
|
||||
.then(() => {
|
||||
state.emulator.resize(cols, rows)
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort mirror tracking; live PTY streaming must continue even
|
||||
// if xterm rejects a raced resize during teardown.
|
||||
})
|
||||
}
|
||||
|
||||
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
|
||||
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
|
||||
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
// Why: headless writes are queued to preserve xterm parser order. Clear
|
||||
// must join that same chain or an earlier PTY chunk can finish after the
|
||||
// clear request and repopulate mobile scrollback.
|
||||
state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback())
|
||||
await state.writeChain
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithResolveMobileSessionTerminalCommand } from './orca-runtime-resolve-mobile-session-terminal-command'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type {
|
||||
RuntimeMobileSessionCreateTerminalResult,
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeMobileSessionTerminalTab
|
||||
} from '../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
buildHeadlessMobileSessionTabGroups,
|
||||
buildMaterializedHeadlessParentLayout,
|
||||
getHeadlessMobileSessionGroupId
|
||||
} from './mobile-session-layout-projection'
|
||||
|
||||
export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand {
|
||||
protected async createRuntimeOwnedMobileSessionTerminal(
|
||||
worktreeId: string,
|
||||
activate: boolean,
|
||||
afterTabId?: string,
|
||||
opts: {
|
||||
command?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
identity?: { tabId: string; leafId: string; sessionId?: string }
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
targetGroupId?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
|
||||
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
|
||||
// Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn.
|
||||
const stableSessionId =
|
||||
opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`)
|
||||
const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined
|
||||
const terminal = await this.createTerminal(`id:${worktreeId}`, {
|
||||
focus: false,
|
||||
command: opts.command,
|
||||
cwd,
|
||||
env: opts.env,
|
||||
envToDelete: opts.envToDelete,
|
||||
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
||||
startupCommandDelivery: opts.startupCommandDelivery,
|
||||
...(opts.identity
|
||||
? {
|
||||
tabId: opts.identity.tabId,
|
||||
leafId: opts.identity.leafId,
|
||||
...(stableSessionId ? { sessionId: stableSessionId } : {})
|
||||
}
|
||||
: stableSessionId
|
||||
? { sessionId: stableSessionId }
|
||||
: {}),
|
||||
...(isNewSession ? { isNewSession: true } : {}),
|
||||
persistHostSessionBinding: true,
|
||||
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
|
||||
deferMobileSessionPublish: true,
|
||||
signal: opts.signal
|
||||
})
|
||||
const livePty = this.getLivePtyForHandle(terminal.handle)
|
||||
if (!livePty) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}`
|
||||
const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID()
|
||||
if (opts.viewMode) {
|
||||
// Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default.
|
||||
this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode })
|
||||
}
|
||||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const existingSurface =
|
||||
existing?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' &&
|
||||
candidate.parentTabId === parentTabId &&
|
||||
candidate.leafId === leafId
|
||||
) ?? null
|
||||
const parentLayout = buildMaterializedHeadlessParentLayout(
|
||||
leafId,
|
||||
livePty.pty.ptyId,
|
||||
existingSurface?.parentLayout
|
||||
)
|
||||
const tab: RuntimeMobileSessionTerminalTab = {
|
||||
type: 'terminal',
|
||||
id: `${parentTabId}::${leafId}`,
|
||||
parentTabId,
|
||||
leafId,
|
||||
ptyId: livePty.pty.ptyId,
|
||||
title: terminal.title ?? livePty.pty.title ?? 'Terminal',
|
||||
...(cwd ? { startupCwd: cwd } : {}),
|
||||
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
||||
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
||||
parentLayout,
|
||||
isActive: activate
|
||||
}
|
||||
const tabs = (existing?.tabs ?? [])
|
||||
.filter((candidate) => candidate.id !== tab.id)
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId
|
||||
? { parentLayout }
|
||||
: {}),
|
||||
isActive: activate ? false : candidate.isActive
|
||||
}))
|
||||
const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1
|
||||
if (insertAfter >= 0) {
|
||||
tabs.splice(insertAfter + 1, 0, tab)
|
||||
} else {
|
||||
tabs.push(tab)
|
||||
}
|
||||
const next: RuntimeMobileSessionTabsSnapshot = {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: `headless:${Date.now().toString(36)}`,
|
||||
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
|
||||
// Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too.
|
||||
activeGroupId:
|
||||
activate && opts.targetGroupId
|
||||
? opts.targetGroupId
|
||||
: (existing?.activeGroupId ?? getHeadlessMobileSessionGroupId(worktreeId)),
|
||||
activeTabId: activate ? tab.id : (existing?.activeTabId ?? null),
|
||||
activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null),
|
||||
tabGroups: buildHeadlessMobileSessionTabGroups(
|
||||
worktreeId,
|
||||
tabs,
|
||||
activate ? tab : null,
|
||||
existing?.tabGroups,
|
||||
opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined
|
||||
),
|
||||
// Why: keep group split geometry on new-tab creation, else opening a terminal while split loses the arrangement.
|
||||
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
|
||||
tabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, next)
|
||||
const result = this.toMobileSessionTabsResult(next)
|
||||
const changeSequence = ++this.mobileSessionTabsChangeSequence
|
||||
for (const subscription of this.mobileSessionTabListeners) {
|
||||
subscription.listener(
|
||||
this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId),
|
||||
changeSequence
|
||||
)
|
||||
}
|
||||
const created = result.tabs.find((candidate) => candidate.id === tab.id)
|
||||
if (!created || created.type !== 'terminal') {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return {
|
||||
tab: created,
|
||||
publicationEpoch: result.publicationEpoch,
|
||||
snapshotVersion: result.snapshotVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
export type { RuntimeTerminalCreate } from '../../shared/runtime-types'
|
||||
export {
|
||||
createTerminalRevealWarning,
|
||||
ownerSurfacing,
|
||||
resolveTerminalPresentation
|
||||
} from './orca-runtime-core'
|
||||
export { isValidHostTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
export { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
export { randomUUID } from 'node:crypto'
|
||||
export {
|
||||
copySleepingAgentLaunchConfig,
|
||||
inferCapturedClaudeAgentTeamsMode,
|
||||
mergeTerminalEnvDeletionKeys
|
||||
} from './runtime-agent-launch-resolution'
|
||||
export { buildClaudeAgentTeamsLaunchPlan } from './claude-agent-teams-shim-env'
|
||||
export {
|
||||
addClaudeTeammateModeAuto,
|
||||
addClaudeTeammateModeInProcess
|
||||
} from '../../shared/claude-agent-teams-tmux-compat'
|
||||
export { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
|
||||
export { getTerminalViewColorQueryReplyColors } from './terminal-view-attribute-store'
|
||||
export type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
export { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
export { getRuntimeDesktopSurface } from './runtime-desktop-surface'
|
||||
export type { IpcMainEvent } from 'electron'
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import * as dependencies from './orca-runtime-create-terminal-dependencies'
|
||||
import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
|
||||
import type { RuntimeTerminalPresentation } from '../../shared/runtime-types'
|
||||
|
||||
export async function createDesktopTerminal(
|
||||
runtime: OrcaRuntimeWithCreateTerminal,
|
||||
worktreeSelector: string | undefined,
|
||||
opts: dependencies.TerminalCreateOptions,
|
||||
presentation: RuntimeTerminalPresentation | undefined,
|
||||
rendererWindow: Electron.BrowserWindow | null
|
||||
): Promise<dependencies.RuntimeTerminalCreate> {
|
||||
runtime.assertGraphReady()
|
||||
const win = rendererWindow ?? runtime.getAuthoritativeWindow()
|
||||
const workspace = worktreeSelector
|
||||
? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
: null
|
||||
const launchOpts = workspace
|
||||
? await runtime.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
: opts
|
||||
const worktreeId = workspace?.id
|
||||
const cwd = workspace
|
||||
? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
|
||||
: launchOpts.cwd
|
||||
const requestId = dependencies.randomUUID()
|
||||
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
dependencies.getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
|
||||
reject(new Error('Terminal creation timed out'))
|
||||
}, 10000)
|
||||
const handler = (
|
||||
event: dependencies.IpcMainEvent,
|
||||
response: {
|
||||
requestId: string
|
||||
tabId?: string
|
||||
title?: string
|
||||
error?: string
|
||||
}
|
||||
): void => {
|
||||
if (event.sender !== win.webContents || response.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timer)
|
||||
dependencies.getRuntimeDesktopSurface().removeIpcListener('terminal:tabCreateReply', handler)
|
||||
if (response.error) {
|
||||
reject(new Error(response.error))
|
||||
} else {
|
||||
resolve({ tabId: response.tabId!, title: response.title ?? launchOpts.title ?? '' })
|
||||
}
|
||||
}
|
||||
dependencies.getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
|
||||
win.webContents.send('terminal:requestTabCreate', {
|
||||
requestId,
|
||||
worktreeId,
|
||||
command: launchOpts.command,
|
||||
cwd,
|
||||
...(launchOpts.env ? { env: launchOpts.env } : {}),
|
||||
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
|
||||
...(launchOpts.resumeProviderSession
|
||||
? { resumeProviderSession: launchOpts.resumeProviderSession }
|
||||
: {}),
|
||||
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
title: launchOpts.title,
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...dependencies.ownerSurfacing(opts.surfaceOwner !== false)
|
||||
})
|
||||
})
|
||||
const handle = await runtime.waitForTerminalHandle(reply.tabId)
|
||||
return {
|
||||
handle,
|
||||
tabId: reply.tabId,
|
||||
worktreeId: worktreeId ?? '',
|
||||
title: reply.title,
|
||||
...runtime.getPtyExecutionHostMetadata(runtime.handles.get(handle)?.ptyId ?? null),
|
||||
surface: 'visible'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithApplyTrackedPtyTitle } from './orca-runtime-apply-tracked-pty-title'
|
||||
import type {
|
||||
RuntimePtyTitleTrackerEntry,
|
||||
RuntimePtyWorktreeRecord
|
||||
} from './runtime-terminal-state-records'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
|
||||
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
|
||||
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import type { ProcessedAgentStatusChunk } from '../../shared/agent-status-osc'
|
||||
import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
|
||||
export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle {
|
||||
protected createTerminalSideEffectCommandCodeDetector(
|
||||
ptyId: string
|
||||
): NonNullable<RuntimePtyTitleTrackerEntry['commandCodeDetector']> {
|
||||
return createCommandCodeOutputStatusDetector({
|
||||
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
|
||||
onWorking: (prompt) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt })
|
||||
},
|
||||
onDone: (prompt) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected extractLastOsc7CwdForPty(
|
||||
ptyId: string,
|
||||
data: string
|
||||
): { path: string; hostname: string } | null {
|
||||
const previousTail = this.osc7ScanTailByPtyId.get(ptyId)
|
||||
if (!previousTail && !data.includes('\x1b]7;')) {
|
||||
return null
|
||||
}
|
||||
const input = `${previousTail ?? ''}${data}`
|
||||
const scanTail = extractOscScanTail(input, 4096)
|
||||
if (scanTail.length > 0) {
|
||||
this.osc7ScanTailByPtyId.set(ptyId, scanTail)
|
||||
} else {
|
||||
this.osc7ScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
const uri = extractLastOsc7Uri(input)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const pathFlavor = this.pathFlavorForPty(pty)
|
||||
return uri
|
||||
? parseFileUriPathParts(uri, {
|
||||
pathFlavor,
|
||||
remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32',
|
||||
wslDistro: pty?.connectionId
|
||||
? undefined
|
||||
: (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined)
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
protected recordOsc7MetadataForPty(
|
||||
ptyId: string,
|
||||
data: string
|
||||
): { cwd: string | null; cwdChanged: boolean } {
|
||||
const osc7 = this.extractLastOsc7CwdForPty(ptyId, data)
|
||||
const cwd = osc7?.path ?? null
|
||||
const cwdChanged =
|
||||
cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd
|
||||
if (cwdChanged) {
|
||||
this.terminalCwdByPtyId.set(ptyId, cwd)
|
||||
}
|
||||
if (osc7) {
|
||||
if (osc7.hostname) {
|
||||
this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname)
|
||||
} else {
|
||||
this.terminalFileUriHostnameByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
return { cwd, cwdChanged }
|
||||
}
|
||||
|
||||
protected pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' {
|
||||
if (!pty?.connectionId) {
|
||||
return process.platform === 'win32' ? 'win32' : 'posix'
|
||||
}
|
||||
const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath
|
||||
return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix'
|
||||
}
|
||||
|
||||
/** Returns true when any retained agent-row snapshot changed in a
|
||||
* client-visible way, so the caller can republish session snapshots. */
|
||||
protected emitTerminalAgentStatusEvents(
|
||||
ptyId: string,
|
||||
chunk: ProcessedAgentStatusChunk
|
||||
): boolean {
|
||||
// Why: snapshot retention (for mobile worktree.ps) must run even when no
|
||||
// renderer listener is attached, so we don't early-return on a missing
|
||||
// onTerminalAgentStatus — only the per-target emit below is gated on it.
|
||||
if (chunk.payloads.length === 0) {
|
||||
return false
|
||||
}
|
||||
const targets = new Map<
|
||||
string,
|
||||
{
|
||||
source: 'mounted-leaf' | 'pty-record'
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
>()
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const connectionId = pty?.connectionId ?? null
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
const paneKey = this.makeRuntimePaneKey(leaf)
|
||||
targets.set(paneKey, {
|
||||
source: 'mounted-leaf',
|
||||
paneKey,
|
||||
tabId: leaf.tabId,
|
||||
worktreeId: leaf.worktreeId,
|
||||
connectionId
|
||||
})
|
||||
}
|
||||
if (targets.size === 0 && pty?.paneKey) {
|
||||
targets.set(pty.paneKey, {
|
||||
source: 'pty-record',
|
||||
paneKey: pty.paneKey,
|
||||
tabId: pty.tabId ?? undefined,
|
||||
worktreeId: pty.worktreeId,
|
||||
connectionId
|
||||
})
|
||||
}
|
||||
let retainedChanged = false
|
||||
for (const payload of chunk.payloads) {
|
||||
this.recordAgentPromptLifecycleState(
|
||||
ptyId,
|
||||
mapExplicitAgentStateToRuntimeTerminalStatus(payload.state)
|
||||
)
|
||||
for (const target of targets.values()) {
|
||||
retainedChanged =
|
||||
this.retainAgentRowSnapshot(
|
||||
ptyId,
|
||||
target.paneKey,
|
||||
target.worktreeId,
|
||||
target.tabId,
|
||||
target.connectionId ?? null,
|
||||
payload
|
||||
) || retainedChanged
|
||||
if (!this.onTerminalAgentStatus) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
this.onTerminalAgentStatus({
|
||||
ptyId,
|
||||
...target,
|
||||
payload
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[runtime] terminal agent status listener threw', {
|
||||
ptyId,
|
||||
paneKey: target.paneKey,
|
||||
state: payload.state,
|
||||
agentType: payload.agentType,
|
||||
err
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return retainedChanged
|
||||
}
|
||||
|
||||
protected retainAgentRowSnapshot(
|
||||
ptyId: string,
|
||||
paneKey: string,
|
||||
worktreeId: string | undefined,
|
||||
tabId: string | undefined,
|
||||
connectionId: string | null,
|
||||
payload: ParsedAgentStatusPayload
|
||||
): boolean {
|
||||
return this.agentRows.retain({
|
||||
ptyId,
|
||||
paneKey,
|
||||
worktreeId,
|
||||
tabId,
|
||||
connectionId,
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
protected clearAgentRowSnapshotsForPty(ptyId: string): void {
|
||||
this.agentRows.clearPty(ptyId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithTerminalCreateDeduplication } from './orca-runtime-terminal-create-deduplication'
|
||||
import * as dependencies from './orca-runtime-create-terminal-dependencies'
|
||||
import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop'
|
||||
import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan'
|
||||
import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit'
|
||||
|
||||
export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication {
|
||||
async createTerminal(
|
||||
worktreeSelector?: string,
|
||||
opts: dependencies.TerminalCreateOptions = {}
|
||||
): Promise<dependencies.RuntimeTerminalCreate> {
|
||||
if (opts.startupAgent && worktreeSelector === undefined) {
|
||||
throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`)
|
||||
}
|
||||
const presentation = dependencies.resolveTerminalPresentation(opts)
|
||||
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
|
||||
const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow()
|
||||
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
|
||||
const shouldCreateInBackground =
|
||||
worktreeSelector !== undefined &&
|
||||
(Boolean(opts.agentSessionClaim) ||
|
||||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
|
||||
availableAuthoritativeWindow === null)
|
||||
if (shouldCreateInBackground) {
|
||||
if (!this.ptyController?.spawn) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
|
||||
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
|
||||
const reportPtySpawnCommitted = createPtySpawnCommitReporter(launchOpts.onPtySpawnCommitted)
|
||||
const cwd =
|
||||
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
|
||||
let preAllocatedHandle =
|
||||
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
|
||||
const hintedTabId = launchOpts.tabId?.trim()
|
||||
const canAdoptPaneIdentity =
|
||||
hintedTabId !== undefined &&
|
||||
dependencies.isValidHostTerminalTabId(hintedTabId) &&
|
||||
launchOpts.leafId !== undefined &&
|
||||
dependencies.isTerminalLeafId(launchOpts.leafId)
|
||||
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID()
|
||||
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID()
|
||||
let paneKey = dependencies.makePaneKey(tabId, leafId)
|
||||
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
|
||||
worktreeId: workspace.id,
|
||||
connectionId: workspace.connectionId,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
let stablePaneCreateReleased = false
|
||||
const releaseStablePaneCreate = (): void => {
|
||||
if (stablePaneCreateReleased) {
|
||||
return
|
||||
}
|
||||
stablePaneCreateReleased = true
|
||||
claimedStablePaneCreate?.()
|
||||
}
|
||||
try {
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
const launchToken = launchOpts.launchConfig
|
||||
? (launchOpts.launchToken ?? dependencies.randomUUID())
|
||||
: undefined
|
||||
const baseEnv = {
|
||||
...launchOpts.env,
|
||||
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
|
||||
}
|
||||
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
|
||||
let agentTeamsPlan: Awaited<ReturnType<typeof dependencies.buildClaudeAgentTeamsLaunchPlan>>
|
||||
let sequencedStartupCommand: string | undefined
|
||||
let effectiveLaunchConfig = launchOpts.launchConfig
|
||||
try {
|
||||
const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({
|
||||
launchConfig: launchOpts.launchConfig,
|
||||
command: launchOpts.command,
|
||||
claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand,
|
||||
claudeAgentTeamsMode,
|
||||
baseEnv: { ...process.env, ...baseEnv },
|
||||
adoptedBeforeLaunch,
|
||||
createTeamEnv: (shimDir, shimBin) =>
|
||||
this.claudeAgentTeams.createLaunchEnv({
|
||||
leaderHandle: preAllocatedHandle,
|
||||
baseEnv: { ...process.env, ...baseEnv },
|
||||
shimDir,
|
||||
shimBin
|
||||
}).env
|
||||
})
|
||||
agentTeamsPlan = agentTeams.plan
|
||||
sequencedStartupCommand = agentTeams.sequencedStartupCommand
|
||||
effectiveLaunchConfig = agentTeams.effectiveLaunchConfig
|
||||
} catch (error) {
|
||||
releaseStablePaneCreate?.()
|
||||
throw error
|
||||
}
|
||||
const env = this.buildTerminalWorkspaceEnv(
|
||||
workspace,
|
||||
{
|
||||
...baseEnv,
|
||||
...(sequencedStartupCommand
|
||||
? { [dependencies.SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
|
||||
: {})
|
||||
},
|
||||
paneKey,
|
||||
tabId,
|
||||
agentTeamsPlan?.env
|
||||
)
|
||||
const terminalColorQueryReplies =
|
||||
launchOpts.terminalColorQueryReplies ??
|
||||
dependencies.getTerminalViewColorQueryReplyColors()
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
let result: Awaited<ReturnType<NonNullable<dependencies.RuntimePtyController['spawn']>>>
|
||||
try {
|
||||
result = await this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
command: sequencedStartupCommand
|
||||
? launchOpts.command
|
||||
: (agentTeamsPlan?.command ?? launchOpts.command),
|
||||
launchAgent: launchOpts.launchAgent,
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
env,
|
||||
envToDelete: dependencies.mergeTerminalEnvDeletionKeys(
|
||||
launchOpts.envToDelete,
|
||||
agentTeamsPlan?.envToDelete
|
||||
),
|
||||
resumeProviderSession: launchOpts.resumeProviderSession,
|
||||
telemetry: launchOpts.telemetry,
|
||||
connectionId: workspace.connectionId,
|
||||
worktreeId: workspace.id,
|
||||
preAllocatedHandle,
|
||||
tabId,
|
||||
leafId,
|
||||
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
|
||||
...(launchOpts.agentSessionClaim
|
||||
? {
|
||||
agentSessionEnsure: {
|
||||
claim: launchOpts.agentSessionClaim,
|
||||
surface: {
|
||||
worktreeId: workspace.id,
|
||||
tabId,
|
||||
leafId,
|
||||
terminalHandle: preAllocatedHandle
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(launchOpts.agentSessionCreateOperationId
|
||||
? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId }
|
||||
: {}),
|
||||
...(launchOpts.signal ? { signal: launchOpts.signal } : {}),
|
||||
...(launchOpts.onPtySpawnCommitted
|
||||
? { onPtySpawnCommitted: reportPtySpawnCommitted }
|
||||
: {}),
|
||||
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
} finally {
|
||||
releaseStablePaneCreate?.()
|
||||
}
|
||||
if (!result.stablePaneOwner) {
|
||||
reportPtySpawnCommitted()
|
||||
}
|
||||
const adoptedStablePane = Boolean(result.stablePaneOwner)
|
||||
if (result.agentSessionEnsure) {
|
||||
const canonicalSurface = result.agentSessionEnsure.owner.surface
|
||||
preAllocatedHandle = canonicalSurface.terminalHandle
|
||||
tabId = canonicalSurface.tabId
|
||||
leafId = canonicalSurface.leafId
|
||||
paneKey = dependencies.makePaneKey(tabId, leafId)
|
||||
} else if (result.stablePaneOwner) {
|
||||
preAllocatedHandle = result.stablePaneOwner.handle
|
||||
tabId = result.stablePaneOwner.tabId
|
||||
leafId = result.stablePaneOwner.leafId
|
||||
paneKey = dependencies.makePaneKey(tabId, leafId)
|
||||
}
|
||||
try {
|
||||
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
this.registerPty(result.id, workspace.id, workspace.connectionId, {
|
||||
tabId,
|
||||
leafId,
|
||||
terminalHandle: preAllocatedHandle,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
|
||||
})
|
||||
if (launchOpts.structuredAgentSessionId) {
|
||||
dependencies.agentSessionPtyWriteGate.bindPty(
|
||||
result.id,
|
||||
launchOpts.structuredAgentSessionId
|
||||
)
|
||||
}
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
pty.runtimeSessionOwned = true
|
||||
if (!adoptedStablePane) {
|
||||
if (launchOpts.title) {
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.title = launchOpts.title
|
||||
pty.titleUpdatedAt = observedAt
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt)
|
||||
} else {
|
||||
pty.title = null
|
||||
pty.titleUpdatedAt = null
|
||||
}
|
||||
pty.launchConfig = effectiveLaunchConfig
|
||||
? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig)
|
||||
: null
|
||||
pty.launchToken = launchToken ?? null
|
||||
pty.launchIncarnationId = launchToken ? pty.incarnationId : null
|
||||
pty.launchAgent = launchOpts.launchAgent ?? null
|
||||
}
|
||||
pty.tabId = tabId
|
||||
pty.paneKey = paneKey
|
||||
}
|
||||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||||
if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
|
||||
tabId,
|
||||
leafId,
|
||||
title: launchOpts.title ?? null,
|
||||
activate: presentation === 'focused',
|
||||
selectIfNoActiveTab: presentation !== 'background',
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
|
||||
})
|
||||
}
|
||||
let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background'
|
||||
let warning: string | undefined
|
||||
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
|
||||
try {
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
surface = 'visible'
|
||||
} catch (err) {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
warning = dependencies.createTerminalRevealWarning(handle, err)
|
||||
}
|
||||
} else if (presentation !== 'background') {
|
||||
warning = dependencies.createTerminalRevealWarning(handle)
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
tabId,
|
||||
paneKey,
|
||||
ptyId: result.id,
|
||||
worktreeId: workspace.id,
|
||||
title: pty?.title ?? launchOpts.title ?? null,
|
||||
...this.getPtyExecutionHostMetadata(result.id),
|
||||
surface,
|
||||
...(result.pid ? { processId: result.pid } : {}),
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {}),
|
||||
...(adoptedStablePane ? { isReattach: true as const } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
} finally {
|
||||
releaseStablePaneCreate()
|
||||
}
|
||||
}
|
||||
return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithResolveExitWaiters } from './orca-runtime-resolve-exit-waiters'
|
||||
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
|
||||
import { formatMessagePointer } from './orchestration/formatter'
|
||||
import { isCursorAgentOrchestrationTarget } from './orca-runtime-core'
|
||||
|
||||
export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolveExitWaiters {
|
||||
// Why: normal delivery stays event-driven; the bounded mailbox retry only repairs missed liveness edges.
|
||||
protected deliverPendingMessages(
|
||||
leaf: RuntimeLeafRecord,
|
||||
options: {
|
||||
mailboxHandle?: string
|
||||
reservedTypes?: ReadonlySet<string>
|
||||
skipAbsenceProbe?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
if (!this._orchestrationDb) {
|
||||
return
|
||||
}
|
||||
|
||||
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
const mailboxHandle = options.mailboxHandle ?? handle
|
||||
|
||||
if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) {
|
||||
let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId)
|
||||
if (!parked) {
|
||||
parked = new Map()
|
||||
this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked)
|
||||
}
|
||||
const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes
|
||||
const reservedTypes =
|
||||
priorReservedTypes || options.reservedTypes
|
||||
? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])])
|
||||
: undefined
|
||||
parked.set(mailboxHandle, { leaf, reservedTypes })
|
||||
return
|
||||
}
|
||||
|
||||
// Why filter here and not at the trigger: the push reads every pending row,
|
||||
// not just the one that woke it, so a row a pull has claimed would be typed
|
||||
// into the pane AND returned by that pull's check. Live waiters cover the
|
||||
// still-blocked case; reservedTypes carries the notify-time snapshot for a
|
||||
// waiter resolved later in the same drain, which is already gone from the map.
|
||||
const unread = this._orchestrationDb
|
||||
.getUndeliveredUnreadMessages(mailboxHandle)
|
||||
.filter(
|
||||
(message) =>
|
||||
!options.reservedTypes?.has(message.type) &&
|
||||
!this.messageWaiters.typeHasLiveWaiter(mailboxHandle, message.type)
|
||||
)
|
||||
if (unread.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const watermark = this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1
|
||||
const priorPointedIds = this.pointedMessageIdsByHandle.get(mailboxHandle)
|
||||
if (
|
||||
!unread.some(
|
||||
(message) => message.sequence > watermark || priorPointedIds?.has(message.id) !== true
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!leaf.writable || !leaf.ptyId) {
|
||||
return
|
||||
}
|
||||
const newestSequence = unread.at(-1)?.sequence
|
||||
if (newestSequence === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!options.skipAbsenceProbe &&
|
||||
this.ptyController?.probePtyLiveness &&
|
||||
!this.controllerKnowsPtyIsLive(leaf.ptyId)
|
||||
) {
|
||||
// Why: a fire-and-forget write to a prior process's ptyId reports success
|
||||
// and would mark these delivered while losing them. Proven absence keeps
|
||||
// them queued for a future surface; unknown liveness still delivers.
|
||||
const probedPtyId = leaf.ptyId
|
||||
// Why: triggers arriving mid-probe must not each arm a continuation — the
|
||||
// Every continuation would re-read the same unread rows. The single armed
|
||||
// continuation re-reads fresh rows when it fires, so nothing is lost.
|
||||
if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) {
|
||||
return
|
||||
}
|
||||
this.probeDeferredDeliveryPtyIds.add(probedPtyId)
|
||||
void this.isLeafPtyProvenAbsent(probedPtyId)
|
||||
.then((absent) => {
|
||||
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
|
||||
if (!absent && leaf.ptyId === probedPtyId) {
|
||||
// Why a macrotask and not the stale reservation snapshot: a `remote:`
|
||||
// pty answers the probe null before its first await, so this chain can
|
||||
// settle in microtasks and overtake the resumption of a check resolved
|
||||
// meanwhile — that check's waiter is already out of the map and its
|
||||
// rows are not yet read, so the push would inject what it returns.
|
||||
// Yielding the turn lets every queued check mark its rows read first;
|
||||
// re-reading then (rather than replaying a reservation this probe may
|
||||
// have outlived) is what keeps an orphaned row from stranding.
|
||||
setTimeout(() => {
|
||||
// Why current state, not the closure: the gate that authorized this
|
||||
// push ran before the probe. A same-id cold restore inside the probe
|
||||
// window keeps ptyId identical and makes the leaf writable again, so
|
||||
// an id-only check would type the pointer plus Enter into a process
|
||||
// whose idle was never observed. Re-read the live-idle gate.
|
||||
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (
|
||||
currentLeaf?.ptyId === probedPtyId &&
|
||||
currentLeaf.lastAgentStatus === 'idle' &&
|
||||
currentLeaf.lastAgentStatusObservedLive
|
||||
) {
|
||||
this.deliverPendingMessages(currentLeaf, {
|
||||
mailboxHandle,
|
||||
skipAbsenceProbe: true
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.probeDeferredDeliveryPtyIds.delete(probedPtyId)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const deliveryPtyId = leaf.ptyId
|
||||
const flight: { enterTimer: ReturnType<typeof setTimeout> | null } = { enterTimer: null }
|
||||
this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight)
|
||||
// Why: every sync outcome — failed write, Cursor branch, or a throw —
|
||||
// must end the flight here, or a leaked flag parks this pty's deliveries
|
||||
// forever. Only an armed Enter hands settling to its own callback.
|
||||
let settlesInEnterCallback = false
|
||||
try {
|
||||
const payload = formatMessagePointer(unread.length, mailboxHandle)
|
||||
const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false
|
||||
if (!wrote) {
|
||||
return
|
||||
}
|
||||
this.lastPointedMessageSequenceByHandle.set(
|
||||
mailboxHandle,
|
||||
Math.max(watermark, newestSequence)
|
||||
)
|
||||
const pointedIdsAfterWrite =
|
||||
this.pointedMessageIdsByHandle.get(mailboxHandle) ?? new Set<string>()
|
||||
for (const message of unread) {
|
||||
pointedIdsAfterWrite.add(message.id)
|
||||
}
|
||||
this.pointedMessageIdsByHandle.set(mailboxHandle, pointedIdsAfterWrite)
|
||||
|
||||
const tabTitle = this.tabs.get(leaf.tabId)?.title
|
||||
if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) {
|
||||
// Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control.
|
||||
return
|
||||
}
|
||||
|
||||
// Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay.
|
||||
flight.enterTimer = setTimeout(() => {
|
||||
try {
|
||||
// Why current state, not the closure: graph resync replaces leaf
|
||||
// objects, so the captured record can read writable=true after the
|
||||
// pty died, and an exit retire may have superseded this flight.
|
||||
if (this.messageDeliveryFlightsByPtyId.get(deliveryPtyId) !== flight) {
|
||||
return
|
||||
}
|
||||
const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) {
|
||||
return
|
||||
}
|
||||
this.ptyController?.write(deliveryPtyId, '\r')
|
||||
} catch {
|
||||
// Terminal may have closed during the delay; mail remains queued for check.
|
||||
} finally {
|
||||
// Why finally: every outcome — submit, refusal, throw — ends the flight,
|
||||
// and settle re-runs any trigger parked during it so nothing strands.
|
||||
this.settlePendingMessageDelivery(deliveryPtyId, flight)
|
||||
}
|
||||
}, 500)
|
||||
settlesInEnterCallback = true
|
||||
} finally {
|
||||
if (!settlesInEnterCallback) {
|
||||
this.settlePendingMessageDelivery(deliveryPtyId, flight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithScheduleWaitBlockedCheck } from './orca-runtime-schedule-wait-blocked-check'
|
||||
import type { PtyTransientFact } from '../providers/types'
|
||||
import type {
|
||||
TerminalSideEffectBatch,
|
||||
TerminalSideEffectFact
|
||||
} from '../../shared/terminal-side-effect-facts'
|
||||
import { isCursorNativeAgentTitle, normalizeTerminalTitle } from '../../shared/agent-detection'
|
||||
import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects'
|
||||
|
||||
export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithScheduleWaitBlockedCheck {
|
||||
/** A transient fact the daemon detected while it held scan authority —
|
||||
* emitted through the same fact channel as byte-scanned facts. Arrives
|
||||
* between chunks, so recordTerminalSideEffectFact emits it immediately. */
|
||||
emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void {
|
||||
switch (fact.kind) {
|
||||
case 'bell':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
|
||||
return
|
||||
case 'command-finished':
|
||||
this.retirePtyAgentLaunchAuthority(ptyId)
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'command-finished',
|
||||
exitCode: fact.exitCode
|
||||
})
|
||||
return
|
||||
case 'pr-link':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link })
|
||||
return
|
||||
case '2031-subscribe':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
|
||||
return
|
||||
case '2031-unsubscribe':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
|
||||
}
|
||||
}
|
||||
|
||||
/** The daemon keep-tail dropped this PTY's oldest undelivered output; the
|
||||
* next delivered chunk is discontinuous. Reset every cross-chunk parse
|
||||
* carry so a half-open escape from before the gap cannot corrupt what
|
||||
* follows, and drop the mobile headless mirror — it rebuilds from the
|
||||
* delivered tail / snapshot seeds instead of parsing a gapped stream. */
|
||||
notePtyDataGap(ptyId: string, droppedChars = 0): void {
|
||||
if (droppedChars > 0) {
|
||||
// Why: the daemon snapshot's seq counts bytes its monitoring stream
|
||||
// dropped. Advancing without parsing preserves that absolute domain so
|
||||
// post-snapshot live chunks can be reconciled instead of duplicated.
|
||||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars
|
||||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||||
}
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||||
if (pty) {
|
||||
pty.tailPendingAnsi = ''
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
leaf.tailPendingAnsi = ''
|
||||
}
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.osc7ScanTailByPtyId.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.disposeHeadlessTerminal(ptyId)
|
||||
}
|
||||
|
||||
/** Record one derived side-effect fact: batched per chunk while applying
|
||||
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
|
||||
protected recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
|
||||
if (!this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
if (entry?.applyingChunk) {
|
||||
entry.pendingFacts.push(fact)
|
||||
return
|
||||
}
|
||||
this.emitTerminalSideEffectBatch(ptyId, [fact])
|
||||
}
|
||||
|
||||
protected emitTerminalSideEffectBatch(
|
||||
ptyId: string,
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { replay?: boolean } = {}
|
||||
): void {
|
||||
if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) {
|
||||
return
|
||||
}
|
||||
const batch: TerminalSideEffectBatch = {
|
||||
ptyId,
|
||||
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
|
||||
facts,
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
...this.resolveTerminalSideEffectAttribution(ptyId)
|
||||
}
|
||||
if (this.terminalSideEffectLocalConsumerAvailable) {
|
||||
try {
|
||||
this.onTerminalSideEffects?.(batch)
|
||||
} catch (err) {
|
||||
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
|
||||
}
|
||||
}
|
||||
if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) {
|
||||
this.emitClientEvent({ type: 'terminalSideEffects', batch })
|
||||
}
|
||||
}
|
||||
|
||||
/** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the
|
||||
* first mounted leaf, fall back to the spawn-time PTY record binding. */
|
||||
protected resolveTerminalSideEffectAttribution(ptyId: string): {
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
paneKey?: string
|
||||
connectionId?: string | null
|
||||
} {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const connectionId = pty?.connectionId ?? null
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
return {
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
paneKey: this.makeRuntimePaneKey(leaf),
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
if (pty?.paneKey) {
|
||||
return {
|
||||
worktreeId: pty.worktreeId,
|
||||
...(pty.tabId ? { tabId: pty.tabId } : {}),
|
||||
paneKey: pty.paneKey,
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Title-only replay batch for renderer (re)attach — the no-attention-replay
|
||||
* rule: snapshots restore title state, never historical bells/completions. */
|
||||
getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null {
|
||||
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker
|
||||
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
|
||||
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
|
||||
// Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a
|
||||
// tracker title Orca synthesized from hooks — but with no tracker title it is the pane's
|
||||
// only Cursor identity, so restored/mobile tabs keep it (#10258).
|
||||
const rawTitle =
|
||||
recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle))
|
||||
? recordTitle
|
||||
: null
|
||||
if (normalizedTitle === null && !rawTitle) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
ptyId,
|
||||
seq: this.ptyOutputSequenceById.get(ptyId) ?? 0,
|
||||
replay: true,
|
||||
facts: [
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!),
|
||||
rawTitle: rawTitle ?? normalizedTitle!
|
||||
}
|
||||
],
|
||||
...this.resolveTerminalSideEffectAttribution(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw last title from main's tracked PTY/leaf records — the title surface
|
||||
* the tracker (live bytes + synthetic frames) keeps current. */
|
||||
protected getTrackedRawTitleForPty(ptyId: string): string | null {
|
||||
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
|
||||
if (recordTitle) {
|
||||
return recordTitle
|
||||
}
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
if (leaf.lastOscTitle) {
|
||||
return leaf.lastOscTitle
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean {
|
||||
return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true
|
||||
}
|
||||
|
||||
/** Display fallback for identities intentionally omitted from liveness records. */
|
||||
protected getTrackedDisplayTitleForPty(ptyId: string): string | null {
|
||||
return (
|
||||
this.getTrackedRawTitleForPty(ptyId) ??
|
||||
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ??
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user