From a11e8f6e79e077b5659d7e6c2e5cbf1c93955808 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Tue, 13 Oct 2026 10:21:07 -0400 Subject: [PATCH 1/7] FLAB: Add session lifecycle controls and parallel React warm-up Fix owned process-tree cleanup and make dashboard browser opening opt-in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa54f8e0-47eb-4686-99dd-fc2e63d0239c --- .github/agents/fixlab-autofix.agent.md | 5 + .github/agents/fixlab.agent.md | 3 + agents/fixlab.md | 5 + bin/fixlab.js | 10 +- com.github.copilot/agents/fixlab.agent.md | 5 + dashboard/public/app.js | 88 ++++- dashboard/public/index.html | 14 + dashboard/server.js | 375 ++++++++++++++++++---- dashboard/sessions.js | 114 +++++++ docs/architecture.md | 8 + docs/getting-started.md | 45 ++- docs/installation.md | 6 +- e2e/dashboard-readiness.spec.js | 93 +++++- templates/fixlab-autofix.agent.md | 5 + test/agent-rules.test.js | 2 + test/dashboard-server.test.js | 228 ++++++++++++- test/dashboard-sessions.test.js | 43 +++ test/fixlab-cli.test.js | 18 ++ 18 files changed, 987 insertions(+), 80 deletions(-) create mode 100644 dashboard/sessions.js create mode 100644 test/dashboard-sessions.test.js diff --git a/.github/agents/fixlab-autofix.agent.md b/.github/agents/fixlab-autofix.agent.md index 204ee66..7025732 100644 --- a/.github/agents/fixlab-autofix.agent.md +++ b/.github/agents/fixlab-autofix.agent.md @@ -95,6 +95,11 @@ push later reviewed checkpoints to the same branch, and never mark the PR ready until every required gate passes or the developer explicitly accepts the recorded outcome. +Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise +description of the coding change. Use the repository's PR template and clear +Markdown sections for the problem, changes, validation and risks. Keep +validation checkboxes accurate; do not describe a pending gate as passed. + ## Guardrails - Handle one bug at a time; do not bundle unrelated fixes or refactors. diff --git a/.github/agents/fixlab.agent.md b/.github/agents/fixlab.agent.md index 9471f57..8731b1f 100644 --- a/.github/agents/fixlab.agent.md +++ b/.github/agents/fixlab.agent.md @@ -13,5 +13,8 @@ review, local validation, live validation when configured, evidence, and pull request outcome. Never report a skipped or timed-out gate as passed. Never stop unrelated processes or expose credentials and private test data. +Prefix FixLab-created PR titles with `FLAB:` and format their descriptions with +the repository's PR template, clear validation evidence and remaining risks. + Run `npm run validate` for changes to FixLab itself and summarize exact evidence and remaining risks. diff --git a/agents/fixlab.md b/agents/fixlab.md index d26a326..c75afc3 100644 --- a/agents/fixlab.md +++ b/agents/fixlab.md @@ -73,6 +73,11 @@ keep validation status and risks current in the PR, and never mark it ready for review until every required gate passes or repository policy records an explicitly accepted outcome. +Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise +description of the coding change. Use the repository's PR template and clear +Markdown sections for the problem, changes, validation and risks. Keep +validation checkboxes accurate; do not describe a pending gate as passed. + Runtime-synthesized Playwright scenarios are transient validation artifacts by default. Remove their source files and validation-only configuration edits before review, commit, push, or pull-request creation. Do not add newly diff --git a/bin/fixlab.js b/bin/fixlab.js index 9ea6402..107ac11 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -87,7 +87,7 @@ Usage: fixlab exec [repository] [--cwd ] [--stage ] [--reuse] [--timeout-seconds ] -- [args...] fixlab run [repository] [--runtime ] [--environment ] [--] [request...] fixlab validate [repository] --pr [--runtime ] - fixlab dashboard [repository] [--port ] [--no-open] [--keep-awake-minutes ] [--runtime ] + fixlab dashboard [repository] [--port ] [--open|--no-open] [--keep-awake-minutes ] [--runtime ] fixlab chat [repository] [--port ] [--runtime ] fixlab keep-awake --minutes fixlab --help @@ -1037,10 +1037,14 @@ function parseValidateArguments(args) { function parseDashboardArguments(args) { let repositoryArgument; let port = DEFAULT_DASHBOARD_PORT; - let open = true; + let open = false; let keepAwakeMinutes = 0; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; + if (argument === "--open") { + open = true; + continue; + } if (argument === "--no-open") { open = false; continue; @@ -1234,7 +1238,7 @@ async function onboard({ return doctorResult; } if (startDashboard) { - return dashboard(repository, DEFAULT_DASHBOARD_PORT, true, runtime); + return dashboard(repository, DEFAULT_DASHBOARD_PORT, false, runtime); } console.log(""); diff --git a/com.github.copilot/agents/fixlab.agent.md b/com.github.copilot/agents/fixlab.agent.md index d26a326..c75afc3 100644 --- a/com.github.copilot/agents/fixlab.agent.md +++ b/com.github.copilot/agents/fixlab.agent.md @@ -73,6 +73,11 @@ keep validation status and risks current in the PR, and never mark it ready for review until every required gate passes or repository policy records an explicitly accepted outcome. +Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise +description of the coding change. Use the repository's PR template and clear +Markdown sections for the problem, changes, validation and risks. Keep +validation checkboxes accurate; do not describe a pending gate as passed. + Runtime-synthesized Playwright scenarios are transient validation artifacts by default. Remove their source files and validation-only configuration edits before review, commit, push, or pull-request creation. Do not add newly diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 1f4675e..3c47fd4 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -125,6 +125,9 @@ const frontendStatusBadge = document.querySelector("#frontend-status"); const frontendGuidance = document.querySelector("#frontend-guidance"); const frontendStart = document.querySelector("#frontend-start"); const frontendStop = document.querySelector("#frontend-stop"); +const sessionsList = document.querySelector("#sessions-list"); +const sessionsMessage = document.querySelector("#sessions-message"); +const sessionsRefresh = document.querySelector("#sessions-refresh"); const onboardingStatus = document.querySelector("#onboarding-status"); const onboardingOrganization = document.querySelector( "#onboarding-organization" @@ -611,7 +614,8 @@ async function startFrontend() { renderFrontendStatus( await fetchJson("/api/frontend/start", { method: "POST" }) ); - } catch { + } catch (error) { + formError.textContent = error.message; await refreshFrontendStatus(); } } @@ -622,9 +626,88 @@ frontendStart.addEventListener("click", () => { void startFrontend(); }); +async function refreshSessions() { + try { + const body = await fetchJson("/api/sessions"); + sessionsList.replaceChildren(); + for (const session of body.sessions) { + const row = document.createElement("article"); + const description = document.createElement("p"); + description.className = "guidance"; + description.textContent = session.available + ? `${session.id === body.ownerId ? "This dashboard" : "Other FixLab dashboard"} · ${session.id} · Job ${session.job?.id ?? "none"} (${session.job?.status ?? "not started"}) · React ${session.frontend.running ? `running (PID ${session.frontend.pid})` : "stopped"}` + : `Owner ${session.id} is unavailable: ${session.error}. No process will be stopped.`; + row.append(description); + if (session.available) { + const controls = document.createElement("div"); + controls.className = "button-row"; + const reconnect = document.createElement("a"); + reconnect.href = session.url; + reconnect.textContent = "Reconnect to live dashboard"; + controls.append(reconnect); + const actions = [ + { label: "Resume job", action: "resume", resource: "job", enabled: session.job?.canResume || session.job?.running }, + { label: "Stop agent", action: "stop", resource: "job", enabled: session.job?.running }, + { label: "Start / resume React", action: "resume", resource: "frontend", enabled: !session.frontend.running }, + { label: "Stop React", action: "stop", resource: "frontend", enabled: session.frontend.running }, + { label: "Shutdown sessions", action: "shutdown", resource: "all", enabled: session.job?.running || session.frontend.running || session.authentication.running } + ]; + for (const control of actions) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "secondary"; + button.textContent = control.label; + button.disabled = !control.enabled; + button.addEventListener("click", async () => { + const destructive = control.action !== "resume"; + if (destructive && !window.confirm( + `${control.label} for FixLab owner ${session.id} at ${session.url}, job ${session.job?.id ?? "none"}? Only this owner's processes will be stopped; saved job context remains resumable.` + )) { + return; + } + button.disabled = true; + try { + await fetchJson("/api/sessions/control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ownerId: session.id, + jobId: session.job?.id ?? null, + action: control.action, + resource: control.resource, + confirmed: destructive + }) + }); + sessionsMessage.textContent = `${control.label} accepted for owner ${session.id}.`; + await refreshSessions(); + await refresh(); + await refreshFrontendStatus(); + } catch (error) { + sessionsMessage.textContent = error.message; + button.disabled = false; + } + }); + controls.append(button); + } + row.append(controls); + } + sessionsList.append(row); + } + } catch (error) { + sessionsMessage.textContent = error.message; + } +} + +sessionsRefresh.addEventListener("click", refreshSessions); + frontendStop.addEventListener("click", async () => { frontendStop.disabled = true; try { + const owner = await fetchJson("/api/session"); + if (!window.confirm(`Stop React owned by FixLab dashboard ${owner.id}? Only its frontend process tree will be stopped.`)) { + await refreshFrontendStatus(); + return; + } renderFrontendStatus( await fetchJson("/api/frontend/stop", { method: "POST" }) ); @@ -1260,6 +1343,7 @@ form.addEventListener("submit", async (event) => { : "common", runAllUiScenarios: data.get("runAllUiScenarios") === "on", targetEnvironment: data.get("targetEnvironment"), + prestartFrontend: data.get("prestartFrontend") === "on", recordPlaywrightVideo: data.get("recordPlaywrightVideo") === "on", manualLocalhostTest: @@ -1557,4 +1641,6 @@ setInterval(refresh, 1000); setInterval(refreshMetrics, 5000); setInterval(refreshPlaywrightStatus, 5000); setInterval(refreshFrontendStatus, 5000); +void refreshSessions(); +setInterval(refreshSessions, 5000); setInterval(refreshPlaywrightEvidence, 10000); diff --git a/dashboard/public/index.html b/dashboard/public/index.html index a259557..f68b16e 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -41,6 +41,16 @@

React frontend

+
+
+

Session controls

+ +
+

Start a job below or use Start React. Reconnect to a live dashboard, resume saved job context, or stop a confirmed FixLab owner. Shutdown sessions stops that dashboard's agent, React and authentication sessions; the dashboard remains available to resume.

+

+
+
+

Repository onboarding

@@ -65,6 +75,10 @@

Repository onboarding

+
Intake diff --git a/dashboard/server.js b/dashboard/server.js index 06954cb..082d4d8 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -35,6 +35,7 @@ import { storeScreenshots } from "./intake.js"; import { formatPreflightFailure } from "./preflight.js"; +import { createSessionRegistry } from "./sessions.js"; export const DASHBOARD_HOST = "127.0.0.1"; export const DEFAULT_DASHBOARD_PORT = 4317; @@ -364,6 +365,7 @@ function publicQueue(jobs) { pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + prestartFrontend: job.prestartFrontend ?? false, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: job.manualLocalhostUrl, @@ -403,6 +405,7 @@ function dashboardSnapshot(job) { pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + prestartFrontend: job.prestartFrontend ?? false, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: "", @@ -872,6 +875,7 @@ ${testDataGuidance} - Start long-running independent validation commands in parallel when the runtime supports it. While those commands run, prepare or refresh the authorized draft PR description with the confirmed symptom, scoped change, checks already passed, checks still running, and known risks. - Push later reviewed checkpoints to the same branch only after their affected focused checks pass. Reuse the same draft PR and update its evidence instead of waiting to publish one large final update or creating duplicates. - Keep the pull request draft and clearly marked validation-in-progress while any required gate is pending, running, blocked, skipped with unaccepted risk, or failed. Never mark it ready for review or report the pr stage passed until all required gates pass or repository policy records an explicitly accepted outcome. +- Prefix FixLab-created pull-request titles with FLAB: followed by a concise description of the coding change. Format the body with the repository's PR template, clear Markdown sections for the problem, changes, validation and risks, and accurate validation checkboxes. - Emit one browser evidence line after the scenario is selected and again if the actual source or mutation behavior changes: FIXLAB_TEST|source|mutation-mode|scenario - source must be one of: ${[...testDataSources].join(", ")}. @@ -1526,6 +1530,7 @@ function createExecutor({ command, packageRoot, buildInvocation }) { const child = spawn(executable, invocation.args, { cwd: repository, shell: false, + detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] }); @@ -1542,7 +1547,7 @@ function createExecutor({ command, packageRoot, buildInvocation }) { completion, terminate() { if (child.exitCode === null && child.signalCode === null) { - child.kill(); + stopProcessTree(child); } } }; @@ -2223,13 +2228,22 @@ function stopProcessTree(child) { return; } if (process.platform === "win32" && child.pid) { - spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + const result = spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); + if (result.error || result.status !== 0) { + throw result.error ?? new Error(`could not stop the owned process tree (exit ${result.status})`); + } return; } - child.kill(); + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } + } } async function frontendStatus(repository, connection) { @@ -2239,18 +2253,18 @@ async function frontendStatus(repository, connection) { } catch (error) { return { configured: false, - running: Boolean(connection.handle), + running: Boolean(connection.handle) || connection.starting === true, healthy: false, - owned: Boolean(connection.handle), + owned: Boolean(connection.handle) || connection.starting === true, error: error.message, lastResult: connection.lastResult }; } return { configured: true, - running: Boolean(connection.handle), + running: Boolean(connection.handle) || connection.starting === true, healthy: await frontendIsHealthy(healthUrl), - owned: Boolean(connection.handle), + owned: Boolean(connection.handle) || connection.starting === true, healthUrl, error: "", lastResult: connection.lastResult @@ -2434,9 +2448,97 @@ export function createDashboardServer({ const completedJobs = []; const artifactDirectories = new Set(); const playwrightConnection = { handle: null, lastResult: null }; - const frontendConnection = { handle: null, lastResult: null }; + const frontendConnection = { handle: null, lastResult: null, starting: false }; + let frontendStartGeneration = 0; const playwrightArtifacts = new Map(); const cacheContext = createCacheContext(resolvedRepository); + const sessions = createSessionRegistry( + cacheContext?.repositoryIdentityHash ?? resolvedRepository + ); + let dashboardUrl = ""; + let closing = false; + + async function launchFrontend() { + const generation = frontendStartGeneration; + const configuration = frontendStartConfig(resolvedRepository); + if (frontendConnection.handle || frontendConnection.starting) { + throw new Error("FixLab already started the frontend"); + } + frontendConnection.starting = true; + try { + const owners = await sessions.list(); + const conflict = owners.find((owner) => + owner.id !== sessions.id && owner.available && owner.frontend?.running + ); + if (conflict) { + throw new Error(`Frontend is owned by FixLab dashboard ${conflict.id}; reconnect or confirm stopping that owner.`); + } + if (closing || generation !== frontendStartGeneration) { + throw new Error("frontend startup was cancelled"); + } + if (await frontendIsHealthy(configuration.healthUrl)) { + return { started: false }; + } + if (closing || generation !== frontendStartGeneration) { + throw new Error("frontend startup was cancelled"); + } + frontendConnection.lastResult = null; + const child = spawn(configuration.command, { + cwd: configuration.workingDirectory, + shell: true, + detached: process.platform !== "win32", + stdio: "ignore", + windowsHide: true + }); + frontendConnection.handle = child; + child.once("error", (error) => { + frontendConnection.lastResult = { + ok: false, + message: error.message, + finishedAt: new Date().toISOString() + }; + if (frontendConnection.handle === child) { + frontendConnection.handle = null; + } + }); + child.once("close", (code) => { + if (frontendConnection.handle === child) { + frontendConnection.handle = null; + frontendConnection.lastResult = { + ok: code === 0, + message: `Frontend process exited with code ${code ?? "unknown"}.`, + finishedAt: new Date().toISOString() + }; + } + }); + return { started: true }; + } finally { + frontendConnection.starting = false; + } + } + + async function prewarmFrontend(job) { + try { + const result = frontendConnection.handle || frontendConnection.starting + ? { started: false } + : await launchFrontend(); + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: result.started + ? "React warm-up started in parallel with the agent. Live testing must wait for health and verify the server serves the validated checkout." + : "React warm-up reuses the existing frontend; live testing must verify its health and checkout." + }); + } catch (error) { + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `React warm-up did not start: ${error.message}. The agent must report any live-test prerequisite that remains blocked.` + }); + } + } const metricsPath = cacheContext ? join(dirname(cacheContext.cachePath), "dashboard-metrics.json") : null; @@ -2523,6 +2625,9 @@ export function createDashboardServer({ } function startJob(job, prompt, resume = false) { + if (closing) { + throw new Error("the dashboard is shutting down"); + } const profileIdleTimeoutMinutes = Number( loadRepositoryProfile(resolvedRepository).validation ?.agentIdleTimeoutMinutes @@ -2597,7 +2702,9 @@ export function createDashboardServer({ handle = executor({ repository: resolvedRepository, artifactDirectories: job.artifactDirectory ? [job.artifactDirectory] : [], - prompt, + prompt: job.prestartFrontend && job.targetEnvironment === "local" + ? `${prompt}\nReact warm-up is requested in parallel in the dashboard repository. Before live testing, reuse that server only after its profile health check passes and you verify it serves the checkout containing the changes being validated. If using another isolated worktree, start the correct checkout instead; a healthy baseline server does not prove the fix. Never mark local-stack passed merely because warm-up was requested.` + : prompt, sessionId: job.id, resume, onOutput(stream, text) { @@ -2640,16 +2747,29 @@ export function createDashboardServer({ } }); activeHandle = handle; + if (job.prestartFrontend && job.targetEnvironment === "local") { + void prewarmFrontend(job); + } armIdleWatchdog(); heartbeatTimer = setInterval(emitHeartbeat, executionHeartbeatMs); heartbeatTimer.unref?.(); Promise.race([Promise.resolve(handle.completion), idleCompletion]) .then((result) => { finishJob(job, result); + if (job.stopRequested) { + job.status = "blocked"; + job.error = "Session stopped by the user; resume when ready."; + delete job.stopRequested; + } recordJobMetric(job); }) .catch((error) => { finishJob(job, { code: null, error }); + if (job.stopRequested) { + job.status = "blocked"; + job.error = "Session stopped by the user; resume when ready."; + delete job.stopRequested; + } recordJobMetric(job); }) .finally(() => { @@ -2658,6 +2778,9 @@ export function createDashboardServer({ if (activeHandle === handle) { activeHandle = null; } + if (closing) { + return; + } if (job.pendingInputs.length > 0) { const pendingInputs = job.pendingInputs.splice(0); const details = pendingInputs @@ -2681,11 +2804,11 @@ export function createDashboardServer({ } return; } - startNextJob(); + return startNextJob(); }); } - function startNextJob() { + async function startNextJob() { if ( activeHandle || queuedJobs.length === 0 || @@ -2704,6 +2827,13 @@ export function createDashboardServer({ currentJob.lastActivityAt = currentJob.startedAt; recordJobMetric(currentJob); try { + const owners = await sessions.list(); + const conflict = owners.find((owner) => + owner.id !== sessions.id && owner.available && owner.job?.running + ); + if (conflict) { + throw new Error(`Queued job cannot start while FixLab dashboard ${conflict.id} owns the repository's agent session. Reconnect or confirm stopping that owner.`); + } startJob(currentJob, currentJob.pendingPrompt); delete currentJob.pendingPrompt; } catch (error) { @@ -2719,6 +2849,143 @@ export function createDashboardServer({ `http://${request.headers.host ?? DASHBOARD_HOST}` ); + if (closing && request.method === "POST") { + sendJson(response, 503, { error: "the dashboard is shutting down" }); + return; + } + + if (request.method === "GET" && requestUrl.pathname === "/api/session") { + sendJson(response, 200, { + id: sessions.id, + scopeId: sessions.scopeId, + repository: resolvedRepository, + job: currentJob ? { + id: currentJob.id, + status: currentJob.status, + running: Boolean(activeHandle), + canResume: publicJob(currentJob).canResume + } : null, + frontend: { + running: Boolean(frontendConnection.handle) || frontendConnection.starting, + pid: frontendConnection.handle?.pid ?? null + }, + authentication: { running: Boolean(playwrightConnection.handle) } + }); + return; + } + + if (request.method === "GET" && requestUrl.pathname === "/api/sessions") { + try { + sendJson(response, 200, { + ownerId: sessions.id, + sessions: await sessions.list() + }); + } catch (error) { + sendJson(response, 500, { error: error.message }); + } + return; + } + + if ( + request.method === "POST" && + ["/api/sessions/control", "/api/session/control"].includes(requestUrl.pathname) + ) { + try { + if (!request.headers["content-type"]?.startsWith("application/json")) { + throw new Error("Content-Type must be application/json"); + } + const body = await readJsonBody(request); + if ( + !["stop", "resume", "shutdown"].includes(body.action) || + !["job", "frontend", "all"].includes(body.resource) || + typeof body.ownerId !== "string" + ) { + throw new Error("a session owner, action and resource are required"); + } + if (body.action !== "resume" && body.confirmed !== true) { + sendJson(response, 409, { error: "explicit confirmation is required" }); + return; + } + if (requestUrl.pathname === "/api/sessions/control") { + const result = await sessions.control(body.ownerId, body); + sendJson(response, result.status, result.body); + return; + } + if (body.ownerId !== sessions.id) { + sendJson(response, 409, { error: "the session owner has changed" }); + return; + } + if ( + ["job", "all"].includes(body.resource) && + body.jobId !== (currentJob?.id ?? null) + ) { + sendJson(response, 409, { error: "the owned job has changed; refresh before continuing" }); + return; + } + if (body.action === "resume" && body.resource === "all") { + throw new Error("resume a job or frontend individually"); + } + if (body.action === "shutdown") { + if (body.resource !== "all") { + throw new Error("shutdown requires resource all"); + } + stopProcessTree(playwrightConnection.handle); + playwrightConnection.handle = null; + } + const paths = body.resource === "all" + ? ["/api/frontend/stop", ...(activeHandle ? ["/api/job/stop"] : [])] + : body.resource === "frontend" + ? [body.action === "resume" ? "/api/frontend/start" : "/api/frontend/stop"] + : body.action === "resume" && activeHandle + ? [] + : [body.action === "resume" ? "/api/job/input" : "/api/job/stop"]; + for (const path of paths) { + const result = await fetch(`${dashboardUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "continue", + details: "Resume the stopped FixLab session using its saved context and existing evidence." + }), + signal: AbortSignal.timeout(10000), + redirect: "error" + }); + const resultBody = await result.json(); + if (!result.ok) { + sendJson(response, result.status, resultBody); + return; + } + } + sendJson(response, 202, { accepted: true, ownerId: sessions.id }); + } catch (error) { + sendJson(response, 400, { error: error.message }); + } + return; + } + + if ( + request.method === "POST" && + ["/api/jobs", "/api/job/input", "/api/frontend/start"].includes(requestUrl.pathname) + ) { + try { + const resource = requestUrl.pathname === "/api/frontend/start" ? "frontend" : "job"; + const owners = await sessions.list(); + const conflict = owners.find((owner) => + owner.id !== sessions.id && owner.available && owner[resource]?.running + ); + if (conflict) { + sendJson(response, 409, { + error: `Another FixLab dashboard owns the ${resource} session. Reconnect to its dashboard or confirm stopping it in Session controls.`, + owner: conflict + }); + return; + } + } catch (error) { + sendJson(response, 503, { error: `Could not verify session ownership: ${error.message}` }); + return; + } + } + if (request.method === "GET" && requestUrl.pathname === "/api/status") { persistDashboardState(); sendJson(response, 200, { @@ -2936,7 +3203,7 @@ export function createDashboardServer({ } archiveJob(dismissedJob); currentJob = null; - startNextJob(); + await startNextJob(); persistDashboardState(); sendJson(response, 202, { job: publicJob(currentJob), @@ -3127,10 +3394,12 @@ export function createDashboardServer({ timestamp: new Date().toISOString(), stream: "dashboard", message: - "Stop requested from FixLab chat; stopping only the owned executor." + "Stop requested by the user; stopping only the owned executor process tree." }); try { activeHandle.terminate(); + currentJob.pendingInputs = []; + currentJob.stopRequested = true; } catch (error) { sendJson(response, 500, { error: `could not stop the FixLab-owned executor: ${error.message}` @@ -3284,6 +3553,11 @@ export function createDashboardServer({ }); return; } + const prestartFrontend = body.prestartFrontend ?? false; + if (typeof prestartFrontend !== "boolean") { + sendJson(response, 400, { error: "prestartFrontend must be a boolean" }); + return; + } const repositoryProfile = loadRepositoryProfile(resolvedRepository); const preflightIssues = preflight ? preflight({ repository: resolvedRepository, profile: repositoryProfile }) @@ -3442,6 +3716,7 @@ export function createDashboardServer({ pullRequestStrategy, runAllUiScenarios, targetEnvironment, + prestartFrontend, recordPlaywrightVideo, manualLocalhostTest, manualLocalhostUrl, @@ -3679,6 +3954,7 @@ export function createDashboardServer({ cwd: configuration.workingDirectory, env: { ...process.env, ...configuration.environment }, shell: true, + detached: process.platform !== "win32", stdio: "ignore", windowsHide: false }); @@ -3730,54 +4006,19 @@ export function createDashboardServer({ request.method === "POST" && requestUrl.pathname === "/api/frontend/start" ) { - let configuration; - try { - configuration = frontendStartConfig(resolvedRepository); - } catch (error) { - sendJson(response, 400, { error: error.message }); - return; - } - if (frontendConnection.handle) { + if (frontendConnection.handle || frontendConnection.starting) { sendJson(response, 409, { error: "FixLab already started the frontend" }); return; } - if (await frontendIsHealthy(configuration.healthUrl)) { - sendJson(response, 200, { + try { + const result = await launchFrontend(); + sendJson(response, result.started ? 202 : 200, { ...(await frontendStatus(resolvedRepository, frontendConnection)), - started: false + ...result }); - return; + } catch (error) { + sendJson(response, 400, { error: error.message }); } - frontendConnection.lastResult = null; - const child = spawn(configuration.command, { - cwd: configuration.workingDirectory, - shell: true, - stdio: "ignore", - windowsHide: true - }); - frontendConnection.handle = child; - child.once("error", (error) => { - frontendConnection.lastResult = { - ok: false, - message: error.message, - finishedAt: new Date().toISOString() - }; - frontendConnection.handle = null; - }); - child.once("close", (code) => { - if (frontendConnection.handle === child) { - frontendConnection.handle = null; - } - frontendConnection.lastResult = { - ok: code === 0, - message: `Frontend process exited with code ${code ?? "unknown"}.`, - finishedAt: new Date().toISOString() - }; - }); - sendJson(response, 202, { - ...(await frontendStatus(resolvedRepository, frontendConnection)), - started: true - }); return; } @@ -3786,11 +4027,18 @@ export function createDashboardServer({ requestUrl.pathname === "/api/frontend/stop" ) { const child = frontendConnection.handle; + const wasStarting = frontendConnection.starting; + frontendStartGeneration += 1; + try { + stopProcessTree(child); + } catch (error) { + sendJson(response, 500, { error: error.message }); + return; + } frontendConnection.handle = null; - stopProcessTree(child); sendJson(response, 200, { ...(await frontendStatus(resolvedRepository, frontendConnection)), - stopped: Boolean(child) + stopped: Boolean(child) || wasStarting }); return; } @@ -3828,6 +4076,13 @@ export function createDashboardServer({ server.listen(validatedPort, DASHBOARD_HOST, resolveListen); }); const address = server.address(); + dashboardUrl = `http://${DASHBOARD_HOST}:${address.port}`; + try { + sessions.register(dashboardUrl); + } catch (error) { + await new Promise((resolveClose) => server.close(resolveClose)); + throw error; + } return { host: DASHBOARD_HOST, port: address.port, @@ -3835,6 +4090,8 @@ export function createDashboardServer({ }; }, async close() { + closing = true; + frontendStartGeneration += 1; persistDashboardState(); if (activeHandle?.terminate) { activeHandle.terminate(); @@ -3844,8 +4101,9 @@ export function createDashboardServer({ playwrightConnection.handle.exitCode === null && playwrightConnection.handle.signalCode === null ) { - playwrightConnection.handle.kill(); + stopProcessTree(playwrightConnection.handle); } + playwrightConnection.handle = null; stopProcessTree(frontendConnection.handle); frontendConnection.handle = null; for (const directory of artifactDirectories) { @@ -3860,6 +4118,7 @@ export function createDashboardServer({ } server.close((error) => (error ? reject(error) : resolveClose())); }); + sessions.unregister(); } }; } diff --git a/dashboard/sessions.js b/dashboard/sessions.js new file mode 100644 index 0000000..fa79a42 --- /dev/null +++ b/dashboard/sessions.js @@ -0,0 +1,114 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ownerPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u; + +function validateOwner(record, id) { + const url = new URL(record.url); + if ( + record.id !== id || + !ownerPattern.test(id) || + url.protocol !== "http:" || + url.hostname !== "127.0.0.1" || + !url.port || + url.pathname !== "/" || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error("invalid FixLab session owner"); + } + return { id, url: url.origin }; +} + +export function createSessionRegistry(repositoryIdentity) { + const key = createHash("sha256").update(repositoryIdentity).digest("hex"); + const directory = join(tmpdir(), "fixlab-dashboard-sessions", key); + const id = randomUUID(); + const path = join(directory, `${id}.json`); + let owner = null; + + function readOwner(ownerId) { + if (!ownerPattern.test(ownerId)) { + throw new Error("invalid FixLab session owner ID"); + } + const ownerPath = join(directory, `${ownerId}.json`); + const info = lstatSync(ownerPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > 2048) { + throw new Error("invalid FixLab session ownership record"); + } + return validateOwner(JSON.parse(readFileSync(ownerPath, "utf8")), ownerId); + } + + async function inspectOwner(record) { + const response = await fetch(`${record.url}/api/session`, { + signal: AbortSignal.timeout(1500), + redirect: "error" + }); + if (!response.ok) { + throw new Error(`owner returned HTTP ${response.status}`); + } + const session = await response.json(); + if (session.id !== record.id || session.scopeId !== key) { + throw new Error("the port no longer belongs to this repository's FixLab session"); + } + return { ...session, url: record.url }; + } + + return { + id, + scopeId: key, + register(url) { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + owner = validateOwner({ id, url }, id); + writeFileSync(path, JSON.stringify(owner), { flag: "wx", mode: 0o600 }); + }, + unregister() { + if (owner) { + unlinkSync(path); + owner = null; + } + }, + async list() { + if (!owner) { + throw new Error("FixLab session ownership is not registered"); + } + const entries = readdirSync(directory).filter((name) => + /^[0-9a-f-]+\.json$/u.test(name) + ); + if (entries.length > 100) { + throw new Error("too many retained FixLab session ownership records"); + } + return Promise.all(entries.map(async (name) => { + const ownerId = name.slice(0, -5); + try { + return { ...(await inspectOwner(readOwner(ownerId))), available: true }; + } catch (error) { + return { id: ownerId, available: false, error: error.message }; + } + })); + }, + async control(ownerId, control) { + const record = readOwner(ownerId); + await inspectOwner(record); + const response = await fetch(`${record.url}/api/session/control`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...control, ownerId }), + signal: AbortSignal.timeout(10000), + redirect: "error" + }); + return { status: response.status, body: await response.json() }; + } + }; +} diff --git a/docs/architecture.md b/docs/architecture.md index a66b0d3..a216fb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,14 @@ Git directory so bug outcomes and pull-request readiness remain visible after a dashboard update or restart. It is not the durable, authenticated team broker described below. +Same-repository dashboard instances publish private, temporary loopback +ownership records and verify the random instance ID at the live endpoint +before forwarding a session-control request. The session panel reconnects to +live owners or resumes their saved job context; stop and shutdown require +explicit owner confirmation. Shutdown leaves the HTTP dashboard available +and stops only its tracked agent, frontend and authentication processes. +Conflicting reachable owners are reported rather than automatically killed. + Dashboard requests are typed as `bug-fix` or `small-enhancement`. Bug fixes require evidence-backed diagnosis and reproduction. Small enhancements use a risk-scaled fast path: a concise acceptance contract, affected-surface and diff --git a/docs/getting-started.md b/docs/getting-started.md index 93e0f9b..9dcbb7f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -230,10 +230,51 @@ The **React frontend** panel starts the profile's `applications.frontend.command in `applications.frontend.workingDirectory` ahead of time and watches `healthUrl`. Connect Playwright starts it automatically, so authentication and live tests reuse a warm server instead of waiting for a cold compile. FixLab -stops only the process it started, when you click **Stop React** or the -dashboard exits, and never touches a server that was already running. A +stops the process tree it started, when you click **Stop React** or the +dashboard exits, including frontend children launched through a shell or +package manager. On Linux and macOS, that tree runs in a dedicated process +group. FixLab never touches a server that was already running. A Playwright config that sets `reuseExistingServer` picks up the running server. +Dashboard startup, onboarding startup and React warm-up do not open browser +tabs automatically. Open the printed loopback URL yourself, use the explicit +`fixlab dashboard --open` option, or request **open dashboard** in terminal +chat. `--no-open` remains supported. Loopback binding stays at `127.0.0.1`; +changing browser-opening behavior does not expose the service to the network. + +**Warm React in parallel when a local job starts** is enabled by default in the +job form. Select the **local** validation environment to start the profile +command alongside diagnosis, including when a queued job becomes active. +Disable the checkbox to keep startup manual. API callers opt in with +`prestartFrontend: true`; DEV, SIT and profile-defined environment selections +are not automatically warmed. Warm-up does not delay the agent, count as a +passed health check, or replace the live-test gate. A server can be reused only +when it is healthy and serves the checkout containing the change under test; +an isolated worktree must not validate against a warmed baseline checkout. +Warm-up errors are reported in job activity/logs and the React panel. + +The **Session controls** panel discovers FixLab dashboards for the same +repository (including linked worktrees), shows their owner and job IDs, and +links to the live dashboard without restarting its agent. **Resume job** uses +the same session ID and saved context after a stop or dashboard restart. +**Start / resume React** starts the profile command again; it does not restore +application memory. Starting a job or frontend is blocked when another +reachable FixLab dashboard owns that resource. Reconnect to that owner or +explicitly confirm stopping its session before retrying. + +**Stop agent**, **Stop React**, and **Shutdown sessions** identify the owner +and require confirmation. Shutdown stops that owner's agent, frontend and +Playwright authentication processes, while leaving its dashboard available to +resume. It does not stop unrelated processes or unverified/unreachable owners. +Ownership records contain only loopback addresses and random owner IDs in a +repository-keyed OS temporary directory; clean dashboard shutdown removes its +record. An unavailable owner is shown explicitly and cannot be controlled. + +FixLab-created pull-request titles use **FLAB:** followed by the coding change. +Descriptions follow the repository's PR template with Markdown sections for +the problem, changes, validation and risks, including explicit pending or +blocked gates rather than success-shaped checkboxes. + The dashboard remains **Not ready** until the profile also includes a frontend The dashboard remains **Not ready** until the profile also includes a frontend **Not ready** until the profile also includes a frontendstartup command, loopback health URL, Playwright test command, explicit diff --git a/docs/installation.md b/docs/installation.md index cc84b0b..879c669 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -492,13 +492,17 @@ repository path. It can also be supplied directly: fixlab chat C:\source\application ``` -Use `fixlab dashboard` when the browser UI should be started explicitly: +Use `fixlab dashboard` to start the local UI server without opening a browser: ```powershell fixlab dashboard fixlab dashboard --runtime copilot ``` +Open the printed URL manually, request `open dashboard` in terminal chat, or +opt in explicitly with `fixlab dashboard --open`. Onboarding and dashboard +reconnections do not automatically open another tab. + It binds only to `127.0.0.1`, uses port `4317` by default, and opens the system browser. Select another repository or port, or suppress browser opening: diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js index b8e98fe..564f49c 100644 --- a/e2e/dashboard-readiness.spec.js +++ b/e2e/dashboard-readiness.spec.js @@ -1,9 +1,10 @@ import { expect, test } from "@playwright/test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { mkdtempSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { createServer as createPortProbe } from "node:net"; import { createDashboardServer } from "../dashboard/server.js"; const packageRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -11,6 +12,14 @@ let repository; let dashboard; let baseUrl; +async function availablePort() { + const probe = createPortProbe(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const port = probe.address().port; + await new Promise((resolve) => probe.close(resolve)); + return port; +} + function executor({ onOutput }) { onOutput( "stdout", @@ -116,12 +125,7 @@ test.beforeAll(async () => { executor, workItemLoader }); - await new Promise((resolve, reject) => { - dashboard.server.once("error", reject); - dashboard.server.listen(0, "127.0.0.1", resolve); - }); - const address = dashboard.server.address(); - baseUrl = `http://127.0.0.1:${address.port}`; + baseUrl = (await dashboard.listen({ port: await availablePort() })).url; }); test.afterAll(async () => { @@ -129,6 +133,81 @@ test.afterAll(async () => { rmSync(repository, { recursive: true, force: true }); }); +test("session controls reconnect, confirm another owner and resume its stopped job", async ({ page }) => { + const sessionRepository = mkdtempSync(join(tmpdir(), "fixlab-session-e2e-")); + const directory = join(sessionRepository, ".github", "fixlab"); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, "repository-profile.json"), + readFileSync(join(repository, ".github", "fixlab", "repository-profile.json")) + ); + let finish; + let stops = 0; + const calls = []; + const owner = createDashboardServer({ + repository: sessionRepository, + packageRoot, + executor: (input) => { + calls.push(input); + return { + completion: new Promise((resolve) => { finish = resolve; }), + terminate() { + stops += 1; + finish({ code: null }); + } + }; + } + }); + const observer = createDashboardServer({ + repository: sessionRepository, + packageRoot, + executor: () => { throw new Error("only the owner can start the job"); } + }); + try { + const ownerUrl = (await owner.listen({ port: await availablePort() })).url; + const observerUrl = (await observer.listen({ port: await availablePort() })).url; + const started = await fetch(`${ownerUrl}/api/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Resume this live session.", + requestType: "bug-fix", + mode: "validate-only" + }) + }); + expect(started.status).toBe(202); + const identity = await (await fetch(`${ownerUrl}/api/session`)).json(); + await page.goto(observerUrl); + const row = page.locator("#sessions-list article").filter({ hasText: identity.id }); + await expect(row).toContainText("Other FixLab dashboard"); + await expect(row.getByRole("link", { name: "Reconnect to live dashboard" })) + .toHaveAttribute("href", ownerUrl); + page.once("dialog", (dialog) => dialog.dismiss()); + await row.getByRole("button", { name: "Stop agent", exact: true }).click(); + expect(stops).toBe(0); + page.once("dialog", (dialog) => { + expect(dialog.message()).toContain(identity.id); + return dialog.accept(); + }); + await row.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expect(row).toContainText("(blocked)"); + expect(stops).toBe(1); + await row.getByRole("button", { name: "Resume job", exact: true }).click(); + await expect(row).toContainText("(running)"); + expect(calls).toHaveLength(2); + expect(calls[1].sessionId).toBe(calls[0].sessionId); + expect(calls[1].resume).toBe(true); + page.once("dialog", (dialog) => dialog.accept()); + await row.getByRole("button", { name: "Shutdown sessions", exact: true }).click(); + await expect(row).toContainText("(blocked)"); + expect(stops).toBe(2); + } finally { + await owner.close(); + await observer.close(); + rmSync(sessionRepository, { recursive: true, force: true }); + } +}); + test("dashboard exposes repository readiness through the running server", async ({ page, request diff --git a/templates/fixlab-autofix.agent.md b/templates/fixlab-autofix.agent.md index 204ee66..7025732 100644 --- a/templates/fixlab-autofix.agent.md +++ b/templates/fixlab-autofix.agent.md @@ -95,6 +95,11 @@ push later reviewed checkpoints to the same branch, and never mark the PR ready until every required gate passes or the developer explicitly accepts the recorded outcome. +Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise +description of the coding change. Use the repository's PR template and clear +Markdown sections for the problem, changes, validation and risks. Keep +validation checkboxes accurate; do not describe a pending gate as passed. + ## Guardrails - Handle one bug at a time; do not bundle unrelated fixes or refactors. diff --git a/test/agent-rules.test.js b/test/agent-rules.test.js index 7d578c7..350f850 100644 --- a/test/agent-rules.test.js +++ b/test/agent-rules.test.js @@ -106,6 +106,8 @@ test("cross-runtime validation guidance stays aligned", () => { for (const entryPoint of agentEntryPoints) { const content = readFileSync(join(root, entryPoint), "utf8"); + assert.match(content, /`FLAB:`/, `${entryPoint} must identify FixLab-created PRs`); + assert.match(content, /repository's PR template/, `${entryPoint} must format PR bodies`); assert.match( content, /profile selects `non-production-read-only`[\s\S]{0,160}selects any profile-approved non-production environment[\s\S]{0,220}primary business-data source/, diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 340fd95..f4055b0 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, @@ -1645,7 +1646,7 @@ test("dashboard checks and starts repository-owned Playwright authentication", a } }); -test("dashboard starts, reuses and stops the profile-defined frontend", async () => { +async function checkFrontendCleanup(cleanup) { const repository = createRepository(); const port = await availablePort(); writeFileSync( @@ -1655,7 +1656,7 @@ test("dashboard starts, reuses and stops the profile-defined frontend", async () applications: { frontend: { workingDirectory: ".", - command: "node frontend.js", + command: "node frontend-launcher.js", port, healthUrl: `http://127.0.0.1:${port}` } @@ -1667,10 +1668,18 @@ test("dashboard starts, reuses and stops the profile-defined frontend", async () join(repository, "frontend.js"), `require("node:http").createServer((q, s) => s.end("ok")).listen(${port}, "127.0.0.1");` ); + writeFileSync( + join(repository, "frontend-launcher.js"), + `require("node:child_process").spawn(process.execPath, ["frontend.js"], { stdio: "inherit" });` + ); const { dashboard, url } = await startDashboard(repository, () => ({ completion: new Promise(() => {}), terminate() {} })); + const observer = await startDashboard(repository, () => ({ + completion: new Promise(() => {}), + terminate() {} + })); try { const initial = await jsonRequest(url, "/api/frontend/status"); @@ -1698,23 +1707,58 @@ test("dashboard starts, reuses and stops the profile-defined frontend", async () method: "POST" }); assert.equal(again.response.status, 409); - - const stopped = await jsonRequest(url, "/api/frontend/stop", { + const conflict = await jsonRequest(observer.url, "/api/frontend/start", { method: "POST" }); - assert.equal(stopped.body.stopped, true); + assert.equal(conflict.response.status, 409); + const ownerId = conflict.body.owner.id; + + if (cleanup === "close") { + await dashboard.close(); + } else { + const stopped = await jsonRequest(observer.url, "/api/sessions/control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ownerId, + resource: "frontend", + action: "stop", + confirmed: true + }) + }); + assert.equal(stopped.response.status, 202); + } + let healthy = true; for (let attempt = 0; attempt < 50; attempt += 1) { - status = await jsonRequest(url, "/api/frontend/status"); - if (!status.body.healthy) { + try { + const response = await fetch(`http://127.0.0.1:${port}`, { + signal: AbortSignal.timeout(1000) + }); + await response.body?.cancel(); + } catch (error) { + if (["ECONNRESET", "UND_ERR_SOCKET"].includes(error.cause?.code)) { + continue; + } + assert.equal(error.cause?.code, "ECONNREFUSED"); + healthy = false; break; } await new Promise((resolve) => setTimeout(resolve, 100)); } - assert.equal(status.body.healthy, false); + assert.equal(healthy, false); } finally { await dashboard.close(); + await observer.dashboard.close(); rmSync(repository, { recursive: true, force: true }); } +} + +test("dashboard starts, reuses and stops the profile-defined frontend", async () => { + await checkFrontendCleanup("stop"); +}); + +test("dashboard shutdown stops the frontend process tree", async () => { + await checkFrontendCleanup("close"); }); test("dashboard does not restart a frontend that is already healthy", async () => { @@ -1754,6 +1798,11 @@ test("dashboard does not restart a frontend that is already healthy", async () = assert.equal(result.body.started, false); assert.equal(result.body.healthy, true); assert.equal(result.body.owned, false); + const stopped = await jsonRequest(url, "/api/frontend/stop", { + method: "POST" + }); + assert.equal(stopped.body.stopped, false); + assert.equal(stopped.body.healthy, true); } finally { await dashboard.close(); await new Promise((resolve) => external.close(resolve)); @@ -1761,6 +1810,78 @@ test("dashboard does not restart a frontend that is already healthy", async () = } }); +for (const scenario of [ + { environment: "local", prestartFrontend: true, warm: true }, + { environment: "local", prestartFrontend: false, warm: false }, + { environment: "dev", prestartFrontend: true, warm: false } +]) { + test(`React warm-up is parallel and environment-bound (${scenario.environment}, ${scenario.prestartFrontend})`, async () => { + const repository = createRepository(); + const port = await availablePort(); + writeFileSync( + join(repository, ".github", "fixlab", "repository-profile.json"), + JSON.stringify(validProfile({ + applications: { + frontend: { + command: "node frontend.js", + workingDirectory: ".", + port, + healthUrl: `http://127.0.0.1:${port}` + } + } + })) + ); + writeFileSync( + join(repository, "frontend.js"), + `setTimeout(() => require("node:http").createServer((q, s) => s.end("ok")).listen(${port}, "127.0.0.1"), 500);` + ); + let invocation; + const { dashboard, url } = await startDashboard(repository, (input) => { + invocation = input; + return { completion: new Promise(() => {}), terminate() {} }; + }); + try { + const result = await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Warm the local app while diagnosing.", + requestType: "bug-fix", + mode: "validate-only", + targetEnvironment: scenario.environment, + prestartFrontend: scenario.prestartFrontend + }) + }); + assert.equal(result.response.status, 202); + assert.ok(invocation, "the agent must start before React finishes compilation"); + if (scenario.warm) { + assert.match(invocation.prompt, /healthy baseline server does not prove the fix/); + let healthy = false; + for (let attempt = 0; attempt < 80; attempt += 1) { + const status = await jsonRequest(url, "/api/frontend/status"); + healthy = status.body.healthy; + if (healthy) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.equal(healthy, true); + const job = await jsonRequest(url, "/api/job"); + assert.equal(job.body.job.status, "running"); + assert.equal(job.body.job.stages["local-stack"].status, "pending"); + assert.ok(job.body.job.logs.some((entry) => /warm-up started in parallel/.test(entry.message))); + } else { + const session = await jsonRequest(url, "/api/session"); + assert.equal(session.body.frontend.running, false); + assert.doesNotMatch(invocation.prompt, /React warm-up is requested/); + } + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } + }); +} + test("zero exit with missing terminal markers fails as incomplete", async () => { const repository = createRepository(); const executor = ({ onOutput }) => { @@ -3244,6 +3365,97 @@ test("dashboard shutdown terminates only its active executor handle", async () = } }); +test("same-repository session controls confirm the owner, stop and resume saved job context", async () => { + const repository = createGitRepository(); + const calls = []; + let finish; + let stopped = 0; + const owner = await startDashboard(repository, (input) => { + calls.push(input); + return { + completion: new Promise((resolve) => { finish = resolve; }), + terminate() { + stopped += 1; + finish({ code: null }); + } + }; + }); + const other = await startDashboard(repository, () => { + throw new Error("the other dashboard must not start an agent"); + }); + const post = (url, path, body) => jsonRequest(url, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + try { + const job = await post(owner.url, "/api/jobs", { + request: "Retain this session context.", + requestType: "bug-fix", + mode: "validate-only" + }); + const listed = await jsonRequest(other.url, "/api/sessions"); + assert.equal(listed.body.sessions.length, 2); + const session = listed.body.sessions.find((entry) => entry.job?.running); + assert.equal(session.job.id, job.body.job.id); + assert.equal(session.url, owner.url); + const conflict = await post(other.url, "/api/jobs", { + request: "This must not start while another owner holds the repository.", + requestType: "bug-fix", + mode: "validate-only" + }); + assert.equal(conflict.response.status, 409); + assert.equal(conflict.body.owner.id, session.id); + const control = { + ownerId: session.id, + jobId: session.job.id, + resource: "job", + action: "stop" + }; + const unconfirmed = await post(other.url, "/api/sessions/control", control); + assert.equal(unconfirmed.response.status, 409); + assert.equal(stopped, 0); + const stale = await post(other.url, "/api/sessions/control", { + ...control, confirmed: true, jobId: randomUUID() + }); + assert.equal(stale.response.status, 409); + assert.equal(stopped, 0); + const reconnect = await post(other.url, "/api/sessions/control", { + ...control, action: "resume" + }); + assert.equal(reconnect.response.status, 202); + assert.equal(calls.length, 1); + const confirmed = await post(other.url, "/api/sessions/control", { + ...control, confirmed: true + }); + assert.equal(confirmed.response.status, 202); + assert.equal(stopped, 1); + const blocked = await jsonRequest(owner.url, "/api/job"); + assert.equal(blocked.body.job.status, "blocked"); + assert.equal(blocked.body.job.canResume, true); + const resumed = await post(other.url, "/api/sessions/control", { + ...control, action: "resume" + }); + assert.equal(resumed.response.status, 202); + assert.equal(calls.length, 2); + assert.equal(calls[1].sessionId, calls[0].sessionId); + assert.equal(calls[1].resume, true); + const shutdown = await post(other.url, "/api/sessions/control", { + ...control, action: "shutdown", resource: "all", confirmed: true + }); + assert.equal(shutdown.response.status, 202); + assert.equal(stopped, 2); + assert.equal((await jsonRequest(owner.url, "/api/status")).response.status, 200); + await owner.dashboard.close(); + const remaining = await jsonRequest(other.url, "/api/sessions"); + assert.equal(remaining.body.sessions.length, 1); + } finally { + await owner.dashboard.close(); + await other.dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("dashboard bounds retained raw logs while preserving stage summaries", async () => { const repository = createRepository(); const executor = ({ onOutput }) => { diff --git a/test/dashboard-sessions.test.js b/test/dashboard-sessions.test.js new file mode 100644 index 0000000..53cb0ad --- /dev/null +++ b/test/dashboard-sessions.test.js @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import test from "node:test"; +import { createSessionRegistry } from "../dashboard/sessions.js"; + +test("session ownership rejects external URLs and arbitrary owner identifiers", async () => { + const registry = createSessionRegistry(randomUUID()); + assert.throws(() => registry.register("https://example.com"), /invalid/); + assert.throws(() => registry.register("http://127.0.0.1:4317/private"), /invalid/); + await assert.rejects(registry.control("..\\other", {}), /invalid/); +}); + +test("session ownership refuses a reused port or another repository's live owner", async () => { + const registry = createSessionRegistry(randomUUID()); + let session = { id: randomUUID(), scopeId: registry.scopeId }; + let controls = 0; + const server = createServer((request, response) => { + if (request.method === "POST") { + controls += 1; + } + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(session)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + registry.register(`http://127.0.0.1:${server.address().port}`); + try { + const owners = await registry.list(); + assert.equal(owners[0].available, false); + await assert.rejects(registry.control(registry.id, { + resource: "all", action: "shutdown", confirmed: true + }), /no longer belongs/); + session = { id: registry.id, scopeId: randomUUID() }; + await assert.rejects(registry.control(registry.id, { + resource: "all", action: "shutdown", confirmed: true + }), /no longer belongs/); + assert.equal(controls, 0); + } finally { + registry.unregister(); + server.closeIdleConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index 308c365..b90def7 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -790,6 +790,24 @@ test("dashboard rejects unknown options before starting", () => { assert.match(result.stderr, /unknown dashboard option/); }); +test("dashboard browser opening is opt-in, including after onboarding", () => { + const content = readFileSync(cli, "utf8"); + assert.match( + content, + /function parseDashboardArguments\(args\) \{[\s\S]*?let open = false;/ + ); + assert.match(content, /if \(argument === "--open"\) \{\s+open = true;/); + assert.match( + content, + /return dashboard\(repository, DEFAULT_DASHBOARD_PORT, false, runtime\);/ + ); + const missing = join(tmpdir(), `fixlab-browser-opt-in-${process.pid}`); + const result = run(["dashboard", missing, "--open"], process.cwd()); + assert.equal(result.status, 1); + assert.doesNotMatch(result.stderr, /unknown dashboard option/); + assert.match(result.stderr, /Cannot start FixLab dashboard/); +}); + test("keep-awake validates bounded timers", () => { assert.equal(parseKeepAwakeMinutes("90"), 90); assert.throws( From d38dc3b7223162dbe5656c9f8ae1a04331d77f3a Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Tue, 13 Oct 2026 10:28:50 -0400 Subject: [PATCH 2/7] FLAB: Document explicit dashboard browser opening Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa54f8e0-47eb-4686-99dd-fc2e63d0239c --- docs/commands.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index f4dcf6f..bbd7f27 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -74,12 +74,15 @@ read credentials, or make network requests. ## Local dashboard command ```text -fixlab dashboard [repository] [--port ] [--no-open] [--keep-awake-minutes ] +fixlab dashboard [repository] [--port ] [--open|--no-open] [--keep-awake-minutes ] ``` -The command starts the packaged local UI on `127.0.0.1:4317` by default and -opens the system browser unless `--no-open` is supplied. It does not run during -installation or `fixlab init`. The UI starts one selected-runtime job at a time +The command starts the packaged local UI on `127.0.0.1:4317` by default without +opening the browser. Open the printed URL manually, request `open dashboard` +in chat, or explicitly supply `--open`. `--no-open` remains supported. +Onboarding and reconnecting do not automatically open browser tabs. +The dashboard does not run during installation or `fixlab init`. +The UI starts one selected-runtime job at a time and displays the intake, diagnosis, reproduce, fix, review, local-stack, live-test, and pull-request stages with polled logs. From 876e7004c83d33b64464ee053735800dc6f88c30 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Tue, 13 Oct 2026 13:11:18 -0400 Subject: [PATCH 3/7] FLAB: Consolidate sessions, browser recovery and Copilot models Keep Windows descendant containment as an explicit draft-only merge gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa54f8e0-47eb-4686-99dd-fc2e63d0239c --- README.md | 13 + agents/fixlab.md | 8 + bin/fixlab.js | 85 +++- com.github.copilot/agents/fixlab.agent.md | 8 + dashboard/chat.js | 54 ++- dashboard/executor-lease.js | 148 +++++++ dashboard/public/app.js | 57 ++- dashboard/public/index.html | 7 + dashboard/server.js | 336 +++++++++++--- docs/architecture.md | 23 + docs/getting-started.md | 15 + e2e/dashboard-readiness.spec.js | 67 ++- .../ci/fixtures/executor-lease-contender.mjs | 33 ++ test/dashboard-executor-lease.test.js | 153 +++++++ test/dashboard-server.test.js | 410 +++++++++++++++++- test/fixlab-cli.test.js | 51 ++- 16 files changed, 1345 insertions(+), 123 deletions(-) create mode 100644 dashboard/executor-lease.js create mode 100644 scripts/ci/fixtures/executor-lease-contender.mjs create mode 100644 test/dashboard-executor-lease.test.js diff --git a/README.md b/README.md index eadeb25..7add52f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,19 @@ agency copilot For direct mode, install and authenticate GitHub Copilot CLI, then use `--runtime copilot` or set `FIXLAB_RUNTIME=copilot`. +Select a direct Copilot model with `--model ` or `FIXLAB_MODEL`. +For the account-dependent HydraFusion research preview: + +```powershell +fixlab dashboard C:\path\to\application --runtime copilot --model hydrafusion --open +``` + +The Copilot dashboard also offers a per-job model selector. Leave it at +Copilot default to avoid an override. Model selection is not supported by the +Agency runtime; unavailable models produce runtime errors rather than silently +switching models. Preview models may buffer stage updates, and FixLab does not +guarantee lower token usage, charges, or elapsed time. + ### 2. Install and initialize FixLab ```shell diff --git a/agents/fixlab.md b/agents/fixlab.md index c75afc3..68d25f6 100644 --- a/agents/fixlab.md +++ b/agents/fixlab.md @@ -97,6 +97,14 @@ behavior only rather than validation of the selected backend or its data. ## Safety boundaries +Keep execution economical without weakening evidence: reuse verified session +context instead of rediscovering unchanged repository facts, batch independent +reads, and keep command output compact. Handle bounded work directly; delegate +only independent tasks that justify separate context, not routine searches or +one continuous investigation. On retry, rerun the failed gate and any affected +prerequisites, not unrelated completed stages. Required checks still apply; +never omit them to claim token savings. + - Never select production automatically. - Never expose, copy, or commit credentials, tokens, customer data, or private test records. diff --git a/bin/fixlab.js b/bin/fixlab.js index 107ac11..e875cb4 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -16,12 +16,15 @@ import { fileURLToPath } from "node:url"; import { buildAgencyInvocation, buildCopilotInvocation, + copilotModelArguments, configuredValidationEnvironments, createDashboardServer, DEFAULT_DASHBOARD_PORT, FIXLAB_RUNTIMES, + HYDRAFUSION_MODEL, inspectRepository, linkedDependencyDirectories, + validateCopilotModel, validateTargetEnvironment, validateLiveTestProfile, validatePort @@ -77,18 +80,18 @@ function printUsage() { console.log(`FixLab CLI Usage: - fixlab [--runtime ] - fixlab onboard [repository] [--yes] [--authenticate] [--start-dashboard] [--runtime ] + fixlab [--runtime ] [--model ] + fixlab onboard [repository] [--yes] [--authenticate] [--start-dashboard] [--runtime ] [--model ] fixlab init [repository] fixlab prepare [repository] [--yes] fixlab doctor [repository] [--runtime ] fixlab setup-playwright [repository] [--yes] fixlab authenticate [repository] [--yes] fixlab exec [repository] [--cwd ] [--stage ] [--reuse] [--timeout-seconds ] -- [args...] - fixlab run [repository] [--runtime ] [--environment ] [--] [request...] - fixlab validate [repository] --pr [--runtime ] - fixlab dashboard [repository] [--port ] [--open|--no-open] [--keep-awake-minutes ] [--runtime ] - fixlab chat [repository] [--port ] [--runtime ] + fixlab run [repository] [--runtime ] [--model ] [--environment ] [--] [request...] + fixlab validate [repository] --pr [--runtime ] [--model ] + fixlab dashboard [repository] [--port ] [--open|--no-open] [--keep-awake-minutes ] [--runtime ] [--model ] + fixlab chat [repository] [--port ] [--runtime ] [--model ] fixlab keep-awake --minutes fixlab --help @@ -116,6 +119,8 @@ Runtime: copilot Use GitHub Copilot CLI directly. Set FIXLAB_RUNTIME or pass --runtime to select the runtime. +Set FIXLAB_MODEL or pass --model to select a GitHub Copilot CLI model. +HydraFusion research preview: --runtime copilot --model ${HYDRAFUSION_MODEL}. Run with --environment to select an allowed non-production environment from the repository profile. For the Agency-like interactive experience, run FixLab with only the repository path. @@ -475,6 +480,7 @@ function validateRuntime(value) { function parseRuntimeArguments(args) { let runtime; + let model = process.env.FIXLAB_MODEL ?? ""; try { runtime = validateRuntime(process.env.FIXLAB_RUNTIME ?? "agency"); } catch (error) { @@ -509,9 +515,29 @@ function parseRuntimeArguments(args) { } continue; } + if (!afterSeparator && argument === "--model") { + if (!args[index + 1]) { + return { error: "--model requires a model identifier" }; + } + model = args[index + 1]; + index += 1; + continue; + } + if (!afterSeparator && argument.startsWith("--model=")) { + model = argument.slice("--model=".length); + if (!model) { + return { error: "--model requires a model identifier" }; + } + continue; + } remaining.push(argument); } - return { runtime, args: remaining }; + try { + model = validateCopilotModel(model, runtime); + } catch (error) { + return { error: error.message }; + } + return { runtime, model, args: remaining }; } function doctor(repository, runtime) { @@ -896,7 +922,7 @@ function authenticate(repository, approved) { return 0; } -function launch(repository, request, runtime, targetEnvironment = "") { +function launch(repository, request, runtime, targetEnvironment = "", model = "") { const { profile, error } = loadProfile(repository); if (error) { console.error( @@ -951,6 +977,7 @@ function launch(repository, request, runtime, targetEnvironment = "") { packageRoot, prompt, sessionId: randomUUID(), + model, additionalDirs: linkedDependencyDirectories(repository) }) : buildAgencyInvocation({ @@ -966,6 +993,7 @@ function launch(repository, request, runtime, targetEnvironment = "") { packageRoot, "--agent", "fixlab:fixlab", + ...(runtime === "copilot" ? copilotModelArguments(model) : []), ...(prompt ? ["--interactive", prompt] : []) ] }; @@ -1157,6 +1185,7 @@ function parseOnboardArguments(args) { return { repository: resolveRepository(repositoryArgument), runtime: runtimeArguments.runtime, + model: runtimeArguments.model, approved, authenticateBrowser, startDashboard @@ -1166,6 +1195,7 @@ function parseOnboardArguments(args) { async function onboard({ repository, runtime, + model, approved, authenticateBrowser, startDashboard @@ -1176,6 +1206,9 @@ async function onboard({ console.log("FixLab onboarding"); console.log(` repository: ${repository}`); console.log(` runtime: ${runtime}`); + if (model) { + console.log(` model: ${model}`); + } const initResult = init(repository); if (initResult !== 0) { @@ -1238,13 +1271,13 @@ async function onboard({ return doctorResult; } if (startDashboard) { - return dashboard(repository, DEFAULT_DASHBOARD_PORT, false, runtime); + return dashboard(repository, DEFAULT_DASHBOARD_PORT, false, runtime, 0, model); } console.log(""); console.log("FixLab onboarding is ready."); console.log("Start the dashboard with:"); - console.log(` fixlab dashboard "${repository}" --runtime ${runtime}`); + console.log(` fixlab dashboard "${repository}" --runtime ${runtime}${model ? ` --model ${model}` : ""}`); return 0; } @@ -1277,7 +1310,8 @@ async function dashboard( port, shouldOpen, runtime, - keepAwakeMinutes = 0 + keepAwakeMinutes = 0, + model = "" ) { const readiness = inspectRepository(repository); if (!readiness.repositoryReady) { @@ -1288,6 +1322,7 @@ async function dashboard( repository, packageRoot, runtime, + model, preflight: runJobPreflight }); let address; @@ -1301,6 +1336,9 @@ async function dashboard( console.log(`FixLab dashboard: ${address.url}`); console.log(`Repository: ${repository}`); console.log(`Runtime: ${runtime}`); + if (model) { + console.log(`Model: ${model}`); + } let keepAwakeHandle; if (keepAwakeMinutes > 0) { try { @@ -1441,6 +1479,7 @@ async function startDashboardForChat({ repositoryArgument, port, runtime, + model, baseUrl }) { let repository; @@ -1467,7 +1506,7 @@ async function startDashboardForChat({ console.log( "The repository is not dashboard-ready. Opening the interactive FixLab onboarding session first." ); - const onboardingResult = launch(repository, "", runtime); + const onboardingResult = launch(repository, "", runtime, "", model); if (onboardingResult !== 0) { throw new Error( `FixLab onboarding exited with code ${onboardingResult}` @@ -1492,7 +1531,8 @@ async function startDashboardForChat({ String(port), "--no-open", "--runtime", - runtime + runtime, + ...(model ? ["--model", model] : []) ], { detached: true, @@ -1578,7 +1618,8 @@ async function main(args) { runArguments.repository, runArguments.request, parsed.runtime, - runArguments.targetEnvironment + runArguments.targetEnvironment, + parsed.model ); } @@ -1596,7 +1637,9 @@ async function main(args) { return launch( parsed.repository, `Validate pull request ${parsed.pullRequest} without modifying source code or the pull request.`, - runtimeArguments.runtime + runtimeArguments.runtime, + "", + runtimeArguments.model ); } @@ -1616,7 +1659,8 @@ async function main(args) { parsed.port, parsed.open, runtimeArguments.runtime, - parsed.keepAwakeMinutes + parsed.keepAwakeMinutes, + runtimeArguments.model ); } @@ -1674,16 +1718,19 @@ async function main(args) { repositoryArgument: repository, port: alternatePort, runtime: runtimeArguments.runtime, + model: runtimeArguments.model, baseUrl: alternateUrl }); }; return runChat({ baseUrl, + model: runtimeArguments.model, recoverConnection: () => startDashboardForChat({ repositoryArgument: parsed.repositoryArgument, port: parsed.port, runtime: runtimeArguments.runtime, + model: runtimeArguments.model, baseUrl }), selectRepository: async ({ readiness }) => { @@ -1725,14 +1772,16 @@ async function main(args) { } if (parsed.args.length !== 1) { console.error( - "interactive FixLab accepts one repository path and an optional --runtime" + "interactive FixLab accepts one repository path and optional --runtime and --model values" ); return 1; } return launch( resolveRepository(parsed.args[0]), "", - parsed.runtime + parsed.runtime, + "", + parsed.model ); } diff --git a/com.github.copilot/agents/fixlab.agent.md b/com.github.copilot/agents/fixlab.agent.md index c75afc3..68d25f6 100644 --- a/com.github.copilot/agents/fixlab.agent.md +++ b/com.github.copilot/agents/fixlab.agent.md @@ -97,6 +97,14 @@ behavior only rather than validation of the selected backend or its data. ## Safety boundaries +Keep execution economical without weakening evidence: reuse verified session +context instead of rediscovering unchanged repository facts, batch independent +reads, and keep command output compact. Handle bounded work directly; delegate +only independent tasks that justify separate context, not routine searches or +one continuous investigation. On retry, rerun the failed gate and any affected +prerequisites, not unrelated completed stages. Required checks still apply; +never omit them to claim token savings. + - Never select production automatically. - Never expose, copy, or commit credentials, tokens, customer data, or private test records. diff --git a/dashboard/chat.js b/dashboard/chat.js index 29c6ed5..d92e988 100644 --- a/dashboard/chat.js +++ b/dashboard/chat.js @@ -1,4 +1,5 @@ import { clearLine, createInterface, cursorTo } from "node:readline"; +import { stripVTControlCharacters } from "node:util"; const DEFAULT_LOG_COUNT = 20; const MAX_LOG_COUNT = 100; @@ -155,6 +156,7 @@ async function requestJson(fetchImpl, url, options) { export async function runChat({ baseUrl, + model = "", input = process.stdin, output = process.stdout, errorOutput = process.stderr, @@ -176,21 +178,35 @@ export async function runChat({ let interactive = false; let handlingLine = false; let readline; + let lastPollingError = ""; const pendingMessages = []; - const writeMessage = (stream, message = "") => { + const writeMessages = (messages) => { + const safeMessages = messages.map(({ stream, message }) => ({ + stream, + message: stripVTControlCharacters(String(message)).replace(/\r/g, "") + })); + if (safeMessages.length === 0) { + return; + } if (interactive && readline && !handlingLine && !closed) { if (readline.line) { - pendingMessages.push({ stream, message }); + pendingMessages.push(...safeMessages); return; } clearLine(output, 0); cursorTo(output, 0); - stream.write(`${message}\n`); + for (const { stream, message } of safeMessages) { + stream.write(`${message}\n`); + } readline.prompt(true); return; } - stream.write(`${message}\n`); + for (const { stream, message } of safeMessages) { + stream.write(`${message}\n`); + } }; + const writeMessage = (stream, message = "") => + writeMessages([{ stream, message }]); const write = (message = "") => writeMessage(output, message); const writeError = (message) => writeMessage(errorOutput, message); const flushPendingMessages = () => { @@ -206,12 +222,15 @@ export async function runChat({ const loadStatus = async ({ announce = false } = {}) => { const body = await requestJson(fetchImpl, `${dashboardUrl}/api/status`); + const messages = []; + const announceMessage = (message) => messages.push({ stream: output, message }); currentJob = body.job; currentReadiness = body.readiness ?? null; if (!currentJob) { if (announce) { - write("No FixLab job is available. Start one in the dashboard first."); + announceMessage("No FixLab job is available. Start one in the dashboard first."); } + writeMessages(messages); return null; } if (currentJob.id !== lastJobId) { @@ -221,23 +240,28 @@ export async function runChat({ ...(currentJob.logs ?? []).map((entry) => Number(entry.index)) ); lastStatus = currentJob.status; - write(formatJobStatus(currentJob)); + announceMessage(formatJobStatus(currentJob)); + writeMessages(messages); return currentJob; } for (const entry of currentJob.logs ?? []) { - if (Number(entry.index) <= lastLogIndex || !entry.message) { + if (Number(entry.index) <= lastLogIndex) { continue; } - write(`[${entry.stream}] ${entry.message}`); lastLogIndex = Number(entry.index); + const message = stripVTControlCharacters(entry.message ?? "").replace(/\r/g, ""); + if (message.trim()) { + announceMessage(`[${entry.stream}] ${message}`); + } } if (currentJob.status !== lastStatus) { - write(`[status] ${lastStatus} -> ${currentJob.status}`); + announceMessage(`[status] ${lastStatus} -> ${currentJob.status}`); lastStatus = currentJob.status; } if (announce) { - write(formatJobStatus(currentJob)); + announceMessage(formatJobStatus(currentJob)); } + writeMessages(messages); return currentJob; }; @@ -261,6 +285,7 @@ export async function runChat({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: details, + ...(model ? { model } : {}), mode: "fix-and-validate", requestType: "bug-fix", pullRequestStrategy: "common", @@ -368,8 +393,15 @@ export async function runChat({ polling = true; try { await loadStatus(); + if (lastPollingError) { + write("Dashboard connection restored."); + lastPollingError = ""; + } } catch (error) { - writeError(`Dashboard polling failed: ${error.message}`); + if (error.message !== lastPollingError) { + writeError(`Dashboard polling failed: ${error.message}`); + lastPollingError = error.message; + } } finally { polling = false; } diff --git a/dashboard/executor-lease.js b/dashboard/executor-lease.js new file mode 100644 index 0000000..cb19347 --- /dev/null +++ b/dashboard/executor-lease.js @@ -0,0 +1,148 @@ +import { spawnSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync +} from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +export function canonicalExecutionRepository(repository) { + const root = realpathSync.native(repository); + const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { + cwd: root, + encoding: "utf8", + windowsHide: true, + timeout: 10000 + }); + let identity; + let directory; + if (result.status === 0) { + const common = result.stdout.trim(); + if (!common) { + throw new Error("Git did not identify the repository's common directory"); + } + identity = realpathSync.native(isAbsolute(common) ? common : resolve(root, common)); + directory = join(identity, "fixlab", "executor-lease"); + } else if ( + !result.error && + !existsSync(join(root, ".git")) && + /not a git repository/i.test(result.stderr) + ) { + identity = root; + } else { + throw new Error("Cannot verify the canonical repository for executor ownership"); + } + const canonical = process.platform === "win32" ? identity.toLowerCase() : identity; + const key = createHash("sha256").update(canonical).digest("hex"); + return { + key, + directory: directory ?? join(tmpdir(), "fixlab-executor-leases", key), + repository: identity + }; +} + +export function createExecutorLease(repository, ownerId) { + const scope = canonicalExecutionRepository(repository); + const metadataPath = join(scope.directory, "owner.json"); + + function reservedError() { + let description = "incomplete or unreadable ownership metadata"; + try { + const directoryInfo = lstatSync(scope.directory); + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) { + throw new Error("untrusted lease directory"); + } + const info = lstatSync(metadataPath); + if (info.isFile() && !info.isSymbolicLink() && info.size <= 8192) { + const record = JSON.parse(readFileSync(metadataPath, "utf8")); + description = `owner ${record.ownerId}, job ${record.jobId}, state ${record.state}`; + } + } catch { + // An incomplete reservation remains exclusive after an owner crash. + } + return new Error( + `Repository executor lease is reserved (${description}). ` + + "Reconnect to its owner or wait for confirmed executor termination. " + + "An unreachable owner is not proof that its executor stopped; do not clear the lease without verifying that the owner and executor tree are quiescent." + ); + } + + return { + scope, + acquire(jobId) { + const parent = resolve(scope.directory, ".."); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + try { + mkdirSync(scope.directory, { mode: 0o700 }); + } catch (error) { + if (error.code === "EEXIST") { + throw reservedError(); + } + throw error; + } + const record = { + version: 1, + acquisition: randomUUID(), + ownerId, + jobId, + ownerPid: process.pid, + ownerStartedAt: performance.timeOrigin, + executorPid: null, + state: "starting", + acquiredAt: new Date().toISOString() + }; + const directoryInfo = lstatSync(scope.directory); + writeFileSync(metadataPath, JSON.stringify(record), { + flag: "wx", + mode: 0o600 + }); + let released = false; + const verify = () => { + const currentDirectory = lstatSync(scope.directory); + const currentFile = lstatSync(metadataPath); + if ( + !currentDirectory.isDirectory() || + currentDirectory.isSymbolicLink() || + currentDirectory.ino !== directoryInfo.ino || + currentDirectory.dev !== directoryInfo.dev || + !currentFile.isFile() || + currentFile.isSymbolicLink() + ) { + throw new Error("Executor lease ownership paths changed"); + } + const current = JSON.parse(readFileSync(metadataPath, "utf8")); + if (current.acquisition !== record.acquisition || current.ownerId !== ownerId) { + throw new Error("Executor lease acquisition changed; refusing to alter another owner"); + } + }; + return { + update(state, executorPid = record.executorPid) { + if (released) { + throw new Error("Executor lease has already been released"); + } + verify(); + record.state = state; + record.executorPid = executorPid ?? null; + writeFileSync(metadataPath, JSON.stringify(record), { mode: 0o600 }); + }, + release() { + if (released) { + return; + } + verify(); + unlinkSync(metadataPath); + rmdirSync(scope.directory); + released = true; + } + }; + } + }; +} diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 3c47fd4..6f6d5c2 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -13,6 +13,9 @@ const form = document.querySelector("#job-form"); const startButton = document.querySelector("#start-button"); const formError = document.querySelector("#form-error"); const readinessElement = document.querySelector("#readiness"); +const modelControl = document.querySelector("#model-control"); +const modelSelect = document.querySelector("#model"); +let modelConfigured = false; const stagesElement = document.querySelector("#stages"); const jobStatusElement = document.querySelector("#job-status"); const testEvidenceElement = document.querySelector("#test-evidence"); @@ -169,6 +172,7 @@ let selectedScreenshots = []; let activeJob = null; let selectedJobId = null; let pendingRetryAfterConnectJobId = null; +let playwrightAuthenticationRunning = false; const observedJobs = new Map(); const expandedJobIds = new Set(); let renderedEvidenceJobId = null; @@ -230,6 +234,25 @@ function renderReadiness(readiness) { } } +function renderRuntime(runtime) { + const supported = runtime?.supportsModelSelection === true; + modelControl.hidden = !supported; + modelSelect.disabled = !supported; + if (!supported) { + modelSelect.value = ""; + modelConfigured = false; + return; + } + if (!modelConfigured) { + const defaultModel = runtime.defaultModel ?? ""; + if (![...modelSelect.options].some((option) => option.value === defaultModel)) { + modelSelect.add(new Option(defaultModel, defaultModel)); + } + modelSelect.value = defaultModel; + modelConfigured = true; + } +} + function labelTestEvidence(value) { return String(value ?? "profile-defined") .split("-") @@ -292,7 +315,7 @@ function renderJob(job, currentActiveJob = job) { ? Date.now() - Date.parse(job.lastActivityAt) : null; jobStatusElement.textContent = job - ? `${job.status} · ${job.requestType} · ${ + ? `${job.status} · ${job.requestType}${job.model ? ` · ${job.model}` : ""} · ${ job.targetEnvironment || "profile default" } · ${formatDuration(job.durationMs)}${ lastActivityAge === null @@ -480,10 +503,8 @@ function renderJob(job, currentActiveJob = job) { failManualLocalhostButton.hidden = !canConfirmManualLocalhost; failManualLocalhostButton.disabled = !canConfirmManualLocalhost; retryLiveTestButton.hidden = !canRetryLiveTest; - retryLiveTestButton.disabled = !canRetryLiveTest; connectRetryLiveTestButton.hidden = !canRetryLiveTest; - connectRetryLiveTestButton.disabled = - !canRetryLiveTest || Boolean(pendingRetryAfterConnectJobId); + updateLiveTestRetryAvailability(); skipLiveTestButton.hidden = !canRetryLiveTest; skipLiveTestButton.disabled = !canRetryLiveTest; approvePullRequestButton.hidden = !canApprovePullRequest; @@ -535,7 +556,23 @@ function renderJob(job, currentActiveJob = job) { } } +function updateLiveTestRetryAvailability() { + retryLiveTestButton.disabled = + retryLiveTestButton.hidden || playwrightAuthenticationRunning; + connectRetryLiveTestButton.disabled = + connectRetryLiveTestButton.hidden || + playwrightAuthenticationRunning || + Boolean(pendingRetryAfterConnectJobId); + const reason = playwrightAuthenticationRunning + ? "Finish sign-in and close its browser window before retrying the live test." + : ""; + retryLiveTestButton.title = reason; + connectRetryLiveTestButton.title = reason; +} + function renderPlaywrightStatus(status) { + playwrightAuthenticationRunning = Boolean(status.running); + updateLiveTestRetryAvailability(); playwrightStatus.className = `badge ${ status.running ? "running" : status.ready ? "passed" : "blocked" }`; @@ -554,10 +591,12 @@ function renderPlaywrightStatus(status) { .map((entry) => `${entry.ready ? "ready" : "missing"}: ${entry.path}`) .join(" · "); playwrightGuidance.textContent = - status.error || - status.lastResult?.message || - pathSummary || - "Add browserAutomation.authentication to the repository profile."; + status.running + ? "Authentication is still running. Finish sign-in and close its browser window if it stays open; live-test retries wait until the helper exits." + : status.error || + status.lastResult?.message || + pathSummary || + "Add browserAutomation.authentication to the repository profile."; } async function resumeAfterConnect(status) { @@ -1297,6 +1336,7 @@ async function refresh() { if (!selectedJobId || !observedJobs.has(selectedJobId)) { selectedJobId = activeJob?.id ?? null; } + renderRuntime(body.runtime); renderReadiness(body.readiness); renderJob(observedJobs.get(selectedJobId) ?? activeJob, activeJob); renderQueue(activeJob, body.queue, body.history); @@ -1337,6 +1377,7 @@ form.addEventListener("submit", async (event) => { body: JSON.stringify({ request: data.get("request"), requestType: data.get("requestType"), + model: data.get("model") ?? "", mode: data.get("mode"), pullRequestStrategy: data.get("separatePullRequests") === "on" ? "per-bug" diff --git a/dashboard/public/index.html b/dashboard/public/index.html index f68b16e..41d96b7 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -75,6 +75,13 @@

Repository onboarding

+