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
This commit is contained in:
@@ -17,7 +17,7 @@ import { createLLM, toOAITools, LLMMessage } from './llm';
|
|||||||
import { AgentConfig } from './agents';
|
import { AgentConfig } from './agents';
|
||||||
import { executeTool, ToolContext } from './tools';
|
import { executeTool, ToolContext } from './tools';
|
||||||
import { resolvePrompt } from './prompts/loader';
|
import { resolvePrompt } from './prompts/loader';
|
||||||
import { isTheiaAvailable, theiaExec, syncToTheia, getTheiaContainer } from './theia-exec';
|
import { isTheiaAvailable, theiaExec, syncRepoToTheia, isTheiaSyncAvailable } from './theia-exec';
|
||||||
|
|
||||||
const MAX_TURNS = 60;
|
const MAX_TURNS = 60;
|
||||||
|
|
||||||
@@ -116,12 +116,12 @@ async function autoCommitAndDeploy(
|
|||||||
const giteaToken = process.env.GITEA_API_TOKEN || '';
|
const giteaToken = process.env.GITEA_API_TOKEN || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sync files into Theia so "Open in Theia" shows the agent's work
|
// Sync files into Theia via the sync-server so "Open in Theia" shows latest code
|
||||||
if (isTheiaAvailable() && opts.theiaWorkspaceSubdir) {
|
if (opts.giteaRepo && await isTheiaSyncAvailable()) {
|
||||||
await emit({ ts: now(), type: 'info', text: `Syncing files to Theia…` });
|
await emit({ ts: now(), type: 'info', text: `Syncing to Theia…` });
|
||||||
const syncResult = await syncToTheia(repoRoot, opts.theiaWorkspaceSubdir);
|
const syncResult = await syncRepoToTheia(opts.giteaRepo);
|
||||||
if (syncResult.ok) {
|
if (syncResult.ok) {
|
||||||
await emit({ ts: now(), type: 'info', text: '✓ Files available in Theia — open theia.vibnai.com to inspect.' });
|
await emit({ ts: now(), type: 'info', text: `✓ Theia synced (${syncResult.action}) — open theia.vibnai.com to inspect.` });
|
||||||
} else {
|
} else {
|
||||||
console.warn('[session-runner] Theia sync failed:', syncResult.error);
|
console.warn('[session-runner] Theia sync failed:', syncResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { PROTECTED_GITEA_REPOS } from './tools/security';
|
|||||||
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
import { orchestratorChat, listSessions, clearSession } from './orchestrator';
|
||||||
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
|
import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas';
|
||||||
import { LLMMessage, createLLM } from './llm';
|
import { LLMMessage, createLLM } from './llm';
|
||||||
import { getTheiaContainer } from './theia-exec';
|
import { getTheiaContainer, isTheiaSyncAvailable } from './theia-exec';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -616,8 +616,11 @@ app.listen(PORT, () => {
|
|||||||
// Theia bridge status
|
// Theia bridge status
|
||||||
const theiaContainer = getTheiaContainer();
|
const theiaContainer = getTheiaContainer();
|
||||||
if (theiaContainer) {
|
if (theiaContainer) {
|
||||||
console.log(`Theia bridge: active (container: ${theiaContainer})`);
|
console.log(`Theia docker exec: active (container: ${theiaContainer})`);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Theia bridge: not available — docker socket not mounted or container not found. Commands will run locally.');
|
console.log('Theia docker exec: not available (docker socket not mounted) — using HTTP sync instead');
|
||||||
}
|
}
|
||||||
|
isTheiaSyncAvailable().then(ok => {
|
||||||
|
console.log(`Theia HTTP sync: ${ok ? 'reachable at ' + (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001') : 'not reachable — sync will be skipped'}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,73 +3,109 @@
|
|||||||
*
|
*
|
||||||
* Bridge between vibn-agent-runner and the Theia container.
|
* Bridge between vibn-agent-runner and the Theia container.
|
||||||
*
|
*
|
||||||
* The agent runner and Theia run as separate Docker containers on the same
|
* Both containers share the `coolify` Docker network, so the agent runner
|
||||||
* host. With /var/run/docker.sock mounted in the runner, this module:
|
* can reach Theia at http://theia-code-os:3000 internally.
|
||||||
*
|
*
|
||||||
* 1. Discovers the Theia container at startup (by label or explicit name)
|
* Theia runs a lightweight sync-server.js on port 3001 that:
|
||||||
* 2. theiaExec(cmd, workdir) — runs a command inside Theia via docker exec
|
* - POST /sync { repo: "mark/sportsy" } → git pull (or clone) inside Theia
|
||||||
* 3. syncToTheia(localDir, theiaWorkspacePath) — copies files from the
|
* - GET /health
|
||||||
* runner's local workspace into Theia so "Open in Theia" shows them
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* After the agent auto-commits to Gitea, we call this endpoint so the
|
||||||
* import { theiaExec, syncToTheia, getTheiaContainer } from './theia-exec';
|
* 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:
|
* Environment variables:
|
||||||
* THEIA_CONTAINER — explicit container name/id (fastest)
|
* THEIA_SYNC_URL — full URL for the sync server (default: http://theia-code-os:3001)
|
||||||
* THEIA_COOLIFY_UUID — Coolify app UUID to search by label (fallback)
|
* THEIA_CONTAINER — container name for docker exec (optional, Phase 2.1)
|
||||||
* default: b8wswk0ggcg88k0c8osk4c4c
|
|
||||||
* THEIA_WORKSPACE — workspace path inside Theia (default: /home/node/workspace)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, execFileSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
const THEIA_WORKSPACE = process.env.THEIA_WORKSPACE ?? '/home/node/workspace';
|
const THEIA_SYNC_URL = (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001').replace(/\/$/, '');
|
||||||
const THEIA_COOLIFY_UUID = process.env.THEIA_COOLIFY_UUID ?? 'b8wswk0ggcg88k0c8osk4c4c';
|
|
||||||
|
|
||||||
let _cachedContainer: string | null = null;
|
// ── Sync endpoint (HTTP — no docker socket needed) ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the running Theia container. Returns the container name/id or null.
|
* Tell Theia to git pull (or clone) the given repo so "Open in Theia" shows
|
||||||
* Result is cached after first successful lookup.
|
* the agent's latest committed files.
|
||||||
|
*
|
||||||
|
* @param giteaRepo e.g. "mark/sportsy"
|
||||||
*/
|
*/
|
||||||
export function getTheiaContainer(): string | null {
|
export async function syncRepoToTheia(giteaRepo: string): Promise<{ ok: boolean; action?: string; error?: string }> {
|
||||||
if (_cachedContainer) return _cachedContainer;
|
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));
|
||||||
|
|
||||||
// 1. Explicit env var — fastest
|
const data = await res.json() as { ok: boolean; action?: string; error?: string };
|
||||||
if (process.env.THEIA_CONTAINER) {
|
return data;
|
||||||
_cachedContainer = process.env.THEIA_CONTAINER.trim();
|
} catch (err: any) {
|
||||||
return _cachedContainer;
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Search by Coolify application UUID label
|
/**
|
||||||
|
* Check if the Theia sync server is reachable.
|
||||||
|
*/
|
||||||
|
export async function isTheiaSyncAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const byAppId = execSync(
|
const controller = new AbortController();
|
||||||
`docker ps --filter "label=coolify.applicationId=${THEIA_COOLIFY_UUID}" --format "{{.Names}}" 2>/dev/null`,
|
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 }
|
{ timeout: 5000 }
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
if (byAppId) { _cachedContainer = byAppId.split('\n')[0].trim(); return _cachedContainer; }
|
if (byLabel) { _theiaContainer = byLabel.split('\n')[0].trim(); return _theiaContainer; }
|
||||||
} catch { /* docker not available or socket not mounted */ }
|
} catch { /* docker not available */ }
|
||||||
|
|
||||||
// 3. Search by partial name match
|
|
||||||
try {
|
try {
|
||||||
const byName = execSync(
|
const byName = execSync(
|
||||||
`docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`,
|
`docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`,
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
).toString().trim();
|
).toString().trim();
|
||||||
if (byName) { _cachedContainer = byName.split('\n')[0].trim(); return _cachedContainer; }
|
if (byName) { _theiaContainer = byName.split('\n')[0].trim(); return _theiaContainer; }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
_theiaContainer = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if docker exec into Theia is available */
|
|
||||||
export function isTheiaAvailable(): boolean {
|
export function isTheiaAvailable(): boolean {
|
||||||
return getTheiaContainer() !== null;
|
return getTheiaContainer() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a shell command inside the Theia container.
|
* Run a command inside the Theia container via docker exec.
|
||||||
* Returns { exitCode, stdout, stderr }.
|
* Requires /var/run/docker.sock to be mounted in the agent runner.
|
||||||
*/
|
*/
|
||||||
export async function theiaExec(
|
export async function theiaExec(
|
||||||
command: string,
|
command: string,
|
||||||
@@ -77,15 +113,13 @@ export async function theiaExec(
|
|||||||
): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> {
|
): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> {
|
||||||
const container = getTheiaContainer();
|
const container = getTheiaContainer();
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return { exitCode: 1, stdout: '', stderr: '', error: 'Theia container not available — running locally' };
|
return { exitCode: 1, stdout: '', stderr: '', error: 'Theia container not reachable via docker exec' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = workdir ?? THEIA_WORKSPACE;
|
const cwd = workdir ?? '/home/project';
|
||||||
// Wrap in bash so the command runs in a shell with proper cd
|
|
||||||
const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`;
|
const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`;
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
try {
|
|
||||||
const { execFile } = require('child_process') as typeof import('child_process');
|
const { execFile } = require('child_process') as typeof import('child_process');
|
||||||
execFile(
|
execFile(
|
||||||
'docker', ['exec', container, 'bash', '-c', wrapped],
|
'docker', ['exec', container, 'bash', '-c', wrapped],
|
||||||
@@ -99,19 +133,12 @@ export async function theiaExec(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
|
||||||
resolve({ exitCode: 1, stdout: '', stderr: '', error: err.message });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy files from the runner's local workspace directory into Theia's workspace.
|
* Copy local directory into the Theia container via docker cp.
|
||||||
* Creates the destination directory if it doesn't exist.
|
* Requires /var/run/docker.sock to be mounted.
|
||||||
*
|
|
||||||
* @param localDir Absolute path in the runner container, e.g. /workspaces/mark_sportsy
|
|
||||||
* @param theiaPath Relative path inside THEIA_WORKSPACE, e.g. "mark_sportsy"
|
|
||||||
* or pass null to copy into the workspace root
|
|
||||||
*/
|
*/
|
||||||
export async function syncToTheia(
|
export async function syncToTheia(
|
||||||
localDir: string,
|
localDir: string,
|
||||||
@@ -119,25 +146,15 @@ export async function syncToTheia(
|
|||||||
): Promise<{ ok: boolean; error?: string }> {
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
const container = getTheiaContainer();
|
const container = getTheiaContainer();
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return { ok: false, error: 'Theia container not available — sync skipped' };
|
return { ok: false, error: 'Theia container not reachable via docker' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const destDir = theiaPath
|
const destDir = theiaPath ? `/home/project/${theiaPath}` : '/home/project';
|
||||||
? `${THEIA_WORKSPACE}/${theiaPath}`
|
|
||||||
: THEIA_WORKSPACE;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure destination directory exists
|
|
||||||
execSync(`docker exec ${container} mkdir -p ${JSON.stringify(destDir)}`, { timeout: 10_000 });
|
execSync(`docker exec ${container} mkdir -p ${JSON.stringify(destDir)}`, { timeout: 10_000 });
|
||||||
|
|
||||||
// docker cp copies the contents of localDir into destDir
|
|
||||||
// Trailing /. means "copy contents, not the directory itself"
|
|
||||||
execSync(`docker cp "${localDir}/." "${container}:${destDir}/"`, { timeout: 60_000 });
|
execSync(`docker cp "${localDir}/." "${container}:${destDir}/"`, { timeout: 60_000 });
|
||||||
|
|
||||||
console.log(`[theia-exec] Synced ${localDir} → ${container}:${destDir}`);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[theia-exec] Sync failed:`, err.message);
|
|
||||||
return { ok: false, error: err.message };
|
return { ok: false, error: err.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user