From 7031e7192ad193741dec253ad1e1cf4b40d19519 Mon Sep 17 00:00:00 2001 From: Hanno Blankenstein Date: Fri, 19 Jun 2026 22:12:22 +1000 Subject: [PATCH 1/2] feat(collab): per-session ECS Fargate preview tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolate frontend dev servers (ng-serve / pnpm run start) into dedicated ECS Fargate tasks per collab session, eliminating memory competition with the collab container that caused OOM crashes. Key changes: - preview-launcher: active singleton → Map; ECS RunTask/StopTask when ECS_CLUSTER_ARN is set; process-spawn fallback preserved when env var is absent - preview-router: dynamic upstream IP from ECS task private IP instead of hardcoded 127.0.0.1; session-aware WS routing via preview_sid cookie - server.ts: ?cs= query param sets preview_sid cookie for session-scoped proxy routing across multiple concurrent preview tasks - router.ts: three new internal endpoints (register, heartbeat, log) for preview tasks to phone home; all stopPreview/restartPreview callers updated to pass sessionId as first arg - Dockerfile.preview + scripts/preview-entrypoint.js: lightweight Node 20 image that registers, heartbeats, and streams logs back to collab server - @aws-sdk/client-ecs 3.993.0 added to package.json Infra team deliverables (separate repo): - ECS task definition opencode-collab-preview (2 vCPU / 8 GB, EFS mount) - IAM: collab task role ecs:RunTask + ecs:StopTask + iam:PassRole - Security group opencode-preview-sg (TCP 8080 inbound from collab SG) - Env vars on collab container: ECS_CLUSTER_ARN, ECS_PREVIEW_TASK_DEFINITION, ECS_PREVIEW_SUBNETS, ECS_PREVIEW_SECURITY_GROUP, COLLAB_BASE_URL Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.preview | 50 + packages/opencode/package.json | 1 + .../opencode/src/collab/preview-launcher.ts | 1374 ++++++++--------- .../opencode/src/collab/preview-router.ts | 113 +- packages/opencode/src/collab/router.ts | 89 +- packages/opencode/src/server/server.ts | 72 +- scripts/preview-entrypoint.js | 260 ++++ 7 files changed, 1108 insertions(+), 851 deletions(-) create mode 100644 Dockerfile.preview create mode 100644 scripts/preview-entrypoint.js diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 000000000000..59e9019796d6 --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.7 +# +# Preview ECS task image — Node 20 + pnpm 10 + git credential helper. +# +# Each ECS task runs a single frontend dev server (e.g. `pnpm run start`). +# The collab container proxies to this task's private IP on port 8080. +# Tasks are ephemeral: the collab server launches one per session on demand +# and stops it when idle (30 min), session-deleted, or lifetime-capped (2 h). +# +# EFS mount at /var/opencode/workspaces/ must be configured in the task +# definition — same access point as the collab container. + +FROM node:20-slim + +# System packages: git for fetching private git+https deps, curl for ECS +# metadata + collab-server registration, openssh-client so git can fork ssh +# for repos that haven't been rewritten to https yet. +RUN apt-get update -qq && apt-get install -y --no-install-recommends \ + git ca-certificates curl openssh-client && \ + rm -rf /var/lib/apt/lists/* + +# Rewrite ssh-form GitHub URLs to HTTPS — mirrors the main Dockerfile logic +# so pnpm-lock.yaml git deps resolve correctly here too. +RUN git config --system --add url."https://github.com/".insteadOf "git@github.com:" && \ + git config --system --add url."https://github.com/".insteadOf "ssh://git@github.com/" && \ + git config --system --add url."https://github.com/".insteadOf "git+ssh://git@github.com/" && \ + git config --system --add url."https://github.com/".insteadOf "https://git@github.com:" && \ + git config --system --add url."https://github.com/".insteadOf "git+https://git@github.com:" + +# GIT_ASKPASS — answers git's credential prompts for private HTTPS fetches. +# Reads GITHUB_TOKEN injected per-task via ECS container overrides. +RUN printf '#!/bin/sh\ncase "$1" in\n Username*) echo x-access-token ;;\n Password*) echo "$GITHUB_TOKEN" ;;\nesac\n' \ + > /usr/local/bin/git-askpass-token && \ + chmod a+rx /usr/local/bin/git-askpass-token +ENV GIT_ASKPASS=/usr/local/bin/git-askpass-token + +# pnpm@10 — matches unleashlive/frontend's lockfile / deploy-cirrus.yml. +RUN npm install --global pnpm@10 2>&1 | tail -3 && pnpm --version + +# Non-root user (uid 10001) — mirrors the collab container. +RUN useradd -u 10001 -m opencode + +# Entrypoint script (Node.js — avoids a bash version dependency). +COPY scripts/preview-entrypoint.js /usr/local/bin/preview-entrypoint.js +RUN chmod a+rx /usr/local/bin/preview-entrypoint.js + +USER opencode + +EXPOSE 8080 +ENTRYPOINT ["node", "/usr/local/bin/preview-entrypoint.js"] diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e9693f8fa5fe..6a5e26d7bed1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -90,6 +90,7 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", + "@aws-sdk/client-ecs": "3.993.0", "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", diff --git a/packages/opencode/src/collab/preview-launcher.ts b/packages/opencode/src/collab/preview-launcher.ts index aa081e9980aa..b9aff7a1ef83 100644 --- a/packages/opencode/src/collab/preview-launcher.ts +++ b/packages/opencode/src/collab/preview-launcher.ts @@ -2,35 +2,38 @@ * Frontend live-preview launcher (Driver-clicks-button → dev server runs in * the workspace). * - * One preview at a time per container. The first session whose Driver clicks - * "Launch" wins — concurrent attempts get 409. The running process is - * tracked in module-level state because the OS process is itself in-memory; - * persisting state to SQLite would just create a sync problem. + * Two modes, selected by whether ECS_CLUSTER_ARN is set at runtime: * - * Lifecycle: + * ── Process mode (local dev / no ECS_CLUSTER_ARN) ────────────────────────── + * One preview per session, run as a child process inside this container. + * Exactly as before — pnpm install → pnpm run start, SIGTERM on stop. + * + * ── ECS mode (production, ECS_CLUSTER_ARN set) ───────────────────────────── + * One ECS Fargate task per session. The collab container proxies HTTP and + * WebSocket traffic to the task's private IP on port 8080. The task is + * started via RunTask (with COLLAB_SESSION_ID, REPO_FULL_NAME, etc. in the + * container overrides) and stopped via StopTask. The task itself runs + * scripts/preview-entrypoint.js which: + * 1. Fetches its own private IP from ECS task metadata + * 2. POSTs to POST /collab/preview-task/register + * 3. Sends heartbeats every 60 s to POST /collab/preview-task/heartbeat + * 4. Pipes stdout/stderr lines to POST /collab/preview-task/log * - * launchPreview(...) → pnpm install (streaming log) → pnpm run start - * stopPreview() → SIGTERM the child - * restartPreview() → stop + relaunch with same args + * Multiple sessions can run previews simultaneously in ECS mode (the + * per-session Map replaces the old singleton). Idle shutdown (30 min), + * lifetime cap (2 h), and install-hang detection work in both modes. * - * Auto-stop triggers: - * - 30 min of zero `/preview//*` traffic (idle sweep, 60s interval) - * - Container shutdown (process dies with us) - * - Collab session deleted (caller hooks cleanupSessionWorkspace) - * - Git HEAD changes in the workspace (caller hooks the queue executor) + * Lifecycle: * - * URL of the running preview: served by the existing /preview//* proxy - * in collab/preview-router.ts. The proxy already gates on a valid collab - * cookie (ADR-0001), so participants — and only participants — can reach it. - * The proxy TCP-connects to 127.0.0.1: and rewrites the Host header - * to `local.unleashlive.com:` so the dev server sees its expected - * hostname — no /etc/hosts entry needed (which is just as well, since AWS - * forbids container-level `extraHosts` on tasks with `networkMode=awsvpc`, - * see DEPLOYMENT.md → Frontend live-preview loopback alias). + * launchPreview(...) → "installing" → ready → "running" + * stopPreview(sid) → ECS StopTask / SIGTERM child + * restartPreview(sid) → stop + relaunch with same args * - * Per-repo config: `.opencode-preview.json` in the repo root. When absent, - * defaults match the unleashlive/frontend setup (the zero-config case the - * feature was built for). + * URL routing: the collab proxy in preview-router.ts uses the session's + * privateIp (ECS mode) or 127.0.0.1 (process mode) to forward requests. + * The SPA appends `?cs=` on first load to the preview host URL; + * the proxy reads it, sets a `preview_sid` cookie, and routes subsequent + * requests to the right ECS task. */ import { spawn, type ChildProcess } from "child_process" @@ -40,6 +43,25 @@ import { repoWorkspacePath } from "./workspace" import { previewUrl } from "./preview-host" import type { CollabEvent } from "@opencode-ai/collab" +// ── ECS SDK (lazy — only imported when ECS_CLUSTER_ARN is set) ───────────── + +type ECSClientType = import("@aws-sdk/client-ecs").ECSClient +type RunTaskCommandType = typeof import("@aws-sdk/client-ecs").RunTaskCommand +type StopTaskCommandType = typeof import("@aws-sdk/client-ecs").StopTaskCommand + +let _ecsClient: ECSClientType | null = null + +async function getEcsClient(): Promise { + if (_ecsClient) return _ecsClient + const { ECSClient } = await import("@aws-sdk/client-ecs") + _ecsClient = new ECSClient({ region: process.env.AWS_REGION ?? "ap-southeast-2" }) + return _ecsClient +} + +function isEcsMode(): boolean { + return !!process.env.ECS_CLUSTER_ARN +} + // ── Configuration ────────────────────────────────────────────────────────── /** Frontend defaults — applied when no `.opencode-preview.json` is present. */ @@ -52,115 +74,39 @@ const FRONTEND_DEFAULTS: PreviewConfig = { upstreamScheme: "http", } -/** Idle window — no traffic for this long → SIGTERM. */ +/** Idle window — no traffic for this long → stop the preview. */ const IDLE_TIMEOUT_MS = 30 * 60 * 1000 -/** Absolute lifetime cap for a single preview run. Angular CLI 19's `ng - * serve` (wrapping Vite 6) has a slow heap leak under sustained traffic: - * Vite's optimizeDeps cache rebundles on every new route entry and doesn't - * release the previous generation, so RAM ratchets up by hundreds of MB - * per hour. We observed the 16 GB Fargate task getting OOM-killed at the - * ~1-hour mark twice in a row on 2026-06-12. A hard lifetime cap means - * the preview gets stopped cleanly *before* the kernel OOM-killer takes - * opencode down with it. Driver can press Launch to re-spawn — the - * workspace is preserved, only the dev-server process dies. */ +/** Absolute lifetime cap for a single preview run. */ const MAX_LIFETIME_MS = 2 * 60 * 60 * 1000 -/** Memory circuit-breaker — when the container's total RSS exceeds this, - * stop the preview before the kernel OOM-killer fires. Fargate task is - * sized at 16 GB; opencode itself uses ~500 MB; a healthy ng-serve peaks - * around 4-5 GB. 12 GB leaves ~3-4 GB of headroom — enough that a brief - * spike (e.g. a heavy compile) doesn't trip the breaker, but well short - * of the 16 GB ceiling where the kernel takes the WHOLE task down. */ +/** Memory circuit-breaker — process mode only. ECS tasks have isolated memory. */ const MEMORY_CAP_BYTES = 12 * 1024 * 1024 * 1024 -/** Install-hang watchdog (S1) — if a preview is still in the `installing` - * phase and has produced no stdout/stderr for this long, presume the - * install is wedged and stop it. pnpm emits progress lines constantly - * during a healthy install, so 5 min of total silence is a strong wedge - * signal (dead registry, stuck native build, OOMing dep). Distinct from - * IDLE_TIMEOUT_MS, which only counts request traffic and so never fires - * during install. */ +/** Install-hang watchdog — if the task/process has emitted nothing (ECS: no + * heartbeat; process mode: no stdout/stderr) for this long while still in + * the "installing" phase, presume the install is wedged and stop it. */ const INSTALL_SILENCE_TIMEOUT_MS = 5 * 60 * 1000 -/** Crash-loop breaker (S2) — refuse to auto-resume a session on boot once it - * has crashed during install this many times within BREAKER_WINDOW_MS. A - * Driver pressing Launch manually overrides the breaker (and resets the - * count); a successful "ready" transition also resets it. */ +/** Crash-loop breaker — refuse to auto-resume after this many install crashes. */ const BREAKER_CRASH_THRESHOLD = 3 const BREAKER_WINDOW_MS = 60 * 60 * 1000 /** How often the sweep runs (idle / lifetime / memory / install-hang checks). */ const SWEEP_INTERVAL_MS = 60 * 1000 -/** Cap on retained install / run log lines (so memory is bounded across a - * long-running session). */ +/** Cap on retained install / run log lines. */ const LOG_LINES_RETAINED = 200 // ── Types ────────────────────────────────────────────────────────────────── export interface PreviewConfig { - /** Shell command for first-launch dep install. Run via `sh -c`. */ readonly installCommand?: string - /** Shell command that starts the dev server bound to a port. */ readonly command: string - /** Port the dev server binds to inside the container. Used by - * /preview//* to find the upstream. */ readonly port: number - /** Button + status-banner label in the SPA. */ readonly label: string - /** Regex on stdout; first match flips status → "running". When undefined - * we treat the process as "running" 2s after spawn (best-effort). */ readonly readyPattern?: string - /** - * Transport the dev server speaks on its local port. Defaults to "http". - * - * Set to "https" when the dev server runs TLS in-container (e.g. Angular - * CLI with `--ssl`, Vite with `--https`, CRA with `HTTPS=true`). The - * preview-router will then TLS-connect to 127.0.0.1: instead of - * speaking plain HTTP, and accept the dev server's self-signed cert via - * `rejectUnauthorized: false` (safe over loopback — there is no MITM - * surface inside the same container). - * - * When `"https"`, the WS upgrade path also flips from net.connect to - * tls.connect — HMR / WebSocket traffic stays end-to-end encrypted from - * the browser's wss:// through the ALB to the dev server. - * - * Most repos shouldn't need this: terminating TLS twice in the same - * container adds no security (the ALB already speaks TLS to the - * browser). Use only when the dev server's own code branches on - * `location.protocol === "https:"` (service-worker registration, - * secure-context APIs). - */ readonly upstreamScheme?: "http" | "https" - - /** - * URL prefix the dev server expects to receive on incoming requests. - * - * Default (undefined): preview-router STRIPS `/preview//` or - * `/preview/` from the URL before forwarding, so the dev server sees - * paths starting at `/`. Matches the common Vite/webpack-dev-server - * + Next dev contract where the app runs at the URL root. - * - * Set to e.g. `"/preview/"` for dev servers that align their internal - * routing with the public base path — notably Angular's - * `@angular-devkit/build-angular:dev-server` builder, which derives - * its `servePath` from the build target's `baseHref`. In that mode - * ng serve refuses requests whose path doesn't start with `/preview/` - * ("The server is configured with a public base URL of /preview"). - * Setting `servePath: "/preview/"` here tells the proxy to forward - * the prefix verbatim, satisfying ng serve's expectation. - * - * Should match `` in the served index.html so client-side - * navigation + asset URLs all resolve against the same prefix. For - * Angular: define a build configuration with `baseHref: "/preview/"` - * and a matching serve configuration with `servePath: "/preview/"`, - * then set this field to `"/preview/"` so the proxy stops stripping. - * - * Format: leading slash required, trailing slash recommended for - * clarity. Invalid values trigger the same warn-and-default pattern - * as readyPattern. - */ readonly servePath?: string } @@ -174,78 +120,59 @@ export interface PreviewStateSnapshot { readonly status: PreviewStatus readonly startedAt: number readonly lastTraffic: number - /** Last N lines of combined stdout+stderr — for the install/run UI. */ readonly recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }> readonly errorMessage?: string - /** Absolute URL the SPA should link to for opening this preview. - * `https://${previewHost()}/` when a dedicated preview host is configured - * (root serve), else the legacy portless `/preview/` path. Computed - * server-side so the SPA never hard-codes the host. */ readonly url: string + /** Private IP of the ECS task (ECS mode) or null (process mode). */ + readonly privateIp: string | null } -interface ActiveState extends PreviewStateSnapshot { - child: ChildProcess - config: PreviewConfig - // Mutable accumulators (not snapshot-able directly) +interface ActiveState { + // ── Identity ────────────────────────────────────────────────────────── + collabSessionId: string + repoFullName: string + port: number + label: string + // ── Status (mutable) ────────────────────────────────────────────────── + status: PreviewStatus + startedAt: number + lastTraffic: number + errorMessage?: string + url: string + // ── Common internals ────────────────────────────────────────────────── _log: Array<{ stream: "stdout" | "stderr"; line: string; ts: number }> - /** Epoch-ms of the most-recent stdout/stderr line from the child. The - * install-hang watchdog (S1) reads this every sweep: if the preview is - * still in the `installing` phase and has emitted nothing for - * INSTALL_SILENCE_TIMEOUT_MS, the install is presumed wedged (dead - * registry, stuck native build, OOMing dep) and gets stopped so memory - * is freed and the Driver can retry — instead of sitting until the - * 30-minute idle cap, which only counts request traffic, not output. */ - _lastOutput: number - /** True iff stopPreview was called for THIS state (vs the process exiting - * on its own). Lets the exit handler decide between firing - * collab:preview_stopped (clean exit we triggered) vs collab:preview_failed - * (unexpected death) vs collab:preview_stopped (clean exit we did NOT - * trigger — e.g. a dev server with a `--build` flag that finishes - * building and exits naturally). */ _stopRequested: boolean - /** GitHub OAuth token the original launch used for git fetches. Cached - * here so `restartPreview` (Driver button OR branch-change auto-restart) - * reuses it without a fresh DB lookup. NEVER surfaced via - * `getPreviewState()` — that constructor strips this field explicitly. - * May be null when launchPreview was called without a token (public-only - * install) — restartPreview then also runs unauthenticated. */ _gitAccessToken: string | null - /** Transport the upstream dev server speaks on its loopback port. - * Read by preview-router.ts via `getActiveUpstreamScheme(port)` to - * decide between plain TCP / TLS for the proxy hop. Materialised - * from the resolved PreviewConfig at launch time so a swap of the - * `.opencode-preview.json` file mid-session doesn't change behaviour - * for the running process (the file is re-read on the next Restart). - * NEVER surfaced via `getPreviewState()` — `_`-prefixed convention. */ _upstreamScheme: "http" | "https" - /** URL prefix the dev server expects on incoming requests, or null - * to strip `/preview/`. Read by preview-router.ts via - * `getActiveServePath(port)` to decide whether the forwarded path - * is `` (default, strip) or `` (keep prefix). - * See PreviewConfig.servePath docstring for the Angular use-case. */ _servePath: string | null + // ── ECS-mode fields (null in process mode) ──────────────────────────── + taskArn: string | null + privateIp: string | null + /** Last heartbeat received from the ECS task (ECS mode) or last stdout/ + * stderr line emitted by the child (process mode). Used by the install- + * hang watchdog to detect silence in the "installing" phase. */ + _lastHeartbeat: number + // ── Process-mode fields (null in ECS mode) ──────────────────────────── + _child: ChildProcess | null + config: PreviewConfig | null } -// ── Module state (singleton — "first-launch wins") ───────────────────────── +// ── Module state ─────────────────────────────────────────────────────────── + +/** Active previews keyed by collab session ID. Multiple entries are allowed + * in ECS mode; process mode still allows only one at a time (enforced in + * launchPreview). The Map replaces the old `active: ActiveState | null` + * singleton so the rest of the system can support concurrent previews. */ +const active = new Map() -let active: ActiveState | null = null let sweepTimer: ReturnType | null = null -/** - * Pending restart timer. restartPreview defers the inner launchPreview by - * 100 ms so the OS port releases cleanly between stop and re-bind. If - * something stops the preview (session delete, container shutdown, another - * stopPreview) during that 100 ms window, we must cancel this timer or the - * deferred launch fires against a workspace that may no longer exist — - * surfacing as a confusing "Preview failed: Workspace for X not cloned yet" - * for a session the user already deleted. - */ -let pendingRestart: ReturnType | null = null -/** - * SSE broadcaster injected from router.ts. Avoids a circular import; the - * router calls `setPreviewBroadcaster(broadcastSse)` once at startup. - */ +/** Pending restart timers, one per session. */ +const pendingRestarts = new Map>() + +/** Last-known git HEAD per session (branch-change auto-restart, process mode). */ +const lastKnownHeads = new Map() + type Broadcaster = (collabSessionId: string, event: CollabEvent) => void let broadcast: Broadcaster = () => {} @@ -253,12 +180,26 @@ export function setPreviewBroadcaster(fn: Broadcaster): void { broadcast = fn } +// ── Internal helpers ─────────────────────────────────────────────────────── + +function makeSnapshot(st: ActiveState): PreviewStateSnapshot { + return { + collabSessionId: st.collabSessionId, + repoFullName: st.repoFullName, + port: st.port, + label: st.label, + status: st.status, + startedAt: st.startedAt, + lastTraffic: st.lastTraffic, + recentLog: st._log.slice(-LOG_LINES_RETAINED), + errorMessage: st.errorMessage, + url: previewUrl(), + privateIp: st.privateIp, + } +} + // ── Public API ───────────────────────────────────────────────────────────── -/** - * Read `.opencode-preview.json` from the repo workspace. Returns the - * frontend defaults when the file is absent or invalid. - */ export function previewConfigForRepo( collabSessionId: string, repoFullName: string, @@ -271,19 +212,10 @@ export function previewConfigForRepo( console.warn(`[collab.preview] ${path} missing required command/port — using defaults`) return FRONTEND_DEFAULTS } - // Validate port range. Outside 1024-65535 is either privileged (will - // fail to bind as uid 10001) or an outright invalid TCP port. Reject - // and fall back to defaults so a typo in the config doesn't ship a - // confusing EACCES / EINVAL error to the SPA. if (!Number.isInteger(raw.port) || raw.port < 1024 || raw.port > 65535) { - console.warn( - `[collab.preview] ${path} port=${raw.port} outside 1024-65535 — using defaults`, - ) + console.warn(`[collab.preview] ${path} port=${raw.port} outside 1024-65535 — using defaults`) return FRONTEND_DEFAULTS } - // Validate readyPattern compiles. An invalid regex would throw at - // first stdout line in wireChildStreams; better to surface it now - // and fall back to the built-in heuristic. let readyPattern: string | undefined = undefined if (typeof raw.readyPattern === "string") { try { @@ -293,11 +225,6 @@ export function previewConfigForRepo( console.warn(`[collab.preview] ${path} readyPattern is not a valid regex; ignoring:`, e) } } - // Validate upstreamScheme — only "http" and "https" are honoured. - // Anything else (typo, "tcp", "ssh", a number, …) WARN + falls back - // to the default "http". Matches the readyPattern try/warn shape so - // a misconfigured .opencode-preview.json never blocks a launch — it - // just downgrades to the closest sensible behaviour. let upstreamScheme: "http" | "https" = FRONTEND_DEFAULTS.upstreamScheme ?? "http" if (raw.upstreamScheme !== undefined) { if (raw.upstreamScheme === "http" || raw.upstreamScheme === "https") { @@ -309,12 +236,6 @@ export function previewConfigForRepo( ) } } - - // Validate servePath — must be a string starting with "/". Anything - // else (number, missing-leading-slash, empty) WARN + falls back to - // undefined (= proxy strips /preview/ as it has always done). Empty - // string treated like undefined since "no prefix" is what stripping - // already provides. let servePath: string | undefined = undefined if (raw.servePath !== undefined) { if (typeof raw.servePath === "string" && raw.servePath.startsWith("/") && raw.servePath !== "/") { @@ -326,7 +247,6 @@ export function previewConfigForRepo( ) } } - return { command: raw.command, port: raw.port, @@ -343,14 +263,6 @@ export function previewConfigForRepo( } } -/** - * Decide whether a repo is "preview-capable": - * - The repo workspace exists (workspace init completed), AND - * - Either an `.opencode-preview.json` is present, OR the repo name is - * "frontend" (the zero-config case) - * - * Used by GET /collab/session/:id to flag the SPA banner / button. - */ export function repoHasPreview(collabSessionId: string, repoFullName: string): boolean { const dest = repoWorkspacePath(collabSessionId, repoFullName) if (!existsSync(dest)) return false @@ -359,129 +271,112 @@ export function repoHasPreview(collabSessionId: string, repoFullName: string): b return existsSync(join(dest, ".opencode-preview.json")) } -/** Snapshot for SSE / GET state. Drops the ChildProcess + config refs. */ -export function getPreviewState(): PreviewStateSnapshot | null { - if (!active) return null - return { - collabSessionId: active.collabSessionId, - repoFullName: active.repoFullName, - port: active.port, - label: active.label, - status: active.status, - startedAt: active.startedAt, - lastTraffic: active.lastTraffic, - recentLog: active._log.slice(-LOG_LINES_RETAINED), - errorMessage: active.errorMessage, - url: previewUrl(), +/** + * Snapshot for SSE / GET state. + * With sessionId: returns that session's state or null. + * Without sessionId: returns the first active preview (backward-compat for + * the /preview/holder endpoint which wants "any active preview"). + */ +export function getPreviewState(sessionId?: string): PreviewStateSnapshot | null { + if (sessionId !== undefined) { + const st = active.get(sessionId) + return st ? makeSnapshot(st) : null } + for (const st of active.values()) { + return makeSnapshot(st) + } + return null } /** - * Bump the lastTraffic timestamp. Hooked from preview-router.ts so the - * idle-sweep timer knows the preview is in active use. Cheap — single - * timestamp write on every request. + * Bump lastTraffic for a session (or all sessions when sessionId is omitted). + * Called from preview-router.ts on every proxied HTTP request. */ -export function markPreviewTraffic(): void { - if (active) (active as { lastTraffic: number }).lastTraffic = Date.now() +export function markPreviewTraffic(sessionId?: string): void { + const now = Date.now() + if (sessionId !== undefined) { + const st = active.get(sessionId) + if (st) st.lastTraffic = now + return + } + for (const st of active.values()) { + st.lastTraffic = now + } } -/** - * Return the upstream transport the currently-active preview speaks on - * the given port. Called by `preview-router.ts` once per HTTP request - * and once per WS upgrade — picks between plain HTTP/TCP and HTTPS/TLS - * for the loopback proxy hop. - * - * Returns "http" (the safe default) when: - * - No preview is currently active (defensive — the router shouldn't - * reach an upstream connect in this case, but a leftover SSE event - * could race). - * - A preview IS active but its port doesn't match the requested one - * (the URL path's `` segment was made-up or for a stale - * session). In both cases the connect attempt will fail at the - * TCP layer anyway; we just pick the cheaper transport. - * - * Returns the active preview's resolved `upstreamScheme` ("http" or - * "https") when ports match — read once at launch time from the repo's - * `.opencode-preview.json` so a Driver swapping the file mid-session - * doesn't change behaviour for the running process. - */ -export function getActiveUpstreamScheme(port: number): "http" | "https" { - if (active && active.port === port) return active._upstreamScheme +export function getActiveUpstreamScheme(port: number, sessionId?: string): "http" | "https" { + if (sessionId !== undefined) { + const st = active.get(sessionId) + return st ? st._upstreamScheme : "http" + } + for (const st of active.values()) { + if (st.port === port) return st._upstreamScheme + } return "http" } -/** - * Return the port the currently-running preview is bound to, or null when - * no preview is active. Used by `parsePreviewPath` in preview-router.ts to - * route the portless `/preview/...` form: when the first path segment isn't - * a valid port, fall back to whatever port the running preview claimed at - * launch time. - * - * Single-replica + first-launch-wins (ADR-0009 + the launchPreview 409 path) - * means there's at most ONE active preview per container, so this returns a - * scalar without ambiguity. Future multi-preview support (separate ADR) - * will need to take a hint — for example the cookie's collab_sid — to pick - * which session's preview to target. - */ -export function getActivePreviewPort(): number | null { - return active ? active.port : null +export function getActivePreviewPort(sessionId?: string): number | null { + if (sessionId !== undefined) { + return active.get(sessionId)?.port ?? null + } + for (const st of active.values()) { + return st.port + } + return null } -/** - * Return the servePath the currently-active preview is configured for, or - * null when the proxy should use the default strip-`/preview/` behavior. - * - * Called by `preview-router.ts` once per HTTP request and once per WS - * upgrade — decides between forwarding the stripped path (default) and - * forwarding the path with `servePath` prepended (keep-prefix mode for - * dev servers like Angular CLI that derive their servePath from - * `baseHref`). - * - * Returns null when: - * - No preview is currently active (defensive). - * - A preview IS active but its port doesn't match the requested one. - * - The active preview's PreviewConfig.servePath is undefined (= the - * dev server expects to receive root-relative paths, so strip). - * - * Returns a string (e.g. "/preview/") when the active preview was launched - * with a configured `servePath` that the proxy should preserve verbatim - * in the forwarded URL. - */ -export function getActiveServePath(port: number): string | null { - if (active && active.port === port) return active._servePath +export function getActiveServePath(port: number, sessionId?: string): string | null { + if (sessionId !== undefined) { + return active.get(sessionId)?._servePath ?? null + } + for (const st of active.values()) { + if (st.port === port) return st._servePath + } return null } +/** Get the private IP for a session's ECS task (ECS mode), or null (process mode / not running). */ +export function getPreviewPrivateIp(sessionId: string): string | null { + return active.get(sessionId)?.privateIp ?? null +} + export type LaunchResult = | { ok: true; state: PreviewStateSnapshot } | { ok: false; status: 409; error: string; existing: PreviewStateSnapshot } | { ok: false; status: 400 | 404 | 500; error: string } /** - * Spawn the preview. First-launch wins; second call while another preview - * is active returns 409 with the existing state so the caller can render a - * "already running in session X" message. + * Launch a preview for the given session. * - * `gitAccessToken` is the GitHub OAuth token the install pipeline should - * present to `git` when fetching private dependencies (npm packages declared - * as `git+ssh://` or `git+https://` URLs in package.json / pnpm-lock.yaml). - * Threaded via `GITHUB_TOKEN` env into the child process, which the - * container's GIT_ASKPASS helper (see Dockerfile) reads to answer git's - * credential prompt. The token NEVER lands on disk (no .gitconfig write, - * no URL embedding, no lockfile entry). Pass null / omit for public-only - * installs. + * ECS mode: spawns an ECS Fargate task; returns immediately with status + * "installing". The task will POST /preview-task/register once it starts. + * + * Process mode: spawns a child process inside this container; same behaviour + * as before. Only one process-mode preview per container (first wins). */ export function launchPreview( collabSessionId: string, repoFullName: string, gitAccessToken?: string | null, ): LaunchResult { - if (active) { + // Reject duplicate launches for the same session. + if (active.has(collabSessionId)) { + return { + ok: false, + status: 409, + error: `Preview already running for session ${collabSessionId} repo ${repoFullName}.`, + existing: makeSnapshot(active.get(collabSessionId)!), + } + } + + // Process mode: only one preview per container. + if (!isEcsMode() && active.size > 0) { + const existing = active.values().next().value as ActiveState return { ok: false, status: 409, - error: `Preview already running in session ${active.collabSessionId} for ${active.repoFullName}. Ask that session's Driver to stop it first.`, - existing: getPreviewState()!, + error: `Preview already running in session ${existing.collabSessionId} for ${existing.repoFullName}. Ask that session's Driver to stop it first.`, + existing: makeSnapshot(existing), } } @@ -489,56 +384,244 @@ export function launchPreview( if (!existsSync(cwd)) { return { ok: false, status: 404, error: `Workspace for ${repoFullName} not cloned yet.` } } + const config = previewConfigForRepo(collabSessionId, repoFullName) + const now = Date.now() + + if (isEcsMode()) { + return launchPreviewEcs(collabSessionId, repoFullName, config, gitAccessToken ?? null, now) + } else { + return launchPreviewProcess(collabSessionId, repoFullName, config, gitAccessToken ?? null, cwd, now) + } +} + +// ── ECS launch ───────────────────────────────────────────────────────────── + +function launchPreviewEcs( + collabSessionId: string, + repoFullName: string, + config: PreviewConfig, + gitAccessToken: string | null, + now: number, +): LaunchResult { + const state: ActiveState = { + collabSessionId, + repoFullName, + port: 8080, // ECS tasks always bind to port 8080 + label: config.label, + status: "installing", + startedAt: now, + lastTraffic: now, + _log: [], + _stopRequested: false, + _gitAccessToken: gitAccessToken, + _upstreamScheme: config.upstreamScheme ?? "http", + _servePath: config.servePath ?? null, + errorMessage: undefined, + url: previewUrl(), + taskArn: null, + privateIp: null, + _lastHeartbeat: now, + _child: null, + config, + } + active.set(collabSessionId, state) + + console.log( + `[collab.preview] launching ECS task session=${collabSessionId} repo=${repoFullName}`, + ) + + // Fire-and-forget ECS RunTask; the task will register itself on boot. + void ecsRunTask(collabSessionId, repoFullName, gitAccessToken).then((taskArn) => { + const st = active.get(collabSessionId) + if (!st || st !== state) return // stopped before task started + state.taskArn = taskArn + console.log(`[collab.preview] ECS task started arn=${taskArn} session=${collabSessionId}`) + }).catch((err) => { + const st = active.get(collabSessionId) + if (!st || st !== state) return + const msg = err instanceof Error ? err.message : String(err) + console.error(`[collab.preview] ECS RunTask failed for session=${collabSessionId}: ${msg}`) + active.delete(collabSessionId) + broadcast(collabSessionId, { + type: "collab:preview_failed", + collabSessionId, + error: `Failed to start ECS task: ${msg}`, + }) + if (active.size === 0) stopSweepLoop() + }) + + startSweepLoop() + broadcast(collabSessionId, { type: "collab:preview_started", state: makeSnapshot(state) }) + return { ok: true, state: makeSnapshot(state) } +} + +async function ecsRunTask( + collabSessionId: string, + repoFullName: string, + gitAccessToken: string | null, +): Promise { + const { RunTaskCommand } = await import("@aws-sdk/client-ecs") + const cluster = process.env.ECS_CLUSTER_ARN! + const taskDef = process.env.ECS_PREVIEW_TASK_DEFINITION! + const subnets = (process.env.ECS_PREVIEW_SUBNETS ?? "").split(",").filter(Boolean) + const sg = process.env.ECS_PREVIEW_SECURITY_GROUP! + const collabBaseUrl = process.env.COLLAB_BASE_URL ?? "" + + const envOverrides: Array<{ name: string; value: string }> = [ + { name: "COLLAB_SESSION_ID", value: collabSessionId }, + { name: "REPO_FULL_NAME", value: repoFullName }, + { name: "COLLAB_BASE_URL", value: collabBaseUrl }, + ] + if (gitAccessToken) { + envOverrides.push({ name: "GITHUB_TOKEN", value: gitAccessToken }) + } + + const client = await getEcsClient() + const result = await client.send( + new RunTaskCommand({ + cluster, + taskDefinition: taskDef, + launchType: "FARGATE", + networkConfiguration: { + awsvpcConfiguration: { + subnets, + securityGroups: [sg], + assignPublicIp: "DISABLED", + }, + }, + overrides: { + containerOverrides: [{ name: "preview", environment: envOverrides }], + }, + }), + ) + + const task = result.tasks?.[0] + if (!task?.taskArn) { + const failures = result.failures?.map((f) => `${f.reason}: ${f.detail}`).join("; ") + throw new Error(`ECS RunTask returned no task. Failures: ${failures ?? "unknown"}`) + } + return task.taskArn +} + +async function ecsStopTask(taskArn: string, collabSessionId: string): Promise { + try { + const { StopTaskCommand } = await import("@aws-sdk/client-ecs") + const client = await getEcsClient() + await client.send( + new StopTaskCommand({ + cluster: process.env.ECS_CLUSTER_ARN!, + task: taskArn, + reason: `Stopped by collab server for session ${collabSessionId}`, + }), + ) + } catch (err) { + console.warn(`[collab.preview] ecsStopTask failed for ${taskArn}:`, err) + } +} + +// ── ECS task callbacks (called from router.ts) ───────────────────────────── + +/** + * Called when the ECS preview task POSTs to /preview-task/register. + * Stores the private IP so the proxy can route requests to this task. + */ +export function registerPreviewTask( + collabSessionId: string, + privateIp: string, + taskArn: string, +): void { + const st = active.get(collabSessionId) + if (!st) { + console.warn( + `[collab.preview] registerPreviewTask: no active preview for session=${collabSessionId} — task may have been stopped before it registered`, + ) + return + } + st.privateIp = privateIp + if (taskArn && taskArn !== "local") st.taskArn = taskArn + console.log( + `[collab.preview] preview task registered session=${collabSessionId} ip=${privateIp} arn=${st.taskArn}`, + ) + // Don't broadcast an extra "started" event here — we already sent one when + // the launch began. The proxy will start working as soon as privateIp is set. +} - // Compose the shell pipeline: install (if configured) && start. Using - // `sh -c` keeps PIDs single — easier to SIGTERM the whole tree on stop. +/** + * Called when the ECS preview task POSTs to /preview-task/heartbeat. + * Resets the install-hang watchdog clock. + */ +export function receiveHeartbeat(collabSessionId: string): void { + const st = active.get(collabSessionId) + if (st) st._lastHeartbeat = Date.now() +} + +/** + * Called when the ECS preview task POSTs to /preview-task/log. + * Stores the log line and detects the "ready" transition. + */ +export function receivePreviewLog( + collabSessionId: string, + stream: "stdout" | "stderr", + line: string, +): void { + const st = active.get(collabSessionId) + if (!st) return + + // Every log line resets the heartbeat clock (counts as liveness evidence). + st._lastHeartbeat = Date.now() + + st._log.push({ stream, line, ts: Date.now() }) + if (st._log.length > LOG_LINES_RETAINED * 2) { + st._log.splice(0, st._log.length - LOG_LINES_RETAINED) + } + + console[stream === "stderr" ? "error" : "log"]( + `[collab.preview/${collabSessionId}/${stream}] ${line.slice(0, 1_024)}`, + ) + + // Status transition: "installing" → "running" when ready pattern matches. + if (st.status === "installing") { + let ready = false + try { + ready = + (st.config?.readyPattern !== undefined && + new RegExp(st.config.readyPattern).test(line)) || + /\b(local|ready|listening|started server on)\b/i.test(line) + } catch {} + if (ready) { + st.status = "running" + void import("./session") + .then((Session) => Session.clearPreviewCrashCount(collabSessionId)) + .catch((err) => console.warn("[collab.preview] clearPreviewCrashCount failed:", err)) + broadcast(collabSessionId, { type: "collab:preview_started", state: makeSnapshot(st) }) + } + } + + broadcast(collabSessionId, { type: "collab:preview_log", line: line.slice(0, 2_000), stream }) +} + +// ── Process launch ───────────────────────────────────────────────────────── + +function launchPreviewProcess( + collabSessionId: string, + repoFullName: string, + config: PreviewConfig, + gitAccessToken: string | null, + cwd: string, + now: number, +): LaunchResult { const shellCmd = config.installCommand ? `${config.installCommand} && ${config.command}` : config.command - // Log the resolved launch parameters so CloudWatch shows exactly what - // we're about to spawn. The shellCmd may include the OAuth token if - // someone embeds it in a custom installCommand — we deliberately do - // NOT log gitAccessToken itself anywhere, but the shellCmd value is - // operator-authored config and we treat it as safe to log. console.log( `[collab.preview] launching session=${collabSessionId} repo=${repoFullName} ` + - `port=${config.port} scheme=${config.upstreamScheme ?? "http"} ` + - `cwd=${cwd}\n` + + `port=${config.port} scheme=${config.upstreamScheme ?? "http"} cwd=${cwd}\n` + `[collab.preview] shellCmd: ${shellCmd}`, ) - const env: NodeJS.ProcessEnv = { - ...process.env, - OPENCODE_PREVIEW: "1", - PORT: String(config.port), - // Inherit the container's NODE_OPTIONS (if any) unchanged and let the dev - // server's own start script manage its V8 heap. - // - // We used to cap with `--max-old-space-size=2048` here as a defensive - // measure against a Vite explosion eating the container. That cap - // bit unleashlive/frontend hard: their `ng:highmem` alias deliberately - // bumps Node's heap to compile a real-sized Angular app, and our - // appended 2048 was either winning the merge race (Angular OOM-killed - // mid-compile) or losing it (container OOM-killed with no warning). - // Either way: crashes. - // - // Safety net is now at the ECS task level — the deploy workflow's - // jq-patch pins `memory: "8192"` / `cpu: "2048"` on every register - // (see .github/workflows/deploy-collab.yml). A runaway dev server - // will still hit the 8 GB ceiling and the kernel OOM-killer will - // drop the WHOLE task (single-replica per ADR-0009 — we'd rather - // crash cleanly than corrupt SQLite), but well-behaved dev servers - // peak below it. Per-repo opt-in to a tighter cap can live in - // `.opencode-preview.json` later if needed; for now, no launcher-side - // policy. - } - // Per-launch GitHub OAuth token for the install's git fetches. Picked up - // by the container's GIT_ASKPASS helper (Dockerfile) as `Password` against - // the static `Username: x-access-token`. Lives only in this child's env; - // unset GITHUB_TOKEN globally so spawning an unauthenticated pnpm install - // by some other path doesn't accidentally inherit it. + const env: NodeJS.ProcessEnv = { ...process.env, OPENCODE_PREVIEW: "1", PORT: String(config.port) } if (gitAccessToken) { env.GITHUB_TOKEN = gitAccessToken } else { @@ -547,16 +630,6 @@ export function launchPreview( let child: ChildProcess try { - // `detached: true` puts the child + all its descendants in a NEW process - // group whose pgid === child.pid. Without this, sh→pnpm→node forms a - // tree where `child.kill("SIGTERM")` only signals `sh`; the dev server - // (node) keeps running and holds port 8080. The next launch then 409s - // on port-in-use until something garbage-collects the orphan. - // - // We kill via process.kill(-child.pid, signal) in stopPreview to fan - // the signal across the entire group. detached doesn't actually - // detach from us (we keep stdio, keep the parent watching exit) — - // it's just the pgid creation we want. child = spawn("sh", ["-c", shellCmd], { cwd, env, @@ -572,7 +645,6 @@ export function launchPreview( return { ok: false, status: 500, error: "Spawned process has no pid (immediate crash?)." } } - const now = Date.now() const state: ActiveState = { collabSessionId, repoFullName, @@ -582,146 +654,86 @@ export function launchPreview( startedAt: now, lastTraffic: now, _log: [], - _lastOutput: now, - recentLog: [], + _lastHeartbeat: now, errorMessage: undefined, - // Snapshot URL (server-computed). Required on PreviewStateSnapshot, so - // ActiveState (which extends it) must carry it too; getPreviewState() - // re-derives the same stable, env-based value when it builds a snapshot. url: previewUrl(), - child, - config, _stopRequested: false, - // Cache for restartPreview. Normalise undefined → null so the field is - // always a concrete `string | null` (avoids a third "unknown" case). - _gitAccessToken: gitAccessToken ?? null, - // Read by preview-router.ts via getActiveUpstreamScheme(port). Default - // to "http" when the resolved config omits it (legacy .opencode-preview.json - // files predate this field). + _gitAccessToken: gitAccessToken, _upstreamScheme: config.upstreamScheme ?? "http", - // Read by preview-router.ts via getActiveServePath(port). Null when - // not set (= legacy strip-prefix behavior); string when the dev - // server expects the prefix kept (e.g. Angular CLI's dev-server with - // baseHref-derived servePath). _servePath: config.servePath ?? null, + // ECS fields — unused in process mode + taskArn: null, + privateIp: null, + // Process fields + _child: child, + config, } - active = state - - // Reset the HEAD tracker for the new preview's workspace. Without this - // a relaunch (or a brand-new preview for a different session/repo) would - // compare against the previous preview's last-seen HEAD, generating a - // spurious "branch changed" → auto-restart loop on the first LLM turn. - lastKnownHead = null - - // V2 telemetry — log whether the framework dep-optimization cache survived - // the previous container. Angular CLI / Vite write to `/.angular/cache`, - // which lives on EFS and SHOULD persist across deploys; when it does, the - // second-launch compile drops from ~2 min to ~20 s. If this logs "absent" - // on a session that's been launched before, the cache is getting wiped and - // that's a regression worth chasing. Cheap + best-effort; never throws. - logPreviewCacheState(cwd) + active.set(collabSessionId, state) + lastKnownHeads.delete(collabSessionId) + logPreviewCacheState(cwd) wireChildStreams(state) startSweepLoop() - broadcast(collabSessionId, { - type: "collab:preview_started", - state: getPreviewState()!, - }) - - return { ok: true, state: getPreviewState()! } + broadcast(collabSessionId, { type: "collab:preview_started", state: makeSnapshot(state) }) + return { ok: true, state: makeSnapshot(state) } } -/** - * Stop the running preview iff it belongs to this collab session. Used by - * the DELETE /collab/session/:id handler and any other cleanup path — safe - * to call unconditionally; no-op when no preview is running OR the running - * preview is for a different session. - */ +// ── Stop / restart ───────────────────────────────────────────────────────── + export function stopIfOwnedBySession(collabSessionId: string): void { - if (active && active.collabSessionId === collabSessionId) { - stopPreview(`session ${collabSessionId} deleted`) + if (active.has(collabSessionId)) { + stopPreview(collabSessionId, `session ${collabSessionId} deleted`) } } -/** - * Stop the running preview. SIGTERM gives the dev server a chance to - * shutdown cleanly (release the port, flush HMR sockets); SIGKILL after - * a 5s grace window. - */ -export function stopPreview(reason: string = "explicit"): void { - // Always cancel a pending restart, even when no preview is currently - // active. A user could click Stop during the 100 ms restart window, - // or session-delete could fire just before the deferred launch. The - // ghost relaunch would otherwise either 404 (workspace gone) or - // succeed but for a session that no longer exists. - if (pendingRestart) { - clearTimeout(pendingRestart) - pendingRestart = null +export function stopPreview(collabSessionId: string, reason: string = "explicit"): void { + // Cancel any pending restart for this session. + const pendingTimer = pendingRestarts.get(collabSessionId) + if (pendingTimer) { + clearTimeout(pendingTimer) + pendingRestarts.delete(collabSessionId) } - if (!active) return - // Mark the state so the child's exit handler can distinguish a stop we - // initiated (silent) from a clean self-exit (broadcasts stopped) from a - // crash (broadcasts failed). - active._stopRequested = true - const { child, collabSessionId } = active - const sessionId = collabSessionId - - // Signal the whole process group — sh → pnpm → node — not just the - // top-level shell. `detached: true` in spawn() guarantees pgid === - // child.pid. Negative pid syntax on process.kill targets the group. - // Fall back to plain child.kill if pgid signalling fails (e.g. the - // child already exited). - const killGroup = (sig: NodeJS.Signals) => { - try { - if (child.pid) process.kill(-child.pid, sig) - } catch { - try { child.kill(sig) } catch {} + + const st = active.get(collabSessionId) + if (!st) return + + st._stopRequested = true + active.delete(collabSessionId) + lastKnownHeads.delete(collabSessionId) + + if (st._child) { + // Process mode — SIGTERM the group, SIGKILL after 5 s. + const child = st._child + const killGroup = (sig: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, sig) + } catch { + try { child.kill(sig) } catch {} + } } + killGroup("SIGTERM") + const killTimer = setTimeout(() => killGroup("SIGKILL"), 5_000) + child.once("exit", () => clearTimeout(killTimer)) + } else if (st.taskArn) { + // ECS mode — StopTask is async; fire-and-forget. + void ecsStopTask(st.taskArn, collabSessionId) } - killGroup("SIGTERM") - const killTimer = setTimeout(() => killGroup("SIGKILL"), 5_000) - child.once("exit", () => clearTimeout(killTimer)) - - console.log(`[collab.preview] stopped (${reason}) for session ${sessionId}`) - active = null - lastKnownHead = null - stopSweepLoop() + console.log(`[collab.preview] stopped (${reason}) for session ${collabSessionId}`) + if (active.size === 0) stopSweepLoop() - broadcast(sessionId, { - type: "collab:preview_stopped", - collabSessionId: sessionId, - }) + broadcast(collabSessionId, { type: "collab:preview_stopped", collabSessionId }) } -/** - * Stop + relaunch with the SAME args. Used by the SPA's Restart button AND - * by the branch-checkout hook below. Returns the same shape as launchPreview. - * - * Important: we snapshot port + label BEFORE the stop, because `stopPreview` - * sets `active = null` synchronously. Without the pre-stop snapshot the - * returned `state` would have `port: 0, label: "preview"` (the previous - * defensive-default fallback was a bug — the SPA showed port 0 in the - * banner until SSE caught up). - * - * The actual relaunch fires 100 ms later via setTimeout so the dev server's - * old port is fully released before the new one binds. If the inner launch - * fails (e.g. the workspace was wiped between stop and relaunch, or - * something else grabbed the slot first), we broadcast collab:preview_failed - * so the SPA's banner reflects the truth instead of staying stuck in - * "installing". - */ -export function restartPreview(): LaunchResult { - if (!active) { +export function restartPreview(collabSessionId: string): LaunchResult { + const st = active.get(collabSessionId) + if (!st) { return { ok: false, status: 404, error: "No preview is currently running." } } - const { collabSessionId, repoFullName, port, label, config } = active - // Snapshot the cached token BEFORE stopPreview clears `active`. Same - // reason as the port/label snapshot — we need it to survive the - // synchronous null-out so the deferred relaunch can re-authenticate - // any git fetches the install pipeline kicks off again. - const cachedToken = active._gitAccessToken + const { repoFullName, port, label } = st + const cachedToken = st._gitAccessToken + const installing: PreviewStateSnapshot = { collabSessionId, repoFullName, @@ -732,20 +744,16 @@ export function restartPreview(): LaunchResult { lastTraffic: Date.now(), recentLog: [], url: previewUrl(), + privateIp: null, } - stopPreview("restart") + stopPreview(collabSessionId, "restart") - // Relaunch after the port is fully released. 100 ms is generous for - // Node http listeners; the previous 50 ms was tight on slow runners. - // Track the timer in module state so stopPreview can cancel it if a - // later stop / delete arrives before the relaunch fires. - pendingRestart = setTimeout(() => { - pendingRestart = null + const timer = setTimeout(() => { + pendingRestarts.delete(collabSessionId) const result = launchPreview(collabSessionId, repoFullName, cachedToken) if (!result.ok) { console.error(`[collab.preview] restart relaunch failed: ${result.error}`) - // Surface to the SPA so its banner doesn't stay stuck "installing". broadcast(collabSessionId, { type: "collab:preview_failed", collabSessionId, @@ -753,60 +761,40 @@ export function restartPreview(): LaunchResult { }) } }, 100) + pendingRestarts.set(collabSessionId, timer) - // Avoid an "unused" lint by referencing config — also documents that the - // config carries through to the relaunch via previewConfigForRepo on the - // workspace, not via the in-memory state. - void config return { ok: true, state: installing } } -/** - * If a preview is running AND its workspace's git HEAD has changed since the - * preview started, restart it. Caller is the queue executor — it knows when - * an LLM turn finished (which is when checkout/pull/reset most often - * happens). Best-effort: a failure here logs + continues; doesn't surface - * to the user. - */ -let lastKnownHead: string | null = null +// ── Branch-change auto-restart (process mode) ────────────────────────────── export async function maybeRestartOnBranchChange(): Promise { - if (!active) return - // Only auto-restart when the preview is actually running. Restarting - // an in-progress install would throw away ~minutes of work for no - // visible benefit (the install hasn't even bound the port yet, so - // no user-visible mass-file-change problem exists). Status flips to - // "failed" → next launch is the user's call; we shouldn't second- - // guess that either. - if (active.status !== "running") return - - const { collabSessionId, repoFullName } = active - try { - const { readRepoBranch } = await import("./workspace") - const head = await readRepoBranch(collabSessionId, repoFullName) - if (!head) return - if (lastKnownHead === null) { - lastKnownHead = head - return - } - if (head !== lastKnownHead) { - console.log(`[collab.preview] HEAD changed (${lastKnownHead} → ${head}); restarting`) - lastKnownHead = head - restartPreview() + // Iterate all process-mode previews in "running" state. + for (const [sessionId, st] of active.entries()) { + if (!st._child || st.status !== "running") continue + const { repoFullName } = st + try { + const { readRepoBranch } = await import("./workspace") + const head = await readRepoBranch(sessionId, repoFullName) + if (!head) continue + const prev = lastKnownHeads.get(sessionId) + if (prev === undefined) { + lastKnownHeads.set(sessionId, head) + continue + } + if (head !== prev) { + console.log(`[collab.preview] HEAD changed (${prev} → ${head}) session=${sessionId}; restarting`) + lastKnownHeads.set(sessionId, head) + restartPreview(sessionId) + } + } catch (err) { + console.warn(`[collab.preview] HEAD check failed session=${sessionId}:`, err) } - } catch (err) { - console.warn("[collab.preview] HEAD check failed:", err) } } -// ── Internal wiring ──────────────────────────────────────────────────────── +// ── Internal: process mode wiring ───────────────────────────────────────── -/** - * Best-effort V2 telemetry: log the framework dep-optimization cache state so - * we can confirm it persists across container restarts (the thing that makes - * a second-launch compile fast). Shallow + bounded — counts top-level - * entries under `.angular/cache`, never walks the whole tree, never throws. - */ function logPreviewCacheState(cwd: string): void { try { const cacheDir = join(cwd, ".angular", "cache") @@ -826,18 +814,14 @@ function logPreviewCacheState(cwd: string): void { } function wireChildStreams(state: ActiveState): void { + const child = state._child + if (!child) return + const onLine = (stream: "stdout" | "stderr") => (chunk: Buffer) => { - // Stop emitting log/state events for a child whose state has been - // replaced (stopPreview cleared `active`, or a restart spun up a new - // ActiveState). The OS may still flush a few hundred bytes of stdout - // between SIGTERM and process exit; without this guard those bytes - // surface in the SPA as zombie log lines AFTER the user already saw - // "Preview stopped". - if (active !== state) return - - // Feed the install-hang watchdog (S1): any output — progress, warning, - // error — counts as liveness. Reset the clock before processing lines. - state._lastOutput = Date.now() + const currentState = active.get(state.collabSessionId) + if (currentState !== state) return // replaced by a restart + + state._lastHeartbeat = Date.now() const lines = chunk.toString("utf8").split("\n").filter(Boolean) for (const line of lines) { @@ -846,23 +830,9 @@ function wireChildStreams(state: ActiveState): void { state._log.splice(0, state._log.length - LOG_LINES_RETAINED) } - // Mirror to container stdout so CloudWatch captures the dev server's - // output without the operator needing iframe-terminal access. Tag - // with the collab session id + stream so a single log group with - // multiple sessions stays searchable. Truncate to 1 KB per line so - // a runaway dev server can't blow up the log stream. const consoleFn = stream === "stderr" ? console.error : console.log - consoleFn( - `[collab.preview/${state.collabSessionId}/${stream}] ${line.slice(0, 1024)}`, - ) + consoleFn(`[collab.preview/${state.collabSessionId}/${stream}] ${line.slice(0, 1024)}`) - // Husky 8.x rewrites .git/hooks/ from its `prepare` lifecycle script - // (which pnpm runs as part of `pnpm install`). This clobbers the - // prepare-commit-msg hook we installed at session init AND at boot - // sweep, so every subsequent commit silently drops the collab - // trailers (Collaborative-Commit + Co-authored-by) until something - // re-installs our hook. Watch for the sentinel line and re-install - // immediately afterwards. Idempotent + cheap — one file write. if (/husky - Git hooks installed/i.test(line)) { void import("./workspace").then((Workspace) => Workspace.reinstallCollabHookForRepo(state.collabSessionId, state.repoFullName).then( @@ -871,39 +841,27 @@ function wireChildStreams(state: ActiveState): void { `[collab.preview] re-installed prepare-commit-msg hook after husky overwrote it (session=${state.collabSessionId} repo=${state.repoFullName})`, ), ), - ).catch((err) => - console.warn("[collab.preview] post-husky hook reinstall failed:", err), - ) + ).catch((err) => console.warn("[collab.preview] post-husky hook reinstall failed:", err)) } - // Status transition: "installing" → "running" on the readyPattern OR - // on a built-in heuristic (the line mentions "Local:" / "ready" / "listening"). - // RegExp construction is validated at config-load (previewConfigForRepo - // rejects an invalid pattern with WARN), but a defensive try/catch - // here keeps stdout processing safe against any pattern-shape we - // didn't anticipate. if (state.status === "installing") { let ready = false try { ready = - (state.config.readyPattern !== undefined && + (state.config?.readyPattern !== undefined && new RegExp(state.config.readyPattern).test(line)) || /\b(local|ready|listening|started server on)\b/i.test(line) } catch (e) { console.warn("[collab.preview] readyPattern match threw:", e) } if (ready) { - ;(state as { status: PreviewStatus }).status = "running" - // S2: a clean install → running transition means this workspace is - // healthy; reset its crash-loop counter so a future transient - // failure starts from zero and the breaker doesn't fire on a - // session that's actually fine. Fire-and-forget DB write. + state.status = "running" void import("./session") .then((Session) => Session.clearPreviewCrashCount(state.collabSessionId)) .catch((err) => console.warn("[collab.preview] clearPreviewCrashCount failed:", err)) broadcast(state.collabSessionId, { type: "collab:preview_started", - state: getPreviewState()!, + state: makeSnapshot(state), }) } } @@ -916,85 +874,56 @@ function wireChildStreams(state: ActiveState): void { } } - state.child.stdout?.on("data", onLine("stdout")) - state.child.stderr?.on("data", onLine("stderr")) + child.stdout?.on("data", onLine("stdout")) + child.stderr?.on("data", onLine("stderr")) - state.child.once("exit", (code, signal) => { - if (active !== state) return // already replaced + child.once("exit", (code, signal) => { + const currentState = active.get(state.collabSessionId) + if (currentState !== state) return // already replaced const wasStopped = state._stopRequested console.log( `[collab.preview] child exit session=${state.collabSessionId} ` + `code=${code} signal=${signal} wasStopped=${wasStopped} status=${state.status}`, ) - if (wasStopped) { - // stopPreview() drove this exit. It already broadcast - // collab:preview_stopped + nulled `active`; nothing further to do. - return - } + if (wasStopped) return // stopPreview already handled broadcast + cleanup if (code === 0 && signal === null) { - // Clean self-exit that WE didn't initiate. Happens when the user's - // start command exits cleanly (e.g. a `--build` flag that finishes - // building and exits, or `pnpm run` resolved to a script that just - // prints help). Broadcast stopped so the SPA flips back to the - // Launch button instead of staying stuck in installing/running. console.log(`[collab.preview] process exited cleanly on its own for session ${state.collabSessionId}`) broadcast(state.collabSessionId, { type: "collab:preview_stopped", collabSessionId: state.collabSessionId, }) - active = null - lastKnownHead = null - stopSweepLoop() + active.delete(state.collabSessionId) + lastKnownHeads.delete(state.collabSessionId) + if (active.size === 0) stopSweepLoop() return } - // Unexpected death — non-zero code OR signal we didn't send. Surface - // as a failure so the user can read the tail of the log and Retry. const msg = `Preview process exited with code ${code} ${signal ? `(signal ${signal})` : ""}` console.error(`[collab.preview] ${msg}`) - // S2 crash-loop breaker: only an INSTALL-phase crash feeds the counter. - // A crash after reaching "running" is a different failure class (dev - // server runtime error) and shouldn't suppress boot-resume — the - // workspace installed fine, so resuming it on the next boot is - // reasonable. An install crash, by contrast, tends to be deterministic - // (broken lockfile, missing dep, OOM during native build) and WILL - // recur on every boot — that's exactly what the breaker guards against. if (state.status === "installing") { void import("./session") .then((Session) => Session.recordPreviewCrash(state.collabSessionId)) .catch((err) => console.warn("[collab.preview] recordPreviewCrash failed:", err)) } - ;(state as { status: PreviewStatus }).status = "failed" - ;(state as { errorMessage?: string }).errorMessage = msg + state.status = "failed" + state.errorMessage = msg broadcast(state.collabSessionId, { type: "collab:preview_failed", collabSessionId: state.collabSessionId, error: msg, }) - active = null - lastKnownHead = null - stopSweepLoop() + active.delete(state.collabSessionId) + lastKnownHeads.delete(state.collabSessionId) + if (active.size === 0) stopSweepLoop() }) } -/** - * Read the container's total RSS in bytes via the cgroups v2 interface - * Fargate exposes. Returns null on platforms where the file isn't present - * (macOS dev, older kernels, etc.) — the caller treats null as "skip the - * memory check, the other caps still apply". - * - * cgroups v2 is what every Fargate platform version 1.4+ uses; if AWS - * regresses to v1 we'd need /sys/fs/cgroup/memory/memory.usage_in_bytes - * instead, but that's not on the roadmap. - * - * The value is the WHOLE container's RSS — opencode + preview + everything. - * That's exactly what we want: the kernel OOM-killer makes the same - * accounting; tripping the breaker before the kernel does saves opencode. - */ +// ── Internal: memory probe (process mode only) ───────────────────────────── + function readContainerMemoryBytes(): number | null { try { return Number(readFileSync("/sys/fs/cgroup/memory.current", "utf8").trim()) @@ -1003,69 +932,58 @@ function readContainerMemoryBytes(): number | null { } } +// ── Internal: sweep loop ─────────────────────────────────────────────────── + function startSweepLoop(): void { if (sweepTimer) return sweepTimer = setInterval(() => { - if (!active) return + if (active.size === 0) { stopSweepLoop(); return } const now = Date.now() - // 1. Idle cap — no traffic for IDLE_TIMEOUT_MS → assume the dev server - // is unused; stop it to free the slot for another session. - if (now - active.lastTraffic > IDLE_TIMEOUT_MS) { - stopPreview(`idle ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m`) - return - } + for (const [sessionId, st] of active.entries()) { + // 1. Idle cap. + if (now - st.lastTraffic > IDLE_TIMEOUT_MS) { + stopPreview(sessionId, `idle ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m`) + continue + } - // 1b. Install-hang watchdog (S1) — a preview still in the `installing` - // phase that has emitted zero output for INSTALL_SILENCE_TIMEOUT_MS - // is presumed wedged. A healthy pnpm install / ng compile emits - // progress constantly, so prolonged silence means a dead registry, - // stuck native build, or an OOMing dep holding memory with no - // forward progress. Stop it now rather than waiting out the 30 min - // idle cap (which never fires here — no request traffic during - // install). We record the crash explicitly here (rather than - // relying on the exit handler, which skips crash-recording when WE - // initiated the stop) so a workspace that hangs install on every - // boot eventually trips the crash-loop breaker instead of wasting - // 5 min per boot indefinitely. - if (active.status === "installing" && now - active._lastOutput > INSTALL_SILENCE_TIMEOUT_MS) { - const hungSession = active.collabSessionId - void import("./session") - .then((Session) => Session.recordPreviewCrash(hungSession)) - .catch((err) => console.warn("[collab.preview] recordPreviewCrash (hang) failed:", err)) - stopPreview( - `install hung — no output for ${Math.round(INSTALL_SILENCE_TIMEOUT_MS / 60_000)}m — Driver can re-Launch`, - ) - return - } + // 2. Install-hang watchdog: "installing" + no heartbeat/output for too long. + if (st.status === "installing" && now - st._lastHeartbeat > INSTALL_SILENCE_TIMEOUT_MS) { + if (st._child) { + // Process mode: record the crash so the breaker can engage. + const sid = sessionId + void import("./session") + .then((Session) => Session.recordPreviewCrash(sid)) + .catch((err) => console.warn("[collab.preview] recordPreviewCrash (hang) failed:", err)) + } + stopPreview( + sessionId, + `install hung — no ${st._child ? "output" : "heartbeat"} for ${Math.round(INSTALL_SILENCE_TIMEOUT_MS / 60_000)}m`, + ) + continue + } - // 2. Lifetime cap — preview has been alive for MAX_LIFETIME_MS, - // regardless of traffic. Forces a clean restart before the - // ng-serve / Vite heap leak overflows the task's memory. Driver - // can immediately Launch again; the workspace is preserved. - if (now - active.startedAt > MAX_LIFETIME_MS) { - stopPreview( - `lifetime cap ${Math.round(MAX_LIFETIME_MS / 60_000)}m exceeded — Driver can re-Launch`, - ) - return - } + // 3. Lifetime cap. + if (now - st.startedAt > MAX_LIFETIME_MS) { + stopPreview( + sessionId, + `lifetime cap ${Math.round(MAX_LIFETIME_MS / 60_000)}m exceeded — Driver can re-Launch`, + ) + continue + } - // 3. Memory circuit-breaker — stop the preview before the kernel - // OOM-killer takes the whole task (and opencode with it). Skipped - // silently when /sys/fs/cgroup/memory.current isn't readable - // (non-Linux dev, cgroups v1, etc.) — the lifetime cap still - // applies as a backstop. - const used = readContainerMemoryBytes() - if (used !== null && used > MEMORY_CAP_BYTES) { - const usedMB = Math.round(used / (1024 * 1024)) - const capMB = Math.round(MEMORY_CAP_BYTES / (1024 * 1024)) - stopPreview( - `memory cap ${capMB}MB exceeded (current ${usedMB}MB) — Driver can re-Launch`, - ) - return + // 4. Memory circuit-breaker (process mode only; ECS tasks have isolated memory). + if (st._child) { + const used = readContainerMemoryBytes() + if (used !== null && used > MEMORY_CAP_BYTES) { + const usedMB = Math.round(used / (1024 * 1024)) + const capMB = Math.round(MEMORY_CAP_BYTES / (1024 * 1024)) + stopPreview(sessionId, `memory cap ${capMB}MB exceeded (current ${usedMB}MB)`) + continue + } + } } }, SWEEP_INTERVAL_MS) - // Don't let the timer keep the event loop alive forever on shutdown. if (typeof sweepTimer.unref === "function") sweepTimer.unref() } @@ -1075,27 +993,15 @@ function stopSweepLoop(): void { sweepTimer = null } +// ── Boot resume ──────────────────────────────────────────────────────────── + /** - * Re-spawn the previously-running preview on container boot. - * - * Reads `collab_session.preview_intent` (set by POST /preview/launch, cleared - * by POST /preview/stop and by Session.deleteCollabSession's soft-delete) - * and picks the row with the most-recent `preview_intent_at` — the - * "first-launch-wins" contract from `launchPreview` means at most ONE - * preview can be active per container, so we never need to spawn multiple. + * Re-spawn previews on container boot. * - * Called fire-and-forget from `serve.ts` right after `runCollabMigrations()`. - * Failures here log but do NOT block boot — a stuck preview must not gate - * the rest of the collab API coming online. - * - * Note: this runs on EVERY container start, including the FIRST start of a - * fresh deploy where no `.opencode-preview.json` or workspace exists yet. - * The `launchPreview` call's `existsSync(cwd)` guard returns 404 in that - * case — we log and move on. We deliberately do NOT clear the intent on - * such a failure: the workspace clone may still be in flight (e.g. - * `initSessionWorkspace` hasn't finished yet on a session created - * milliseconds before shutdown), and a Driver pressing Launch again will - * succeed once the clone lands. + * Reads `collab_session.preview_intent` rows. In ECS mode, each fresh intent + * launches a new ECS task. In process mode, only the most-recent eligible + * intent is resumed (first-launch-wins). Both modes apply the same freshness + * cap (24 h) and crash-loop breaker. */ export async function resumePreviewsOnBoot(): Promise { let session: typeof import("./session") @@ -1121,22 +1027,10 @@ export async function resumePreviewsOnBoot(): Promise { } if (intents.length === 0) return - // Freshness cap. Without this, a session whose intent landed days ago can - // get auto-resumed on every container boot indefinitely — and if that - // workspace's `.opencode-preview.json` is broken (stale config, missing - // build-script approvals, wrong NODE_OPTIONS) it crash-loops, eating - // memory until the kernel OOM-killer takes down the whole task. We - // observed this on 2026-06-12: a days-old intent for cs_81500bbc… kept - // re-spawning a broken ng-serve on every boot, OOM-killing the task and - // bouncing it through 503. - // - // Cap = 24 h. Resume is meant for cross-deploy continuity within a - // single working session ("operator clicked deploy 5 min ago, want the - // preview back when the new task lands"), not for resurrecting a wish - // from a previous workweek. Driver can always press Launch manually - // for older sessions. const MAX_INTENT_AGE_MS = 24 * 60 * 60 * 1000 const now = Date.now() + + // Partition into stale / fresh. const stale: typeof intents = [] const fresh: typeof intents = [] for (const i of intents) { @@ -1144,108 +1038,60 @@ export async function resumePreviewsOnBoot(): Promise { else fresh.push(i) } if (stale.length > 0) { - console.log( - `[collab.preview] resumePreviewsOnBoot: ${stale.length} stale intent(s) past ${MAX_INTENT_AGE_MS}ms — clearing`, - ) + console.log(`[collab.preview] resumePreviewsOnBoot: ${stale.length} stale intent(s) — clearing`) for (const s of stale) { - try { - session.setPreviewIntent(s.collabSessionId, null) - } catch (err) { - console.warn( - `[collab.preview] resumePreviewsOnBoot: setPreviewIntent(null) threw for session=${s.collabSessionId}:`, - err, - ) - } + try { session.setPreviewIntent(s.collabSessionId, null) } catch {} } } if (fresh.length === 0) return - // Crash-loop breaker (S2). A fresh intent whose workspace has crashed - // during install BREAKER_CRASH_THRESHOLD+ times within the recent window - // is almost certainly deterministically broken (bad lockfile, missing - // dep, OOM during native build) — auto-resuming it just burns another - // install attempt + memory every boot. Skip those; the Driver can press - // Launch manually (which clears the counter and overrides the breaker) - // once they've fixed the underlying workspace/config issue. The freshness - // cap above handles age; this handles repeated failure within the window. - const eligible: typeof fresh = [] - for (const i of fresh) { + // Apply crash-loop breaker. + const eligible = fresh.filter((i) => { const recentlyTripped = i.crashAt > 0 && now - i.crashAt < BREAKER_WINDOW_MS if (i.crashCount >= BREAKER_CRASH_THRESHOLD && recentlyTripped) { console.warn( - `[collab.preview] resumePreviewsOnBoot: session=${i.collabSessionId} repo=${i.repoFullName} ` + - `skipped — crash-loop breaker (${i.crashCount} install crashes within ${Math.round(BREAKER_WINDOW_MS / 60_000)}m). ` + - `Driver must Launch manually to retry.`, + `[collab.preview] resumePreviewsOnBoot: session=${i.collabSessionId} skipped — crash-loop breaker (${i.crashCount} crashes). Driver must Launch manually.`, ) - continue + return false } - eligible.push(i) - } + return true + }) if (eligible.length === 0) return - // First-launch-wins constraint (one preview per container) means we pick - // the most-recently active intent and ignore the rest. If multiple - // intents survived to disk, the rest will sit clear in the DB until a - // Driver explicitly Launches one — we never auto-stomp a more-recent - // wish in favour of a stale one. - const pick = eligible[0] + // In process mode, only one preview per container. + const toResume = isEcsMode() ? eligible : [eligible[0]!] + console.log( - `[collab.preview] resumePreviewsOnBoot: ${intents.length} intent(s) on disk; picking session=${pick.collabSessionId} repo=${pick.repoFullName} (most-recent)`, + `[collab.preview] resumePreviewsOnBoot: resuming ${toResume.length} intent(s) (${isEcsMode() ? "ECS" : "process"} mode)`, ) - // Look up the session owner's most-recent unexpired OAuth token so the - // resumed install can authenticate any private git+https dependencies - // (npm packages declared as git deps in package.json that resolve to - // private unleashlive repos). No Driver "clicker" exists on a boot - // resume — the owner is the canonical fallback (always a Driver per - // ADR-0005, always present in collab_session). Returns null if every - // login for this user has expired or no row exists, in which case the - // resumed install runs unauthenticated and private deps will fail with - // git's standard credential-prompt error (which surfaces in the - // preview banner's log tail). - let gitAccessToken: string | null = null - try { - const cs = session.getCollabSession(pick.collabSessionId) - if (cs) { - const cookieAuth = await import("./cookie-auth") - gitAccessToken = cookieAuth.latestAccessTokenForGithubId(cs.ownerGithubId) - if (!gitAccessToken) { - console.warn( - `[collab.preview] resumePreviewsOnBoot: no fresh OAuth token for owner github_id=${cs.ownerGithubId}; resumed install will run unauthenticated`, - ) + const cookieAuth = await import("./cookie-auth").catch(() => null) + + for (const pick of toResume) { + let gitAccessToken: string | null = null + try { + const cs = session.getCollabSession(pick.collabSessionId) + if (cs && cookieAuth) { + gitAccessToken = cookieAuth.latestAccessTokenForGithubId(cs.ownerGithubId) } - } - } catch (err) { - console.warn( - `[collab.preview] resumePreviewsOnBoot: owner-token lookup threw; resumed install will run unauthenticated:`, - err, - ) - } + } catch {} - let result: LaunchResult - try { - result = launchPreview(pick.collabSessionId, pick.repoFullName, gitAccessToken) - } catch (err) { - console.error( - `[collab.preview] resumePreviewsOnBoot: launchPreview threw for session=${pick.collabSessionId}:`, - err, - ) - return - } + let result: LaunchResult + try { + result = launchPreview(pick.collabSessionId, pick.repoFullName, gitAccessToken) + } catch (err) { + console.error(`[collab.preview] resumePreviewsOnBoot: launchPreview threw session=${pick.collabSessionId}:`, err) + continue + } - if (!result.ok) { - // 404 (workspace not cloned yet) is the common, expected case for sessions - // mid-init at shutdown — keep the intent so the next boot tries again. - // Other errors (500 spawn-failed, 409 race with a manual launch) we just - // log: the Driver can press Launch manually to retry, and we don't want - // a buggy preview to block boot recovery for other sessions on the box. - console.warn( - `[collab.preview] resumePreviewsOnBoot: launchPreview returned status=${result.status} error="${result.error}" for session=${pick.collabSessionId}; leaving intent in place`, + if (!result.ok) { + console.warn( + `[collab.preview] resumePreviewsOnBoot: launchPreview status=${result.status} "${result.error}" session=${pick.collabSessionId}`, + ) + continue + } + console.log( + `[collab.preview] resumePreviewsOnBoot: re-spawned session=${pick.collabSessionId} port=${result.state.port}`, ) - return } - - console.log( - `[collab.preview] resumePreviewsOnBoot: successfully re-spawned preview for session=${pick.collabSessionId} on port ${result.state.port}`, - ) } diff --git a/packages/opencode/src/collab/preview-router.ts b/packages/opencode/src/collab/preview-router.ts index 84a64c57437e..fb74205a3cd3 100644 --- a/packages/opencode/src/collab/preview-router.ts +++ b/packages/opencode/src/collab/preview-router.ts @@ -26,9 +26,23 @@ import { connect as tlsConnect } from "node:tls" import type { IncomingMessage } from "node:http" import type { Socket } from "node:net" import { lookupCookieIdentityFromHeaders } from "./cookie-auth" -import { getActiveUpstreamScheme, getActivePreviewPort, getActiveServePath } from "./preview-launcher" +import { getActiveUpstreamScheme, getActivePreviewPort, getActiveServePath, getPreviewPrivateIp } from "./preview-launcher" import { previewHost } from "./preview-host" +function parseCookies(cookieHeader: string): Record { + const out: Record = {} + for (const part of cookieHeader.split(";")) { + const idx = part.indexOf("=") + if (idx < 0) continue + const key = part.slice(0, idx).trim() + const val = part.slice(idx + 1).trim() + if (key) { + try { out[key] = decodeURIComponent(val) } catch { out[key] = val } + } + } + return out +} + const PREVIEW_PREFIX = "/preview/" /** @@ -147,22 +161,29 @@ function upstreamHostHeader(port: number): string { * is no MITM surface to defend against, and chained cert verification * against an IP literal isn't possible anyway. */ -export async function handlePreviewHttp(req: Request, port: number, rest: string): Promise { +export interface PreviewUpstreamOpts { + /** Private IP of the ECS task. When provided, used instead of 127.0.0.1. */ + upstreamIp?: string + /** Upstream transport scheme override (skip port-based lookup). */ + upstreamScheme?: "http" | "https" + /** Upstream serve-path prefix override (skip port-based lookup). */ + servePath?: string | null + /** Session ID used for scheme/servePath lookup (skips port scan). */ + sessionId?: string +} + +export async function handlePreviewHttp( + req: Request, + port: number, + rest: string, + opts?: PreviewUpstreamOpts, +): Promise { const url = new URL(req.url) - const scheme = getActiveUpstreamScheme(port) - // Path to send to the upstream dev server. - // - // - servePath is null (default) → forward the stripped `rest` (legacy - // behavior; dev server listens at "/" and sees /main.js, /chunk-X.js). - // - servePath is a string → prepend it to `rest`, so the dev - // server receives e.g. /preview/main.js (matches an Angular CLI - // dev-server whose baseHref-derived servePath is "/preview/"). - // - // `rest` always starts with "/" (parsePreviewPath guarantees) so the - // simple concat works without double-slashing. - const servePath = getActiveServePath(port) + const scheme = opts?.upstreamScheme ?? getActiveUpstreamScheme(port, opts?.sessionId) + const servePath = opts?.servePath !== undefined ? opts.servePath : getActiveServePath(port, opts?.sessionId) const upstreamPath = servePath ? servePath.replace(/\/$/, "") + rest : rest - const target = `${scheme}://${PREVIEW_UPSTREAM_TCP_HOST}:${port}${upstreamPath}${url.search}` + const upstreamHost = opts?.upstreamIp ?? PREVIEW_UPSTREAM_TCP_HOST + const target = `${scheme}://${upstreamHost}:${port}${upstreamPath}${url.search}` // Log every request so CloudWatch shows the full proxy attempt history. // Volume is bounded by `/preview//*` traffic, which is itself idle- @@ -279,19 +300,11 @@ export async function handlePreviewHttp(req: Request, port: number, rest: string // stack trace if `err` is an Error — usually it's a TypeError("fetch // failed") wrapping a transport error in `.cause`. console.error( - `[collab.preview-proxy] upstream ${scheme}://${PREVIEW_UPSTREAM_TCP_HOST}:${port}${rest} failed: ${detail}`, + `[collab.preview-proxy] upstream ${scheme}://${upstreamHost}:${port}${rest} failed: ${detail}`, err instanceof Error && (err as Error & { cause?: unknown }).cause ? `cause: ${String((err as Error & { cause?: unknown }).cause)}` : "", ) - // Hint at scheme mismatch — common 502 cause once HTTPS-upstream support - // exists. Two cases: - // - Proxy is configured "http" (default) but the dev server bound TLS - // → the byte-level "Unable to connect" / EPROTO from TLS handshake - // failure surfaces as a 502 here. Set `upstreamScheme: "https"`. - // - Proxy is configured "https" but the dev server is plain HTTP - // → similar shape, opposite direction. Drop `upstreamScheme` or - // set it to "http". const schemeHint = scheme === "https" ? `

Proxy is configured to speak HTTPS to the upstream. If the dev server is actually plain HTTP, drop "upstreamScheme" from .opencode-preview.json (or set it to "http").

` @@ -300,7 +313,7 @@ export async function handlePreviewHttp(req: Request, port: number, rest: string `Preview unavailable` + `
` + `

Preview unavailable

` + - `

Couldn't reach ${scheme}://${PREVIEW_UPSTREAM_TCP_HOST}:${port} from inside the workspace container.

` + + `

Couldn't reach ${scheme}://${upstreamHost}:${port} from inside the workspace container.

` + `

Is a dev server actually listening on port ${port}? In the iframe terminal:

` + `
ss -lntp | grep ${port}
` + `

If the dev server is up but this still 502s, check that it's bound to 0.0.0.0 (or 127.0.0.1) rather than an external interface. Vite/Webpack default to localhost-only, which is fine; --host 0.0.0.0 works too.

` + @@ -336,9 +349,16 @@ export function attachPreviewUpgrade(server: { // dev / fallback when no preview host is configured). const reqHost = ((req.headers["host"] as string | undefined) ?? "").toLowerCase().split(":")[0] const ph = previewHost() + // Read preview_sid cookie early so we can use it for host-based port lookup. + const wsCookieHeader = (req.headers["cookie"] as string | undefined) ?? "" + const wsPreviewSid = parseCookies(wsCookieHeader)["preview_sid"] ?? null let parsed: { port: number; rest: string } | null if (ph && reqHost === ph) { - const activePort = getActivePreviewPort() + // Host-based: use the session from the preview_sid cookie (ECS mode) or + // fall back to the first active port (process mode / no cookie). + const activePort = wsPreviewSid + ? getActivePreviewPort(wsPreviewSid) + : getActivePreviewPort() parsed = activePort === null ? null : { port: activePort, rest: pathname || "/" } } else { parsed = parsePreviewPath(pathname) @@ -350,11 +370,7 @@ export function attachPreviewUpgrade(server: { } // Authenticate the WebSocket upgrade BEFORE the handshake completes. - // The browser sees a clean 403 (vs a successful WS that immediately - // closes with code 1008) and we never touch the WS framing layer for - // unauthorised callers. Cookie-only check — see ADR-0001; v1 doesn't - // bind port to a specific session. - const cookieHeader = (req.headers["cookie"] as string | undefined) ?? "" + const cookieHeader = wsCookieHeader if (!lookupCookieIdentityFromHeaders(cookieHeader)) { try { clientSocket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n") @@ -363,41 +379,30 @@ export function attachPreviewUpgrade(server: { return } - // Connect to the loopback dev server. Plain TCP for the default - // "http" upstream, TLS for the opt-in "https" upstream (Angular CLI - // --ssl etc.). Both `netConnect` and `tlsConnect` return a Duplex - // with identical .write / .on('data') / .pipe() surface, so the rest - // of this handler (the handshake-write below + the bidirectional - // pipe at the bottom) doesn't need to branch. - // - // `rejectUnauthorized: false` for TLS: we're connecting to literal - // 127.0.0.1 inside the same container — no MITM surface to defend - // against, and chain validation against an IP literal is impossible - // anyway. See `handlePreviewHttp`'s tls option for the matching - // rationale on the HTTP path. - const upstreamScheme = getActiveUpstreamScheme(parsed.port) - // Mirror the HTTP path's keep-prefix logic: when the active preview - // declared a `servePath`, the dev server's WS endpoint also lives - // under that prefix (Angular's @vite/client connects to - // /preview/@vite/client, not /@vite/client). Prepend servePath to - // the stripped `rest` to satisfy the dev server's routing. - const upstreamServePath = getActiveServePath(parsed.port) + // Resolve which ECS task (or loopback) this WebSocket should connect to. + // In ECS mode, the preview_sid cookie identifies the session, and the + // session's registered private IP is the TCP target. In process mode + // (or when no session cookie is present), fall back to 127.0.0.1. + const previewSid = wsPreviewSid + const upstreamIp = previewSid ? (getPreviewPrivateIp(previewSid) ?? "127.0.0.1") : "127.0.0.1" + const upstreamScheme = getActiveUpstreamScheme(parsed.port, previewSid ?? undefined) + const upstreamServePath = getActiveServePath(parsed.port, previewSid ?? undefined) const wsUpstreamPath = upstreamServePath ? upstreamServePath.replace(/\/$/, "") + (parsed.rest || "/") : (parsed.rest || "/") console.log( - `[collab.preview-proxy] WS upgrade ${pathname} → ${upstreamScheme}://127.0.0.1:${parsed.port}${wsUpstreamPath}`, + `[collab.preview-proxy] WS upgrade ${pathname} → ${upstreamScheme}://${upstreamIp}:${parsed.port}${wsUpstreamPath}`, ) const upstreamSocket: Socket = upstreamScheme === "https" - ? (tlsConnect({ host: "127.0.0.1", port: parsed.port, rejectUnauthorized: false }) as unknown as Socket) - : netConnect({ host: "127.0.0.1", port: parsed.port }) + ? (tlsConnect({ host: upstreamIp, port: parsed.port, rejectUnauthorized: false }) as unknown as Socket) + : netConnect({ host: upstreamIp, port: parsed.port }) const cleanup = (err?: Error) => { if (err) { console.error( `[collab.preview-proxy] WS upgrade upstream error ` + - `${upstreamScheme}://127.0.0.1:${parsed.port}: ${err.message}`, + `${upstreamScheme}://${upstreamIp}:${parsed.port}: ${err.message}`, ) try { clientSocket.write( diff --git a/packages/opencode/src/collab/router.ts b/packages/opencode/src/collab/router.ts index 57d89e9e8250..dacba1d6ab05 100644 --- a/packages/opencode/src/collab/router.ts +++ b/packages/opencode/src/collab/router.ts @@ -24,6 +24,9 @@ * POST /collab/session/:id/preview/stop → Driver stops the running dev server * POST /collab/session/:id/preview/restart → Driver SIGTERMs + relaunches * GET /collab/session/:id/preview/state → snapshot of the running preview + * POST /collab/preview-task/register → ECS task registers its private IP (internal) + * POST /collab/preview-task/heartbeat → ECS task keepalive (internal) + * POST /collab/preview-task/log → ECS task log line forwarding (internal) * GET /collab/claude-creds/status → does the container have Claude auth? * POST /collab/claude-creds → upload a fresh Claude credentials JSON */ @@ -914,6 +917,57 @@ function handleCollabRequestInner(req: Request): Promise | Response { }) } + // ── ECS preview-task internal endpoints (no user auth — reachable only from + // the preview task's private subnet, not from the public internet) ─────── + + // POST /collab/preview-task/register + // Body: { collabSessionId: string, privateIp: string, taskArn: string } + // Called by preview-entrypoint.js on task startup to give the collab server + // the task's private IP so the proxy can route traffic to it. + if (req.method === "POST" && path === "/collab/preview-task/register") { + const body = (await req.json().catch(() => ({}))) as { + collabSessionId?: string + privateIp?: string + taskArn?: string + } + const { collabSessionId, privateIp, taskArn } = body + if (typeof collabSessionId !== "string" || typeof privateIp !== "string") { + return json({ error: "Missing collabSessionId or privateIp" }, 400) + } + Preview.registerPreviewTask(collabSessionId, privateIp, taskArn ?? "unknown") + return json({ ok: true }) + } + + // POST /collab/preview-task/heartbeat + // Body: { collabSessionId: string } + // Called every 60 s by the ECS task to reset the install-hang watchdog. + if (req.method === "POST" && path === "/collab/preview-task/heartbeat") { + const body = (await req.json().catch(() => ({}))) as { collabSessionId?: string } + if (typeof body.collabSessionId !== "string") { + return json({ error: "Missing collabSessionId" }, 400) + } + Preview.receiveHeartbeat(body.collabSessionId) + return json({ ok: true }) + } + + // POST /collab/preview-task/log + // Body: { collabSessionId: string, stream: "stdout"|"stderr", line: string } + // Called for each stdout/stderr line the ECS task emits. The collab server + // stores the line, detects the ready pattern, and rebroadcasts via SSE. + if (req.method === "POST" && path === "/collab/preview-task/log") { + const body = (await req.json().catch(() => ({}))) as { + collabSessionId?: string + stream?: string + line?: string + } + if (typeof body.collabSessionId !== "string" || typeof body.line !== "string") { + return json({ error: "Missing collabSessionId or line" }, 400) + } + const stream = body.stream === "stderr" ? "stderr" : "stdout" + Preview.receivePreviewLog(body.collabSessionId, stream, body.line) + return json({ ok: true }) + } + // GET /collab/me — current authenticated user info if (req.method === "GET" && path === "/collab/me") { const sess = getSession(req) @@ -1701,13 +1755,11 @@ async function handleSessionRoutes(req: Request, url: URL, path: string): Promis // POST /collab/session/:id/preview/stop — Driver only. if (req.method === "POST" && parts[3] === "preview" && parts[4] === "stop") { if (caller.role !== "driver") return json({ error: "Forbidden — Drivers only" }, 403) - const cur = Preview.getPreviewState() - if (!cur || cur.collabSessionId !== sessionId) { + const cur = Preview.getPreviewState(sessionId) + if (!cur) { return json({ error: "No preview running for this session." }, 404) } - Preview.stopPreview("explicit") - // Clear the intent so resumePreviewsOnBoot doesn't resurrect a preview - // the Driver intentionally tore down. + Preview.stopPreview(sessionId, "explicit") Session.setPreviewIntent(sessionId, null) return json({ ok: true }) } @@ -1717,11 +1769,11 @@ async function handleSessionRoutes(req: Request, url: URL, path: string): Promis if (caller.role !== "driver") return json({ error: "Forbidden — Drivers only" }, 403) const rl = checkRateLimit(`preview-launch:${sess.githubId}`, 10, 60 * 60 * 1000) if (!rl.ok) return rateLimitedResponse(rl.retryAfter) - const cur = Preview.getPreviewState() - if (!cur || cur.collabSessionId !== sessionId) { + const cur = Preview.getPreviewState(sessionId) + if (!cur) { return json({ error: "No preview running for this session." }, 404) } - const result = Preview.restartPreview() + const result = Preview.restartPreview(sessionId) if (!result.ok) return json({ error: result.error }, result.status) return json(result.state, 202) } @@ -1730,8 +1782,8 @@ async function handleSessionRoutes(req: Request, url: URL, path: string): Promis // Snapshot of the running preview (if any) so the SPA can render its // launcher banner on page load without waiting for the next SSE event. if (req.method === "GET" && parts[3] === "preview" && parts[4] === "state") { - const cur = Preview.getPreviewState() - if (!cur || cur.collabSessionId !== sessionId) return json(null, 200) + const cur = Preview.getPreviewState(sessionId) + if (!cur) return json(null, 200) return new Response(JSON.stringify(cur), { status: 200, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, @@ -1739,16 +1791,11 @@ async function handleSessionRoutes(req: Request, url: URL, path: string): Promis } // GET /collab/session/:id/preview/holder — any participant. - // Identifies which collab session currently holds the single, container-wide - // preview slot, plus who's in that session — so a Driver who hits the - // "already running in session X" 409 can see whose Driver to ask to stop it - // (rather than just an opaque session id). DELIBERATELY global: it reports - // the holder even when that's a DIFFERENT session than this one (that's the - // whole point). Safe to expose — every collab participant is an org member - // (ADR-0001), and the 409 already leaks the holder's session id. Returns - // null when no preview is running anywhere. + // Identifies which collab session currently holds a preview slot. + // In multi-session mode returns this session's preview; falls back to any + // active preview so the 409 "session X already has one" flow still works. if (req.method === "GET" && parts[3] === "preview" && parts[4] === "holder") { - const cur = Preview.getPreviewState() + const cur = Preview.getPreviewState(sessionId) ?? Preview.getPreviewState() if (!cur) return json(null, 200) const holder = Session.getCollabSession(cur.collabSessionId) return json( @@ -2322,8 +2369,8 @@ function handleSse( // FOR THIS session. Without this, a SPA reload mid-preview would // miss the started event and the launcher banner would show the // "Launch" button as if nothing were running. - const previewState = Preview.getPreviewState() - if (previewState && previewState.collabSessionId === collabSessionId) { + const previewState = Preview.getPreviewState(collabSessionId) + if (previewState) { send({ type: "collab:preview_started", state: previewState }) } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8c92eddd3243..d42e7ec1178a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,9 +1,9 @@ import "./init-projectors" import { handleCollabRequest } from "@/collab/router" -import { parsePreviewPath, handlePreviewHttp, attachPreviewUpgrade } from "@/collab/preview-router" +import { parsePreviewPath, handlePreviewHttp, attachPreviewUpgrade, type PreviewUpstreamOpts } from "@/collab/preview-router" import { cookieAuthorizesRequest, lookupCookieIdentity } from "@/collab/cookie-auth" -import { markPreviewTraffic, getActivePreviewPort } from "@/collab/preview-launcher" +import { markPreviewTraffic, getActivePreviewPort, getPreviewPrivateIp, getActiveUpstreamScheme, getActiveServePath } from "@/collab/preview-launcher" import { previewHost } from "@/collab/preview-host" import { Database } from "@/storage/db" import { NodeHttpServer } from "@effect/platform-node" @@ -60,6 +60,20 @@ const NO_PREVIEW_HTML = * large downloads, SSE all work). Shared by the dedicated-preview-host * branch and the legacy `/preview/` path branch. */ +function parseCookieHeader(cookieHeader: string): Record { + const out: Record = {} + for (const part of cookieHeader.split(";")) { + const idx = part.indexOf("=") + if (idx < 0) continue + const key = part.slice(0, idx).trim() + const val = part.slice(idx + 1).trim() + if (key) { + try { out[key] = decodeURIComponent(val) } catch { out[key] = val } + } + } + return out +} + function previewServerResponse(webResponse: Response) { const previewHeaders = new Headers(webResponse.headers) if (webResponse.body) { @@ -104,27 +118,60 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => const ph = previewHost() if (ph && host === ph) { const webRequest = yield* HttpServerRequest.toWeb(req) - // Same shell-trust gate as the legacy path (ADR-0001): a valid collab - // cookie is enough. cookieAuthorizesRequest now allows when the Host - // is the preview host (see cookie-auth.ts rule a0). if (cookieAuthorizesRequest(webRequest) !== "allow") { return HttpServerResponse.raw(new TextEncoder().encode("Forbidden"), { status: 403, headers: new Headers({ "content-type": "text/plain" }), }) } - markPreviewTraffic() - const port = getActivePreviewPort() + + // Session routing: the SPA appends ?cs= on first load. + // We read it, set a preview_sid cookie (so subsequent requests are + // routed to the same ECS task without repeating the query param), + // then route to the session's private IP. + const reqUrl = new URL(webRequest.url) + const csFromQuery = reqUrl.searchParams.get("cs") + const existingPreviewSid = parseCookieHeader(webRequest.headers.get("cookie") ?? "")["preview_sid"] ?? null + const previewSid = csFromQuery ?? existingPreviewSid + + markPreviewTraffic(previewSid ?? undefined) + + const port = previewSid ? (getActivePreviewPort(previewSid) ?? null) : getActivePreviewPort() if (port === null) { return HttpServerResponse.raw(new TextEncoder().encode(NO_PREVIEW_HTML), { status: 200, headers: new Headers({ "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }), }) } - // Root serve: forward the WHOLE pathname (servePath is null now that the - // frontend builds at base href "/"), so /assets/x.js → /assets/x.js. - const webResponse = yield* Effect.promise(() => handlePreviewHttp(webRequest, port, pathname || "/")) - return previewServerResponse(webResponse) + + const upstreamOpts: PreviewUpstreamOpts = previewSid + ? { + upstreamIp: getPreviewPrivateIp(previewSid) ?? undefined, + upstreamScheme: getActiveUpstreamScheme(port, previewSid), + servePath: getActiveServePath(port, previewSid), + sessionId: previewSid, + } + : {} + + const webResponse = yield* Effect.promise(() => + handlePreviewHttp(webRequest, port, pathname || "/", upstreamOpts), + ) + const effectResponse = previewServerResponse(webResponse) + + // When ?cs was in the query string, set the preview_sid cookie on the + // response so subsequent navigations route to the same ECS task without + // requiring the query param every time. Session-scoped (no MaxAge) so + // it expires when the browser tab closes. + if (csFromQuery) { + const collabDomain = process.env["COLLAB_DOMAIN"]?.trim().toLowerCase().split(":")[0] + const domainAttr = collabDomain ? `Domain=.${collabDomain}; ` : "" + return HttpServerResponse.setHeader( + effectResponse, + "set-cookie", + `preview_sid=${encodeURIComponent(csFromQuery)}; ${domainAttr}Path=/; SameSite=Lax`, + ) + } + return effectResponse } // GET / and GET /collab — collab landing. Authenticated users are @@ -208,7 +255,8 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => pathname === "/collab/session" || pathname.startsWith("/collab/session/") || pathname === "/collab/claude-creds" || - pathname === "/collab/claude-creds/status" + pathname === "/collab/claude-creds/status" || + pathname.startsWith("/collab/preview-task/") if (!isCollabApi) return yield* app // toWeb converts Effect's HttpServerRequest → standard Web API Request (body included) diff --git a/scripts/preview-entrypoint.js b/scripts/preview-entrypoint.js new file mode 100644 index 000000000000..b57afec49102 --- /dev/null +++ b/scripts/preview-entrypoint.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node +/** + * ECS preview task entrypoint. + * + * On startup: + * 1. Fetch task's private IP from ECS task metadata v4 + * 2. Register with collab server (POST /collab/preview-task/register) — retries 5× + * 3. Start heartbeat loop (POST /collab/preview-task/heartbeat every 60 s) + * 4. cd into the session workspace + * 5. Read .opencode-preview.json for install + start commands (defaults: pnpm i && pnpm run start) + * 6. Run the commands, piping each stdout/stderr line to POST /collab/preview-task/log + * + * Required env vars (injected by ECS container overrides): + * COLLAB_SESSION_ID — collab session this task serves + * REPO_FULL_NAME — e.g. "unleashlive/frontend" + * COLLAB_BASE_URL — e.g. "https://collab.utils.unleashlive.com" + * + * Optional: + * GITHUB_TOKEN — OAuth token for private git+https deps + * WORKSPACE_ROOT — default /var/opencode/workspaces + */ + +"use strict" + +const { spawn } = require("child_process") +const fs = require("fs") +const http = require("http") +const https = require("https") +const path = require("path") + +// ── Env ────────────────────────────────────────────────────────────────────── + +const SESSION_ID = process.env.COLLAB_SESSION_ID +const REPO_FULL_NAME = process.env.REPO_FULL_NAME +const COLLAB_BASE_URL = (process.env.COLLAB_BASE_URL ?? "").replace(/\/$/, "") +const GITHUB_TOKEN = process.env.GITHUB_TOKEN +const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ?? "/var/opencode/workspaces" + +if (!SESSION_ID || !REPO_FULL_NAME || !COLLAB_BASE_URL) { + console.error("[preview] FATAL: missing required env vars: COLLAB_SESSION_ID, REPO_FULL_NAME, COLLAB_BASE_URL") + process.exit(1) +} + +const WORKSPACE_DIR = path.join(WORKSPACE_ROOT, SESSION_ID, REPO_FULL_NAME) + +// ── HTTP helper ─────────────────────────────────────────────────────────────── + +function post(url, body, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + const u = new URL(url) + const mod = u.protocol === "https:" ? https : http + const data = JSON.stringify(body) + const timer = setTimeout(() => reject(new Error("request timed out")), timeoutMs) + const req = mod.request( + { + hostname: u.hostname, + port: u.port || (u.protocol === "https:" ? 443 : 80), + path: u.pathname + u.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + clearTimeout(timer) + let buf = "" + res.on("data", (c) => (buf += c)) + res.on("end", () => resolve({ status: res.statusCode, body: buf })) + }, + ) + req.on("error", (err) => { clearTimeout(timer); reject(err) }) + req.write(data) + req.end() + }) +} + +// ── ECS metadata ───────────────────────────────────────────────────────────── + +async function getPrivateIp() { + const metaUri = process.env.ECS_CONTAINER_METADATA_URI_V4 + if (!metaUri) { + console.warn("[preview] ECS_CONTAINER_METADATA_URI_V4 not set — using 127.0.0.1 (local dev?)") + return "127.0.0.1" + } + return new Promise((resolve) => { + const req = http.get(`${metaUri}/task`, (res) => { + let buf = "" + res.on("data", (c) => (buf += c)) + res.on("end", () => { + try { + const meta = JSON.parse(buf) + for (const container of meta.Containers ?? []) { + for (const net of container.Networks ?? []) { + const ip = (net.IPv4Addresses ?? [])[0] + if (ip) { resolve(ip); return } + } + } + } catch {} + console.warn("[preview] could not parse ECS task metadata; using 127.0.0.1") + resolve("127.0.0.1") + }) + }) + req.on("error", () => { + console.warn("[preview] ECS metadata fetch failed; using 127.0.0.1") + resolve("127.0.0.1") + }) + req.setTimeout(5_000, () => { + req.destroy() + console.warn("[preview] ECS metadata timed out; using 127.0.0.1") + resolve("127.0.0.1") + }) + }) +} + +// ── Register with collab server ─────────────────────────────────────────────── + +async function register(privateIp, taskArn) { + const body = { collabSessionId: SESSION_ID, privateIp, taskArn } + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const res = await post(`${COLLAB_BASE_URL}/collab/preview-task/register`, body) + if (res.status === 200) { + console.log(`[preview] registered with collab server (ip=${privateIp} taskArn=${taskArn})`) + return true + } + console.warn(`[preview] register attempt ${attempt}/5 → HTTP ${res.status}: ${res.body}`) + } catch (err) { + console.warn(`[preview] register attempt ${attempt}/5 failed: ${err.message}`) + } + // Exponential back-off: 2s, 4s, 6s, 8s + await new Promise((r) => setTimeout(r, 2_000 * attempt)) + } + return false +} + +// ── Heartbeat loop ──────────────────────────────────────────────────────────── + +function startHeartbeat() { + const interval = setInterval(async () => { + try { + await post(`${COLLAB_BASE_URL}/collab/preview-task/heartbeat`, { collabSessionId: SESSION_ID }) + } catch (err) { + // Heartbeat failures are non-fatal — the sweep loop on the collab + // server will detect the silence and stop the task if needed. + console.warn(`[preview] heartbeat failed: ${err.message}`) + } + }, 60_000) + if (typeof interval.unref === "function") interval.unref() + return interval +} + +// ── Log forwarding ──────────────────────────────────────────────────────────── + +async function postLog(stream, line) { + try { + await post( + `${COLLAB_BASE_URL}/collab/preview-task/log`, + { collabSessionId: SESSION_ID, stream, line: line.slice(0, 2_000) }, + 5_000, + ) + } catch { + // Non-fatal — line is already on stdout/stderr for CloudWatch. + } +} + +// ── Resolve dev-server command ──────────────────────────────────────────────── + +function resolveCommand() { + const defaults = { install: "pnpm i --shamefully-hoist=true", start: "pnpm run start" } + try { + const raw = JSON.parse(fs.readFileSync(path.join(WORKSPACE_DIR, ".opencode-preview.json"), "utf8")) + const start = typeof raw.command === "string" ? raw.command : defaults.start + const install = raw.installCommand === undefined + ? defaults.install + : (typeof raw.installCommand === "string" ? raw.installCommand : "") + return install ? `${install} && ${start}` : start + } catch { + return `${defaults.install} && ${defaults.start}` + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + // 1. Private IP + task ARN + const privateIp = await getPrivateIp() + const taskArn = process.env.ECS_CONTAINER_METADATA_URI_V4 ?? "local" + + // 2. Register + const ok = await register(privateIp, taskArn) + if (!ok) { + console.error("[preview] could not register after 5 attempts — exiting") + process.exit(1) + } + + // 3. Heartbeat + startHeartbeat() + + // 4. cd into workspace + if (!fs.existsSync(WORKSPACE_DIR)) { + console.error(`[preview] workspace not found: ${WORKSPACE_DIR}`) + process.exit(1) + } + process.chdir(WORKSPACE_DIR) + + // 5. Resolve command + const cmd = resolveCommand() + console.log(`[preview] launching: ${cmd}`) + console.log(`[preview] cwd: ${WORKSPACE_DIR}`) + + // 6. Spawn + const env = { ...process.env, PORT: "8080", OPENCODE_PREVIEW: "1" } + if (GITHUB_TOKEN) { + env.GITHUB_TOKEN = GITHUB_TOKEN + } else { + delete env.GITHUB_TOKEN + } + + const child = spawn("sh", ["-c", cmd], { + cwd: WORKSPACE_DIR, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }) + + const onLine = (stream) => (chunk) => { + const lines = chunk.toString("utf8").split("\n").filter(Boolean) + for (const line of lines) { + const fn = stream === "stderr" ? console.error : console.log + fn(`[preview/${stream}] ${line.slice(0, 1_024)}`) + postLog(stream, line) // fire-and-forget + } + } + + child.stdout.on("data", onLine("stdout")) + child.stderr.on("data", onLine("stderr")) + + child.once("exit", (code, signal) => { + console.log(`[preview] child exited code=${code} signal=${signal}`) + process.exit(code ?? 1) + }) + + // Forward signals to the child process group + for (const sig of ["SIGTERM", "SIGINT"]) { + process.on(sig, () => { + console.log(`[preview] received ${sig} — forwarding to child`) + try { + if (child.pid) process.kill(-child.pid, sig) + } catch { + try { child.kill(sig) } catch {} + } + }) + } +} + +main().catch((err) => { + console.error("[preview] fatal:", err) + process.exit(1) +}) From b5346ff52520af485ae99073a3f0e83f374b8201 Mon Sep 17 00:00:00 2001 From: Hanno Blankenstein Date: Mon, 22 Jun 2026 21:31:52 +1000 Subject: [PATCH 2/2] fix(collab): credit every driver+contributor as Co-authored-by in commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs caused session participants to be missing from Co-authored-by git trailers: 1. writeParticipantsFile included viewers (role=viewer), so only they were filtered — but more importantly, drivers who hadn't joined yet at clone time were silently excluded from the file. Now only driver/contributor roles are written (viewers read along; no credit). 2. initSessionWorkspace used a snapshot of participants taken at call time. Participants who redeemed an invite while a repo was mid-clone were in the in-memory session but not in that snapshot, so their email never appeared in .git/collab-participants.json. Fixed by re-reading from getCollabSession() after each repo clone completes. 3. reinstallCollabHooksOnBoot (runs on every container restart) only refreshed the hook script, not the participants file. Any ECS task replacement left a stale participants list on disk, so all commits after the restart used the pre-restart roster. Fixed by calling writeParticipantsFile in the boot sweep alongside the hook install. Co-Authored-By: Claude Sonnet 4.6 --- packages/opencode/src/collab/workspace.ts | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/collab/workspace.ts b/packages/opencode/src/collab/workspace.ts index 58388c56c366..ba943b52a33c 100644 --- a/packages/opencode/src/collab/workspace.ts +++ b/packages/opencode/src/collab/workspace.ts @@ -159,9 +159,19 @@ export async function initSessionWorkspace( } // Drop the participants list next to the hook so the hook can read it - // at commit time. Refreshed by refreshParticipantsFile whenever the - // participant list changes (invite redemption, role change, leave). - writeParticipantsFile(dest, participants) + // at commit time. Re-read from session state here rather than using the + // snapshot passed in — catches anyone who joined while the clone was in + // flight (the in-memory session is updated in real-time by the invite + // redemption path, even during cloning). + let currentParticipants = participants + try { + const { getCollabSession } = await import("./session") + const cs = getCollabSession(collabSessionId) + if (cs) currentParticipants = cs.participants + } catch { + // non-fatal: fall back to the snapshot + } + writeParticipantsFile(dest, currentParticipants) // (Re)install the collab commit hook every time — covers fresh clones // and existing checkouts that pre-date the feature. @@ -191,8 +201,10 @@ function pickCommitAuthor(participants: Participant[]): { name: string; email: s * the prepare-commit-msg hook can read it at commit time. Atomic via * tmpfile + rename so a racing commit doesn't see a half-written file. * - * Format: `[{ "id": 123, "login": "alice" }, …]` — minimal because the hook - * only needs id + login to construct the no-reply email. + * Only drivers and contributors are written — viewers read along but are not + * credited as co-authors on commits. + * + * Format: `[{ "id": 123, "login": "alice", "role": "driver" }, …]` */ function writeParticipantsFile(repoPath: string, participants: Participant[]): void { const gitDir = join(repoPath, ".git") @@ -200,7 +212,9 @@ function writeParticipantsFile(repoPath: string, participants: Participant[]): v const target = join(gitDir, "collab-participants.json") const tmp = target + ".tmp" const payload = JSON.stringify( - participants.map((p) => ({ id: p.githubId, login: p.githubLogin })), + participants + .filter((p) => p.role === "driver" || p.role === "contributor") + .map((p) => ({ id: p.githubId, login: p.githubLogin, role: p.role })), ) try { writeFileSync(tmp, payload, { mode: 0o644 }) @@ -386,6 +400,13 @@ export async function reinstallCollabHooksOnBoot(): Promise { continue } try { + // Refresh the participants file alongside the hook — the boot sweep + // previously only reinstalled the hook script, leaving a stale + // participants list on disk after a container restart. Without this, + // any participant who joined after the last explicit refresh (invite / + // role change) would be missing from Co-authored-by trailers on all + // subsequent commits. + writeParticipantsFile(dest, cs.participants ?? []) installCollabCommitHook(dest, cs.id, cs.name ?? "", repo, cs.branch ?? null) installed++ } catch (err) {