diff --git a/src/agent-session-runner.ts b/src/agent-session-runner.ts index c197f30..9f7896b 100644 --- a/src/agent-session-runner.ts +++ b/src/agent-session-runner.ts @@ -17,7 +17,7 @@ import { createLLM, toOAITools, LLMMessage } from './llm'; import { AgentConfig } from './agents'; import { executeTool, ToolContext } from './tools'; 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; @@ -116,12 +116,12 @@ async function autoCommitAndDeploy( const giteaToken = process.env.GITEA_API_TOKEN || ''; try { - // Sync files into Theia so "Open in Theia" shows the agent's work - if (isTheiaAvailable() && opts.theiaWorkspaceSubdir) { - await emit({ ts: now(), type: 'info', text: `Syncing files to Theia…` }); - const syncResult = await syncToTheia(repoRoot, opts.theiaWorkspaceSubdir); + // Sync files into Theia via the sync-server so "Open in Theia" shows latest code + if (opts.giteaRepo && await isTheiaSyncAvailable()) { + await emit({ ts: now(), type: 'info', text: `Syncing to Theia…` }); + const syncResult = await syncRepoToTheia(opts.giteaRepo); 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 { console.warn('[session-runner] Theia sync failed:', syncResult.error); } diff --git a/src/server.ts b/src/server.ts index 428897b..c747f43 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ import { PROTECTED_GITEA_REPOS } from './tools/security'; import { orchestratorChat, listSessions, clearSession } from './orchestrator'; import { atlasChat, listAtlasSessions, clearAtlasSession } from './atlas'; import { LLMMessage, createLLM } from './llm'; -import { getTheiaContainer } from './theia-exec'; +import { getTheiaContainer, isTheiaSyncAvailable } from './theia-exec'; const app = express(); app.use(cors()); @@ -616,8 +616,11 @@ app.listen(PORT, () => { // Theia bridge status const theiaContainer = getTheiaContainer(); if (theiaContainer) { - console.log(`Theia bridge: active (container: ${theiaContainer})`); + console.log(`Theia docker exec: active (container: ${theiaContainer})`); } 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'}`); + }); }); diff --git a/src/theia-exec.ts b/src/theia-exec.ts index ff737e7..8cdef32 100644 --- a/src/theia-exec.ts +++ b/src/theia-exec.ts @@ -3,73 +3,109 @@ * * Bridge between vibn-agent-runner and the Theia container. * - * The agent runner and Theia run as separate Docker containers on the same - * host. With /var/run/docker.sock mounted in the runner, this module: + * Both containers share the `coolify` Docker network, so the agent runner + * can reach Theia at http://theia-code-os:3000 internally. * - * 1. Discovers the Theia container at startup (by label or explicit name) - * 2. theiaExec(cmd, workdir) — runs a command inside Theia via docker exec - * 3. syncToTheia(localDir, theiaWorkspacePath) — copies files from the - * runner's local workspace into Theia so "Open in Theia" shows them + * Theia runs a lightweight sync-server.js on port 3001 that: + * - POST /sync { repo: "mark/sportsy" } → git pull (or clone) inside Theia + * - GET /health * - * Usage: - * import { theiaExec, syncToTheia, getTheiaContainer } from './theia-exec'; + * 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_CONTAINER — explicit container name/id (fastest) - * THEIA_COOLIFY_UUID — Coolify app UUID to search by label (fallback) - * default: b8wswk0ggcg88k0c8osk4c4c - * THEIA_WORKSPACE — workspace path inside Theia (default: /home/node/workspace) + * 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, execFileSync } from 'child_process'; +import { execSync } from 'child_process'; -const THEIA_WORKSPACE = process.env.THEIA_WORKSPACE ?? '/home/node/workspace'; -const THEIA_COOLIFY_UUID = process.env.THEIA_COOLIFY_UUID ?? 'b8wswk0ggcg88k0c8osk4c4c'; +const THEIA_SYNC_URL = (process.env.THEIA_SYNC_URL ?? 'http://theia-code-os:3001').replace(/\/$/, ''); -let _cachedContainer: string | null = null; +// ── Sync endpoint (HTTP — no docker socket needed) ──────────────────────────── /** - * Find the running Theia container. Returns the container name/id or null. - * Result is cached after first successful lookup. + * 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 function getTheiaContainer(): string | null { - if (_cachedContainer) return _cachedContainer; +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 { + 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; - // 1. Explicit env var — fastest if (process.env.THEIA_CONTAINER) { - _cachedContainer = process.env.THEIA_CONTAINER.trim(); - return _cachedContainer; + _theiaContainer = process.env.THEIA_CONTAINER.trim(); + return _theiaContainer; } - // 2. Search by Coolify application UUID label try { - const byAppId = execSync( - `docker ps --filter "label=coolify.applicationId=${THEIA_COOLIFY_UUID}" --format "{{.Names}}" 2>/dev/null`, + const byLabel = execSync( + `docker ps --filter "label=coolify.applicationId=b8wswk0ggcg88k0c8osk4c4c" --format "{{.Names}}" 2>/dev/null`, { timeout: 5000 } ).toString().trim(); - if (byAppId) { _cachedContainer = byAppId.split('\n')[0].trim(); return _cachedContainer; } - } catch { /* docker not available or socket not mounted */ } + if (byLabel) { _theiaContainer = byLabel.split('\n')[0].trim(); return _theiaContainer; } + } catch { /* docker not available */ } - // 3. Search by partial name match try { const byName = execSync( `docker ps --filter "name=theia" --format "{{.Names}}" 2>/dev/null`, { timeout: 5000 } ).toString().trim(); - if (byName) { _cachedContainer = byName.split('\n')[0].trim(); return _cachedContainer; } + if (byName) { _theiaContainer = byName.split('\n')[0].trim(); return _theiaContainer; } } catch { /* ignore */ } + _theiaContainer = null; return null; } -/** Returns true if docker exec into Theia is available */ export function isTheiaAvailable(): boolean { return getTheiaContainer() !== null; } /** - * Run a shell command inside the Theia container. - * Returns { exitCode, stdout, stderr }. + * 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, @@ -77,41 +113,32 @@ export async function theiaExec( ): Promise<{ exitCode: number; stdout: string; stderr: string; error?: string }> { const container = getTheiaContainer(); 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; - // Wrap in bash so the command runs in a shell with proper cd + const cwd = workdir ?? '/home/project'; const wrapped = `cd ${JSON.stringify(cwd)} && ${command}`; return new Promise(resolve => { - try { - 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, - }); - } - ); - } catch (err: any) { - resolve({ exitCode: 1, stdout: '', stderr: '', error: err.message }); - } + 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 files from the runner's local workspace directory into Theia's workspace. - * Creates the destination directory if it doesn't exist. - * - * @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 + * Copy local directory into the Theia container via docker cp. + * Requires /var/run/docker.sock to be mounted. */ export async function syncToTheia( localDir: string, @@ -119,25 +146,15 @@ export async function syncToTheia( ): Promise<{ ok: boolean; error?: string }> { const container = getTheiaContainer(); 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 - ? `${THEIA_WORKSPACE}/${theiaPath}` - : THEIA_WORKSPACE; - + const destDir = theiaPath ? `/home/project/${theiaPath}` : '/home/project'; try { - // Ensure destination directory exists 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 }); - - console.log(`[theia-exec] Synced ${localDir} → ${container}:${destDir}`); return { ok: true }; } catch (err: any) { - console.error(`[theia-exec] Sync failed:`, err.message); return { ok: false, error: err.message }; } }