mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
Linked worktrees now get a per-worktree userData profile under
orca-dev-wt/<hash>, a deterministic collision-checked renderer port
(15200+) and CDP port (9333+), so parallel `pnpm dev` runs no longer hit
the single-instance lock. The main checkout keeps the shared orca-dev
profile; --shared-profile / --worktree-profile / ORCA_DEV_PROFILE override.
The runner prints `ORCA_DEV_INSTANCE {json}` on stdout and mirrors it to
out/dev-instance.json so agents can attach over CDP. orca-dev.mjs and the
terminal wrappers follow the profile the worktree's dev app used.
Adds profile seeding (pnpm dev:seed-profile, pnpm dev --seed-profile,
pnpm dev:fresh --seed-from): an allowlisted slim copy of projects,
workspaces, settings, SSH targets, and the account session, written as
orca-data.json for Orca's own import; the source is only read.
Storage reclaim removes per-worktree profiles once their worktree is gone
and no app, runtime, or daemon still uses them. dev-fresh-profile.sh is
replaced by a cross-platform dev-fresh-profile.mjs.
311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import net from 'node:net'
|
|
import path from 'node:path'
|
|
|
|
export const SHARED_DEV_PROFILE_DIR_NAME = 'orca-dev'
|
|
// Why the "orca-dev" prefix: CLI/orchestration dev-mode checks match "orca-dev" in ORCA_USER_DATA_PATH.
|
|
// Why short: runtime sockets live in userData and macOS caps socket paths at 104 bytes.
|
|
export const WORKTREE_DEV_PROFILES_DIR_NAME = 'orca-dev-wt'
|
|
export const WORKTREE_PROFILE_MARKER_FILE = 'orca-dev-worktree.json'
|
|
export const WORKTREE_PROFILE_NAME_PATTERN = /^[\da-f]{10}$/
|
|
export const DEV_PROFILE_MODES = ['shared', 'worktree']
|
|
|
|
// Why these ranges: clear of Vite's 5173+ (common in projects opened inside dev Orca),
|
|
// Chrome's 9222, and Orca's production ports (pairing WebSocket 6768).
|
|
export const RENDERER_PORT_RANGE = { base: 15200, spread: 200 }
|
|
export const CDP_PORT_RANGE = { base: 9333, spread: 200 }
|
|
const PORT_SWEEP = 64
|
|
|
|
// Values the runner derives per worktree; a dev Orca exports them to every terminal it spawns.
|
|
const RUNNER_DERIVED_ENV_KEYS = [
|
|
'ORCA_DEV_REPO_ROOT',
|
|
'ORCA_DEV_INSTANCE_KEY',
|
|
'ORCA_DEV_BRANCH',
|
|
'ORCA_DEV_WORKTREE_NAME',
|
|
'ORCA_DEV_INSTANCE_LABEL',
|
|
'ORCA_DEV_DOCK_TITLE',
|
|
'ORCA_DEV_RENDERER_PORT'
|
|
]
|
|
|
|
export function getAppDataDir(env = process.env, platform = process.platform) {
|
|
if (platform === 'darwin') {
|
|
return path.join(env.HOME ?? '', 'Library', 'Application Support')
|
|
}
|
|
if (platform === 'win32') {
|
|
return env.APPDATA ?? path.join(env.USERPROFILE ?? '', 'AppData', 'Roaming')
|
|
}
|
|
return env.XDG_CONFIG_HOME || path.join(env.HOME ?? '', '.config')
|
|
}
|
|
|
|
export function getSharedDevUserDataPath(env = process.env, platform = process.platform) {
|
|
return path.join(getAppDataDir(env, platform), SHARED_DEV_PROFILE_DIR_NAME)
|
|
}
|
|
|
|
export function getWorktreeDevProfilesRoot(env = process.env, platform = process.platform) {
|
|
return path.join(getAppDataDir(env, platform), WORKTREE_DEV_PROFILES_DIR_NAME)
|
|
}
|
|
|
|
export function getWorktreeDevUserDataPath(
|
|
instanceKey,
|
|
env = process.env,
|
|
platform = process.platform
|
|
) {
|
|
const name = createHash('sha1').update(instanceKey).digest('hex').slice(0, 10)
|
|
return path.join(getWorktreeDevProfilesRoot(env, platform), name)
|
|
}
|
|
|
|
export function sameFilesystemPath(left, right, platform = process.platform) {
|
|
const a = path.resolve(left)
|
|
const b = path.resolve(right)
|
|
return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
}
|
|
|
|
function isLinkedWorktree(repoRoot) {
|
|
try {
|
|
// Linked worktrees have a `.git` file pointing at the main checkout's git dir.
|
|
return lstatSync(path.join(repoRoot, '.git')).isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A dev Orca's terminals inherit the runner's derived env. A `pnpm dev` started from one of those
|
|
* terminals in another worktree must derive its own identity instead of reusing the parent's.
|
|
*/
|
|
export function dropInheritedDevInstanceEnv(env, repoRoot) {
|
|
const inheritedRoot = env.ORCA_DEV_REPO_ROOT
|
|
if (!inheritedRoot || sameFilesystemPath(inheritedRoot, repoRoot)) {
|
|
return
|
|
}
|
|
// An explicit override differs from the parent app's own userData; an inherited one matches it.
|
|
if (env.ORCA_DEV_USER_DATA_PATH && env.ORCA_DEV_USER_DATA_PATH === env.ORCA_USER_DATA_PATH) {
|
|
delete env.ORCA_DEV_USER_DATA_PATH
|
|
}
|
|
for (const key of RUNNER_DERIVED_ENV_KEYS) {
|
|
delete env[key]
|
|
}
|
|
}
|
|
|
|
/** Precedence: ORCA_DEV_USER_DATA_PATH, then --shared/--worktree-profile or ORCA_DEV_PROFILE, then checkout type. */
|
|
export function resolveDevProfile({
|
|
repoRoot,
|
|
instanceKey = repoRoot,
|
|
mode,
|
|
env = process.env,
|
|
platform = process.platform
|
|
}) {
|
|
if (env.ORCA_DEV_USER_DATA_PATH) {
|
|
return { kind: 'custom', userDataPath: path.resolve(env.ORCA_DEV_USER_DATA_PATH) }
|
|
}
|
|
const requested = mode ?? (env.ORCA_DEV_PROFILE || undefined)
|
|
if (requested !== undefined && !DEV_PROFILE_MODES.includes(requested)) {
|
|
throw new Error(
|
|
`ORCA_DEV_PROFILE must be one of ${DEV_PROFILE_MODES.join(', ')} (got ${JSON.stringify(requested)})`
|
|
)
|
|
}
|
|
// Why the main checkout keeps orca-dev: it is the long-lived profile developers already use.
|
|
const kind = requested ?? (isLinkedWorktree(repoRoot) ? 'worktree' : 'shared')
|
|
return kind === 'shared'
|
|
? { kind, userDataPath: getSharedDevUserDataPath(env, platform) }
|
|
: { kind, userDataPath: getWorktreeDevUserDataPath(instanceKey, env, platform) }
|
|
}
|
|
|
|
export function writeWorktreeProfileMarker(userDataPath, { repoRoot, instanceKey, gitCommonDir }) {
|
|
mkdirSync(userDataPath, { recursive: true, mode: 0o700 })
|
|
const markerPath = path.join(userDataPath, WORKTREE_PROFILE_MARKER_FILE)
|
|
const marker = { version: 1, repoRoot, instanceKey, gitCommonDir: gitCommonDir ?? null }
|
|
const serialized = `${JSON.stringify(marker, null, 2)}\n`
|
|
try {
|
|
if (readFileSync(markerPath, 'utf8') === serialized) {
|
|
return
|
|
}
|
|
} catch {}
|
|
writeFileSync(markerPath, serialized, { mode: 0o600 })
|
|
}
|
|
|
|
export function readWorktreeProfileMarker(userDataPath) {
|
|
try {
|
|
const marker = JSON.parse(
|
|
readFileSync(path.join(userDataPath, WORKTREE_PROFILE_MARKER_FILE), 'utf8')
|
|
)
|
|
return marker?.version === 1 && typeof marker.repoRoot === 'string' ? marker : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function getDevInstanceRecordPath(repoRoot) {
|
|
return path.join(repoRoot, 'out', 'dev-instance.json')
|
|
}
|
|
|
|
export function writeDevInstanceRecord(repoRoot, record) {
|
|
const recordPath = getDevInstanceRecordPath(repoRoot)
|
|
mkdirSync(path.dirname(recordPath), { recursive: true })
|
|
writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
|
}
|
|
|
|
export function readDevInstanceRecord(repoRoot) {
|
|
try {
|
|
const record = JSON.parse(readFileSync(getDevInstanceRecordPath(repoRoot), 'utf8'))
|
|
return typeof record?.userData === 'string' ? record : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** The CLI follows the profile its worktree's dev app last used, so `--shared-profile` runs stay reachable. */
|
|
export function resolveDevCliUserDataPath({
|
|
repoRoot,
|
|
env = process.env,
|
|
platform = process.platform
|
|
}) {
|
|
dropInheritedDevInstanceEnv(env, repoRoot)
|
|
if (env.ORCA_DEV_USER_DATA_PATH || env.ORCA_DEV_PROFILE) {
|
|
return resolveDevProfile({ repoRoot, instanceKey: env.ORCA_DEV_INSTANCE_KEY, env, platform })
|
|
.userDataPath
|
|
}
|
|
const record = readDevInstanceRecord(repoRoot)
|
|
// Custom profiles are often temporary; they keep requiring an explicit ORCA_DEV_USER_DATA_PATH.
|
|
if (record && record.profile !== 'custom' && existsSync(record.userData)) {
|
|
return record.userData
|
|
}
|
|
return resolveDevProfile({ repoRoot, instanceKey: env.ORCA_DEV_INSTANCE_KEY, env, platform })
|
|
.userDataPath
|
|
}
|
|
|
|
function canListen(port, host) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer()
|
|
server.once('error', (error) => {
|
|
try {
|
|
server.close()
|
|
} catch {}
|
|
// Hosts without IPv6 cannot have a conflicting ::1 listener.
|
|
resolve(host === '::1' && ['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(error?.code))
|
|
})
|
|
server.once('listening', () => server.close(() => resolve(true)))
|
|
server.listen({ port, host, exclusive: true })
|
|
})
|
|
}
|
|
|
|
// Why both loopbacks: Vite binds `localhost`, which resolves to ::1 first on some hosts.
|
|
export async function isPortFree(port) {
|
|
return (await canListen(port, '127.0.0.1')) && (await canListen(port, '::1'))
|
|
}
|
|
|
|
/** Deterministic per key so restarts reuse a port; sweeps forward when it is taken. */
|
|
export async function pickInstancePort(seedKey, { base, spread }, isFree = isPortFree) {
|
|
const seed = Number.parseInt(createHash('sha1').update(seedKey).digest('hex').slice(0, 8), 16)
|
|
const start = base + (seed % spread)
|
|
for (let offset = 0; offset < PORT_SWEEP; offset++) {
|
|
if (await isFree(start + offset)) {
|
|
return start + offset
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function parsePortValue(raw) {
|
|
const value = Number.parseInt(raw, 10)
|
|
if (!Number.isInteger(value) || value < 1 || value > 65535 || String(value) !== raw.trim()) {
|
|
return null
|
|
}
|
|
return value
|
|
}
|
|
|
|
async function resolveEnvOrPickedPort(env, envName, seedKey, range) {
|
|
const raw = env[envName]
|
|
if (raw) {
|
|
const port = parsePortValue(raw)
|
|
if (port !== null) {
|
|
return port
|
|
}
|
|
console.error(
|
|
`[orca-dev] Ignoring invalid ${envName}=${JSON.stringify(raw)}; falling back to probe.`
|
|
)
|
|
}
|
|
return pickInstancePort(seedKey, range)
|
|
}
|
|
|
|
function readUserPassedDebugPort(forwardedArgs) {
|
|
for (let index = 0; index < forwardedArgs.length; index++) {
|
|
const arg = forwardedArgs[index]
|
|
if (arg.startsWith('--remote-debugging-port=')) {
|
|
return parsePortValue(arg.slice('--remote-debugging-port='.length))
|
|
}
|
|
if (arg === '--remote-debugging-port') {
|
|
return parsePortValue(forwardedArgs[index + 1] ?? '')
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Why: every `pnpm dev` should be attachable from agent-browser/playwright-cli without manual
|
|
* port juggling. Exports ORCA_DEV_RENDERER_PORT for electron.vite.config.ts.
|
|
*/
|
|
export async function resolveDevInstancePorts({ forwardedArgs, instanceKey, env = process.env }) {
|
|
// Why: exact match (or `=` form) avoids false positives on `--remote-debugging-port-*` flags;
|
|
// `--remote-debugging-pipe` opts into pipe-based debugging, so no port is injected.
|
|
const userPassedPort = forwardedArgs.some(
|
|
(arg) =>
|
|
arg === '--remote-debugging-port' ||
|
|
arg.startsWith('--remote-debugging-port=') ||
|
|
arg === '--remote-debugging-pipe'
|
|
)
|
|
let cdpPort = readUserPassedDebugPort(forwardedArgs)
|
|
let forwardedExtras = []
|
|
if (!userPassedPort) {
|
|
cdpPort = await resolveEnvOrPickedPort(
|
|
env,
|
|
'REMOTE_DEBUGGING_PORT',
|
|
instanceKey,
|
|
CDP_PORT_RANGE
|
|
)
|
|
if (cdpPort !== null) {
|
|
forwardedExtras = [`--remote-debugging-port=${cdpPort}`]
|
|
} else {
|
|
console.error(
|
|
'[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
|
|
)
|
|
}
|
|
}
|
|
const rendererPort = await resolveEnvOrPickedPort(
|
|
env,
|
|
'ORCA_DEV_RENDERER_PORT',
|
|
`renderer:${instanceKey}`,
|
|
RENDERER_PORT_RANGE
|
|
)
|
|
if (rendererPort !== null) {
|
|
env.ORCA_DEV_RENDERER_PORT = String(rendererPort)
|
|
} else {
|
|
delete env.ORCA_DEV_RENDERER_PORT
|
|
}
|
|
return { cdpPort, rendererPort, forwardedExtras }
|
|
}
|
|
|
|
/** Prints one `ORCA_DEV_INSTANCE {json}` stdout line and mirrors it to out/dev-instance.json. */
|
|
export function announceDevInstance({ repoRoot, profile, cdpPort, rendererPort }) {
|
|
// Why 127.0.0.1: matches the interface probed (localhost may resolve to ::1 on IPv6-first hosts).
|
|
const instance = {
|
|
userData: profile.userDataPath,
|
|
profile: profile.kind,
|
|
rendererPort,
|
|
rendererUrl: rendererPort === null ? null : `http://localhost:${rendererPort}`,
|
|
cdpPort,
|
|
cdpUrl: cdpPort === null ? null : `http://127.0.0.1:${cdpPort}`,
|
|
repoRoot,
|
|
runnerPid: process.pid
|
|
}
|
|
if (cdpPort !== null) {
|
|
console.error(`[orca-dev] Remote debugging on ${instance.cdpUrl}`)
|
|
}
|
|
console.error(`[orca-dev] Profile (${profile.kind}): ${profile.userDataPath}`)
|
|
writeDevInstanceRecord(repoRoot, instance)
|
|
// Why stdout: agents grep `ORCA_DEV_INSTANCE ` to find the profile and CDP endpoint.
|
|
console.log(`ORCA_DEV_INSTANCE ${JSON.stringify(instance)}`)
|
|
return instance
|
|
}
|