Files
vibn-agent-runner/src/theia-exec.ts
mawkone b04d7b2e13 feat(phase-2): Theia HTTP sync via sync-server on port 3001
- theia-exec.ts: primary path is now HTTP sync (syncRepoToTheia) via
  sync-server.js running inside Theia on port 3001 — no docker socket needed
- syncRepoToTheia(giteaRepo): POST /sync → Theia git-pulls latest committed code
- isTheiaSyncAvailable(): health check before attempting sync
- docker exec path preserved for future use when socket is mounted
- agent-session-runner: use syncRepoToTheia after auto-commit
- server.ts: log both docker exec + HTTP sync status at startup

Made-with: Cursor
2026-03-07 13:38:07 -08:00

161 lines
5.7 KiB
TypeScript

/**
* theia-exec.ts
*
* Bridge between vibn-agent-runner and the Theia container.
*
* Both containers share the `coolify` Docker network, so the agent runner
* can reach Theia at http://theia-code-os:3000 internally.
*
* Theia runs a lightweight sync-server.js on port 3001 that:
* - POST /sync { repo: "mark/sportsy" } → git pull (or clone) inside Theia
* - GET /health
*
* After the agent auto-commits to Gitea, we call this endpoint so the
* files appear immediately in Theia when the user clicks "Open in Theia →".
*
* theiaExec() is also provided as a future path for running commands inside
* Theia via docker exec once the docker socket is mounted.
*
* Environment variables:
* THEIA_SYNC_URL — full URL for the sync server (default: http://theia-code-os:3001)
* THEIA_CONTAINER — container name for docker exec (optional, Phase 2.1)
*/
import { execSync } from 'child_process';
const THEIA_SYNC_URL = (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001').replace(/\/$/, '');
// ── Sync endpoint (HTTP — no docker socket needed) ────────────────────────────
/**
* Tell Theia to git pull (or clone) the given repo so "Open in Theia" shows
* the agent's latest committed files.
*
* @param giteaRepo e.g. "mark/sportsy"
*/
export async function syncRepoToTheia(giteaRepo: string): Promise<{ ok: boolean; action?: string; error?: string }> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
const res = await fetch(`${THEIA_SYNC_URL}/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: giteaRepo }),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const data = await res.json() as { ok: boolean; action?: string; error?: string };
return data;
} catch (err: any) {
return { ok: false, error: err.message };
}
}
/**
* Check if the Theia sync server is reachable.
*/
export async function isTheiaSyncAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
const res = await fetch(`${THEIA_SYNC_URL}/health`, { signal: controller.signal })
.finally(() => clearTimeout(timeout));
return res.ok;
} catch {
return false;
}
}
// ── Docker exec (Phase 2.1 — requires docker socket mount) ───────────────────
// Falls back gracefully when docker is not available.
let _theiaContainer: string | null | undefined = undefined;
export function getTheiaContainer(): string | null {
if (_theiaContainer !== undefined) return _theiaContainer;
if (process.env.THEIA_CONTAINER) {
_theiaContainer = process.env.THEIA_CONTAINER.trim();
return _theiaContainer;
}
try {
const byLabel = execSync(
`docker ps --filter "label=coolify.applicationId=b8wswk0ggcg88k0c8osk4c4c" --format "{{.Names}}" 2>/dev/null`,
{ timeout: 5000 }
).toString().trim();
if (byLabel) { _theiaContainer = byLabel.split('\n')[0].trim(); return _theiaContainer; }
} catch { /* docker not available */ }
try {
const byName = execSync(
`docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`,
{ timeout: 5000 }
).toString().trim();
if (byName) { _theiaContainer = byName.split('\n')[0].trim(); return _theiaContainer; }
} catch { /* ignore */ }
_theiaContainer = null;
return null;
}
export function isTheiaAvailable(): boolean {
return getTheiaContainer() !== null;
}
/**
* Run a command inside the Theia container via docker exec.
* Requires /var/run/docker.sock to be mounted in the agent runner.
*/
export async function theiaExec(
command: string,
workdir?: string
): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> {
const container = getTheiaContainer();
if (!container) {
return { exitCode: 1, stdout: '', stderr: '', error: 'Theia container not reachable via docker exec' };
}
const cwd = workdir ?? '/home/project';
const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`;
return new Promise(resolve => {
const { execFile } = require('child_process') as typeof import('child_process');
execFile(
'docker', ['exec', container, 'bash', '-c', wrapped],
{ timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
(err, stdout, stderr) => {
resolve({
exitCode: typeof err?.code === 'number' ? err.code : 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
error: err ? err.message : undefined,
});
}
);
});
}
/**
* Copy local directory into the Theia container via docker cp.
* Requires /var/run/docker.sock to be mounted.
*/
export async function syncToTheia(
localDir: string,
theiaPath: string | null
): Promise<{ ok: boolean; error?: string }> {
const container = getTheiaContainer();
if (!container) {
return { ok: false, error: 'Theia container not reachable via docker' };
}
const destDir = theiaPath ? `/home/project/${theiaPath}` : '/home/project';
try {
execSync(`docker exec ${container} mkdir -p ${JSON.stringify(destDir)}`, { timeout: 10_000 });
execSync(`docker cp "${localDir}/." "${container}:${destDir}/"`, { timeout: 60_000 });
return { ok: true };
} catch (err: any) {
return { ok: false, error: err.message };
}
}