From f9b1d65fb126e6b8c77853a1a631e954ab81156a Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 26 Oct 2026 15:13:12 -0700 Subject: [PATCH] chore: improve dev slot system --- scripts/dev-slot.mjs | 53 +++++++++++++++++++++------ scripts/dev-slot.test.mjs | 11 ++++++ src/commands/app.rs | 76 ++++++++++++++++++++++++++------------- src/main.rs | 13 +++++++ 4 files changed, 117 insertions(+), 36 deletions(-) diff --git a/scripts/dev-slot.mjs b/scripts/dev-slot.mjs index 6c192241e..d608b370e 100755 --- a/scripts/dev-slot.mjs +++ b/scripts/dev-slot.mjs @@ -19,7 +19,7 @@ const SCRIPT_PATH = fileURLToPath(import.meta.url) function usage() { return `Usage: - scripts/dev-slot.mjs start --db empty|copy [--worktree PATH] [--open] + scripts/dev-slot.mjs start --db empty|copy [--worktree PATH] [--open|--desktop] scripts/dev-slot.mjs status [--worktree PATH] scripts/dev-slot.mjs stop [--worktree PATH] scripts/dev-slot.mjs cleanup [--worktree PATH] @@ -35,7 +35,7 @@ export function parseArgs(argv) { throw new Error(usage()) } - const options = { command, worktree: process.cwd(), db: null, open: false } + const options = { command, worktree: process.cwd(), db: null, open: false, desktop: false } for (let index = 0; index < rest.length; index += 1) { const arg = rest[index] if (arg === '--worktree') { @@ -50,6 +50,8 @@ export function parseArgs(argv) { index += 1 } else if (arg === '--open') { options.open = true + } else if (arg === '--desktop') { + options.desktop = true } else if (arg === '--help' || arg === '-h') { throw new Error(usage()) } else { @@ -60,9 +62,11 @@ export function parseArgs(argv) { if (command === 'start' && options.db === null) { throw new Error('start requires --db empty or --db copy') } - if (command !== 'start' && (options.db !== null || options.open)) { - throw new Error(`--db and --open are only valid with start`) + if (command !== 'start' && (options.db !== null || options.open || options.desktop)) { + throw new Error(`--db, --open and --desktop are only valid with start`) } + if (options.open && options.desktop) throw new Error('--open and --desktop cannot be combined') + if (options.desktop && process.platform !== 'darwin') throw new Error('--desktop requires macOS') return options } @@ -75,6 +79,17 @@ function worktreeInfo(worktreeInput) { const manifestPath = path.join(worktreePath, 'Cargo.toml') if (!existsSync(manifestPath)) throw new Error(`Not an openresearch-cli worktree: ${worktreePath}`) + if (existsSync(path.join(worktreePath, '.jj'))) { + const root = realpathSync(run('jj', ['--ignore-working-copy', '--repository', worktreePath, 'root'])) + return { + worktreePath: root, manifestPath: path.join(root, 'Cargo.toml'), + worktreeKey: createHash('sha256').update(root).digest('hex').slice(0, 16), + label: path.basename(root).replaceAll(/[^a-zA-Z0-9._-]/g, '-'), + mainCheckout: root, + jj: true, + } + } + const records = run('git', ['-C', worktreePath, 'worktree', 'list', '--porcelain']) .split(/\n\n+/) .map((block) => Object.fromEntries(block.split('\n').map((line) => { @@ -98,7 +113,7 @@ function pathsFor(info, slot = null) { const base = { allocatorRoot, cargoTargetDir: path.join(allocatorRoot, 'cargo-target'), - launchRegistry: path.join(info.mainCheckout, '.claude', 'launch.json'), + launchRegistry: info.jj ? path.join(allocatorRoot, 'jj-launch.json') : path.join(info.mainCheckout, '.claude', 'launch.json'), lockDir: path.join(allocatorRoot, 'slot-allocator.lock'), lifecycleLockDir: path.join(allocatorRoot, 'lifecycle-locks', info.worktreeKey), } @@ -586,14 +601,14 @@ async function waitForUi(state) { function ensureUiDependencies(info) { if (existsSync(path.join(info.worktreePath, 'ui', 'node_modules'))) return - execFileSync('pnpm', ['-C', path.join(info.worktreePath, 'ui'), 'install', '--frozen-lockfile'], { stdio: 'inherit' }) + execFileSync('pnpm', ['-C', path.join(info.worktreePath, 'ui'), 'install', '--frozen-lockfile', '--config.strictDepBuilds=false'], { stdio: 'inherit' }) } function saveState(slotPaths, state) { atomicWriteJson(slotPaths.statePath, state) } -async function startUnlocked(info, dbMode, openBrowser) { +async function startUnlocked(info, dbMode, openBrowser, desktop) { const reservation = await reserveSlot(info, dbMode) const { slotPaths } = reservation const state = reservation.state @@ -642,7 +657,7 @@ async function startUnlocked(info, dbMode, openBrowser) { 'dev', '--port', String(slotPaths.uiPort), '--strictPort', ], { cwd: info.worktreePath, - env: { ...process.env, ORX_BACKEND: `http://127.0.0.1:${slotPaths.backendPort}` }, + env: { ...process.env, npm_config_strict_dep_builds: 'false', ORX_BACKEND: `http://127.0.0.1:${slotPaths.backendPort}` }, }, state.uiLog) saveState(slotPaths, state) } @@ -653,6 +668,22 @@ async function startUnlocked(info, dbMode, openBrowser) { state.phase = 'running' saveState(slotPaths, state) + if (desktop) { + execFileSync('cargo', ['build', '--manifest-path', info.manifestPath], { + cwd: info.worktreePath, env: { ...process.env, ...slotEnvironment(slotPaths) }, stdio: 'inherit', + }) + const contents = path.join(slotPaths.cacheDir, 'OpenResearch Dev.app', 'Contents') + mkdirSync(path.join(contents, 'MacOS'), { recursive: true }) + const executable = path.join(contents, 'MacOS', 'OpenResearch Dev') + cpSync(path.join(slotPaths.cargoTargetDir, 'debug', 'orx'), executable) + writeFileSync(path.join(contents, 'Info.plist'), `\n\nCFBundleExecutableOpenResearch DevCFBundleIdentifierorg.alphaxiv.openresearch.dev.slot${slotPaths.slot}CFBundleNameOpenResearch DevCFBundlePackageTypeAPPL\n`) + const child = spawn(executable, ['--dev-desktop', String(slotPaths.uiPort)], { + detached: true, stdio: 'ignore', env: { ...process.env, ...slotEnvironment(slotPaths) }, + }) + child.on('error', (error) => console.error(`Could not open the desktop app: ${error.message}`)) + child.unref() + } + if (openBrowser) { const url = `http://localhost:${slotPaths.uiPort}/` const opener = process.platform === 'darwin' @@ -673,10 +704,10 @@ async function startUnlocked(info, dbMode, openBrowser) { console.log(` State: ${slotPaths.statePath}`) } -async function start(info, dbMode, openBrowser) { +async function start(info, dbMode, openBrowser, desktop) { const release = await acquireLifecycleLock(info) try { - await startUnlocked(info, dbMode, openBrowser) + await startUnlocked(info, dbMode, openBrowser, desktop) } finally { await release() } @@ -850,7 +881,7 @@ async function main() { } const options = parseArgs(process.argv.slice(2)) const info = worktreeInfo(options.worktree) - if (options.command === 'start') await start(info, options.db, options.open) + if (options.command === 'start') await start(info, options.db, options.open, options.desktop) if (options.command === 'status') printStatus(info) if (options.command === 'stop') await stopOwnedProcesses(info) if (options.command === 'cleanup') await cleanup(info) diff --git a/scripts/dev-slot.test.mjs b/scripts/dev-slot.test.mjs index 49881a53d..4e5b0d155 100644 --- a/scripts/dev-slot.test.mjs +++ b/scripts/dev-slot.test.mjs @@ -24,6 +24,17 @@ test('requires an explicit database mode when starting', () => { assert.equal(parseArgs(['start', '--db', 'copy', '--open']).open, true) }) +test('desktop launch is optional and exclusive to start', () => { + assert.equal(parseArgs(['start', '--db', 'empty']).desktop, false) + assert.throws(() => parseArgs(['status', '--desktop']), /only valid with start/) + assert.throws(() => parseArgs(['start', '--db', 'empty', '--open', '--desktop']), /cannot be combined/) + if (process.platform === 'darwin') { + assert.equal(parseArgs(['start', '--db', 'empty', '--desktop']).desktop, true) + } else { + assert.throws(() => parseArgs(['start', '--db', 'empty', '--desktop']), /requires macOS/) + } +}) + test('empty mode creates no database', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'orx-dev-slot-empty-')) try { diff --git a/src/commands/app.rs b/src/commands/app.rs index 7cf1d94f2..65a7014a8 100644 --- a/src/commands/app.rs +++ b/src/commands/app.rs @@ -186,9 +186,14 @@ pub async fn run() { .unwrap_or(4791) }); #[cfg(target_os = "macos")] - imp::run_event_loop(port); + imp::run_event_loop(port, false); #[cfg(not(target_os = "macos"))] - imp::run_event_loop(port, focus_requests); + imp::run_event_loop(port, false, focus_requests); +} + +#[cfg(all(target_os = "macos", debug_assertions))] +pub fn run_dev(port: u16) { + imp::run_event_loop(port, true); } /// Adopt the user's shell environment in place of the one launchd or the desktop @@ -661,9 +666,17 @@ mod imp { pub(super) fn run_event_loop( port: u16, + external_server: bool, #[cfg(not(target_os = "macos"))] focus_requests: super::instance::FocusRequests, ) { - let origin = format!("http://127.0.0.1:{port}"); + let origin = format!( + "http://{}:{port}", + if external_server { + "localhost" + } else { + "127.0.0.1" + } + ); // Before GTK starts: the window class the dock matches to the desktop // entry's StartupWMClass. #[cfg(target_os = "linux")] @@ -671,32 +684,41 @@ mod imp { let event_loop = EventLoopBuilder::::with_user_event().build(); // Dashboard server on background workers (we're inside main's runtime). - tokio::spawn(async move { - let args = crate::UpArgs { - port, - remote: None, - no_browser: true, - no_agent: false, - model: None, - remote_host: false, - desktop_app: true, - }; - // The window is useless without its server, so the app goes with it. - match crate::commands::up::run(args).await { - Ok(()) => std::process::exit(0), - Err(err) => { - eprintln!("openresearch app: dashboard server exited: {err}"); - crate::show_error_dialog(&format!("The dashboard server stopped: {err}")); - std::process::exit(1); + if !external_server { + tokio::spawn(async move { + let args = crate::UpArgs { + port, + remote: None, + no_browser: true, + no_agent: false, + model: None, + remote_host: false, + desktop_app: true, + }; + // The window is useless without its server, so the app goes with it. + match crate::commands::up::run(args).await { + Ok(()) => std::process::exit(0), + Err(err) => { + eprintln!("openresearch app: dashboard server exited: {err}"); + crate::show_error_dialog(&format!("The dashboard server stopped: {err}")); + std::process::exit(1); + } } - } - }); + }); + } let ready = event_loop.create_proxy(); tokio::spawn(async move { - while tokio::net::TcpStream::connect(("127.0.0.1", port)) - .await - .is_err() + while tokio::net::TcpStream::connect(( + if external_server { + "localhost" + } else { + "127.0.0.1" + }, + port, + )) + .await + .is_err() { tokio::time::sleep(Duration::from_millis(100)).await; } @@ -960,6 +982,10 @@ mod imp { // The server's own shutdown path stops the agents and then exits // the process. Quit::Flushing => { + if external_server { + *control_flow = ControlFlow::Exit; + return; + } crate::commands::up::request_shutdown(); quit = Quit::ShuttingDown; *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_secs(5)); diff --git a/src/main.rs b/src/main.rs index fe4a5d421..299abe474 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1005,6 +1005,19 @@ fn explain_removed_spawn_flags(mut error: clap::Error) -> clap::Error { // on worker threads. A `current_thread` flavor would deadlock. See commands::app. #[tokio::main] async fn main() { + #[cfg(all(target_os = "macos", debug_assertions))] + if std::env::args().nth(1).as_deref() == Some("--dev-desktop") { + let port = std::env::args() + .nth(2) + .and_then(|arg| arg.parse::().ok()); + if let Some(port) = port.filter(|port| (5201..=5209).contains(port)) { + commands::app::run_dev(port); + } else { + eprintln!("--dev-desktop requires a dev slot UI port (5201–5209)"); + std::process::exit(1); + } + return; + } if std::env::var_os("ORX_SSH_ASKPASS_ENDPOINT").is_some() { if jobs::ssh::service::askpass().await.is_err() { std::process::exit(1);