diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3110faa..e23605a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,7 +41,10 @@ jobs: if [ -f coverage/coverage-summary.json ]; then node -e " const t = require('./coverage/coverage-summary.json').total; - const f = (m) => m.pct.toFixed(2) + '% (' + m.covered + '/' + m.total + ')'; + // Colour each category by threshold (like hivemind): green >=90, + // orange 80-89, red <80 β€” so a weak metric is visible at a glance. + const dot = (p) => (p >= 90 ? '🟒' : p >= 80 ? '🟠' : 'πŸ”΄'); + const f = (m) => dot(m.pct) + ' ' + m.pct.toFixed(2) + '% (' + m.covered + '/' + m.total + ')'; const out = [ '### Test coverage', '', diff --git a/packages/server/src/app-hub.ts b/packages/server/src/app-hub.ts new file mode 100644 index 0000000..edfb7dd --- /dev/null +++ b/packages/server/src/app-hub.ts @@ -0,0 +1,199 @@ +import type { UiEvent, Workflow } from '@openrepl/shared'; +import { detectWorkflows, WorkflowManager } from './workflow.js'; +import { PreviewManager } from './preview.js'; +import { CommandRunner } from './runner.js'; + +type AppStatus = Extract; + +interface RunningApp { + mgr: WorkflowManager | null; + installer: CommandRunner | null; + preview: PreviewManager; + workflows: Workflow[]; + activeWorkflow: string | null; + status: AppStatus | null; +} + +/** + * Server-level owner of the *running app*, keyed by workspace dir. The app is + * one thing per workspace; sessions (tabs, reconnects) are many. Keeping run / + * stop / preview here β€” instead of in a per-session mount β€” means every client + * viewing that workspace sees the same status and Stop button, and Stop always + * reaches the real process no matter which tab clicked it. + * + * Events are broadcast with the dir they belong to; a session forwards only the + * events for the workspace it currently has open. + */ +export class AppHub { + private apps = new Map(); + private listeners = new Set<(dir: string, e: UiEvent) => void>(); + /** Per-dir promise chain so overlapping run()/stop() can't interleave and + * clear or swap `app.mgr` mid-start. */ + private locks = new Map>(); + /** How many live sessions have each workspace open, so we can stop its app + * once the last tab for it goes away (no orphaned dev servers holding ports). */ + private attached = new Map(); + + /** A session opened `dir`. */ + attach(dir: string): void { + this.attached.set(dir, (this.attached.get(dir) ?? 0) + 1); + } + + /** A session left `dir` (switched project or disconnected); stop its app when + * no session has it open anymore. */ + detach(dir: string): void { + const n = (this.attached.get(dir) ?? 1) - 1; + if (n > 0) { + this.attached.set(dir, n); + return; + } + this.attached.delete(dir); + void this.stop(dir); + } + + subscribe(fn: (dir: string, e: UiEvent) => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + private broadcast(dir: string, e: UiEvent): void { + for (const l of this.listeners) l(dir, e); + } + + private appFor(dir: string): RunningApp { + const existing = this.apps.get(dir); + if (existing) return existing; + const created: RunningApp = { mgr: null, installer: null, preview: new PreviewManager(), workflows: [], activeWorkflow: null, status: null }; + this.apps.set(dir, created); + return created; + } + + preview(dir: string): PreviewManager | null { + return this.apps.get(dir)?.preview ?? null; + } + status(dir: string): AppStatus | null { + return this.apps.get(dir)?.status ?? null; + } + activeWorkflow(dir: string): string | null { + return this.apps.get(dir)?.activeWorkflow ?? null; + } + + /** The preview of whichever workspace currently has a running app, if any. */ + runningPreview(): PreviewManager | null { + for (const a of this.apps.values()) if (a.preview.getPort() != null) return a.preview; + return null; + } + + /** A dev server the user started in the terminal β€” surface it in the preview. */ + notePort(dir: string, port: number): void { + const a = this.appFor(dir); + if (a.preview.getPort() !== port) { + a.preview.setPort(port); + this.broadcast(dir, { type: 'preview_ready', url: '/__preview/' }); + } + } + + private setStatus(dir: string, status: AppStatus): void { + this.appFor(dir).status = status; + this.broadcast(dir, status); + } + + /** Serialize run()/stop() per dir so they can't interleave. */ + private serialize(dir: string, fn: () => Promise): Promise { + const prev = this.locks.get(dir) ?? Promise.resolve(); + const next = prev.then(fn, fn); + this.locks.set(dir, next.then(() => {}, () => {})); + return next; + } + + /** Run (or re-run) the app for `dir`. Returns the detected workflows. */ + run(dir: string, getEnv: () => Promise>, name?: string): Promise { + return this.serialize(dir, () => this.runLocked(dir, getEnv, name)); + } + + private async runLocked(dir: string, getEnv: () => Promise>, name?: string): Promise { + await this.stopLocked(dir); + const det = await detectWorkflows(dir); + const app = this.appFor(dir); + app.workflows = det.workflows; + if (det.self) { + this.setStatus(dir, { type: 'app_status', state: 'error', message: 'This is the OpenREPL folder itself β€” open your own app folder.' }); + return det.workflows; + } + const wf = name ? det.workflows.find((w) => w.name === name) : det.workflows[0]; + if (!wf) { + this.setStatus(dir, { type: 'app_status', state: 'error', message: 'No runnable app found. Ask the agent to create one (e.g. an index.html or a package.json with a dev/start script).' }); + return det.workflows; + } + try { + if (det.install) { + this.setStatus(dir, { type: 'app_status', state: 'installing', message: `Installing dependencies… (${det.install})` }); + app.installer = new CommandRunner(dir, getEnv, (data) => this.broadcast(dir, { type: 'term_data', data: `[install] ${data}` }), () => {}); + const code = await app.installer.run(det.install); + app.installer = null; + if (code !== 0) { + this.setStatus(dir, { type: 'app_status', state: 'error', message: 'Dependency install failed β€” see the terminal output.' }); + return det.workflows; + } + } + this.setStatus(dir, { type: 'app_status', state: 'starting', message: `Starting workflow "${wf.name}" β€” ${wf.steps.map((s) => s.name).join(' + ')}` }); + // Only the last spawned step exiting means the app is really down: a + // backend+frontend workflow shouldn't flip to "stopped" when one exits. + let live = wf.steps.filter((s) => s.command && !s.static).length; + const mgr = new WorkflowManager( + dir, + getEnv, + (step, data) => this.broadcast(dir, { type: 'term_data', data: `[${step}] ${data}` }), + (port) => { + this.notePort(dir, port); + this.setStatus(dir, { type: 'app_status', state: 'running', message: `"${wf.name}" running` }); + }, + (step, code) => { + if (mgr !== app.mgr) return; // a newer run replaced this workflow + if (--live > 0) return; + app.mgr = null; + app.activeWorkflow = null; + app.preview.clearPort(); + this.setStatus(dir, { type: 'app_status', state: 'stopped', message: `${step} exited (code ${code})` }); + }, + ); + app.mgr = mgr; + await mgr.start(wf); + app.activeWorkflow = wf.name; + } catch (e) { + // spawn ENOENT, a step throwing mid-start, etc. Tear the partial app down + // first (start() may have already spawned children) so we don't orphan + // processes/ports, then surface the error instead of a stuck "starting…". + await this.teardown(app); + this.setStatus(dir, { type: 'app_status', state: 'error', message: `Failed to start: ${e instanceof Error ? e.message : String(e)}` }); + } + return det.workflows; + } + + /** Stop the app for `dir` β€” kills the workflow tree and any in-flight install. */ + stop(dir: string): Promise { + return this.serialize(dir, () => this.stopLocked(dir)); + } + + private async stopLocked(dir: string): Promise { + const app = this.apps.get(dir); + if (app) await this.teardown(app); + this.setStatus(dir, { type: 'app_status', state: 'stopped' }); + } + + /** + * Kill the running app and reset its refs. Clears state *before* awaiting the + * process kill so a rejecting `mgr.stop()` can't leave the app half-stopped β€” + * `stop()` must never reject (detach() calls it fire-and-forget). + */ + private async teardown(app: RunningApp): Promise { + app.installer?.stopRuns(); + app.installer = null; + const mgr = app.mgr; + app.mgr = null; + app.activeWorkflow = null; + // Drop the stale port so runningPreview()/the proxy stop pointing at the + // now-dead server (the source of intermittent "target unreachable" 502s). + app.preview.clearPort(); + if (mgr) await mgr.stop().catch(() => {}); + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b1d2b07..81438a6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,8 @@ import { WebSocketServer } from 'ws'; import type { ClientCommand, UiEvent } from '@openrepl/shared'; import { Session } from './session.js'; import { ProjectRegistry } from './projects.js'; +import { pickPreview } from './preview.js'; +import { AppHub } from './app-hub.js'; const MIME: Record = { '.html': 'text/html', @@ -53,14 +55,19 @@ export async function createServer(opts: ServerOptions = {}): Promise undefined); } - // Single-user assumption: the most recent session owns the preview proxy. + // The running app is workspace-level, not per-connection: it lives in the hub + // so a second tab or a reconnect sees the same status/preview and can Stop it. + const hub = new AppHub(); + const sessions = new Set(); let currentSession: Session | null = null; const server = http.createServer(async (req, res) => { const url = req.url || '/'; if (url.startsWith('/__preview')) { - const preview = currentSession?.getPreview(); + // The hub owns any running app; fall back to a session's preview for a + // dev server started in the terminal before an app is registered. + const preview = hub.runningPreview() ?? pickPreview(sessions, currentSession); if (preview) preview.proxy(req, res); else { res.writeHead(503); @@ -95,7 +102,8 @@ export async function createServer(opts: ServerOptions = {}): Promise { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(event)); }; - const session = new Session(emit, projects); + const session = new Session(emit, projects, hub); + sessions.add(session); currentSession = session; session.init().catch((e) => emit({ type: 'error', scope: 'init', message: String(e) })); @@ -110,7 +118,8 @@ export async function createServer(opts: ServerOptions = {}): Promise { session.close(); - if (currentSession === session) currentSession = null; + sessions.delete(session); + if (currentSession === session) currentSession = [...sessions].pop() ?? null; }); }); diff --git a/packages/server/src/preview.ts b/packages/server/src/preview.ts index be9058f..44de3c4 100644 --- a/packages/server/src/preview.ts +++ b/packages/server/src/preview.ts @@ -12,6 +12,12 @@ export class PreviewManager { this.port = port; } + /** Forget the port when the app stops, so the proxy stops pointing at a dead + * server and the next start re-emits preview_ready to refresh the iframe. */ + clearPort(): void { + this.port = null; + } + getPort(): number | null { return this.port; } @@ -29,21 +35,97 @@ export class PreviewManager { res.end('No dev server detected yet. Run your dev command in the terminal.'); return true; } + this.attempt(req, res, 0); + return true; + } + + /** + * Forward one request to the app, retrying a fresh connection for idempotent + * requests. Dev servers with a reloader (Flask debug, nodemon) briefly refuse + * connections while restarting on boot; without the retry the iframe's first + * GET lands in that gap and shows "Preview target unreachable". + */ + private attempt(req: http.IncomingMessage, res: http.ServerResponse, tries: number): void { + const idempotent = !req.method || req.method === 'GET' || req.method === 'HEAD'; const targetPath = (req.url || '/').replace(/^\/__preview/, '') || '/'; const proxyReq = http.request( - { host: '127.0.0.1', port: this.port, path: targetPath, method: req.method, headers: { ...req.headers, host: `127.0.0.1:${this.port}` } }, + // Force identity encoding: we buffer and rewrite text/html as UTF-8, so a + // gzip/br response from the dev server would otherwise be corrupted. + { host: '127.0.0.1', port: this.port ?? undefined, path: targetPath, method: req.method, headers: { ...req.headers, host: `127.0.0.1:${this.port}`, 'accept-encoding': 'identity' } }, (proxyRes) => { - res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); + const headers = { ...proxyRes.headers }; + // Keep redirects inside the preview: `Location: /login` would otherwise + // send the iframe to OpenREPL's own /login. + const loc = headers.location; + if (typeof loc === 'string' && loc.startsWith('/') && !loc.startsWith('//')) { + headers.location = '/__preview' + loc; + } + // Root-absolute URLs in served HTML (href="/x", src="/x", action="/x") + // resolve against OpenREPL's origin, escape /__preview, and hit the SPA + // fallback β†’ OpenREPL renders inside its own preview ("inception"). + // Rewrite them to stay under /__preview so links, forms and assets go + // to the app instead. + if (String(headers['content-type'] || '').includes('text/html')) { + const chunks: Buffer[] = []; + proxyRes.on('data', (c: Buffer) => chunks.push(c)); + proxyRes.on('end', () => { + const html = rewritePreviewHtml(Buffer.concat(chunks).toString('utf8')); + delete headers['content-length']; // length changed after rewrite + delete headers['content-encoding']; // we send decoded text + res.writeHead(proxyRes.statusCode || 502, headers); + res.end(html); + }); + return; + } + res.writeHead(proxyRes.statusCode || 502, headers); proxyRes.pipe(res); }, ); proxyReq.on('error', () => { + // Retry the reloader gap for idempotent requests (~1s of 200ms backoffs). + if (idempotent && tries < 5) { + setTimeout(() => this.attempt(req, res, tries + 1), 200); + return; + } res.writeHead(502, { 'content-type': 'text/plain' }); res.end('Preview target unreachable.'); }); - req.pipe(proxyReq); - return true; + // A GET/HEAD has no body to forward; ending directly lets us retry cleanly + // without re-piping an already-consumed request stream. + if (idempotent) proxyReq.end(); + else req.pipe(proxyReq); + } +} + +/** + * Prefix root-absolute URLs in HTML with /__preview so a server-rendered app's + * links/forms/assets stay in the preview iframe instead of escaping to OpenREPL. + * Leaves protocol-relative (//host) and absolute (http://) URLs untouched. + */ +export function rewritePreviewHtml(html: string): string { + return html + .replace(/(\s(?:href|src|action)\s*=\s*["'])\/(?!\/)/gi, '$1/__preview/') + .replace(/(url\(\s*["']?)\/(?!\/)/gi, '$1/__preview/'); +} + +/** Anything that can expose a preview β€” a Session, in practice. */ +export interface PreviewSource { + getPreview(): PreviewManager | null; +} + +/** + * Pick which session's preview the /__preview proxy should serve. Ownership + * follows the running app: the first live session whose preview has a detected + * port wins, so a second tab or a reconnect can't steal the proxy from the + * session that actually launched the dev server. Falls back to the most-recent + * session (which may have no port yet) for the pre-launch "no dev server" hint. + */ +export function pickPreview(sessions: Iterable, current: PreviewSource | null): PreviewManager | null { + for (const s of sessions) { + const p = s.getPreview(); + if (p?.getPort() != null) return p; } + return current?.getPreview() ?? null; } export function checkPort(port: number, host = '127.0.0.1', timeout = 500): Promise { diff --git a/packages/server/src/runner.ts b/packages/server/src/runner.ts index 1179037..ad33e86 100644 --- a/packages/server/src/runner.ts +++ b/packages/server/src/runner.ts @@ -8,6 +8,8 @@ import { spawn as ptySpawn, type IPty } from 'node-pty'; */ export class CommandRunner { private shell: IPty | null = null; + /** Live one-shot children so Stop can kill them even without a per-call signal. */ + private active = new Set(); lastOutput = ''; constructor( @@ -46,6 +48,7 @@ export class CommandRunner { // the WHOLE tree on abort β€” `shell: true` spawns a subshell, and killing // only the shell leaves the real command (npm/pip/dev server) running. const p = spawn(command, { cwd: this.cwd, env, shell: true, stdio: 'pipe', detached: true }) as ChildProcessWithoutNullStreams; + this.active.add(p); let buf = ''; const emit = (chunk: Buffer) => { const s = chunk.toString(); @@ -57,16 +60,7 @@ export class CommandRunner { let aborted = false; const onAbort = () => { aborted = true; - try { - if (p.pid) process.kill(-p.pid, 'SIGTERM'); // negative pid = the group - else p.kill('SIGTERM'); - } catch { - try { - p.kill('SIGTERM'); - } catch { - /* already gone */ - } - } + killGroup(p); }; if (signal) { if (signal.aborted) onAbort(); @@ -74,6 +68,7 @@ export class CommandRunner { } const done = (code: number) => { signal?.removeEventListener('abort', onAbort); + this.active.delete(p); this.lastOutput = buf; resolve(code); }; @@ -90,6 +85,16 @@ export class CommandRunner { }); } + /** + * Kill every in-flight one-shot command (whole process group each). Used by + * Stop so a dev server started by the agent or typed in the terminal β€” not + * just a Run-button workflow β€” actually stops. + */ + stopRuns(): void { + for (const p of this.active) killGroup(p); + this.active.clear(); + } + kill(): void { try { this.shell?.kill(); @@ -100,6 +105,20 @@ export class CommandRunner { } } +/** Kill a detached child and its whole process group (SIGTERM), best-effort. */ +function killGroup(p: ChildProcessWithoutNullStreams): void { + try { + if (p.pid) process.kill(-p.pid, 'SIGTERM'); // negative pid = the group + else p.kill('SIGTERM'); + } catch { + try { + p.kill('SIGTERM'); + } catch { + /* already gone */ + } + } +} + /** Detect a localhost dev-server port from arbitrary command output. */ export function detectPort(text: string): number | null { const m = text.match(/localhost:(\d{2,5})|127\.0\.0\.1:(\d{2,5})|:\/\/[^\s]*:(\d{2,5})/i); diff --git a/packages/server/src/session.ts b/packages/server/src/session.ts index d26d66e..550044b 100644 --- a/packages/server/src/session.ts +++ b/packages/server/src/session.ts @@ -6,7 +6,8 @@ import { UsageStore, makeUsageRecord } from './usage.js'; import { Secrets } from './secrets.js'; import { CommandRunner, detectPort } from './runner.js'; import { PreviewManager } from './preview.js'; -import { detectWorkflows, WorkflowManager } from './workflow.js'; +import { detectWorkflows } from './workflow.js'; +import { AppHub } from './app-hub.js'; import { ProviderRegistry } from './providers/registry.js'; import { ProjectRegistry } from './projects.js'; import { buildTools, type ToolDeps } from './agent/tools.js'; @@ -28,25 +29,30 @@ interface Mount { secrets: Secrets; registry: ProviderRegistry; shell: CommandRunner; - preview: PreviewManager; config: OpenReplConfig; - workflowMgr: WorkflowManager | null; workflows: Workflow[]; - activeWorkflow: string | null; } /** One session per WebSocket connection. The active project can be switched at runtime. */ export class Session { private mount: Mount | null = null; private activeRun: AbortController | null = null; + private unsubHub: () => void; constructor( private emit: (event: UiEvent) => void, private projects: ProjectRegistry, - ) {} + private hub: AppHub = new AppHub(), + ) { + // Forward the running app's lifecycle events, but only for the workspace + // this session currently has open. + this.unsubHub = hub.subscribe((dir, e) => { + if (dir === this.mount?.dir) this.emit(e); + }); + } getPreview(): PreviewManager | null { - return this.mount?.preview ?? null; + return this.mount ? this.hub.preview(this.mount.dir) : null; } async init(): Promise { @@ -157,12 +163,25 @@ export class Session { const project = await this.projects.open(dir); const m = await this.createMount(project.path); this.mount = m; + this.hub.attach(m.dir); this.emit({ type: 'ready', workspaceDir: m.dir, provider: m.config.provider }); + // Replay the persisted conversation so reopening a project shows what was + // built and how β€” the history is already loaded for the LLM, just not shown. + this.emit({ type: 'history', messages: m.memory.history() }); this.emit({ type: 'tree', nodes: await m.workspace.tree() }); this.emit({ type: 'secrets', keys: await m.secrets.keys() }); this.sendModelConfig(m); await this.sendWorkflows(m); await this.sendProjects(); + // Push usage on open too: panes stay mounted, so the client's one-time + // get_usage never re-fires on a project switch β€” without this, Usage shows + // empty until a full page refresh. + await this.sendUsage(m); + // Snapshot the shared app state: a reconnect or a second tab must see a + // still-running app (so it gets the live preview and a Stop button). + const status = this.hub.status(m.dir); + if (status) this.emit(status); + if (this.hub.preview(m.dir)?.getPort() != null) this.emit({ type: 'preview_ready', url: '/__preview/' }); } private async createMount(dir: string): Promise { @@ -171,25 +190,22 @@ export class Session { const usage = new UsageStore(dir); const secrets = new Secrets(dir); const registry = new ProviderRegistry((key) => secrets.all().then((s) => s[key])); - const preview = new PreviewManager(); const config = await loadConfig(dir); const shell = new CommandRunner( dir, () => secrets.all(), (data) => { this.emit({ type: 'term_data', data }); + // A dev server started in the terminal: surface it in the shared preview. const port = detectPort(data); - if (port && preview.getPort() !== port) { - preview.setPort(port); - this.emit({ type: 'preview_ready', url: '/__preview/' }); - } + if (port) this.hub.notePort(dir, port); }, (code) => this.emit({ type: 'term_exit', code }), ); await memory.load(); await shell.startShell().catch(() => undefined); workspace.watch((path, kind) => this.emit({ type: 'file_changed', path, kind })); - return { dir, workspace, memory, usage, secrets, registry, shell, preview, config, workflowMgr: null, workflows: [], activeWorkflow: null }; + return { dir, workspace, memory, usage, secrets, registry, shell, config, workflows: [] }; } private async unmount(): Promise { @@ -197,8 +213,11 @@ export class Session { this.activeRun = null; const m = this.mount; if (!m) return; + // The terminal is per-tab, so kill it. The app is workspace-level (hub), so + // it keeps running while other tabs view this dir; detach() stops it once + // this was the last one, so no dev server is left orphaned holding a port. m.shell.kill(); - if (m.workflowMgr) await m.workflowMgr.stop(); + this.hub.detach(m.dir); await m.workspace.close(); this.mount = null; } @@ -208,54 +227,22 @@ export class Session { private async sendWorkflows(m: Mount): Promise { const det = await detectWorkflows(m.dir); m.workflows = det.workflows; - this.emit({ type: 'workflows', workflows: det.workflows, active: m.activeWorkflow }); + this.emit({ type: 'workflows', workflows: det.workflows, active: this.hub.activeWorkflow(m.dir) }); } private async runWorkflow(m: Mount, name?: string): Promise { - await this.stopWorkflow(m); - const det = await detectWorkflows(m.dir); - m.workflows = det.workflows; - - if (det.self) { - return this.emit({ type: 'app_status', state: 'error', message: 'This is the OpenREPL folder itself β€” open your own app folder.' }); - } - const wf = name ? det.workflows.find((w) => w.name === name) : det.workflows[0]; - if (!wf) { - return this.emit({ type: 'app_status', state: 'error', message: 'No runnable app found. Ask the agent to create one (e.g. an index.html or a package.json with a dev/start script).' }); - } - - if (det.install) { - this.emit({ type: 'app_status', state: 'installing', message: `Installing dependencies… (${det.install})` }); - const code = await m.shell.run(det.install); - if (code !== 0) return this.emit({ type: 'app_status', state: 'error', message: 'Dependency install failed β€” see the terminal output.' }); - } - - this.emit({ type: 'app_status', state: 'starting', message: `Starting workflow "${wf.name}" β€” ${wf.steps.map((s) => s.name).join(' + ')}` }); - m.workflowMgr = new WorkflowManager( - m.dir, - () => m.secrets.all(), - (step, data) => this.emit({ type: 'term_data', data: `[${step}] ${data}` }), - (port) => { - if (m.preview.getPort() !== port) { - m.preview.setPort(port); - this.emit({ type: 'preview_ready', url: '/__preview/' }); - } - this.emit({ type: 'app_status', state: 'running', message: `"${wf.name}" running` }); - }, - (step, code) => this.emit({ type: 'app_status', state: 'stopped', message: `${step} exited (code ${code})` }), - ); - await m.workflowMgr.start(wf); - m.activeWorkflow = wf.name; - this.emit({ type: 'workflows', workflows: det.workflows, active: m.activeWorkflow }); + // The app lives in the hub (one per workspace, shared across tabs), so any + // client sees its status and Stop reaches it no matter which tab ran it. + const workflows = await this.hub.run(m.dir, () => m.secrets.all(), name); + m.workflows = workflows; + this.emit({ type: 'workflows', workflows, active: this.hub.activeWorkflow(m.dir) }); } private async stopWorkflow(m: Mount): Promise { - if (m.workflowMgr) { - await m.workflowMgr.stop(); - m.workflowMgr = null; - } - m.activeWorkflow = null; - this.emit({ type: 'app_status', state: 'stopped' }); + await this.hub.stop(m.dir); + // Also kill anything started outside the workflow in this tab's shell β€” a + // dev server the agent launched via run_command, or a terminal command. + m.shell.stopRuns(); } /* --------------------------------- models -------------------------------- */ @@ -412,6 +399,7 @@ export class Session { } async close(): Promise { + this.unsubHub(); await this.unmount(); } } diff --git a/packages/server/test/app-hub.test.ts b/packages/server/test/app-hub.test.ts new file mode 100644 index 0000000..8a3cfdc --- /dev/null +++ b/packages/server/test/app-hub.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { AppHub } from '../src/app-hub.js'; +import type { UiEvent } from '@openrepl/shared'; +import { tmpWorkspace } from './helpers.js'; + +/** + * The hub owns the running app per workspace, shared across sessions. These + * cover the bug the per-session model caused: a second tab / reconnect couldn't + * see the running app or stop it. + */ +describe('AppHub', () => { + const status = (events: Array<[string, UiEvent]>) => + [...events].reverse().find(([, e]) => e.type === 'app_status')?.[1] as + | Extract + | undefined; + + it('broadcasts a running app to every subscriber, and either can stop it', async () => { + const dir = await tmpWorkspace(); + await fs.writeFile(path.join(dir, 'index.html'), '

hi

'); + const hub = new AppHub(); + const a: Array<[string, UiEvent]> = []; + const b: Array<[string, UiEvent]> = []; + hub.subscribe((d, e) => a.push([d, e])); + hub.subscribe((d, e) => b.push([d, e])); + + await hub.run(dir, async () => ({})); + // Both "tabs" see the app running and the shared preview has a port. + expect(status(a)?.state).toBe('running'); + expect(status(b)?.state).toBe('running'); + expect(hub.preview(dir)?.getPort()).not.toBeNull(); + + // The *second* subscriber stops it β€” proving Stop is not tied to the tab + // that started it. Both see 'stopped' and the app is gone. + await hub.stop(dir); + expect(status(a)?.state).toBe('stopped'); + expect(status(b)?.state).toBe('stopped'); + expect(await hub.preview(dir)!.isUp()).toBe(false); + }); + + it('stop clears the preview port so the proxy stops pointing at a dead server', async () => { + const dir = await tmpWorkspace(); + await fs.writeFile(path.join(dir, 'index.html'), '

hi

'); + const hub = new AppHub(); + hub.subscribe(() => {}); + await hub.run(dir, async () => ({})); + expect(hub.runningPreview()).not.toBeNull(); // a running app is proxied + await hub.stop(dir); + // The bug (intermittent 502): a stale port kept runningPreview() returning a + // stopped workspace. After the fix the port is cleared. + expect(hub.preview(dir)?.getPort()).toBeNull(); + expect(hub.runningPreview()).toBeNull(); + }); + + it('reports an error status for a folder with nothing runnable', async () => { + const dir = await tmpWorkspace(); + const hub = new AppHub(); + const seen: Array<[string, UiEvent]> = []; + hub.subscribe((d, e) => seen.push([d, e])); + await hub.run(dir, async () => ({})); + expect(status(seen)?.state).toBe('error'); + }); + + it('notePort surfaces a terminal-started dev server as a preview', () => { + const hub = new AppHub(); + const seen: Array<[string, UiEvent]> = []; + hub.subscribe((d, e) => seen.push([d, e])); + hub.notePort('/ws', 5173); + expect(hub.preview('/ws')?.getPort()).toBe(5173); + expect(seen.some(([, e]) => e.type === 'preview_ready')).toBe(true); + }); + + it('runningPreview ignores a stopped workspace and returns the running one', async () => { + const a = await tmpWorkspace(); + const b = await tmpWorkspace(); + await fs.writeFile(path.join(a, 'index.html'), '

a

'); + await fs.writeFile(path.join(b, 'index.html'), '

b

'); + const hub = new AppHub(); + hub.subscribe(() => {}); + await hub.run(a, async () => ({})); + await hub.run(b, async () => ({})); + const bPort = hub.preview(b)!.getPort(); + await hub.stop(a); // a is stopped; b still runs + expect(hub.runningPreview()?.getPort()).toBe(bPort); + await hub.stop(b); + }); + + it('restarting an app re-broadcasts preview_ready (iframe refreshes)', async () => { + const dir = await tmpWorkspace(); + await fs.writeFile(path.join(dir, 'index.html'), '

hi

'); + const hub = new AppHub(); + let readies = 0; + hub.subscribe((_d, e) => { if (e.type === 'preview_ready') readies++; }); + await hub.run(dir, async () => ({})); + await hub.stop(dir); + await hub.run(dir, async () => ({})); + await hub.stop(dir); + expect(readies).toBe(2); // one per start β€” the stale-port fix re-emits on restart + }); + + it('detach stops the app only when the last session for a dir leaves', async () => { + const dir = await tmpWorkspace(); + await fs.writeFile(path.join(dir, 'index.html'), '

hi

'); + const hub = new AppHub(); + hub.subscribe(() => {}); + hub.attach(dir); + hub.attach(dir); // two tabs open this workspace + await hub.run(dir, async () => ({})); + hub.detach(dir); // one tab leaves β€” app keeps running + expect(hub.runningPreview()).not.toBeNull(); + hub.detach(dir); // last tab leaves β€” app is stopped (no orphaned server) + await new Promise((r) => setTimeout(r, 200)); // detach fires stop() async + expect(hub.runningPreview()).toBeNull(); + }); +}); diff --git a/packages/server/test/preview.test.ts b/packages/server/test/preview.test.ts index f0f2800..a9a4e87 100644 --- a/packages/server/test/preview.test.ts +++ b/packages/server/test/preview.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect } from 'vitest'; -import { PreviewManager, checkPort } from '../src/preview.js'; +import { PreviewManager, checkPort, pickPreview, rewritePreviewHtml, type PreviewSource } from '../src/preview.js'; import { startStaticServer } from '../src/static-server.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { tmpWorkspace } from './helpers.js'; +/** A mounted session: has a PreviewManager, with a detected port or not. */ +function mounted(port: number | null): PreviewSource { + const preview = new PreviewManager(); + if (port != null) preview.setPort(port); + return { getPreview: () => preview }; +} +/** An unmounted session: no project open yet, so no preview at all. */ +function unmounted(): PreviewSource { + return { getPreview: () => null }; +} + describe('PreviewManager', () => { it('tracks the detected port', () => { const p = new PreviewManager(); @@ -32,3 +43,53 @@ describe('checkPort', () => { expect(await checkPort(1, '127.0.0.1', 300)).toBe(false); }); }); + +describe('rewritePreviewHtml', () => { + it('prefixes root-absolute href/src/action so links stay in the preview', () => { + const out = rewritePreviewHtml( + 'x
', + ); + expect(out).toContain('href="/__preview/static/app.css"'); + expect(out).toContain('href="/__preview/add"'); + expect(out).toContain('action="/__preview/"'); + }); + + it('rewrites url(/...) in inline styles', () => { + expect(rewritePreviewHtml('background:url(/bg.png)')).toContain('url(/__preview/bg.png)'); + }); + + it('leaves absolute and protocol-relative URLs untouched', () => { + const html = 'ar'; + expect(rewritePreviewHtml(html)).toBe(html); + }); +}); + +describe('pickPreview', () => { + it('prefers the session whose app is running over the newest connection', () => { + const withApp = mounted(5000); + const current = mounted(null); + expect(pickPreview([withApp, current], current)?.getPort()).toBe(5000); + }); + + it('falls back to the current session when no app has a port yet', () => { + const current = mounted(null); + const chosen = pickPreview([current], current); + expect(chosen).toBe(current.getPreview()); + expect(chosen?.getPort()).toBeNull(); + }); + + it('returns null when the current session has no project mounted', () => { + const current = unmounted(); + expect(pickPreview([current], current)).toBeNull(); + }); + + it('returns null when there are no sessions and no current', () => { + expect(pickPreview([], null)).toBeNull(); + }); + + it('a second connection cannot steal the proxy from the running app', () => { + const a = mounted(5000); + const b = unmounted(); + expect(pickPreview([a, b], b)?.getPort()).toBe(5000); + }); +}); diff --git a/packages/server/test/runner.test.ts b/packages/server/test/runner.test.ts index 29892f1..10dd802 100644 --- a/packages/server/test/runner.test.ts +++ b/packages/server/test/runner.test.ts @@ -28,6 +28,18 @@ describe('CommandRunner.run', () => { expect(code).not.toBe(0); // killed β†’ non-zero, not a false success expect(Date.now() - start).toBeLessThan(4000); // aborted early, not waited out }); + + it('stopRuns() kills a command that has no per-call signal (the Stop button path)', async () => { + // Reproduces the reported bug: a dev server started via the terminal/agent + // has no AbortSignal, so only stopRuns() (invoked by Stop) can end it. + const r = make(); + const start = Date.now(); + const running = r.run('sleep 5'); // no signal + setTimeout(() => r.stopRuns(), 150); + const code = await running; + expect(code).not.toBe(0); + expect(Date.now() - start).toBeLessThan(4000); + }); }); describe('CommandRunner β€” interactive shell (PTY)', () => { diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 2708f61..cadfe3f 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -91,6 +91,7 @@ export type UiEvent = | { type: 'projects'; projects: Project[]; active: string | null; defaultRoot: string } | { type: 'models'; models: ModelInfo[] } | { type: 'model_config'; default: string; roles: Partial>; multiAgent: boolean } + | { type: 'history'; messages: Message[] } | { type: 'error'; scope: string; message: string } | { type: 'done'; runId: string }; diff --git a/packages/web/index.html b/packages/web/index.html index 9f9e5b3..075818f 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,6 +4,12 @@ OpenREPL + + +
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 9d00722..04fc8d2 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type ComponentType } from 'react'; import { store, useStore } from './store.js'; import { Chat } from './panes/Chat.js'; import { FilesEditor } from './panes/FilesEditor.js'; @@ -8,16 +8,17 @@ import { Usage } from './panes/Usage.js'; import { Secrets } from './panes/Secrets.js'; import { Models } from './panes/Models.js'; import { ProjectsSidebar } from './panes/Projects.js'; +import { IconFiles, IconPreview, IconTerminal, IconModels, IconUsage, IconSecrets } from './icons.js'; type Tab = 'editor' | 'preview' | 'terminal' | 'models' | 'usage' | 'secrets'; -const TABS: { id: Tab; label: string }[] = [ - { id: 'editor', label: 'Files' }, - { id: 'preview', label: 'Preview' }, - { id: 'terminal', label: 'Terminal' }, - { id: 'models', label: 'Models' }, - { id: 'usage', label: 'Usage' }, - { id: 'secrets', label: 'Secrets' }, +const TABS: { id: Tab; label: string; icon: ComponentType<{ size?: number }> }[] = [ + { id: 'editor', label: 'Files', icon: IconFiles }, + { id: 'preview', label: 'Preview', icon: IconPreview }, + { id: 'terminal', label: 'Terminal', icon: IconTerminal }, + { id: 'models', label: 'Models', icon: IconModels }, + { id: 'usage', label: 'Usage', icon: IconUsage }, + { id: 'secrets', label: 'Secrets', icon: IconSecrets }, ]; export function App() { @@ -32,11 +33,19 @@ export function App() { return (
- OpenREPL - {activeProject ?? 'no project'} + + R + OpenREPL + + + {activeProject ? activeProject.split('/').pop() : 'no project'} + {activeProject && } - + + + {connected ? 'connected' : 'offline'} +
{notice &&
{notice}
}
@@ -50,6 +59,7 @@ export function App() {