diff --git a/extensions/workspaces/scripts/attach.ts b/extensions/workspaces/scripts/attach.ts index 77030ac3e6..2556450375 100644 --- a/extensions/workspaces/scripts/attach.ts +++ b/extensions/workspaces/scripts/attach.ts @@ -1,12 +1,12 @@ /** - * Workspace Attach Utility (Local) - * - * Re-attaches to a running tmux session inside the container on the worker. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import path from 'path'; -import fs from 'fs'; -import { spawnSync } from 'child_process'; -import { fileURLToPath } from 'url'; +import path from 'node:path'; +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -14,19 +14,22 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..'); const q = (str: string) => `'${str.replace(/'/g, "'\\''")}'`; -export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process.env) { +export async function runAttach( + args: string[], + env: NodeJS.ProcessEnv = process.env, +) { const prNumber = args[0]; const action = args[1] || 'review'; const isLocal = args.includes('--local'); - + if (!prNumber) { - console.error('Usage: npm run workspace:attach [action] [--local]'); + console.error('Usage: workspace attach [action] [--local]'); return 1; } const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json'); if (!fs.existsSync(settingsPath)) { - console.error('โŒ Settings not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Settings not found. Run "workspace setup" first.'); return 1; } const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); @@ -38,18 +41,30 @@ export async function runAttach(args: string[], env: NodeJS.ProcessEnv = process const { projectId, zone } = config; const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`; - const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM }); + const provider = ProviderFactory.getProvider({ + projectId, + zone, + instanceName: targetVM, + }); const sessionName = `workspace-${prNumber}-${action}`; const containerAttach = `sudo docker exec -it maintainer-worker sh -c ${q(`tmux attach-session -t ${sessionName}`)}`; - const finalSSH = provider.getRunCommand(containerAttach, { interactive: true }); + const finalSSH = provider.getRunCommand(containerAttach, { + interactive: true, + }); console.log(`๐Ÿ”— Attaching to session: ${sessionName}...`); - const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID; + const isWithinGemini = + !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID; if (isWithinGemini && !isLocal) { - const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `workspace-attach-${prNumber}.sh`); - fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 }); + const tempCmdPath = path.join( + process.env.TMPDIR || '/tmp', + `workspace-attach-${prNumber}.sh`, + ); + fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { + mode: 0o755, + }); const appleScript = ` on run argv diff --git a/extensions/workspaces/scripts/check.ts b/extensions/workspaces/scripts/check.ts index 195e5a826b..72681bdf1f 100644 --- a/extensions/workspaces/scripts/check.ts +++ b/extensions/workspaces/scripts/check.ts @@ -1,13 +1,21 @@ -import { spawnSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../..'); -export async function runChecker(args: string[], env: NodeJS.ProcessEnv = process.env) { +export async function runChecker( + args: string[], + env: NodeJS.ProcessEnv = process.env, +) { const prNumber = args[0]; if (!prNumber) { console.error('Usage: npm run review:check '); @@ -16,7 +24,7 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json'); if (!fs.existsSync(settingsPath)) { - console.error('โŒ Settings not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Settings not found. Run "workspace setup" first.'); return 1; } const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); @@ -27,11 +35,21 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces } const { projectId, zone, remoteWorkDir } = config; const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`; - const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM }); + const provider = ProviderFactory.getProvider({ + projectId, + zone, + instanceName: targetVM, + }); - console.log(`๐Ÿ” Checking remote status for PR #${prNumber} on ${targetVM}...`); + console.log( + `๐Ÿ” Checking remote status for PR #${prNumber} on ${targetVM}...`, + ); - const branchView = spawnSync('gh', ['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'], { shell: true }); + const branchView = spawnSync( + 'gh', + ['pr', 'view', prNumber, '--json', 'headRefName', '-q', '.headRefName'], + { shell: true }, + ); const branchName = branchView.stdout.toString().trim(); const logDir = `${remoteWorkDir}/${branchName}/.gemini/logs/review-${prNumber}`; @@ -41,13 +59,20 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces console.log('\n--- Task Status ---'); for (const task of tasks) { const exitFile = `${logDir}/${task}.exit`; - const checkExit = await provider.getExecOutput(`[ -f ${exitFile} ] && cat ${exitFile}`, { wrapContainer: 'maintainer-worker' }); - + const checkExit = await provider.getExecOutput( + `[ -f ${exitFile} ] && cat ${exitFile}`, + { wrapContainer: 'maintainer-worker' }, + ); + if (checkExit.status === 0 && checkExit.stdout.trim()) { const code = checkExit.stdout.trim(); - console.log(` ${code === '0' ? 'โœ…' : 'โŒ'} ${task.padEnd(10)}: ${code === '0' ? 'SUCCESS' : `FAILED (exit ${code})`}`); + console.log( + ` ${code === '0' ? 'โœ…' : 'โŒ'} ${task.padEnd(10)}: ${code === '0' ? 'SUCCESS' : `FAILED (exit ${code})`}`, + ); } else { - const checkRunning = await provider.exec(`[ -f ${logDir}/${task}.log ]`, { wrapContainer: 'maintainer-worker' }); + const checkRunning = await provider.exec(`[ -f ${logDir}/${task}.log ]`, { + wrapContainer: 'maintainer-worker', + }); if (checkRunning === 0) { console.log(` โณ ${task.padEnd(10)}: RUNNING`); } else { @@ -58,9 +83,13 @@ export async function runChecker(args: string[], env: NodeJS.ProcessEnv = proces } if (allDone) { - console.log('\nโœจ All remote tasks complete. You can now synthesize the results.'); + console.log( + '\nโœจ All remote tasks complete. You can now synthesize the results.', + ); } else { - console.log('\nโณ Some tasks are still in progress. Check again in a few minutes.'); + console.log( + '\nโณ Some tasks are still in progress. Check again in a few minutes.', + ); } return 0; } diff --git a/extensions/workspaces/scripts/clean.ts b/extensions/workspaces/scripts/clean.ts index d26bb8ff27..09fbe35fe4 100644 --- a/extensions/workspaces/scripts/clean.ts +++ b/extensions/workspaces/scripts/clean.ts @@ -1,20 +1,22 @@ /** - * Universal Workspace Cleanup (Local) - * - * Surgical or full cleanup of sessions and worktrees on the GCE worker. - * Refactored to use WorkerProvider for container compatibility. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; -import readline from 'readline'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import readline from 'node:readline'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../..'); async function confirm(question: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); return new Promise((resolve) => { rl.question(`${question} (y/n): `, (answer) => { rl.close(); @@ -23,13 +25,16 @@ async function confirm(question: string): Promise { }); } -export async function runCleanup(args: string[], env: NodeJS.ProcessEnv = process.env) { +export async function runCleanup( + args: string[], + env: NodeJS.ProcessEnv = process.env, +) { const prNumber = args[0]; const action = args[1]; const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json'); if (!fs.existsSync(settingsPath)) { - console.error('โŒ Settings not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Settings not found. Run "workspace setup" first.'); return 1; } @@ -42,54 +47,81 @@ export async function runCleanup(args: string[], env: NodeJS.ProcessEnv = proces const { projectId, zone } = config; const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`; - const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM }); + const provider = ProviderFactory.getProvider({ + projectId, + zone, + instanceName: targetVM, + }); if (prNumber && action) { const sessionName = `workspace-${prNumber}-${action}`; const worktreePath = `/home/node/.workspaces/worktrees/${sessionName}`; - - console.log(`๐Ÿงน Surgically removing session and worktree for ${prNumber}-${action}...`); - + + console.log( + `๐Ÿงน Surgically removing session and worktree for ${prNumber}-${action}...`, + ); + // Kill specific tmux session inside container - await provider.exec(`tmux kill-session -t ${sessionName} 2>/dev/null`, { wrapContainer: 'maintainer-worker' }); - + await provider.exec(`tmux kill-session -t ${sessionName} 2>/dev/null`, { + wrapContainer: 'maintainer-worker', + }); + // Remove specific worktree inside container - await provider.exec(`cd /home/node/.workspaces/main && git worktree remove -f ${worktreePath} 2>/dev/null && git worktree prune`, { wrapContainer: 'maintainer-worker' }); - + await provider.exec( + `cd /home/node/.workspaces/main && git worktree remove -f ${worktreePath} 2>/dev/null && git worktree prune`, + { wrapContainer: 'maintainer-worker' }, + ); + console.log(`โœ… Cleaned up ${prNumber}-${action}.`); return 0; } // --- Bulk Cleanup --- - console.log(`โš ๏ธ DANGER: You are about to perform a BULK cleanup on ${targetVM}.`); - const confirmed = await confirm(' Are you sure you want to kill ALL sessions and worktrees?'); + console.log( + `โš ๏ธ DANGER: You are about to perform a BULK cleanup on ${targetVM}.`, + ); + const confirmed = await confirm( + ' Are you sure you want to kill ALL sessions and worktrees?', + ); if (!confirmed) { - console.log('โŒ Cleanup cancelled.'); - return 0; + console.log('โŒ Cleanup cancelled.'); + return 0; } console.log(`๐Ÿงน Starting BULK cleanup...`); // 1. Standard Cleanup console.log(' - Killing ALL remote tmux sessions...'); - await provider.exec(`tmux kill-server`, { wrapContainer: 'maintainer-worker' }); + await provider.exec(`tmux kill-server`, { + wrapContainer: 'maintainer-worker', + }); console.log(' - Cleaning up Docker resources...'); await provider.exec(`sudo docker rm -f maintainer-worker || true`); await provider.exec(`sudo docker system prune -af --volumes`); console.log(' - Cleaning up ALL Git Worktrees...'); - await provider.exec(`cd /home/node/.workspaces/main && git worktree prune && rm -rf /home/node/.workspaces/worktrees/*`, { wrapContainer: 'maintainer-worker' }); + await provider.exec( + `cd /home/node/.workspaces/main && git worktree prune && rm -rf /home/node/.workspaces/worktrees/*`, + { wrapContainer: 'maintainer-worker' }, + ); console.log('โœ… Remote environment cleared.'); // 2. Full Wipe Option - const shouldWipe = await confirm('\nWould you like to COMPLETELY wipe the remote workspace (main clone)?'); - + const shouldWipe = await confirm( + '\nWould you like to COMPLETELY wipe the remote workspace (main clone)?', + ); + if (shouldWipe) { console.log(`๐Ÿ”ฅ Wiping /home/node/.workspaces/main...`); - await provider.exec(`rm -rf /home/node/.workspaces/main && mkdir -p /home/node/.workspaces/main`, { wrapContainer: 'maintainer-worker' }); - console.log('โœ… Remote hub wiped. You will need to run npm run workspace:setup again.'); + await provider.exec( + `rm -rf /home/node/.workspaces/main && mkdir -p /home/node/.workspaces/main`, + { wrapContainer: 'maintainer-worker' }, + ); + console.log( + 'โœ… Remote hub wiped. You will need to run workspace setup again.', + ); } return 0; } diff --git a/extensions/workspaces/scripts/fleet.ts b/extensions/workspaces/scripts/fleet.ts index 6eca61479a..7c18cbd882 100644 --- a/extensions/workspaces/scripts/fleet.ts +++ b/extensions/workspaces/scripts/fleet.ts @@ -1,12 +1,12 @@ /** - * Workspace Fleet Manager - * - * Manages dynamic GCP workers for workspaces tasks. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -22,7 +22,9 @@ function getProjectId(): string { try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); return settings.workspace?.projectId; - } catch (e) {} + } catch { + // Ignore + } } return process.env.GOOGLE_CLOUD_PROJECT || ''; } @@ -30,36 +32,47 @@ function getProjectId(): string { async function listWorkers() { const projectId = getProjectId(); if (!projectId) { - console.error('โŒ Project ID not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Project ID not found. Run "workspace setup" first.'); return; } - + console.log(`๐Ÿ” Listing Workspace Workers for ${USER} in ${projectId}...`); - - spawnSync('gcloud', [ - 'compute', 'instances', 'list', - '--project', projectId, - '--filter', `name~^${INSTANCE_PREFIX}`, - '--format', 'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)' - ], { stdio: 'inherit' }); + + spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'list', + '--project', + projectId, + '--filter', + `name~^${INSTANCE_PREFIX}`, + '--format', + 'table(name,zone,status,networkInterfaces[0].networkIP:label=INTERNAL_IP,creationTimestamp)', + ], + { stdio: 'inherit' }, + ); } async function provisionWorker() { const projectId = getProjectId(); if (!projectId) { - console.error('โŒ Project ID not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Project ID not found. Run "workspace setup" first.'); return; } - const provider = ProviderFactory.getProvider({ - projectId: projectId, - zone: DEFAULT_ZONE, - instanceName: INSTANCE_PREFIX + const provider = ProviderFactory.getProvider({ + projectId: projectId, + zone: DEFAULT_ZONE, + instanceName: INSTANCE_PREFIX, }); const status = await provider.getStatus(); if (status.status !== 'UNKNOWN' && status.status !== 'ERROR') { - console.log(`โœ… Worker ${INSTANCE_PREFIX} already exists and is ${status.status}.`); + console.log( + `โœ… Worker ${INSTANCE_PREFIX} already exists and is ${status.status}.`, + ); return; } @@ -68,12 +81,12 @@ async function provisionWorker() { async function stopWorker() { const projectId = getProjectId(); - const provider = ProviderFactory.getProvider({ - projectId: projectId, - zone: DEFAULT_ZONE, - instanceName: INSTANCE_PREFIX + const provider = ProviderFactory.getProvider({ + projectId: projectId, + zone: DEFAULT_ZONE, + instanceName: INSTANCE_PREFIX, }); - + console.log(`๐Ÿ›‘ Stopping workspace worker: ${INSTANCE_PREFIX}...`); await provider.stop(); } @@ -81,14 +94,28 @@ async function stopWorker() { async function rebuildWorker() { const projectId = getProjectId(); console.log(`๐Ÿ”ฅ Rebuilding worker ${INSTANCE_PREFIX}...`); - + const knownHostsPath = path.join(REPO_ROOT, '.gemini/workspaces_known_hosts'); if (fs.existsSync(knownHostsPath)) { - console.log(` - Clearing isolated known_hosts...`); - fs.unlinkSync(knownHostsPath); + console.log(` - Clearing isolated known_hosts...`); + fs.unlinkSync(knownHostsPath); } - spawnSync('gcloud', ['compute', 'instances', 'delete', INSTANCE_PREFIX, '--project', projectId, '--zone', DEFAULT_ZONE, '--quiet'], { stdio: 'inherit' }); + spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'delete', + INSTANCE_PREFIX, + '--project', + projectId, + '--zone', + DEFAULT_ZONE, + '--quiet', + ], + { stdio: 'inherit' }, + ); await provisionWorker(); } diff --git a/extensions/workspaces/scripts/logs.ts b/extensions/workspaces/scripts/logs.ts index 1c24943959..7243f498ed 100644 --- a/extensions/workspaces/scripts/logs.ts +++ b/extensions/workspaces/scripts/logs.ts @@ -1,12 +1,12 @@ /** - * Workspace Log Tailer (Local) - * - * Tails the latest remote logs for a specific job. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../..'); @@ -14,9 +14,9 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..'); export async function runLogs(args: string[]) { const prNumber = args[0]; const action = args[1] || 'review'; - + if (!prNumber) { - console.error('Usage: npm run workspace:logs [action]'); + console.error('Usage: workspace logs [action]'); return 1; } @@ -42,7 +42,10 @@ export async function runLogs(args: string[]) { tail -f "$latest_log" `; - spawnSync(`ssh -F ${sshConfigPath} ${remoteHost} ${JSON.stringify(tailCmd)}`, { stdio: 'inherit', shell: true }); + spawnSync( + `ssh -F ${sshConfigPath} ${remoteHost} ${JSON.stringify(tailCmd)}`, + { stdio: 'inherit', shell: true }, + ); return 0; } diff --git a/extensions/workspaces/scripts/orchestrator.ts b/extensions/workspaces/scripts/orchestrator.ts index 85aa055c44..041c5d4b28 100644 --- a/extensions/workspaces/scripts/orchestrator.ts +++ b/extensions/workspaces/scripts/orchestrator.ts @@ -1,63 +1,71 @@ /** - * Workspace Orchestrator (Local) - * - * Central coordination of remote tasks. - * Wakes workers, prepares worktrees, and launches tmux sessions. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../..'); function q(str: string) { - return `'${str.replace(/'/g, "'\\''")}'`; + return `'${str.replace(/'/g, "'\\''")}'`; } -export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = process.env) { +export async function runOrchestrator( + args: string[], + env: NodeJS.ProcessEnv = process.env, +) { let prNumber = args[0]; let action = args[1] || 'review'; - // Handle "shell" mode: npm run workspace:shell [identifier] + // Handle "shell" mode: workspace shell [identifier] const isShellMode = prNumber === 'shell'; if (isShellMode) { - prNumber = args[1] || `adhoc-${Math.floor(Math.random() * 10000)}`; - action = 'shell'; + prNumber = args[1] || `adhoc-${Math.floor(Math.random() * 10000)}`; + action = 'shell'; } if (!prNumber) { - console.error('โŒ Usage: npm run workspace [action] OR npm run workspace:shell [identifier]'); + console.error( + 'โŒ Usage: workspace [action] OR workspace shell [identifier]', + ); return 1; } // 1. Load Settings const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json'); if (!fs.existsSync(settingsPath)) { - console.error('โŒ Workspace settings not found. Run "npm run workspace:setup" first.'); + console.error( + 'โŒ Workspace settings not found. Run "workspace setup" first.', + ); return 1; } const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); const config = settings.workspace; const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`; - const provider = ProviderFactory.getProvider({ projectId: config.projectId, zone: config.zone, instanceName: targetVM }); + const provider = ProviderFactory.getProvider({ + projectId: config.projectId, + zone: config.zone, + instanceName: targetVM, + }); // 2. Wake Worker & Verify Container await provider.ensureReady(); // Retrieve the remote user to ensure we run git commands correctly - const whoamiRes = await provider.getExecOutput('whoami'); - const remoteUser = whoamiRes.stdout.trim(); + await provider.getExecOutput('whoami'); // Paths - Unified across host and container const hostWorkspaceRoot = `/home/node/.workspaces`; const hostWorkDir = `${hostWorkspaceRoot}/main`; - const containerHome = '/home/node'; const containerWorkspaceRoot = `/home/node/.workspaces`; - + const remotePolicyPath = `${containerWorkspaceRoot}/policies/workspace-policy.toml`; const persistentScripts = `${containerWorkspaceRoot}/scripts`; const sessionName = `workspace-${prNumber}-${action}`; @@ -65,40 +73,42 @@ export async function runOrchestrator(args: string[], env: NodeJS.ProcessEnv = p const hostWorktreeDir = `${hostWorkspaceRoot}/worktrees/${sessionName}`; // 3. Remote Context Setup (Executed on HOST for permission simplicity) - console.log(`๐Ÿš€ Preparing remote environment for ${action} on ${isShellMode ? 'branch/id' : '#'}${prNumber}...`); - + console.log( + `๐Ÿš€ Preparing remote environment for ${action} on ${isShellMode ? 'branch/id' : '#'}${prNumber}...`, + ); + // FIX: Use the host path to check for existence const check = await provider.getExecOutput(`ls -d ${hostWorktreeDir}/.git`); - + // FIX: Ensure container user (node) owns the workspaces directories console.log(' - Synchronizing container permissions...'); await provider.exec(`sudo chown -R 1000:1000 /home/node/.workspaces`); -if (check.status !== 0) { - console.log(` - Provisioning isolated git worktree for ${prNumber}...`); + if (check.status !== 0) { + console.log(` - Provisioning isolated git worktree for ${prNumber}...`); - // We run these on the host. Since setup might have left the repo root-owned, we use sudo. - // We use environment variables to bypass safe.directory checks on a read-only filesystem. - const gitEnv = `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=${hostWorkDir}`; + // We run these on the host. Since setup might have left the repo root-owned, we use sudo. + // We use environment variables to bypass safe.directory checks on a read-only filesystem. + const gitEnv = `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0=${hostWorkDir}`; - const gitFetch = isShellMode + const gitFetch = isShellMode ? `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet origin` : `sudo ${gitEnv} git -C ${hostWorkDir} fetch --quiet upstream pull/${prNumber}/head`; - const gitTarget = isShellMode ? 'FETCH_HEAD' : 'FETCH_HEAD'; + const gitTarget = isShellMode ? 'FETCH_HEAD' : 'FETCH_HEAD'; - const setupCmd = ` - sudo mkdir -p ${hostWorkspaceRoot}/worktrees && \ - sudo chown chronos:chronos ${hostWorkspaceRoot}/worktrees && \ - ${gitFetch} && \ - sudo ${gitEnv} git -C ${hostWorkDir} worktree add --quiet -f ${hostWorktreeDir} ${gitTarget} 2>&1 && \ - sudo chown -R 1000:1000 ${hostWorkspaceRoot} - `; + const setupCmd = ` + sudo mkdir -p ${hostWorkspaceRoot}/worktrees && \ + sudo chown chronos:chronos ${hostWorkspaceRoot}/worktrees && \ + ${gitFetch} && \ + sudo ${gitEnv} git -C ${hostWorkDir} worktree add --quiet -f ${hostWorktreeDir} ${gitTarget} 2>&1 && \ + sudo chown -R 1000:1000 ${hostWorkspaceRoot} + `; const setupRes = await provider.getExecOutput(setupCmd); if (setupRes.status !== 0) { - console.error(' โŒ Failed to provision remote worktree.'); - console.error(' STDOUT:', setupRes.stdout); - console.error(' STDERR:', setupRes.stderr); - return 1; + console.error(' โŒ Failed to provision remote worktree.'); + console.error(' STDOUT:', setupRes.stdout); + console.error(' STDERR:', setupRes.stderr); + return 1; } console.log(' โœ… Worktree provisioned successfully.'); } else { @@ -107,13 +117,19 @@ if (check.status !== 0) { // AUTH: Dynamically retrieve credentials from host-side config/disk const remoteConfigPath = `${hostWorkspaceRoot}/gemini-cli-config/.gemini/settings.json`; - const remoteSettingsRes = await provider.getExecOutput(`cat ${remoteConfigPath}`); + const remoteSettingsRes = await provider.getExecOutput( + `cat ${remoteConfigPath}`, + ); const remoteSettingsJson = remoteSettingsRes.stdout.trim(); - - const apiKeyRes = await provider.getExecOutput(`cat ${remoteConfigPath} | grep apiKey | cut -d '\"' -f 4`); + + const apiKeyRes = await provider.getExecOutput( + `cat ${remoteConfigPath} | grep apiKey | cut -d '"' -f 4`, + ); const remoteApiKey = apiKeyRes.stdout.trim(); - - const ghTokenRes = await provider.getExecOutput(`cat ${hostWorkspaceRoot}/.gh_token`); + + const ghTokenRes = await provider.getExecOutput( + `cat ${hostWorkspaceRoot}/.gh_token`, + ); const remoteGhToken = ghTokenRes.stdout.trim(); // AUTH: Inject credentials and settings directly into the worktree @@ -126,19 +142,23 @@ GEMINI_AUTO_UPDATE=0 GEMINI_SANDBOX=workspace GEMINI_HOST=${targetVM} `.trim(); - await provider.exec(`sudo docker exec maintainer-worker sh -c ${q(`echo ${q(dotEnvContent)} > ${remoteWorktreeDir}/.env`)}`); - + await provider.exec( + `sudo docker exec maintainer-worker sh -c ${q(`echo ${q(dotEnvContent)} > ${remoteWorktreeDir}/.env`)}`, + ); + // Also inject the settings.json into the worktree's .gemini folder for maximum reliability - await provider.exec(`sudo docker exec maintainer-worker sh -c ${q(`mkdir -p ${remoteWorktreeDir}/.gemini && echo ${q(remoteSettingsJson)} > ${remoteWorktreeDir}/.gemini/settings.json`)}`); + await provider.exec( + `sudo docker exec maintainer-worker sh -c ${q(`mkdir -p ${remoteWorktreeDir}/.gemini && echo ${q(remoteSettingsJson)} > ${remoteWorktreeDir}/.gemini/settings.json`)}`, + ); // 4. Execution Logic // In shell mode, we just start gemini. In action mode, we run the entrypoint. - const remoteWorker = isShellMode + const remoteWorker = isShellMode ? `gemini` : `tsx ${persistentScripts}/entrypoint.ts ${prNumber} . ${remotePolicyPath} ${action}`; const authEnv = `-e GEMINI_AUTO_UPDATE=0 ${remoteApiKey ? `-e GEMINI_API_KEY=${remoteApiKey} ` : ''}${remoteGhToken ? `-e GITHUB_TOKEN=${remoteGhToken} -e GH_TOKEN=${remoteGhToken} ` : ''}`; - + // PERSISTENCE: Wrap the entire execution in a tmux session inside the container // We HIDE the tmux status bar to reduce visual noise const tmuxStyle = ` @@ -147,25 +167,37 @@ GEMINI_HOST=${targetVM} const tmuxCmd = `tmux new-session -A -s ${sessionName} ${q(`${tmuxStyle} cd ${remoteWorktreeDir} && ${remoteWorker}; exec $SHELL`)}`; const containerWrap = `sudo docker exec -it -e COLORTERM=truecolor -e TERM=xterm-256color ${authEnv}maintainer-worker sh -c ${q(tmuxCmd)}`; - + const finalSSH = provider.getRunCommand(containerWrap, { interactive: true }); - const isWithinGemini = !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID; - + const isWithinGemini = + !!env.GEMINI_CLI || !!env.GEMINI_SESSION_ID || !!env.GCLI_SESSION_ID; + // 1.5 Handle --open override const openIdx = args.indexOf('--open'); let terminalTarget = config.terminalTarget || 'tab'; if (openIdx !== -1 && args[openIdx + 1]) { - terminalTarget = args[openIdx + 1]; + terminalTarget = args[openIdx + 1]; } const forceMainTerminal = terminalTarget === 'foreground'; - if (!forceMainTerminal && isWithinGemini && env.TERM_PROGRAM === 'iTerm.app') { - const tempCmdPath = path.join(process.env.TMPDIR || '/tmp', `workspace-ssh-${prNumber}.sh`); - fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { mode: 0o755 }); + if ( + !forceMainTerminal && + isWithinGemini && + env.TERM_PROGRAM === 'iTerm.app' + ) { + const tempCmdPath = path.join( + process.env.TMPDIR || '/tmp', + `workspace-ssh-${prNumber}.sh`, + ); + fs.writeFileSync(tempCmdPath, `#!/bin/bash\n${finalSSH}\nrm "$0"`, { + mode: 0o755, + }); - const appleScript = terminalTarget === 'window' ? ` + const appleScript = + terminalTarget === 'window' + ? ` on run argv tell application "iTerm" set newWindow to (create window with default profile) @@ -175,7 +207,8 @@ GEMINI_HOST=${targetVM} activate end tell end run - ` : ` + ` + : ` on run argv tell application "iTerm" tell current window diff --git a/extensions/workspaces/scripts/providers/BaseProvider.ts b/extensions/workspaces/scripts/providers/BaseProvider.ts index c912edb8d1..a21b8ebcfb 100644 --- a/extensions/workspaces/scripts/providers/BaseProvider.ts +++ b/extensions/workspaces/scripts/providers/BaseProvider.ts @@ -5,10 +5,10 @@ */ /** - * WorkspaceProvider interface defines the contract for different remote + * WorkerProvider interface defines the contract for different remote * execution environments (GCE, Workstations, etc.). */ -export interface WorkspaceProvider { +export interface WorkerProvider { /** * Provisions the underlying infrastructure. */ @@ -37,12 +37,19 @@ export interface WorkspaceProvider { /** * Executes a command on the workspace and returns the output. */ - getExecOutput(command: string, options?: ExecOptions): Promise<{ status: number; stdout: string; stderr: string }>; + getExecOutput( + command: string, + options?: ExecOptions, + ): Promise<{ status: number; stdout: string; stderr: string }>; /** * Synchronizes local files to the workspace. */ - sync(localPath: string, remotePath: string, options?: SyncOptions): Promise; + sync( + localPath: string, + remotePath: string, + options?: SyncOptions, + ): Promise; /** * Returns the status of the workspace. diff --git a/extensions/workspaces/scripts/providers/GceCosProvider.ts b/extensions/workspaces/scripts/providers/GceCosProvider.ts index 5f24bf100e..69c53435a9 100644 --- a/extensions/workspaces/scripts/providers/GceCosProvider.ts +++ b/extensions/workspaces/scripts/providers/GceCosProvider.ts @@ -4,14 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import os from 'os'; -import { WorkspaceProvider, SetupOptions, ExecOptions, SyncOptions, WorkspaceStatus } from './BaseProvider.ts'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + type WorkerProvider, + type SetupOptions, + type ExecOptions, + type SyncOptions, + type WorkspaceStatus, +} from './BaseProvider.ts'; import { GceConnectionManager } from './GceConnectionManager.ts'; -export class GceCosProvider implements WorkspaceProvider { +export class GceCosProvider implements WorkerProvider { private projectId: string; private zone: string; private instanceName: string; @@ -20,46 +26,143 @@ export class GceCosProvider implements WorkspaceProvider { private sshAlias = 'gcli-worker'; private conn: GceConnectionManager; - constructor(projectId: string, zone: string, instanceName: string, repoRoot: string) { + constructor( + projectId: string, + zone: string, + instanceName: string, + repoRoot: string, + ) { this.projectId = projectId; this.zone = zone; this.instanceName = instanceName; const workspacesDir = path.join(repoRoot, '.gemini/workspaces'); - if (!fs.existsSync(workspacesDir)) fs.mkdirSync(workspacesDir, { recursive: true }); + if (!fs.existsSync(workspacesDir)) + fs.mkdirSync(workspacesDir, { recursive: true }); this.sshConfigPath = path.join(workspacesDir, 'ssh_config'); this.knownHostsPath = path.join(workspacesDir, 'known_hosts'); this.conn = new GceConnectionManager(projectId, zone, instanceName); } async provision(): Promise { - const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest'; + const imageUri = + 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest'; const region = this.zone.split('-').slice(0, 2).join('-'); const vpcName = 'iap-vpc'; const subnetName = 'iap-subnet'; - console.log(`๐Ÿ—๏ธ Ensuring "Magic" Network Infrastructure in ${this.projectId}...`); + console.log( + `๐Ÿ—๏ธ Ensuring "Magic" Network Infrastructure in ${this.projectId}...`, + ); - const vpcCheck = spawnSync('gcloud', ['compute', 'networks', 'describe', vpcName, '--project', this.projectId], { stdio: 'pipe' }); + const vpcCheck = spawnSync( + 'gcloud', + ['compute', 'networks', 'describe', vpcName, '--project', this.projectId], + { stdio: 'pipe' }, + ); if (vpcCheck.status !== 0) { - spawnSync('gcloud', ['compute', 'networks', 'create', vpcName, '--project', this.projectId, '--subnet-mode=custom'], { stdio: 'inherit' }); + spawnSync( + 'gcloud', + [ + 'compute', + 'networks', + 'create', + vpcName, + '--project', + this.projectId, + '--subnet-mode=custom', + ], + { stdio: 'inherit' }, + ); } - const subnetCheck = spawnSync('gcloud', ['compute', 'networks', 'subnets', 'describe', subnetName, '--project', this.projectId, '--region', region], { stdio: 'pipe' }); + const subnetCheck = spawnSync( + 'gcloud', + [ + 'compute', + 'networks', + 'subnets', + 'describe', + subnetName, + '--project', + this.projectId, + '--region', + region, + ], + { stdio: 'pipe' }, + ); if (subnetCheck.status !== 0) { - spawnSync('gcloud', ['compute', 'networks', 'subnets', 'create', subnetName, - '--project', this.projectId, '--network', vpcName, '--region', region, - '--range=10.0.0.0/24', '--enable-private-ip-google-access'], { stdio: 'inherit' }); + spawnSync( + 'gcloud', + [ + 'compute', + 'networks', + 'subnets', + 'create', + subnetName, + '--project', + this.projectId, + '--network', + vpcName, + '--region', + region, + '--range=10.0.0.0/24', + '--enable-private-ip-google-access', + ], + { stdio: 'inherit' }, + ); } else { - spawnSync('gcloud', ['compute', 'networks', 'subnets', 'update', subnetName, '--project', this.projectId, '--region', region, '--enable-private-ip-google-access'], { stdio: 'pipe' }); + spawnSync( + 'gcloud', + [ + 'compute', + 'networks', + 'subnets', + 'update', + subnetName, + '--project', + this.projectId, + '--region', + region, + '--enable-private-ip-google-access', + ], + { stdio: 'pipe' }, + ); } - const fwCheck = spawnSync('gcloud', ['compute', 'firewall-rules', 'describe', 'allow-corporate-ssh', '--project', this.projectId], { stdio: 'pipe' }); + const fwCheck = spawnSync( + 'gcloud', + [ + 'compute', + 'firewall-rules', + 'describe', + 'allow-corporate-ssh', + '--project', + this.projectId, + ], + { stdio: 'pipe' }, + ); if (fwCheck.status !== 0) { - spawnSync('gcloud', ['compute', 'firewall-rules', 'create', 'allow-corporate-ssh', - '--project', this.projectId, '--network', vpcName, '--allow=tcp:22', '--source-ranges=0.0.0.0/0'], { stdio: 'inherit' }); + spawnSync( + 'gcloud', + [ + 'compute', + 'firewall-rules', + 'create', + 'allow-corporate-ssh', + '--project', + this.projectId, + '--network', + vpcName, + '--allow=tcp:22', + '--source-ranges=0.0.0.0/0', + ], + { stdio: 'inherit' }, + ); } - console.log(`๐Ÿš€ Provisioning GCE COS worker: ${this.instanceName} (Unified Workspace Setup)...`); + console.log( + `๐Ÿš€ Provisioning GCE COS worker: ${this.instanceName} (Unified Workspace Setup)...`, + ); const startupScriptContent = `#!/bin/bash set -e @@ -112,32 +215,55 @@ export class GceCosProvider implements WorkspaceProvider { echo "โœ… Unified Workspace is active." `; - const tmpScriptPath = path.join(os.tmpdir(), `gcli-startup-${Date.now()}.sh`); + const tmpScriptPath = path.join( + os.tmpdir(), + `gcli-startup-${Date.now()}.sh`, + ); fs.writeFileSync(tmpScriptPath, startupScriptContent); - const result = spawnSync('gcloud', [ - 'compute', 'instances', 'create', this.instanceName, - '--project', this.projectId, - '--zone', this.zone, - '--machine-type', 'n2-standard-8', - '--image-family', 'cos-stable', - '--image-project', 'cos-cloud', - '--boot-disk-size', '10GB', - '--boot-disk-type', 'pd-balanced', - '--create-disk', `name=${this.instanceName}-data,size=200,type=pd-balanced,device-name=data,auto-delete=yes`, - '--metadata-from-file', `startup-script=${tmpScriptPath}`, - '--metadata', 'enable-oslogin=TRUE', - '--network-interface', `network=${vpcName},subnet=${subnetName},no-address`, - '--scopes', 'https://www.googleapis.com/auth/cloud-platform', - '--quiet' - ], { stdio: 'inherit' }); + const result = spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'create', + this.instanceName, + '--project', + this.projectId, + '--zone', + this.zone, + '--machine-type', + 'n2-standard-8', + '--image-family', + 'cos-stable', + '--image-project', + 'cos-cloud', + '--boot-disk-size', + '10GB', + '--boot-disk-type', + 'pd-balanced', + '--create-disk', + `name=${this.instanceName}-data,size=200,type=pd-balanced,device-name=data,auto-delete=yes`, + '--metadata-from-file', + `startup-script=${tmpScriptPath}`, + '--metadata', + 'enable-oslogin=TRUE', + '--network-interface', + `network=${vpcName},subnet=${subnetName},no-address`, + '--scopes', + 'https://www.googleapis.com/auth/cloud-platform', + '--quiet', + ], + { stdio: 'inherit' }, + ); fs.unlinkSync(tmpScriptPath); - if (result.status === 0) { - console.log('โณ Waiting for OS Login and SSH to initialize (this takes ~45s)...'); - await new Promise(r => setTimeout(r, 45000)); + console.log( + 'โณ Waiting for OS Login and SSH to initialize (this takes ~45s)...', + ); + await new Promise((r) => setTimeout(r, 45000)); } return result.status ?? 1; @@ -146,47 +272,68 @@ export class GceCosProvider implements WorkspaceProvider { async ensureReady(): Promise { const status = await this.getStatus(); if (status.status !== 'RUNNING') { - console.log(`โš ๏ธ Worker ${this.instanceName} is ${status.status}. Waking it up...`); - const res = spawnSync('gcloud', [ - 'compute', 'instances', 'start', this.instanceName, - '--project', this.projectId, - '--zone', this.zone - ], { stdio: 'inherit' }); + console.log( + `โš ๏ธ Worker ${this.instanceName} is ${status.status}. Waking it up...`, + ); + const res = spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'start', + this.instanceName, + '--project', + this.projectId, + '--zone', + this.zone, + ], + { stdio: 'inherit' }, + ); if (res.status !== 0) return res.status ?? 1; - + console.log('โณ Waiting for boot...'); - await new Promise(r => setTimeout(r, 20000)); + await new Promise((r) => setTimeout(r, 20000)); } // NEW: Verify the container is actually running AND up to date console.log(' - Verifying remote container health and image version...'); - const containerCheck = await this.getExecOutput('sudo docker ps -q --filter "name=maintainer-worker"'); - + const containerCheck = await this.getExecOutput( + 'sudo docker ps -q --filter "name=maintainer-worker"', + ); + let needsUpdate = false; if (containerCheck.status === 0 && containerCheck.stdout.trim()) { - // Check if the volume mounts are correct by checking for files inside .workspaces/main - const mountCheck = await this.getExecOutput('sudo docker exec maintainer-worker ls -A /home/node/.workspaces/main'); - if (mountCheck.status !== 0 || !mountCheck.stdout.trim()) { - console.log(' โš ๏ธ Remote container has incorrect or empty mounts. Triggering refresh...'); - needsUpdate = true; - } else { - // Check if the running image is stale - const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest'; - const tmuxCheck = await this.getExecOutput('sudo docker exec maintainer-worker which tmux'); - if (tmuxCheck.status !== 0) { - console.log(' โš ๏ธ Remote container is stale (missing tmux). Triggering update...'); - needsUpdate = true; - } - } - } else { + // Check if the volume mounts are correct by checking for files inside .workspaces/main + const mountCheck = await this.getExecOutput( + 'sudo docker exec maintainer-worker ls -A /home/node/.workspaces/main', + ); + if (mountCheck.status !== 0 || !mountCheck.stdout.trim()) { + console.log( + ' โš ๏ธ Remote container has incorrect or empty mounts. Triggering refresh...', + ); needsUpdate = true; + } else { + // Check if the running image is stale + const tmuxCheck = await this.getExecOutput( + 'sudo docker exec maintainer-worker which tmux', + ); + if (tmuxCheck.status !== 0) { + console.log( + ' โš ๏ธ Remote container is stale (missing tmux). Triggering update...', + ); + needsUpdate = true; + } + } + } else { + needsUpdate = true; } if (needsUpdate) { - console.log(' โš ๏ธ Container missing or stale. Attempting refresh...'); - const imageUri = 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest'; - // Ensure data mount is available before running - const recoverCmd = ` + console.log(' โš ๏ธ Container missing or stale. Attempting refresh...'); + const imageUri = + 'us-docker.pkg.dev/gemini-code-dev/gemini-cli/maintainer:latest'; + // Ensure data mount is available before running + const recoverCmd = ` (mountpoint -q /mnt/disks/data || sudo mount /dev/disk/by-id/google-data /mnt/disks/data) && \ sudo docker pull ${imageUri} && \ (sudo docker rm -f maintainer-worker || true) && \ @@ -196,12 +343,14 @@ export class GceCosProvider implements WorkspaceProvider { -v ~/.config/gh:/home/node/.config/gh:rw \ ${imageUri} /bin/bash -c "while true; do sleep 1000; done" `; - const recoverRes = await this.exec(recoverCmd); - if (recoverRes !== 0) { - console.error(' โŒ Critical: Failed to refresh maintainer container.'); - return 1; - } - console.log(' โœ… Container refreshed.'); + const recoverRes = await this.exec(recoverCmd); + if (recoverRes !== 0) { + console.error( + ' โŒ Critical: Failed to refresh maintainer container.', + ); + return 1; + } + console.log(' โœ… Container refreshed.'); } return 0; @@ -227,23 +376,31 @@ Host ${this.sshAlias} fs.writeFileSync(this.sshConfigPath, sshEntry); console.log(` โœ… Created project SSH config: ${this.sshConfigPath}`); - console.log(' - Verifying direct connection (may trigger corporate SSO prompt)...'); + console.log( + ' - Verifying direct connection (may trigger corporate SSO prompt)...', + ); const res = this.conn.run('echo 1'); if (res.status !== 0) { - console.error('\nโŒ All connection attempts failed. Please ensure you have "gcert" and IAP permissions.'); - return 1; + console.error( + '\nโŒ All connection attempts failed. Please ensure you have "gcert" and IAP permissions.', + ); + return 1; } - console.log(' โœ… Connection verified. Waiting 10s for remote disk initialization...'); - await new Promise(r => setTimeout(r, 10000)); + console.log( + ' โœ… Connection verified. Waiting 10s for remote disk initialization...', + ); + await new Promise((r) => setTimeout(r, 10000)); return 0; } getRunCommand(command: string, options: ExecOptions = {}): string { let finalCmd = command; if (options.wrapContainer) { - finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`; + finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`; } - return this.conn.getRunCommand(finalCmd, { interactive: options.interactive }); + return this.conn.getRunCommand(finalCmd, { + interactive: options.interactive, + }); } async exec(command: string, options: ExecOptions = {}): Promise { @@ -251,27 +408,47 @@ Host ${this.sshAlias} return res.status; } - async getExecOutput(command: string, options: ExecOptions = {}): Promise<{ status: number; stdout: string; stderr: string }> { + async getExecOutput( + command: string, + options: ExecOptions = {}, + ): Promise<{ status: number; stdout: string; stderr: string }> { let finalCmd = command; if (options.wrapContainer) { - finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`; + finalCmd = `sudo docker exec ${options.interactive ? '-it' : ''} ${options.cwd ? `-w ${options.cwd}` : ''} ${options.wrapContainer} sh -c ${this.quote(command)}`; } - return this.conn.run(finalCmd, { interactive: options.interactive, stdio: options.interactive ? 'inherit' : 'pipe' }); + return this.conn.run(finalCmd, { + interactive: options.interactive, + stdio: options.interactive ? 'inherit' : 'pipe', + }); } - async sync(localPath: string, remotePath: string, options: SyncOptions = {}): Promise { + async sync( + localPath: string, + remotePath: string, + options: SyncOptions = {}, + ): Promise { console.log(`๐Ÿ“ฆ Syncing ${localPath} to remote:${remotePath}...`); return this.conn.sync(localPath, remotePath, options); } async getStatus(): Promise { - const res = spawnSync('gcloud', [ - 'compute', 'instances', 'describe', this.instanceName, - '--project', this.projectId, - '--zone', this.zone, - '--format', 'json(name,status,networkInterfaces[0].networkIP)' - ], { stdio: 'pipe' }); + const res = spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'describe', + this.instanceName, + '--project', + this.projectId, + '--zone', + this.zone, + '--format', + 'json(name,status,networkInterfaces[0].networkIP)', + ], + { stdio: 'pipe' }, + ); if (res.status !== 0) { return { name: this.instanceName, status: 'UNKNOWN' }; @@ -282,19 +459,28 @@ Host ${this.sshAlias} return { name: data.name, status: data.status, - internalIp: data.networkInterfaces?.[0]?.networkIP + internalIp: data.networkInterfaces?.[0]?.networkIP, }; - } catch (e) { - return { name: this.instanceName, status: 'ERROR' }; + } catch { + return { name: this.instanceName, status: 'UNKNOWN' }; } } async stop(): Promise { - const res = spawnSync('gcloud', [ - 'compute', 'instances', 'stop', this.instanceName, - '--project', this.projectId, - '--zone', this.zone - ], { stdio: 'inherit' }); + const res = spawnSync( + 'gcloud', + [ + 'compute', + 'instances', + 'stop', + this.instanceName, + '--project', + this.projectId, + '--zone', + this.zone, + ], + { stdio: 'inherit' }, + ); return res.status ?? 1; } diff --git a/extensions/workspaces/scripts/providers/ProviderFactory.ts b/extensions/workspaces/scripts/providers/ProviderFactory.ts index 303b93614a..f48ffe056a 100644 --- a/extensions/workspaces/scripts/providers/ProviderFactory.ts +++ b/extensions/workspaces/scripts/providers/ProviderFactory.ts @@ -5,16 +5,25 @@ */ import { GceCosProvider } from './GceCosProvider.ts'; -import { WorkspaceProvider } from './BaseProvider.ts'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import type { WorkerProvider } from './BaseProvider.ts'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '../../../../..'); export class ProviderFactory { - static getProvider(config: { projectId: string; zone: string; instanceName: string }): WorkspaceProvider { + static getProvider(config: { + projectId: string; + zone: string; + instanceName: string; + }): WorkerProvider { // Currently we only have GceCosProvider, but this is where we'd branch - return new GceCosProvider(config.projectId, config.zone, config.instanceName, REPO_ROOT); + return new GceCosProvider( + config.projectId, + config.zone, + config.instanceName, + REPO_ROOT, + ); } } diff --git a/extensions/workspaces/scripts/status.ts b/extensions/workspaces/scripts/status.ts index 0d11993343..dc897aaf3e 100644 --- a/extensions/workspaces/scripts/status.ts +++ b/extensions/workspaces/scripts/status.ts @@ -1,11 +1,11 @@ /** - * Workspace Status Inspector (Local) - * - * Orchestrates remote status retrieval via the WorkerProvider. + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 */ -import path from 'path'; -import fs from 'fs'; -import { fileURLToPath } from 'url'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { ProviderFactory } from './providers/ProviderFactory.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -14,7 +14,7 @@ const REPO_ROOT = path.resolve(__dirname, '../../../..'); async function runStatus(env: NodeJS.ProcessEnv = process.env) { const settingsPath = path.join(REPO_ROOT, '.gemini/workspaces/settings.json'); if (!fs.existsSync(settingsPath)) { - console.error('โŒ Settings not found. Run "npm run workspace:setup" first.'); + console.error('โŒ Settings not found. Run "workspace setup" first.'); return 1; } const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); @@ -26,11 +26,17 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) { const { projectId, zone } = config; const targetVM = `gcli-workspace-${env.USER || 'mattkorwel'}`; - const provider = ProviderFactory.getProvider({ projectId, zone, instanceName: targetVM }); + const provider = ProviderFactory.getProvider({ + projectId, + zone, + instanceName: targetVM, + }); console.log(`\n๐Ÿ›ฐ๏ธ Workspace Mission Control: ${targetVM}`); - console.log(`--------------------------------------------------------------------------------`); - + console.log( + `--------------------------------------------------------------------------------`, + ); + const status = await provider.getStatus(); console.log(` - VM State: ${status.status}`); console.log(` - Internal IP: ${status.internalIp || 'N/A'}`); @@ -38,11 +44,14 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) { if (status.status === 'RUNNING') { console.log(`\n๐Ÿงต Active Sessions (tmux):`); // We fetch the list of sessions from INSIDE the container - const tmuxRes = await provider.getExecOutput('tmux list-sessions -F "#S" 2>/dev/null', { wrapContainer: 'maintainer-worker' }); - + const tmuxRes = await provider.getExecOutput( + 'tmux list-sessions -F "#S" 2>/dev/null', + { wrapContainer: 'maintainer-worker' }, + ); + if (tmuxRes.status === 0 && tmuxRes.stdout.trim()) { const sessions = tmuxRes.stdout.trim().split('\n'); - sessions.forEach(s => { + sessions.forEach((s) => { if (s.startsWith('workspace-')) { console.log(` โœ… ${s}`); } else { @@ -54,7 +63,9 @@ async function runStatus(env: NodeJS.ProcessEnv = process.env) { } } - console.log(`--------------------------------------------------------------------------------\n`); + console.log( + `--------------------------------------------------------------------------------\n`, + ); return 0; } diff --git a/extensions/workspaces/skills/workspaces/SKILL.md b/extensions/workspaces/skills/workspaces/SKILL.md index dacb2d6137..e86cec2831 100644 --- a/extensions/workspaces/skills/workspaces/SKILL.md +++ b/extensions/workspaces/skills/workspaces/SKILL.md @@ -20,7 +20,7 @@ This skill enables the agent to utilize **Gemini Workspaces**โ€”a high-performan - **Environment Isolation**: When you need a clean, high-performance environment to verify a fix without polluting the user's local machine. ### How to use Workspaces -1. **Setup**: If the user hasn't initialized their environment, instruct them to run the setup script: +1. **Setup**: If the user hasn't initialized their environment, you MUST run the setup script using npx tsx and the absolute path. Do NOT use npm scripts. ```bash npx tsx ${extensionPath}/extensions/workspaces/scripts/setup.ts ``` @@ -50,9 +50,13 @@ This skill enables the agent to utilize **Gemini Workspaces**โ€”a high-performan ```bash npx tsx ${extensionPath}/extensions/workspaces/scripts/fleet.ts [stop|provision|list] ``` +6. **Attach/Logs**: + ```bash + npx tsx ${extensionPath}/extensions/workspaces/scripts/attach.ts + npx tsx ${extensionPath}/extensions/workspaces/scripts/logs.ts + ``` ## โš ๏ธ Important Constraints +- **NO NPM**: Do NOT attempt to use `npm run` or `npm workspace`. Those commands are deprecated in favor of running the extension scripts directly. +- **npx tsx**: Always use `npx tsx` followed by the absolute path provided in `${extensionPath}`. - **Absolute Paths**: Always use absolute paths (e.g., `/mnt/disks/data/...`) when orchestrating remote commands. -- **npx tsx**: When running scripts manually from the skill directory, always prefix with `npx tsx` to ensure dependencies are available. -- **Be Behavioral**: Prioritize results from live execution (behavioral proofs) over static reading. -- **Multi-tasking**: Remind the user they can continue chatting in the main window while the heavy workspace task runs in the separate terminal window. diff --git a/package.json b/package.json index 68f855a0bf..531f9f75d9 100644 --- a/package.json +++ b/package.json @@ -64,16 +64,6 @@ "telemetry": "node scripts/telemetry.js", "check:lockfile": "node scripts/check-lockfile.js", "clean": "node scripts/clean.js", - "workspace": "tsx ./scripts/workspaces.ts", - "workspace:setup": "tsx ./scripts/workspaces.ts setup", - "workspace:shell": "tsx ./scripts/workspaces.ts shell", - "workspace:check": "tsx ./scripts/workspaces.ts check", - "workspace:clean-all": "tsx ./scripts/workspaces.ts clean-all", - "workspace:kill": "tsx ./scripts/workspaces.ts kill", - "workspace:fleet": "tsx ./scripts/workspaces.ts fleet", - "workspace:status": "tsx ./scripts/workspaces.ts status", - "workspace:attach": "tsx ./scripts/workspaces.ts attach", - "workspace:logs": "tsx ./scripts/workspaces.ts logs", "pre-commit": "node scripts/pre-commit.js" }, "overrides": { diff --git a/scripts/workspace.ts b/scripts/workspace.ts deleted file mode 100644 index 389b661d12..0000000000 --- a/scripts/workspace.ts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '..'); - -const commands: Record = { - setup: '.gemini/skills/workspaces/scripts/setup.ts', - shell: '.gemini/skills/workspaces/scripts/orchestrator.ts shell', - check: '.gemini/skills/workspaces/scripts/check.ts', - 'clean-all': '.gemini/skills/workspaces/scripts/clean.ts', - kill: '.gemini/skills/workspaces/scripts/clean.ts', - fleet: '.gemini/skills/workspaces/scripts/fleet.ts', - status: '.gemini/skills/workspaces/scripts/status.ts', - attach: '.gemini/skills/workspaces/scripts/attach.ts', -}; - -function printUsage() { - console.log('Gemini Workspace Management CLI'); - console.log( - '\nUsage: scripts/workspace.ts [args] [--open foreground|tab|window]', - ); - console.log('\nCommands:'); - console.log( - ' setup Initialize or reconfigure your remote worker', - ); - console.log(' [action] Launch a PR task (review, fix, ready)'); - console.log(' shell [id] Open an ad-hoc interactive session'); - console.log(' status See worker and session overview'); - console.log(' check Deep-dive into PR logs'); - console.log(' kill Surgical removal of a task'); - console.log(' clean-all Full remote cleanup'); - console.log(' fleet Manage VM life cycle (stop, provision)'); - process.exit(1); -} - -async function main() { - const args = process.argv.slice(2); - const cmd = args[0]; - - if (!cmd || cmd === '--help' || cmd === '-h') { - printUsage(); - } - - let scriptPath = commands[cmd]; - let finalArgs = args.slice(1); - - // Default: If it's a number, it's a PR orchestrator task - if (!scriptPath && /^\d+$/.test(cmd)) { - scriptPath = '.gemini/skills/workspaces/scripts/orchestrator.ts'; - finalArgs = args; // Pass the PR number as the first arg - } - - if (!scriptPath) { - console.error(`โŒ Unknown command: ${cmd}`); - printUsage(); - } - - const [realScript, ...internalArgs] = scriptPath.split(' '); - const fullScriptPath = path.join(REPO_ROOT, realScript); - - const result = spawnSync( - 'npx', - ['tsx', fullScriptPath, ...internalArgs, ...finalArgs], - { stdio: 'inherit' }, - ); - - process.exit(result.status ?? 0); -} - -main().catch(console.error); diff --git a/scripts/workspaces.ts b/scripts/workspaces.ts deleted file mode 100755 index 38454984ba..0000000000 --- a/scripts/workspaces.ts +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Unified Workspaces Entry Point (Local) - * - * Central CLI for managing Gemini Workspaces. - * Usage: scripts/workspaces.ts [args] - */ -import { spawnSync } from 'child_process'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '..'); - -const commands: Record = { - 'setup': 'extensions/workspaces/scripts/setup.ts', - 'shell': 'extensions/workspaces/scripts/orchestrator.ts shell', - 'check': 'extensions/workspaces/scripts/check.ts', - 'clean-all': 'extensions/workspaces/scripts/clean.ts', - 'kill': 'extensions/workspaces/scripts/clean.ts', - 'fleet': 'extensions/workspaces/scripts/fleet.ts', - 'status': 'extensions/workspaces/scripts/status.ts', - 'attach': 'extensions/workspaces/scripts/attach.ts', - 'logs': 'extensions/workspaces/scripts/logs.ts', -}; - -function printUsage() { - console.log('Gemini Workspaces Management CLI'); - console.log('\nUsage: scripts/workspaces.ts [args] [--open foreground|tab|window]'); - console.log('\nCommands:'); - console.log(' setup Initialize or reconfigure your remote worker'); - console.log(' [action] Launch a PR task (review, fix, ready)'); - console.log(' shell [id] Open an ad-hoc interactive session'); - console.log(' status See worker and session overview'); - console.log(' check Deep-dive into PR logs'); - console.log(' kill Surgical removal of a task'); - console.log(' clean-all Full remote cleanup'); - console.log(' fleet Manage VM life cycle (stop, provision)'); - process.exit(1); -} - -async function main() { - const args = process.argv.slice(2); - const cmd = args[0]; - - if (!cmd || cmd === '--help' || cmd === '-h') { - printUsage(); - } - - let scriptPath = commands[cmd]; - let finalArgs = args.slice(1); - - // Default: If it's a number, it's a PR orchestrator task - if (!scriptPath && /^\d+$/.test(cmd)) { - scriptPath = 'extensions/workspaces/scripts/orchestrator.ts'; - finalArgs = args; // Pass the PR number as the first arg - } - - if (!scriptPath) { - console.error(`โŒ Unknown command: ${cmd}`); - printUsage(); - } - - const [realScript, ...internalArgs] = scriptPath.split(' '); - const fullScriptPath = path.join(REPO_ROOT, realScript); - - const result = spawnSync('npx', [ - 'tsx', - fullScriptPath, - ...internalArgs, - ...finalArgs - ], { stdio: 'inherit' }); - - process.exit(result.status ?? 0); -} - -main().catch(console.error);