2026 03 20 Platform Infrastructure Evolution
Documentation for Plans
Platform Infrastructure Evolution — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eliminate GitLab as the generation bottleneck, enable progressive real-time preview, add VM command batching, and prepare the pre-warmed VM pool — collectively saving 3-7 minutes per platform generation and enabling the next wave of features (self-healing, WhatsApp ops, visual debugging).
Architecture: Replace GitLab HTTP API with local git repos on the Hetzner server for instant file operations. Wire the existing embeddable-bridge streaming protocol into the generation pipeline so users see modules appearing in real-time. Add a batchExec method to reduce VM command round-trips. Introduce a pre-warmed VM pool for instant sandbox acquisition.
Tech Stack: NestJS, simple-git (already in monorepo), Angular signals, RxJS, embeddable-bridge streaming protocol, Firecracker microVMs, systemd
Session: S246 | Decisions: 8x3430d, 74x70ar, adpeihd, z91iqfj, stucro3, bp1vyz6, locgmpx
Specs:
Prerequisites
- SSH access to Hetzner AX42-U (
[email protected]) simple-gitalready in monorepo package.json (v3.30.0)- backend-ai-api, backend-vitto-api, sandbox-orchestrator running locally
- Familiarity with:
GitProviderServiceinterface,UniverseGitService,SandboxedToolProxyService,execute.phase.ts
File Map
New Files
| File | Responsibility |
|---|---|
APIs/backend-api/src/app/git-provider/local-git-provider.service.ts |
Local git implementation of GitProviderService using simple-git |
APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts |
Tests for local git provider |
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts |
Pre-warmed VM pool manager |
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts |
Tests for VM pool |
Modified Files
| File | Change |
|---|---|
libs/libs-foundational/core-models/src/lib/interfaces/git-provider.interface.ts |
Add 'local' to provider type if needed |
APIs/backend-api/src/app/git-provider/git-provider.module.ts |
Register LocalGitProviderService, add factory for provider selection |
APIs/backend-vitto-api/src/app/studio/universe-git.service.ts |
Refactor to use GitProviderService abstraction instead of direct GitLab HTTP calls |
APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.ts |
Add executeBatchOnVm() method |
APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts |
Auto-detect batchable VM tools, route to executeBatchOnVm() |
APIs/sandbox-orchestrator/src/app/sandbox/sandbox-manager.service.ts |
Integrate VM pool for warm acquisition |
APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts |
Register VmPoolService |
libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts |
Consume per-module SSE events to incrementally update VFS + trigger preview rebuild |
deployment/sandbox-orchestrator/deploy-orchestrator-host.sh |
Already fixed (S246) |
Phase 1: Local Git Provider (eliminate GitLab bottleneck)
Impact: Template init drops from 15-30s to <1s. File commits drop from 2-5 min to <1s. Total: ~3-4 min saved per generation.
Task 1.1: Create LocalGitProviderService
Files:
Create:
APIs/backend-api/src/app/git-provider/local-git-provider.service.tsCreate:
APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.tsReference:
libs/libs-foundational/core-models/src/lib/interfaces/git-provider.interface.tsStep 1: Read the GitProviderService interface
Read libs/libs-foundational/core-models/src/lib/interfaces/git-provider.interface.ts to understand all methods that must be implemented. Focus on: createRepository, pushFiles, getFileContent, getFileTree, createBranch, mergeBranch, getCommitHistory.
- Step 2: Write failing test — createRepository
// APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts
import { LocalGitProviderService } from './local-git-provider.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('LocalGitProviderService', () => {
let service: LocalGitProviderService;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'local-git-test-'));
service = new LocalGitProviderService(tmpDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe('createRepository', () => {
it('should create a git repo with main and develop branches', async () => {
const repo = await service.createRepository('test-space', 'test-universe');
expect(repo).toBeDefined();
expect(repo.name).toBe('test-universe');
// Verify .git exists
const gitDir = path.join(tmpDir, 'test-space', 'test-universe', '.git');
const stat = await fs.stat(gitDir);
expect(stat.isDirectory()).toBe(true);
});
});
});
- Step 3: Run test to verify it fails
Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage
Expected: FAIL — module not found
- Step 4: Implement LocalGitProviderService — core methods
// APIs/backend-api/src/app/git-provider/local-git-provider.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { simpleGit, SimpleGit } from 'simple-git';
import * as fs from 'fs/promises';
import * as path from 'path';
import type {
GitProviderService,
GitRepoInfo,
GitFileContent,
GitFileInfo,
GitBranchInfo,
GitCommitInfo,
GitMergeInfo,
} from '@libs-foundational/core-models';
@Injectable()
export class LocalGitProviderService implements GitProviderService {
private readonly logger = new Logger(LocalGitProviderService.name);
constructor(private readonly reposRoot: string) {}
private repoPath(orgSlug: string, repoSlug: string): string {
return path.join(this.reposRoot, orgSlug, repoSlug);
}
private git(absPath: string): SimpleGit {
return simpleGit(absPath);
}
async createRepository(
orgSlug: string,
repoSlug: string,
options?: { description?: string },
): Promise<GitRepoInfo> {
const absPath = this.repoPath(orgSlug, repoSlug);
await fs.mkdir(absPath, { recursive: true });
const git = this.git(absPath);
await git.init();
await git.addConfig('user.name', 'Platform Generator');
await git.addConfig('user.email', '[email protected]');
// Initial commit on main
const readmePath = path.join(absPath, 'README.md');
await fs.writeFile(readmePath, `# ${repoSlug}\n\n${options?.description || 'Generated platform'}\n`);
await git.add('README.md');
await git.commit('chore: initialize repository');
// Create develop branch
await git.branch(['develop']);
return {
name: repoSlug,
path: `${orgSlug}/${repoSlug}`,
webUrl: `file://${absPath}`,
cloneUrl: absPath,
defaultBranch: 'main',
};
}
async pushFiles(
repoPath: string,
files: Array<{ path: string; content: string }>,
message: string,
branch = 'develop',
): Promise<void> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
// Ensure branch exists and is checked out
const branches = await git.branchLocal();
if (!branches.all.includes(branch)) {
await git.checkoutLocalBranch(branch);
} else {
await git.checkout(branch);
}
// Write all files
for (const file of files) {
const filePath = path.join(absPath, file.path);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, file.content, 'utf-8');
}
// Stage and commit
await git.add('.');
const status = await git.status();
if (status.staged.length > 0 || status.files.length > 0) {
await git.commit(message);
}
}
async getFileContent(
repoPath: string,
filePath: string,
ref = 'develop',
): Promise<GitFileContent> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
const content = await git.show([`${ref}:${filePath}`]);
return { path: filePath, content, encoding: 'utf-8' };
}
async getFileTree(
repoPath: string,
ref = 'develop',
dirPath?: string,
): Promise<GitFileInfo[]> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
const treePath = dirPath ? `${ref}:${dirPath}` : ref;
const result = await git.raw(['ls-tree', '-r', '--name-only', treePath]);
return result
.split('\n')
.filter(Boolean)
.map(name => ({
name: path.basename(name),
path: dirPath ? `${dirPath}/${name}` : name,
type: 'blob' as const,
}));
}
async createBranch(
repoPath: string,
branchName: string,
fromRef = 'develop',
): Promise<GitBranchInfo> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
await git.checkoutBranch(branchName, fromRef);
const log = await git.log({ maxCount: 1 });
return {
name: branchName,
commitSha: log.latest?.hash || '',
};
}
async mergeBranch(
repoPath: string,
source: string,
target: string,
): Promise<GitMergeInfo> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
await git.checkout(target);
const result = await git.merge([source]);
return {
merged: !result.failed,
commitSha: result.commit || '',
conflicts: result.conflicts || [],
};
}
async getCommitHistory(
repoPath: string,
ref = 'develop',
limit = 20,
): Promise<GitCommitInfo[]> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
const log = await git.log({ maxCount: limit, from: ref });
return log.all.map(c => ({
sha: c.hash,
message: c.message,
authorName: c.author_name,
authorEmail: c.author_email,
date: c.date,
}));
}
async deleteBranch(repoPath: string, branchName: string): Promise<void> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
await git.deleteLocalBranch(branchName, true);
}
async listBranches(repoPath: string): Promise<GitBranchInfo[]> {
const absPath = path.join(this.reposRoot, repoPath);
const git = this.git(absPath);
const branches = await git.branchLocal();
return branches.all.map(name => ({ name, commitSha: '' }));
}
}
- Step 5: Run test to verify it passes
Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage
Expected: PASS
- Step 6: Write additional tests — pushFiles, getFileTree, branches
describe('pushFiles', () => {
it('should write files and commit to develop branch', async () => {
await service.createRepository('s1', 'u1');
await service.pushFiles('s1/u1', [
{ path: 'modules/users/users.component.ts', content: 'export class UsersComponent {}' },
{ path: 'modules/users/users.service.ts', content: 'export class UsersService {}' },
], 'feat: add users module');
const tree = await service.getFileTree('s1/u1', 'develop');
const paths = tree.map(f => f.path);
expect(paths).toContain('modules/users/users.component.ts');
expect(paths).toContain('modules/users/users.service.ts');
});
});
describe('branches', () => {
it('should create feature branch and merge to develop', async () => {
await service.createRepository('s1', 'u1');
await service.createBranch('s1/u1', 'feature/gen-test', 'develop');
await service.pushFiles('s1/u1', [
{ path: 'test.ts', content: 'test' },
], 'feat: test file', 'feature/gen-test');
const result = await service.mergeBranch('s1/u1', 'feature/gen-test', 'develop');
expect(result.merged).toBe(true);
const content = await service.getFileContent('s1/u1', 'test.ts', 'develop');
expect(content.content).toBe('test');
});
});
- Step 7: Run all tests
Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage
Expected: ALL PASS
- Step 8: Commit
git add APIs/backend-api/src/app/git-provider/local-git-provider.service.ts APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts
git commit -m "[S246.1] feat: add LocalGitProviderService for local git repo management"
Task 1.2: Register Provider with Factory Selection
Files:
Modify:
APIs/backend-api/src/app/git-provider/git-provider.module.tsStep 1: Read the current git-provider.module.ts
Understand how GitLabProviderService is registered today. We need to add a factory that selects local vs GitLab based on environment.
- Step 2: Add provider factory
// In git-provider.module.ts — add a factory provider:
{
provide: 'GIT_PROVIDER_SERVICE',
useFactory: (config: ConfigService, http: HttpService) => {
const localReposRoot = config.get<string>('LOCAL_REPOS_ROOT');
if (localReposRoot) {
return new LocalGitProviderService(localReposRoot);
}
// Fall back to GitLab
return new GitLabProviderService(config, http);
},
inject: [ConfigService, HttpService],
}
- Step 3: Add env var documentation
Add to .env.local:
# Platform code storage: set LOCAL_REPOS_ROOT to use local git repos instead of GitLab.
# When set, GitLab becomes optional export (async push).
# LOCAL_REPOS_ROOT=/data/platforms
- Step 4: Verify module compiles
Run: npx nx build backend-api --configuration=production
Expected: Build succeeds
- Step 5: Commit
git add APIs/backend-api/src/app/git-provider/git-provider.module.ts .env.local
git commit -m "[S246.1] feat: add provider factory — LOCAL_REPOS_ROOT selects local git vs GitLab"
Task 1.3: Template Initialization via Local Copy
Files:
Modify:
APIs/backend-vitto-api/src/app/studio/universe-git.service.tsStep 1: Read the current initializeFromTemplate() method
Understand the full flow: GitLab fork (fast path) vs file-by-file copy (fallback). Note the return type TemplateInitResult.
- Step 2: Add local template copy method
// Add to UniverseGitService:
private async initializeFromLocalTemplate(
repoPath: string,
templatePath: string,
): Promise<{ fileCount: number }> {
const git = simpleGit(repoPath);
// Copy template files (excluding .git)
const templateEntries = await fs.readdir(templatePath, { withFileTypes: true });
let fileCount = 0;
for (const entry of templateEntries) {
if (entry.name === '.git' || entry.name === 'node_modules') continue;
const src = path.join(templatePath, entry.name);
const dest = path.join(repoPath, entry.name);
await fs.cp(src, dest, { recursive: true });
fileCount++;
}
await git.add('.');
await git.commit('chore: initialize from platform-base-template');
return { fileCount };
}
- Step 3: Wire into initializeFromTemplate() with fallthrough
Add at the top of initializeFromTemplate():
const localReposRoot = this.config.get<string>('LOCAL_REPOS_ROOT');
const localTemplatePath = this.config.get<string>('LOCAL_TEMPLATE_PATH');
if (localReposRoot && localTemplatePath) {
this.logger.log(`Using local template copy from ${localTemplatePath}`);
const result = await this.initializeFromLocalTemplate(
path.join(localReposRoot, orgSlug, repoSlug),
localTemplatePath,
);
return {
gitConfig: { ...gitConfig, provider: 'local' },
templateLoaded: true,
templateFileCount: result.fileCount,
warnings: [],
};
}
// Existing GitLab fork/copy logic continues below...
- Step 4: Test locally with LOCAL_REPOS_ROOT set
Set in .env.local:
LOCAL_REPOS_ROOT=/tmp/test-platforms
LOCAL_TEMPLATE_PATH=/Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template
Restart backend-vitto-api. Trigger a platform generation. Verify:
Template copied in <1s (vs 15-30s GitLab fork)
Files exist at
/tmp/test-platforms/{spaceSlug}/{universeSlug}/.gitdirectory exists with initial commitStep 5: Commit
git add APIs/backend-vitto-api/src/app/studio/universe-git.service.ts
git commit -m "[S246.1] feat: local template copy when LOCAL_REPOS_ROOT is set — <1s vs 15-30s GitLab fork"
Task 1.4: Server Setup — Platform Storage Directory on Hetzner
- Step 1: SSH to server and create directory
ssh [email protected]
mkdir -p /data/platforms
chown root:root /data/platforms
- Step 2: Copy platform-base-template to server
From your Mac:
rsync -avz --exclude='node_modules' --exclude='.git' \
/Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template/ \
[email protected]:/data/templates/platform-base-template/
- Step 3: Verify on server
ssh [email protected] "ls /data/templates/platform-base-template/ | head -10"
Expected: angular.json, package.json, tsconfig.json, src/, etc.
- Step 4: Document in deployment notes
Add to docs/english/Deployment/hetzner-firecracker-operations.md:
## Platform Storage
- `/data/platforms/` — Local git repos for generated platforms (when LOCAL_REPOS_ROOT is set)
- `/data/templates/platform-base-template/` — Template for new platform initialization
- Sync template: `rsync -avz --exclude='node_modules' --exclude='.git' repo-templates/platform-base-template/ [email protected]:/data/templates/platform-base-template/`
- Step 5: Commit docs
git add docs/english/Deployment/hetzner-firecracker-operations.md
git commit -m "[S246.1] docs: add platform storage paths to Hetzner operations guide"
Phase 2: Progressive Per-Module Preview
Impact: Users see modules appearing in real-time during generation instead of waiting 25-40 min for the batch result. Transforms the wait experience.
Key discovery: The execute phase ALREADY emits per-module SSE events with file content included (S234). The frontend just needs to consume them incrementally.
Task 2.1: Wire Generation SSE Events to VFS Incremental Update
Files:
Modify:
libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.tsStep 1: Read the current studio-generation-listener.service.ts
Understand how it currently handles SSE events. Look for module-gen.module-generated event type.
- Step 2: Add incremental VFS update on module-generated event
The SSE event module-gen.module-generated already includes files: GeneratedFileSpec[] with full content (S234, execute.phase.ts:181-207). Wire this to VFS:
// In the SSE event handler for 'module-gen.module-generated':
case 'module-gen.module-generated': {
const { moduleName, files, index, total } = event.data;
if (files?.length) {
// Write files to VFS immediately (don't wait for all modules)
for (const file of files) {
this.vfsService.writeFile(file.path, file.content);
}
// Trigger preview rebuild
this.vfsService.notifyFileChange();
this.logger.log(
`[Progressive] Module ${index + 1}/${total}: ${moduleName} — ${files.length} files added to VFS`,
);
}
break;
}
- Step 3: Verify the preview.service.ts auto-rebuilds on VFS change
Read preview.service.ts — confirm the rebuildTrigger Subject fires on vfs.lastFileChange() via the existing effect(). If so, no changes needed in preview.service.ts — the rebuild is automatic.
- Step 4: Test the progressive flow
Start a platform generation. Observe:
After first module generates (~3-5 min), preview should show that module's routes
After second module, preview should add another tab/route
Each module appears incrementally without waiting for all 12
Step 5: Commit
git add libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts
git commit -m "[S246.1] feat: progressive preview — write per-module files to VFS as generated"
Task 2.2: Add Generation Activity Stream via Embeddable Bridge
Files:
Modify:
libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.tsReference:
libs/libs-ux/embeddable-bridge/src/lib/protocols/stream-protocol.tsStep 1: Add activity stream emission alongside VFS updates
For each generation event, emit an AI_STREAM_CHUNK message through the embeddable bridge so the host app can show a live activity feed:
// Import stream helpers
import { createStreamChunkMessage, generateStreamId } from '@libs-ux/embeddable-bridge';
// In the generation listener, on pipeline start:
private generationStreamId: string | null = null;
onPipelineStart() {
this.generationStreamId = generateStreamId();
// Emit start
this.bridgeAdapter.send(createStreamStartMessage(this.generationStreamId));
}
// In each event handler, emit activity:
private emitActivity(message: string, phase: string) {
if (!this.generationStreamId) return;
this.bridgeAdapter.send(createStreamChunkMessage(
this.generationStreamId,
JSON.stringify({ message, phase, timestamp: Date.now() }),
this.activityIndex++,
false,
));
}
// Usage in event handlers:
case 'module-gen.phase-started':
this.emitActivity(`Phase: ${event.data.phase}`, event.data.phase);
break;
case 'module-gen.module-generated':
this.emitActivity(
`Module ${event.data.index + 1}/${event.data.total}: ${event.data.moduleName} — ${event.data.files?.length || 0} files`,
'execute',
);
break;
case 'module-gen.complete':
this.emitActivity('Generation complete', 'done');
this.bridgeAdapter.send(createStreamEndMessage(this.generationStreamId, this.activityIndex));
this.generationStreamId = null;
break;
- Step 2: Verify embeddable-bridge receives the stream
Check that the host app's HostAdapterService receives AI_STREAM_CHUNK messages. The bridge already handles these — verify in browser devtools Network/Console.
- Step 3: Commit
git add libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts
git commit -m "[S246.1] feat: emit generation activity as embeddable-bridge stream chunks"
Phase 3: VM Command Batching
Impact: Reduces 3-7 seconds of network overhead per VM-heavy task to <1 second. Zero infrastructure changes.
Task 3.1: Add batchExec to SandboxedToolProxyService
Files:
Modify:
APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.tsCreate:
APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts(extend existing)Step 1: Write failing test for batchExec
describe('executeBatchOnVm', () => {
it('should combine multiple commands into one SSH call with tagged output', async () => {
const mockVmApiClient = {
sandboxExec: jest.fn().mockResolvedValue({
stdout: '===BATCH_START_0===\nfile1.ts\n===BATCH_EXIT_0=0===\n===BATCH_END_0===\n' +
'===BATCH_START_1===\nOK\n===BATCH_EXIT_1=0===\n===BATCH_END_1===\n',
stderr: '',
exitCode: 0,
}),
};
const results = await service.executeBatchOnVm('vm-123', [
{ command: 'ls src/', label: 'list-files' },
{ command: 'echo OK', label: 'echo-test' },
]);
expect(mockVmApiClient.sandboxExec).toHaveBeenCalledTimes(1); // ONE call, not two
expect(results).toHaveLength(2);
expect(results[0].stdout).toContain('file1.ts');
expect(results[1].stdout).toContain('OK');
});
});
- Step 2: Run test to verify it fails
Run: npx jest APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts --no-coverage -t "executeBatchOnVm"
Expected: FAIL
- Step 3: Implement executeBatchOnVm
// Add to SandboxedToolProxyService:
async executeBatchOnVm(
vmId: string,
commands: Array<{ command: string; cwd?: string; label?: string }>,
): Promise<Array<{ label: string; stdout: string; stderr: string; exitCode: number }>> {
if (commands.length === 0) return [];
if (commands.length === 1) {
// Single command — no batching overhead
const result = await this.vmApiClient.sandboxExec(vmId, {
command: commands[0].command,
cwd: commands[0].cwd,
});
return [{
label: commands[0].label || 'cmd-0',
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.exitCode ?? 0,
}];
}
// Build tagged batch script
const script = commands.map((c, i) => [
`echo "===BATCH_START_${i}==="`,
c.cwd ? `cd ${c.cwd} 2>/dev/null` : '',
`${c.command} 2>&1`,
`_exit_${i}=$?`,
`echo "===BATCH_EXIT_${i}=$_exit_${i}==="`,
`echo "===BATCH_END_${i}==="`,
].filter(Boolean).join('\n')).join('\n');
const result = await this.vmApiClient.sandboxExec(vmId, { command: script });
return this.parseBatchOutput(result.stdout || '', commands);
}
private parseBatchOutput(
stdout: string,
commands: Array<{ label?: string }>,
): Array<{ label: string; stdout: string; stderr: string; exitCode: number }> {
const results: Array<{ label: string; stdout: string; stderr: string; exitCode: number }> = [];
for (let i = 0; i < commands.length; i++) {
const startMarker = `===BATCH_START_${i}===`;
const endMarker = `===BATCH_END_${i}===`;
const exitPattern = new RegExp(`===BATCH_EXIT_${i}=(\\d+)===`);
const startIdx = stdout.indexOf(startMarker);
const endIdx = stdout.indexOf(endMarker);
let output = '';
let exitCode = 0;
if (startIdx !== -1 && endIdx !== -1) {
output = stdout.slice(startIdx + startMarker.length, endIdx).trim();
const exitMatch = output.match(exitPattern);
if (exitMatch) {
exitCode = parseInt(exitMatch[1], 10);
output = output.replace(exitPattern, '').trim();
}
}
results.push({
label: commands[i].label || `cmd-${i}`,
stdout: output,
stderr: '',
exitCode,
});
}
return results;
}
- Step 4: Run test to verify it passes
Run: npx jest APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts --no-coverage -t "executeBatchOnVm"
Expected: PASS
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.ts APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts
git commit -m "[S246.1] feat: add batchExec to SandboxedToolProxyService — one HTTP call for N commands"
Task 3.2: Auto-Batch in Agent Loop
Files:
Modify:
APIs/backend-vitto-api/src/app/engine/agent-loop.service.tsStep 1: Read the parallel tool execution block
Read agent-loop.service.ts around line 522-572 (the Promise.allSettled block). Understand how multiple tool calls from one LLM response are dispatched.
- Step 2: Add auto-batch detection for VM run_command tools
Before the Promise.allSettled, check if multiple run_command/monorepo_run_command tools target the same VM. If so, route to executeBatchOnVm:
// Before dispatching tools in parallel:
if (options.vmId && options.sandboxedToolProxy) {
const runCommandTools = toolCalls.filter(
t => ['run_command', 'monorepo_run_command'].includes(canonicalVmToolName(t.name)),
);
const otherTools = toolCalls.filter(
t => !['run_command', 'monorepo_run_command'].includes(canonicalVmToolName(t.name)),
);
if (runCommandTools.length > 1) {
// Batch all run_command tools into one HTTP call
const batchResults = await options.sandboxedToolProxy.executeBatchOnVm(
options.vmId,
runCommandTools.map(t => ({
command: t.input.command as string,
cwd: t.input.cwd as string | undefined,
label: t.id,
})),
);
// Map results back to tool response format
for (let i = 0; i < runCommandTools.length; i++) {
toolResults.set(runCommandTools[i].id, {
output: batchResults[i].stdout,
exitCode: batchResults[i].exitCode,
executedOn: 'vm',
vmId: options.vmId,
});
}
// Dispatch remaining non-run_command tools normally
toolCalls = otherTools;
}
}
// ... existing Promise.allSettled for remaining tools
- Step 3: Test with Vitto running multiple commands
Trigger a Vitto task that runs multiple commands (e.g., "check git status and run tests"). Verify in logs:
Only 1 HTTP call to sandbox-orchestrator (not N)
All results returned correctly
Step 4: Commit
git add APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts
git commit -m "[S246.1] feat: auto-batch parallel VM run_command tools into single HTTP call"
Phase 4: Pre-Warmed VM Pool
Impact: VM acquisition drops from ~10s (cold boot) to <100ms (grab from pool). Enables instant sandboxing for self-healing, testing, and visual debugging features.
Task 4.1: Create VmPoolService
Files:
Create:
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.tsCreate:
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.tsStep 1: Write failing test
describe('VmPoolService', () => {
it('should acquire a pre-warmed VM instantly', async () => {
const pool = new VmPoolService(mockSandboxManager, mockConfig);
await pool.initialize(); // Pre-warm
const vm = await pool.acquire('user-1', 'test-session');
expect(vm).toBeDefined();
expect(vm.status).toBe('running');
});
it('should replenish pool after acquisition', async () => {
const pool = new VmPoolService(mockSandboxManager, mockConfig);
await pool.initialize();
const initialSize = pool.getPoolSize();
await pool.acquire('user-1', 'test-session');
// Wait for async replenishment
await new Promise(resolve => setTimeout(resolve, 100));
expect(pool.getPoolSize()).toBe(initialSize); // Replenished
});
it('should fall back to cold boot when pool is empty', async () => {
const pool = new VmPoolService(mockSandboxManager, mockConfig);
// Don't initialize — pool is empty
const vm = await pool.acquire('user-1', 'test-session');
expect(vm).toBeDefined(); // Cold boot fallback
});
});
- Step 2: Implement VmPoolService
// APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SandboxManagerService } from './sandbox-manager.service';
import type { SandboxInfo } from './models/sandbox.types';
@Injectable()
export class VmPoolService implements OnModuleInit {
private readonly logger = new Logger(VmPoolService.name);
private readonly pool: SandboxInfo[] = [];
private readonly targetSize: number;
private readonly memSizeMib: number;
private readonly vcpuCount: number;
private replenishing = false;
constructor(
private readonly manager: SandboxManagerService,
private readonly config: ConfigService,
) {
this.targetSize = parseInt(this.config.get('VM_POOL_SIZE', '3'), 10);
this.memSizeMib = parseInt(this.config.get('VM_POOL_MEM_MIB', '2048'), 10);
this.vcpuCount = parseInt(this.config.get('VM_POOL_VCPU', '2'), 10);
}
async onModuleInit() {
if (this.targetSize > 0) {
this.logger.log(`Pre-warming ${this.targetSize} VMs...`);
await this.initialize();
this.logger.log(`VM pool ready: ${this.pool.length}/${this.targetSize}`);
}
}
async initialize(): Promise<void> {
const promises = Array.from({ length: this.targetSize }, (_, i) =>
this.createPoolVm(`pool-warm-${Date.now()}-${i}`),
);
const results = await Promise.allSettled(promises);
for (const r of results) {
if (r.status === 'fulfilled' && r.value) {
this.pool.push(r.value);
}
}
}
async acquire(userId: string, sessionId: string): Promise<SandboxInfo> {
const vm = this.pool.shift();
if (vm) {
this.logger.log(`Pool acquire: ${vm.sandboxId} for ${sessionId} (${this.pool.length} remaining)`);
// Reassign ownership
vm.sessionId = sessionId;
vm.userId = userId;
vm.lastUsed = Date.now();
// Replenish in background
this.replenishIfNeeded();
return vm;
}
// Pool empty — cold boot fallback
this.logger.warn(`Pool empty, cold booting for ${sessionId}`);
const cold = await this.manager.create({
sessionId,
userId,
memSizeMib: this.memSizeMib,
vcpuCount: this.vcpuCount,
});
return cold;
}
release(sandboxId: string): void {
// Don't return to pool — destroy and let replenishment create fresh ones
this.manager.destroy(sandboxId).catch(err =>
this.logger.warn(`Pool release destroy failed: ${err.message}`),
);
}
getPoolSize(): number {
return this.pool.length;
}
private async replenishIfNeeded(): Promise<void> {
if (this.replenishing || this.pool.length >= this.targetSize) return;
this.replenishing = true;
try {
while (this.pool.length < this.targetSize) {
const vm = await this.createPoolVm(`pool-warm-${Date.now()}-${this.pool.length}`);
if (vm) this.pool.push(vm);
}
} catch (err) {
this.logger.warn(`Pool replenishment failed: ${err.message}`);
} finally {
this.replenishing = false;
}
}
private async createPoolVm(sessionId: string): Promise<SandboxInfo | null> {
try {
return await this.manager.create({
sessionId,
userId: 'pool',
memSizeMib: this.memSizeMib,
vcpuCount: this.vcpuCount,
});
} catch (err) {
this.logger.warn(`Failed to create pool VM: ${err.message}`);
return null;
}
}
}
- Step 3: Run tests
Run: npx jest APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts --no-coverage
Expected: ALL PASS
- Step 4: Register in SandboxModule
// APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts
import { VmPoolService } from './vm-pool.service';
@Module({
controllers: [SandboxController, FileOpsController, DashboardSummaryController, SandboxMetaController],
providers: [
SandboxManagerService,
FirecrackerService,
NetworkService,
VmExecutorService,
CleanupService,
SnapshotService,
DashboardSummaryService,
VmPoolService, // ← Add
],
exports: [SandboxManagerService, SnapshotService, NetworkService, DashboardSummaryService, VmPoolService],
})
- Step 5: Add pool endpoint to controller
// In sandbox.controller.ts — add pool status endpoint:
@Get('pool/status')
async poolStatus() {
return {
size: this.vmPool.getPoolSize(),
target: this.config.get('VM_POOL_SIZE', '3'),
};
}
- Step 6: Add env vars documentation
Add to .env.local:
# Pre-warmed VM pool (0 = disabled, default 3)
VM_POOL_SIZE=3
VM_POOL_MEM_MIB=2048
VM_POOL_VCPU=2
- Step 7: Commit
git add APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts APIs/sandbox-orchestrator/src/app/sandbox/sandbox.controller.ts
git commit -m "[S246.1] feat: pre-warmed VM pool — instant sandbox acquisition from warm pool"
Task 4.2: Deploy VM Pool to Hetzner
- Step 1: Build and deploy updated orchestrator
npx nx build sandbox-orchestrator --configuration=production --skip-nx-cache
scp dist/APIs/sandbox-orchestrator/main.js [email protected]:/var/lib/sandbox-orchestrator/main.js
- Step 2: Add pool env vars on server
ssh [email protected] "cat >> /etc/systemd/system/sandbox-orchestrator.service.d/pool.conf << 'EOF'
[Service]
Environment=VM_POOL_SIZE=3
Environment=VM_POOL_MEM_MIB=2048
Environment=VM_POOL_VCPU=2
EOF"
ssh [email protected] "systemctl daemon-reload && systemctl restart sandbox-orchestrator"
- Step 3: Verify pool is warming
curl -sS "https://sandbox.gograb.space/sandboxes/pool/status"
Expected: {"size":3,"target":"3"}
- Step 4: Verify health and dashboard still work
curl -sS "https://sandbox.gograb.space/health"
curl -sS -H "X-API-Key: $SANDBOX_API_KEY" "https://sandbox.gograb.space/sandboxes/meta/dashboard-summary" | head -c 300
Expected: Health ok, dashboard shows 3 active VMs (pool), capacity reduced accordingly
- Step 5: Commit deployment notes
git add docs/english/Deployment/hetzner-firecracker-operations.md
git commit -m "[S246.1] docs: add VM pool configuration to Hetzner operations guide"
Phase 5: Integration Testing & Verification
Task 5.1: End-to-End Generation Test with Local Git
- Step 1: Set env vars for local git mode
In .env.local:
LOCAL_REPOS_ROOT=/tmp/test-platforms
LOCAL_TEMPLATE_PATH=/Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template
- Step 2: Trigger platform generation via Vitto
Send a message: "Build me a simple task management platform with tasks, projects, and a dashboard."
- Step 3: Verify progressive preview
Watch the Studio preview panel:
Modules should appear one by one as they generate
Activity feed should show stream messages
No GitLab API calls in network tab (confirm via browser devtools)
Step 4: Verify local git repo
ls /tmp/test-platforms/
# Should show: {spaceSlug}/{universeSlug}/
cd /tmp/test-platforms/{spaceSlug}/{universeSlug}
git log --oneline
# Should show: generation commit + initial commit
git branch
# Should show: main, develop, feature/gen-*
- Step 5: Verify VM batching (if applicable)
Check backend-vitto-api logs for batch execution:
[SandboxedToolProxy] Batch exec: 3 commands in 1 HTTP call
- Step 6: Document results and log outcome
Use MCP tools:
outcome({ action: "report", decisionId: "adpeihd", wasSuccessful: true/false, feedback: "..." })
outcome({ action: "report", decisionId: "z91iqfj", wasSuccessful: true/false, feedback: "..." })
Rollback Strategy
Each phase is independent. If any phase breaks:
| Phase | Rollback |
|---|---|
| Phase 1 (local git) | Remove LOCAL_REPOS_ROOT from env → falls back to GitLab |
| Phase 2 (progressive preview) | Revert studio-generation-listener.service.ts → batch-all-then-show resumes |
| Phase 3 (batching) | Revert agent-loop.service.ts → individual HTTP calls resume |
| Phase 4 (VM pool) | Set VM_POOL_SIZE=0 → pool disabled, cold boot resumes |
No phase depends on another. They can be implemented and deployed independently.
Success Criteria
| Metric | Before | After | How to Verify |
|---|---|---|---|
| Template init time | 15-30s | <1s | Time the generation start in logs |
| File commit time | 2-5 min | <1s | Time between "execute complete" and "commit done" |
| First module visible | 25-40 min | ~5 min | Watch preview during generation |
| VM acquisition | ~10s cold boot | <100ms from pool | Time sandbox create in logs |
| Multi-command overhead | N × 860ms | 1 × 860ms | Count HTTP calls in logs |
| GitLab dependency | Required | Optional export | Generate with LOCAL_REPOS_ROOT set, no GitLab API calls |