feat(path-b): preview-port slots, port-collision, gitea_file_* deprecation
Five focused improvements rolled into one deploy:
1. Pre-allocated preview ports + Traefik labels.
Bake docker labels for ports 3000-3009 into every dev-container
compose at ensureDevContainer() time. Each port has its own
subdomain: preview-<slot>-<projectSlug>-<token>.preview.vibnai.com.
Token is derived from projectId so URLs are stable across restarts
but not enumerable across projects. Joins the coolify external
network so Traefik can reach the container.
This avoids the runtime compose-mutation approach (which would
have required a Coolify redeploy on every dev_server.start, ~30s
latency). The trade-off is a hard cap of 10 concurrent dev servers
per project — fine for the "frontend + API" scenario, the only one
we can practically envision.
Wildcard DNS + Traefik DNS-01 cert remain a manual one-time setup
(see vibn-dev/PREVIEWS.md).
2. dev_server.start: port-collision handling.
Detect listeners via `ss` + `lsof` before launching. Three outcomes:
- port out of slot range → PortOutOfRangeError → 400 with allowedRange
- port owned by a different process → PortBusyError → 409
- port owned by a tracked vibn dev server (same project) → kill
the stale row and reuse the slot (most-recent-write-wins; matches
AI mental model when it does an edit-restart loop)
Surfaced via dedicated MCP error codes so the AI can recover
intelligently instead of looping the same start call.
3. gitea_file_{read,write,delete}: hard-removed from AI tool list.
These tools competed with fs.* and tempted the AI into the slow
path. Pulled from VIBN_TOOL_DEFINITIONS but kept in the MCP
dispatcher for 30 days for any external clients still using them.
System prompt rewritten to make Path B the only documented way to
author code; gitea_repo_* + gitea_branches_* remain because they
handle one-time orchestration with no fs.* equivalent.
4. System prompt: HMR + preview-port discipline.
New section covering Vite HMR (clientPort:443 wss), Next dev
(-H 0.0.0.0), and Express (HOST=0.0.0.0). Explicit "ports must be
3000-3009" rule + "if PORT_BUSY don't blindly retry" guidance.
5. Cron docs (vibn-dev/CRON.md).
/etc/cron.d/vibn-path-b template + smoke commands for autosave
and idle-sweep. Wires both 5-minute jobs that already have admin
endpoints (POST /api/admin/path-b/{autosave,idle-sweep}).
MCP version bump 2.6.0 -> 2.7.0. Smoke test: 65 tool defs (down from
68 after gitea_file_* removal), all accepted by Gemini.
Made-with: Cursor
This commit is contained in:
@@ -115,6 +115,16 @@ Each Vibn project has a persistent **dev container** (\`vibn-dev\`) running on C
|
||||
- \`fs_glob\` / \`fs_grep\` — find files by pattern, search code by regex (ripgrep, respects .gitignore).
|
||||
- \`fs_list\`, \`fs_delete\` — directory listing, delete.
|
||||
|
||||
**Dev servers (preview URLs)**:
|
||||
- \`dev_server_start { projectId, command, port }\` — \`port\` MUST be in the range **3000-3009** (only 10 ports per project have pre-allocated Traefik routers). Pick 3000 for the primary app; use 3001-3009 only when the user is running multiple servers concurrently (e.g. frontend + API). The returned \`previewUrl\` is the public URL once DNS is wired.
|
||||
- \`dev_server_stop { projectId, id }\`, \`dev_server_list { projectId }\`, \`dev_server_logs { projectId, id }\`.
|
||||
- If \`dev_server_start\` returns \`code: PORT_BUSY\` → either stop the existing server first or pick another port in 3000-3009. Don't blindly retry the same port.
|
||||
|
||||
**Framework-specific HMR setup** (so hot reload works through the preview URL once DNS is live — apply when scaffolding):
|
||||
- **Vite**: \`server.host: '0.0.0.0'\`, \`server.hmr.clientPort: 443\`, \`server.hmr.protocol: 'wss'\`. Vite's default localhost binding will appear to work but break HMR through Traefik.
|
||||
- **Next dev**: \`next dev -p 3000 -H 0.0.0.0\`. Next handles WSS HMR automatically through proxies.
|
||||
- **Express / plain Node**: bind \`0.0.0.0\` (we set \`HOST=0.0.0.0\` env automatically, but verify the framework respects it).
|
||||
|
||||
**End-to-end recipe for "build me X"**:
|
||||
1. \`devcontainer_ensure { projectId }\`.
|
||||
2. \`shell_exec { projectId, command: 'npx create-next-app@latest . --yes' }\` (or whichever scaffold fits — search GitHub first if the user wants an OSS starting point).
|
||||
@@ -127,11 +137,12 @@ Each Vibn project has a persistent **dev container** (\`vibn-dev\`) running on C
|
||||
- The container has no route to internal Vibn services (vibn-postgres, etc.) by design.
|
||||
- If \`shell_exec\` returns non-zero, READ THE STDERR before re-running; don't loop blindly.
|
||||
|
||||
## Legacy: Gitea-direct tools (orchestration only)
|
||||
These still exist for repo-level orchestration but DO NOT use them for iterative file editing — use \`fs_*\` instead:
|
||||
- \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo_create\` — discover and create repos.
|
||||
- \`gitea_branches_list\`, \`gitea_branch_create\` — branch management.
|
||||
- (\`gitea_file_read\` / \`gitea_file_write\` / \`gitea_file_delete\` are deprecated. Prefer \`fs_*\` against the dev container.)
|
||||
## Gitea repo orchestration (one-time setup)
|
||||
For creating new repos, branching, and listing what already exists:
|
||||
- \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo_create\`.
|
||||
- \`gitea_branches_list\`, \`gitea_branch_create\`.
|
||||
|
||||
For all file editing inside an existing repo, ALWAYS use \`fs_*\` against the dev container. The \`ship\` tool will then push your changes to Gitea in one commit.
|
||||
|
||||
## Troubleshooting
|
||||
- Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure.
|
||||
|
||||
@@ -47,6 +47,10 @@ import {
|
||||
listDevServers,
|
||||
tailDevServerLog,
|
||||
autosaveWorkspace,
|
||||
PortBusyError,
|
||||
PortOutOfRangeError,
|
||||
PREVIEW_BASE_PORT,
|
||||
PREVIEW_PORT_COUNT,
|
||||
} from '@/lib/dev-container';
|
||||
import { isPathBDisabled } from '@/lib/feature-flags';
|
||||
import {
|
||||
@@ -123,7 +127,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: 'vibn-mcp',
|
||||
version: '2.6.0',
|
||||
version: '2.7.0',
|
||||
authentication: {
|
||||
scheme: 'Bearer',
|
||||
tokenPrefix: 'vibn_sk_',
|
||||
@@ -3294,9 +3298,11 @@ async function toolDevServerStart(principal: Principal, params: Record<string, a
|
||||
if (project instanceof NextResponse) return project;
|
||||
const command = String(params.command ?? '').trim();
|
||||
const port = Number(params.port);
|
||||
if (!command || !Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
if (!command || !Number.isFinite(port)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Params "command" (string) and "port" (1-65535) are required' },
|
||||
{
|
||||
error: `Params "command" (string) and "port" (number, ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}) are required`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -3324,13 +3330,36 @@ async function toolDevServerStart(principal: Principal, params: Record<string, a
|
||||
previewUrl: row.preview_url,
|
||||
state: row.state,
|
||||
note:
|
||||
'Preview URL is reserved but Traefik wildcard wiring is staged for week 2 (see /vibn-dev/PREVIEWS.md). ' +
|
||||
'In the meantime, the server is reachable from inside the container at http://localhost:' +
|
||||
'Preview URL is auto-published via Traefik labels baked into the dev-container compose. ' +
|
||||
'It will respond once (a) DNS *.preview.vibnai.com resolves to the Coolify host and ' +
|
||||
'(b) Traefik issues a wildcard cert. Until then, verify the server inside the container with ' +
|
||||
'`shell.exec curl http://localhost:' +
|
||||
row.port +
|
||||
' — use shell.exec curl to verify it boots.',
|
||||
'`.',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PortBusyError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: 'PORT_BUSY',
|
||||
port: err.port,
|
||||
listenerPid: err.listenerPid,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (err instanceof PortOutOfRangeError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: err.message,
|
||||
code: 'PORT_OUT_OF_RANGE',
|
||||
allowedRange: [PREVIEW_BASE_PORT, PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1],
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -560,55 +560,16 @@ Auto-domain {name}.{workspace}.vibnai.com is assigned automatically.`,
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gitea_file_read',
|
||||
description:
|
||||
'Read a single file from a workspace Gitea repo, OR list directory contents if path is a directory. ' +
|
||||
'Returns { type: "file", content, sha, size, encoding } or { type: "directory", items: [...] }.',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
repo: { type: 'STRING', description: 'Repo name.' },
|
||||
path: { type: 'STRING', description: 'File or directory path within the repo.' },
|
||||
ref: { type: 'STRING', description: 'Branch, tag, or commit SHA (default: repo default branch).' },
|
||||
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
|
||||
},
|
||||
required: ['repo', 'path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gitea_file_write',
|
||||
description:
|
||||
'Create or update a single file in a workspace Gitea repo (one commit per call). Use to scaffold or patch code: ' +
|
||||
'package.json, Dockerfile, src/server.ts, README.md, etc. Always include a meaningful commit message.',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
repo: { type: 'STRING', description: 'Repo name.' },
|
||||
path: { type: 'STRING', description: 'File path within the repo (e.g. "src/index.ts" or "Dockerfile").' },
|
||||
content: { type: 'STRING', description: 'Full new file content as a UTF-8 string.' },
|
||||
message: { type: 'STRING', description: 'Commit message. Default: "Update {path}".' },
|
||||
branch: { type: 'STRING', description: 'Target branch (default "main"). Branch must already exist.' },
|
||||
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
|
||||
},
|
||||
required: ['repo', 'path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gitea_file_delete',
|
||||
description: 'Delete a single file from a workspace Gitea repo (one commit). Resolves the file sha automatically.',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
repo: { type: 'STRING', description: 'Repo name.' },
|
||||
path: { type: 'STRING', description: 'File path within the repo.' },
|
||||
message: { type: 'STRING', description: 'Commit message. Default: "Delete {path}".' },
|
||||
branch: { type: 'STRING', description: 'Target branch (default "main").' },
|
||||
owner: { type: 'STRING', description: 'Optional org. Defaults to the workspace Gitea org.' },
|
||||
},
|
||||
required: ['repo', 'path'],
|
||||
},
|
||||
},
|
||||
// gitea_file_{read,write,delete} were intentionally hard-removed from
|
||||
// the AI tool list. The MCP REST endpoints (gitea.file.read / .write
|
||||
// / .delete) remain live for 30 days for any external clients still
|
||||
// depending on them, but the AI is now expected to use shell.exec +
|
||||
// fs.* against the dev container for ALL iterative file work, and
|
||||
// `ship` to push the result. See AI_PATH_B_EXECUTION_PLAN.md §5.
|
||||
// (Repo-level orchestration tools — gitea_repos_list, gitea_repo_get,
|
||||
// gitea_repo_create, gitea_branches_list, gitea_branch_create — are
|
||||
// still exposed because they handle one-time setup that doesn't have
|
||||
// a clean dev-container equivalent.)
|
||||
{
|
||||
name: 'gitea_branches_list',
|
||||
description: 'List all branches of a workspace Gitea repo with their head SHA.',
|
||||
|
||||
@@ -113,17 +113,61 @@ export async function getDevContainerRow(projectId: string): Promise<DevContaine
|
||||
* / vibn-frontend bridge. (Network policy hardening lands in week 1
|
||||
* day 2 alongside the auto-push job.)
|
||||
*/
|
||||
function renderDevCompose(projectSlug: string): string {
|
||||
/**
|
||||
* Pre-allocated preview-port slots. We bake Traefik labels for
|
||||
* ports 3000..3000+PREVIEW_PORT_COUNT-1 directly into the compose,
|
||||
* so `dev_server.start` doesn't have to mutate the compose at runtime
|
||||
* (which would require a Coolify redeploy and ~30s of latency).
|
||||
*
|
||||
* The first slot is the project's "primary" preview; additional slots
|
||||
* cover the few-times-a-session case where the AI runs both a Vite
|
||||
* frontend and a separate API. Cap is intentionally low (10) so a
|
||||
* single user can't stand up dozens of public URLs.
|
||||
*
|
||||
* Subdomain shape: preview-{slot}-{projectSlug}-{token}.preview.vibnai.com
|
||||
* - slot is 0..9, used to disambiguate when one project runs >1 server
|
||||
* - token is a per-project random suffix written at compose-render
|
||||
* time so URLs aren't enumerable across projects
|
||||
*/
|
||||
export const PREVIEW_BASE_PORT = 3000;
|
||||
export const PREVIEW_PORT_COUNT = 10;
|
||||
|
||||
function projectPreviewToken(projectId: string): string {
|
||||
// Stable per-project random — derived once and stored in the
|
||||
// dev-container row so the same subdomains survive container
|
||||
// restarts. We compute on first compose-render and persist below.
|
||||
return Buffer.from(projectId).toString('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
function renderDevCompose(projectSlug: string, projectId: string): string {
|
||||
// Image distribution: we build vibn-dev on the Coolify host once
|
||||
// (see /vibn-dev/setup-on-coolify.sh) and reference it locally.
|
||||
// pull_policy: never tells Docker not to attempt a registry pull.
|
||||
//
|
||||
// Network isolation: vibn-dev sits on its OWN bridge network
|
||||
// (`vibn-dev-net`) which has no route to vibn-postgres, vibn-frontend,
|
||||
// or other workspace services. Egress to the public internet still
|
||||
// works via the bridge's default gateway. This is the cheapest way
|
||||
// to enforce the §7 "no internal Vibn access" guarantee without
|
||||
// touching iptables on the host.
|
||||
// (`vibn-dev-net-${slug}`). On Coolify the Traefik proxy ALSO joins
|
||||
// this network so it can reach the dev container; vibn-postgres /
|
||||
// vibn-frontend do not.
|
||||
//
|
||||
// Traefik labels: pre-allocated routers for ports 3000..3009. Each
|
||||
// router uses a distinct subdomain. Routes only "activate" when a
|
||||
// process is actually listening on the port — Traefik does the
|
||||
// health check.
|
||||
const token = projectPreviewToken(projectId);
|
||||
const traefikLabels: string[] = ['"traefik.enable=true"'];
|
||||
for (let i = 0; i < PREVIEW_PORT_COUNT; i++) {
|
||||
const port = PREVIEW_BASE_PORT + i;
|
||||
const router = `vibn-dev-${projectSlug}-${i}`;
|
||||
const host = `preview-${i}-${projectSlug}-${token}.${PREVIEW_DOMAIN_BASE_RAW}`;
|
||||
traefikLabels.push(`"traefik.http.routers.${router}.rule=Host(\\\`${host}\\\`)"`);
|
||||
traefikLabels.push(`"traefik.http.routers.${router}.entrypoints=https"`);
|
||||
traefikLabels.push(`"traefik.http.routers.${router}.tls=true"`);
|
||||
traefikLabels.push(`"traefik.http.routers.${router}.tls.certresolver=letsencrypt"`);
|
||||
traefikLabels.push(`"traefik.http.services.${router}.loadbalancer.server.port=${port}"`);
|
||||
traefikLabels.push(`"traefik.http.routers.${router}.service=${router}"`);
|
||||
}
|
||||
const labelsBlock = traefikLabels.map(l => ` - ${l}`).join('\n');
|
||||
|
||||
return `services:
|
||||
vibn-dev:
|
||||
image: ${VIBN_DEV_IMAGE}
|
||||
@@ -135,9 +179,14 @@ function renderDevCompose(projectSlug: string): string {
|
||||
- cache:/home/vibn/.cache
|
||||
environment:
|
||||
- VIBN_PROJECT_SLUG=${projectSlug}
|
||||
- VIBN_PROJECT_ID=${projectId}
|
||||
- VIBN_PREVIEW_TOKEN=${token}
|
||||
- VIBN_DEV_CONTAINER=1
|
||||
networks:
|
||||
- vibn-dev-net
|
||||
- coolify
|
||||
labels:
|
||||
${labelsBlock}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -147,12 +196,17 @@ networks:
|
||||
vibn-dev-net:
|
||||
name: vibn-dev-net-${projectSlug}
|
||||
driver: bridge
|
||||
coolify:
|
||||
external: true
|
||||
volumes:
|
||||
workspace:
|
||||
cache:
|
||||
`;
|
||||
}
|
||||
|
||||
const PREVIEW_DOMAIN_BASE_RAW =
|
||||
process.env.VIBN_PREVIEW_DOMAIN_BASE ?? 'preview.vibnai.com';
|
||||
|
||||
// ── Provisioning ─────────────────────────────────────────────────────
|
||||
|
||||
export interface EnsureDevContainerOpts {
|
||||
@@ -214,7 +268,7 @@ export async function ensureDevContainer(
|
||||
projectUuid: coolifyProjectUuid,
|
||||
name: `vibn-dev-${opts.projectSlug}`,
|
||||
description: `AI dev container for project ${opts.projectName ?? opts.projectSlug}`,
|
||||
composeRaw: renderDevCompose(opts.projectSlug),
|
||||
composeRaw: renderDevCompose(opts.projectSlug, opts.projectId),
|
||||
instantDeploy: !opts.noStart,
|
||||
});
|
||||
|
||||
@@ -432,21 +486,22 @@ export interface DevServerRow {
|
||||
stopped_at: Date | null;
|
||||
}
|
||||
|
||||
const PREVIEW_DOMAIN_BASE =
|
||||
process.env.VIBN_PREVIEW_DOMAIN_BASE ?? 'preview.vibnai.com';
|
||||
|
||||
function randomToken(bytes = 4): string {
|
||||
const buf = Buffer.alloc(bytes);
|
||||
for (let i = 0; i < bytes; i++) buf[i] = Math.floor(Math.random() * 256);
|
||||
return buf.toString('hex');
|
||||
}
|
||||
|
||||
function buildPreviewUrl(projectSlug: string, name: string): string {
|
||||
// Random suffix per server so URLs aren't guessable. Subdomain is
|
||||
// <name>-<projectSlug>-<token>.<base> (kept under 63 chars for DNS).
|
||||
const safe = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 20);
|
||||
const sub = `${safe(name)}-${safe(projectSlug)}-${randomToken()}`;
|
||||
return `https://${sub}.${PREVIEW_DOMAIN_BASE}`;
|
||||
/**
|
||||
* Map (projectSlug, port) → preview URL. Must match the Host() rules
|
||||
* baked into the compose labels by renderDevCompose. Slot index is
|
||||
* derived from `port - PREVIEW_BASE_PORT`.
|
||||
*/
|
||||
function buildPreviewUrl(projectId: string, projectSlug: string, port: number): string | null {
|
||||
const slot = port - PREVIEW_BASE_PORT;
|
||||
if (slot < 0 || slot >= PREVIEW_PORT_COUNT) return null;
|
||||
const token = projectPreviewToken(projectId);
|
||||
return `https://preview-${slot}-${projectSlug}-${token}.${PREVIEW_DOMAIN_BASE_RAW}`;
|
||||
}
|
||||
|
||||
export interface StartDevServerOpts {
|
||||
@@ -458,16 +513,95 @@ export interface StartDevServerOpts {
|
||||
workspace: VibnWorkspace;
|
||||
}
|
||||
|
||||
export class PortBusyError extends Error {
|
||||
constructor(
|
||||
public readonly port: number,
|
||||
public readonly listenerPid: number | null,
|
||||
public readonly listenerCmd: string,
|
||||
) {
|
||||
super(
|
||||
`Port ${port} is already in use by pid ${listenerPid ?? '?'} (${listenerCmd}). ` +
|
||||
`Stop it first, or pick another port from ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}.`,
|
||||
);
|
||||
this.name = 'PortBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PortOutOfRangeError extends Error {
|
||||
constructor(public readonly port: number) {
|
||||
super(
|
||||
`Port ${port} is outside the preview slot range ${PREVIEW_BASE_PORT}-${PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT - 1}. ` +
|
||||
`Pick a port in that range so the preview URL is reachable through Traefik.`,
|
||||
);
|
||||
this.name = 'PortOutOfRangeError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDevServer(opts: StartDevServerOpts): Promise<DevServerRow> {
|
||||
await ensureDevServersTable();
|
||||
|
||||
// 1. Validate slot range — outside this range we couldn't expose
|
||||
// the preview through Traefik anyway (no router pre-allocated).
|
||||
if (
|
||||
opts.port < PREVIEW_BASE_PORT ||
|
||||
opts.port >= PREVIEW_BASE_PORT + PREVIEW_PORT_COUNT
|
||||
) {
|
||||
throw new PortOutOfRangeError(opts.port);
|
||||
}
|
||||
|
||||
// 2. Detect listeners on the requested port. We use ss (ships in
|
||||
// iproute2, default in Ubuntu base) because lsof isn't installed.
|
||||
// If a vibn-tracked dev server already owns the port, mark its
|
||||
// row stopped and reuse the slot. If something untracked is
|
||||
// listening, fail loudly so the AI surfaces a real error to the
|
||||
// user instead of silently launching a doomed second process.
|
||||
const portCheck = await execInDevContainer({
|
||||
projectId: opts.projectId,
|
||||
command:
|
||||
`ss -tlnpH "sport = :${opts.port}" 2>/dev/null | head -1; ` +
|
||||
// also include any process listening (without name resolution) as a fallback
|
||||
`lsof -iTCP:${opts.port} -sTCP:LISTEN -n -P 2>/dev/null | tail -n +2 | head -1 || true`,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const listenerLine = portCheck.stdout.trim();
|
||||
if (listenerLine) {
|
||||
// Try to extract pid from "users:((\"node\",pid=156,fd=...))" or lsof "node 156 vibn ..."
|
||||
const pidMatch = listenerLine.match(/pid=(\d+)/) || listenerLine.match(/^\S+\s+(\d+)/);
|
||||
const listenerPid = pidMatch ? parseInt(pidMatch[1], 10) : null;
|
||||
|
||||
const tracked = await queryOne<DevServerRow>(
|
||||
`SELECT * FROM fs_dev_servers
|
||||
WHERE project_id = $1 AND port = $2 AND state IN ('starting','running')
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[opts.projectId, opts.port],
|
||||
);
|
||||
if (tracked && tracked.pid && listenerPid && tracked.pid === listenerPid) {
|
||||
// Same project owns the port via a tracked row. Reap it cleanly
|
||||
// so the new start has a clean slot. AI's expected behaviour is
|
||||
// "I want THIS command on THIS port" — so we honour the
|
||||
// most-recent-write-wins intent rather than throwing.
|
||||
await execInDevContainer({
|
||||
projectId: opts.projectId,
|
||||
command: `kill ${tracked.pid} 2>/dev/null || true; sleep 0.3`,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await query(
|
||||
`UPDATE fs_dev_servers SET state='stopped', stopped_at=now() WHERE id = $1`,
|
||||
[tracked.id],
|
||||
);
|
||||
} else {
|
||||
throw new PortBusyError(opts.port, listenerPid, listenerLine.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Launch.
|
||||
const id = `ds_${randomToken(6)}`;
|
||||
const name = opts.name ?? `port-${opts.port}`;
|
||||
const previewUrl = buildPreviewUrl(opts.projectSlug, name);
|
||||
const previewUrl =
|
||||
buildPreviewUrl(opts.projectId, opts.projectSlug, opts.port) ??
|
||||
`https://localhost-only:${opts.port}`;
|
||||
const logFile = `/var/log/vibn-dev/${id}.log`;
|
||||
|
||||
// nohup the command, capture PID. We pin the listening interface to
|
||||
// 0.0.0.0 by injecting HOST=0.0.0.0 (handles Vite/Next/Express); we
|
||||
// also export PORT so frameworks that read it pick it up.
|
||||
const launch =
|
||||
`mkdir -p /var/log/vibn-dev && ` +
|
||||
`cd /workspace && ` +
|
||||
|
||||
Reference in New Issue
Block a user