feat(workspaces): transform workspaces feature into a distributable extension

This commit is contained in:
mkorwel
2026-03-19 09:53:51 -07:00
parent 8cdd22d27d
commit df2ac184dd
37 changed files with 105 additions and 382 deletions
@@ -1,80 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import fs from 'fs';
import readline from 'readline';
import { runOrchestrator } from '../scripts/orchestrator.ts';
import { runWorker } from '../scripts/worker.ts';
import { ProviderFactory } from '../scripts/providers/ProviderFactory.ts';
vi.mock('child_process');
vi.mock('fs');
vi.mock('readline');
vi.mock('../scripts/providers/ProviderFactory.ts');
describe('Workspace Tooling Matrix', () => {
const mockSettings = {
maintainer: {
workspace: {
projectId: 'test-project',
zone: 'us-west1-a',
remoteWorkDir: '/home/node/dev/main'
}
}
};
const mockProvider = {
provision: vi.fn().mockResolvedValue(0),
ensureReady: vi.fn().mockResolvedValue(0),
setup: vi.fn().mockResolvedValue(0),
exec: vi.fn().mockResolvedValue(0),
getExecOutput: vi.fn().mockResolvedValue({ status: 0, stdout: '', stderr: '' }),
sync: vi.fn().mockResolvedValue(0),
getStatus: vi.fn().mockResolvedValue({ name: 'test-instance', status: 'RUNNING' }),
stop: vi.fn().mockResolvedValue(0)
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSettings));
vi.mocked(ProviderFactory.getProvider).mockReturnValue(mockProvider as any);
vi.mocked(spawnSync).mockImplementation((cmd: any) => {
if (cmd === 'gh') return { status: 0, stdout: Buffer.from('test-branch\n') } as any;
return { status: 0, stdout: Buffer.from('') } as any;
});
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); }),
pid: 1234
} as any;
});
vi.spyOn(process, 'chdir').mockImplementation(() => {});
});
describe('Implement Playbook', () => {
it('should create a branch and run research/implementation', async () => {
await runOrchestrator(['456', 'implement'], {});
expect(mockProvider.exec).toHaveBeenCalledWith(expect.stringContaining('git worktree add'), expect.any(Object));
expect(mockProvider.exec).toHaveBeenCalledWith(expect.stringContaining('tmux new-session'), expect.any(Object));
});
});
describe('Fix Playbook', () => {
it('should launch the agentic fix-pr skill', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
await runWorker(['123', 'test-branch', '/path/policy', 'fix']);
const spawnSyncCalls = vi.mocked(spawnSync).mock.calls;
const fixCall = spawnSyncCalls.find(call =>
JSON.stringify(call).includes("activate the 'fix-pr' skill")
);
expect(fixCall).toBeDefined();
});
});
});
@@ -1,89 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync } from 'child_process';
import fs from 'fs';
import readline from 'readline';
import { runOrchestrator } from '../scripts/orchestrator.ts';
import { runSetup } from '../scripts/setup.ts';
import { ProviderFactory } from '../scripts/providers/ProviderFactory.ts';
vi.mock('child_process');
vi.mock('fs');
vi.mock('readline');
vi.mock('../scripts/providers/ProviderFactory.ts');
describe('Workspace Orchestration (Refactored)', () => {
const mockSettings = {
maintainer: {
workspace: {
projectId: 'test-project',
zone: 'us-west1-a',
remoteWorkDir: '/home/node/dev/main'
}
}
};
const mockProvider = {
provision: vi.fn().mockResolvedValue(0),
ensureReady: vi.fn().mockResolvedValue(0),
setup: vi.fn().mockResolvedValue(0),
exec: vi.fn().mockResolvedValue(0),
getExecOutput: vi.fn().mockResolvedValue({ status: 0, stdout: '', stderr: '' }),
sync: vi.fn().mockResolvedValue(0),
getStatus: vi.fn().mockResolvedValue({ name: 'test-instance', status: 'RUNNING' }),
stop: vi.fn().mockResolvedValue(0)
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSettings));
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
// Explicitly set the mock return value for each test
vi.mocked(ProviderFactory.getProvider).mockReturnValue(mockProvider as any);
vi.mocked(spawnSync).mockImplementation((cmd: any) => {
if (cmd === 'gh') return { status: 0, stdout: Buffer.from('test-branch\n') } as any;
return { status: 0, stdout: Buffer.from('') } as any;
});
vi.spyOn(process, 'chdir').mockImplementation(() => {});
});
describe('orchestrator.ts', () => {
it('should wake the worker and execute remote commands', async () => {
await runOrchestrator(['123'], { USER: 'testuser' });
expect(mockProvider.ensureReady).toHaveBeenCalled();
expect(mockProvider.exec).toHaveBeenCalledWith(expect.stringContaining('git worktree add'), expect.any(Object));
});
});
describe('setup.ts', () => {
const mockInterface = {
question: vi.fn(),
close: vi.fn()
};
beforeEach(() => {
vi.mocked(readline.createInterface).mockReturnValue(mockInterface as any);
});
it('should use the provider to configure SSH and sync scripts', async () => {
mockInterface.question
.mockImplementationOnce((q, cb) => cb('test-project'))
.mockImplementationOnce((q, cb) => cb('us-west1-a'))
.mockImplementationOnce((q, cb) => cb('.internal')) // dnsSuffix
.mockImplementationOnce((q, cb) => cb('n')) // sync auth
.mockImplementationOnce((q, cb) => cb('n')) // scoped token
.mockImplementationOnce((q, cb) => cb('n')); // clone
// Ensure mockProvider is returned
vi.mocked(ProviderFactory.getProvider).mockReturnValue(mockProvider as any);
await runSetup({ USER: 'testuser' });
expect(mockProvider.setup).toHaveBeenCalled();
});
});
});
@@ -1,23 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync } from 'child_process';
import fs from 'fs';
import { runFixPlaybook } from '../../scripts/playbooks/fix.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Fix Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
});
it('should launch the agentic fix-pr skill via spawnSync', async () => {
const status = await runFixPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
expect(status).toBe(0);
const spawnCalls = vi.mocked(spawnSync).mock.calls;
expect(spawnCalls.some(c => JSON.stringify(c).includes("activate the 'fix-pr' skill"))).toBe(true);
});
});
@@ -1,33 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync, spawn } from 'child_process';
import fs from 'fs';
import { runReadyPlaybook } from '../../scripts/playbooks/ready.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Ready Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); })
} as any;
});
});
it('should register and run clean, preflight, and conflict checks', async () => {
runReadyPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
const spawnCalls = vi.mocked(spawn).mock.calls;
expect(spawnCalls.some(c => c[0].includes('npm run clean'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('git fetch origin main'))).toBe(true);
});
});
@@ -1,36 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawn } from 'child_process';
import fs from 'fs';
import { runReviewPlaybook } from '../../scripts/playbooks/review.ts';
vi.mock('child_process');
vi.mock('fs');
describe('Review Playbook', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(fs.mkdirSync).mockReturnValue(undefined as any);
vi.mocked(fs.writeFileSync).mockReturnValue(undefined as any);
vi.mocked(fs.createWriteStream).mockReturnValue({ pipe: vi.fn() } as any);
vi.mocked(spawn).mockImplementation(() => {
return {
stdout: { pipe: vi.fn(), on: vi.fn() },
stderr: { pipe: vi.fn(), on: vi.fn() },
on: vi.fn((event, cb) => { if (event === 'close') cb(0); })
} as any;
});
});
it('should register and run build, ci, and review tasks', async () => {
// We don't await because TaskRunner uses setInterval and we'd need to mock timers
// but we can check if spawn was called with the right commands.
runReviewPlaybook('123', '/tmp/target', '/path/policy', '/path/gemini');
const spawnCalls = vi.mocked(spawn).mock.calls;
expect(spawnCalls.some(c => c[0].includes('npm ci'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes('gh pr checks'))).toBe(true);
expect(spawnCalls.some(c => c[0].includes("activate the 'review-pr' skill"))).toBe(true);
});
});
@@ -1,64 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { GceCosProvider } from '../scripts/providers/GceCosProvider.ts';
vi.mock('child_process');
vi.mock('fs');
describe('GceCosProvider', () => {
const mockConfig = {
projectId: 'test-project',
zone: 'us-west1-a',
instanceName: 'test-instance',
repoRoot: '/test-root'
};
let provider: GceCosProvider;
beforeEach(() => {
vi.resetAllMocks();
provider = new GceCosProvider(mockConfig.projectId, mockConfig.zone, mockConfig.instanceName, mockConfig.repoRoot);
vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') } as any);
});
it('should provision an instance with COS image and startup script', async () => {
await provider.provision();
const calls = vi.mocked(spawnSync).mock.calls;
const createCall = calls.find(c => c[1].includes('create'));
expect(createCall).toBeDefined();
expect(createCall![1]).toContain('cos-stable');
expect(createCall![1]).toContain('test-instance');
});
it('should attempt direct SSH and fallback to IAP on failure', async () => {
// Fail direct SSH
vi.mocked(spawnSync)
.mockReturnValueOnce({ status: 1, stdout: Buffer.from(''), stderr: Buffer.from('fail') } as any) // direct
.mockReturnValueOnce({ status: 0, stdout: Buffer.from('ok'), stderr: Buffer.from('') } as any); // IAP
const result = await provider.exec('echo 1');
expect(result).toBe(0);
const calls = vi.mocked(spawnSync).mock.calls;
expect(calls[0][0]).toBe('ssh');
expect(calls[1][1]).toContain('--tunnel-through-iap');
});
it('should sync files with IAP fallback', async () => {
// Fail direct rsync
vi.mocked(spawnSync)
.mockReturnValueOnce({ status: 1 } as any) // direct
.mockReturnValueOnce({ status: 0 } as any); // IAP
await provider.sync('./local', '/remote');
const calls = vi.mocked(spawnSync).mock.calls;
expect(calls[0][0]).toBe('rsync');
expect(calls[1][1]).toContain('gcloud compute ssh --project test-project --zone us-west1-a --tunnel-through-iap --quiet');
});
});
@@ -301,8 +301,8 @@ and full builds) to a dedicated, high-performance GCP worker.
await provider.exec(`sudo chmod -R 777 ${workspaceRoot}`);
// 1. Sync Scripts & Policies
await provider.sync('.gemini/skills/workspaces/scripts/', `${persistentScripts}/`, { delete: true, sudo: true });
await provider.sync('.gemini/skills/workspaces/policy.toml', `${workspaceRoot}/policies/workspace-policy.toml`, { sudo: true });
await provider.sync('extensions/workspaces/scripts/', `${persistentScripts}/`, { delete: true, sudo: true });
await provider.sync('extensions/workspaces/policies/workspace-policy.toml', `${workspaceRoot}/policies/workspace-policy.toml`, { sudo: true });
// 2. Initialize Remote Gemini Config with Auth
console.log('⚙️ Initializing remote Gemini configuration...');
+39
View File
@@ -0,0 +1,39 @@
---
name: workspaces
description: Expertise in managing and utilizing Gemini Workspaces for high-performance remote development tasks.
---
# Gemini Workspaces Skill
This skill enables the agent to utilize **Gemini Workspaces**—a high-performance, persistent remote development platform. It allows the agent to move intensive tasks (PR reviews, complex repairs, full builds) from the local environment to a dedicated cloud worker.
## 🛠️ Key Capabilities
1. **Persistent Execution**: Jobs run in remote `tmux` sessions. Disconnecting or crashing the local terminal does not stop the remote work.
2. **Parallel Infrastructure**: The agent can launch a heavy task (like a full build or CI run) in a workspace while continuing to assist the user locally.
3. **Behavioral Fidelity**: Remote workers have full tool access (Git, Node, Docker, etc.) and high-performance compute, allowing the agent to provide behavioral proofs of its work.
## 📋 Instructions for the Agent
### When to use Workspaces
- **Intensive Tasks**: Full preflight runs, large-scale refactors, or deep PR reviews.
- **Persistent Logic**: When a task is expected to take longer than a few minutes and needs to survive local connection drops.
- **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 `npm run workspace:setup`.
2. **Launch**: Use the `workspace` command to start a playbook:
```bash
npm run workspace <PR_NUMBER> [action]
```
- Actions: `review` (default), `fix`, `ready`.
3. **Check Status**: See global state and active sessions with `npm run workspace:status`, or deep-dive into specific PR logs with `npm run workspace:check <PR_NUMBER>`.
4. **Cleanup**:
- **Bulk**: Clear all sessions/worktrees with `npm run workspace:clean-all`.
- **Surgical**: Kill a specific PR task with `npm run workspace:kill <PR_NUMBER> <action>`.
5. **Fleet**: Manage VM lifecycle with `npm run workspace:fleet [stop|provision|list]`.
## ⚠️ Important Constraints
- **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.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "workspaces",
"version": "0.1.0",
"description": "High-performance remote development workspaces for Gemini CLI.",
"author": "Google Gemini Team",
"license": "Apache-2.0",
"skills": [
"extensions/workspaces/skills/SKILL.md"
],
"contextFileName": "extensions/workspaces/docs/GEMINI.md"
}
+53 -55
View File
@@ -1,79 +1,77 @@
#!/usr/bin/env npx tsx
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
* Unified Workspaces Entry Point (Local)
*
* Central CLI for managing Gemini Workspaces.
* Usage: scripts/workspaces.ts <command> [args]
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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<string, string> = {
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',
logs: '.gemini/skills/workspaces/scripts/logs.ts',
'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 <command> [args] [--open foreground|tab|window]',
);
console.log('\nCommands:');
console.log(
' setup Initialize or reconfigure your remote worker',
);
console.log(' <pr-number> [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 <pr-number> Deep-dive into PR logs');
console.log(' kill <pr-number> <act> Surgical removal of a task');
console.log(' clean-all Full remote cleanup');
console.log(' fleet <action> Manage VM life cycle (stop, provision)');
process.exit(1);
console.log('Gemini Workspaces Management CLI');
console.log('\nUsage: scripts/workspaces.ts <command> [args] [--open foreground|tab|window]');
console.log('\nCommands:');
console.log(' setup Initialize or reconfigure your remote worker');
console.log(' <pr-number> [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 <pr-number> Deep-dive into PR logs');
console.log(' kill <pr-number> <act> Surgical removal of a task');
console.log(' clean-all Full remote cleanup');
console.log(' fleet <action> Manage VM life cycle (stop, provision)');
process.exit(1);
}
async function main() {
const args = process.argv.slice(2);
const cmd = args[0];
const args = process.argv.slice(2);
const cmd = args[0];
if (!cmd || cmd === '--help' || cmd === '-h') {
printUsage();
}
if (!cmd || cmd === '--help' || cmd === '-h') {
printUsage();
}
let scriptPath = commands[cmd];
let finalArgs = args.slice(1);
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
}
// 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();
}
if (!scriptPath) {
console.error(`❌ Unknown command: ${cmd}`);
printUsage();
}
const [realScript, ...internalArgs] = scriptPath.split(' ');
const fullScriptPath = path.join(REPO_ROOT, realScript);
const [realScript, ...internalArgs] = scriptPath.split(' ');
const fullScriptPath = path.join(REPO_ROOT, realScript);
const result = spawnSync(
'npx',
['tsx', fullScriptPath, ...internalArgs, ...finalArgs],
{ stdio: 'inherit' },
);
const result = spawnSync('npx', [
'tsx',
fullScriptPath,
...internalArgs,
...finalArgs
], { stdio: 'inherit' });
process.exit(result.status ?? 0);
process.exit(result.status ?? 0);
}
main().catch(console.error);